Compare commits
174
Commits
v0.7.0
..
bca9549f3a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bca9549f3a | ||
|
|
232d8e6734 | ||
|
|
8f19a5eb2b | ||
|
|
937ccb9e8e | ||
|
|
6859b5c4ff | ||
|
|
3f5ec8696f | ||
|
|
9fd80b7cef | ||
|
|
7d87200e31 | ||
|
|
51286d0fa3 | ||
|
|
905cbce0c1 | ||
|
|
09ebd44b6c | ||
|
|
2e5c0cc953 | ||
|
|
1660044ed2 | ||
|
|
324928cfca | ||
|
|
5c2c6a0a8f | ||
|
|
ff62918dbb | ||
|
|
54a93c1d14 | ||
|
|
b6bd5cfcb7 | ||
|
|
fdf6282aed | ||
|
|
ca8aabf640 | ||
|
|
a90d3683d3 | ||
|
|
d85be6c456 | ||
|
|
8ef6233ef3 | ||
|
|
91e8b2ab05 | ||
|
|
b2e83bc941 | ||
|
|
3e77a621f5 | ||
|
|
a1f671ed5d | ||
|
|
7c584c1d2e | ||
|
|
709a38feb4 | ||
|
|
35cd28aad4 | ||
|
|
de8d792e37 | ||
|
|
64bb0e366a | ||
|
|
e4502f2437 | ||
|
|
504d065003 | ||
|
|
f09341fcfa | ||
|
|
535ad5af6d | ||
|
|
247f360ae7 | ||
|
|
790b81330a | ||
|
|
69020b2555 | ||
|
|
9ed896d2b9 | ||
|
|
8389d9674e | ||
|
|
5112cc1a62 | ||
|
|
30d1632252 | ||
|
|
7a2b58652a | ||
|
|
5e65627305 | ||
|
|
84dc4e06a2 | ||
|
|
c7ab605e85 | ||
|
|
8aa28205e0 | ||
|
|
b56cb8cba5 | ||
|
|
b3a4d80df3 | ||
|
|
7bd4574b08 | ||
|
|
e32d83933e | ||
|
|
5ce2771718 | ||
|
|
68c7b96a9f | ||
|
|
6a9c48a207 | ||
|
|
9a03b7c8d4 | ||
|
|
361129b6ac | ||
|
|
8069541cd9 | ||
|
|
4a82640c5b | ||
|
|
52660cbb8e | ||
|
|
ca2f9451ab | ||
|
|
b3116de354 | ||
|
|
f6993e6cdb | ||
|
|
f1d1081b67 | ||
|
|
b4d3cb3695 | ||
|
|
124da548b8 | ||
|
|
f4960f2fc5 | ||
|
|
45ef035bae | ||
|
|
b67a5e43eb | ||
|
|
7ac6e08544 | ||
|
|
f94004648d | ||
|
|
e8e1a2623b | ||
|
|
ecb93c7116 | ||
|
|
f01a308287 | ||
|
|
1fb1a8d2d0 | ||
|
|
949cf78636 | ||
|
|
296728d51d | ||
|
|
98205daef6 | ||
|
|
3eec9fd8c6 | ||
|
|
6aaf21aa06 | ||
|
|
ce68803471 | ||
|
|
7c4b484d0a | ||
|
|
fcf4ed3039 | ||
|
|
e01915823a | ||
|
|
9bc0f48514 | ||
|
|
9f599e02e8 | ||
|
|
83c99cc3e5 | ||
|
|
1393a8a3b8 | ||
|
|
2257ee871e | ||
|
|
40625e98ed | ||
|
|
e0bd84247e | ||
|
|
34d5bbc810 | ||
|
|
e16903b286 | ||
|
|
21ea8a84a0 | ||
|
|
dd8354477d | ||
|
|
796b19d923 | ||
|
|
3e1d7db537 | ||
|
|
a4c7d7b298 | ||
|
|
e1fca3eddf | ||
|
|
63e6148cdb | ||
|
|
87d33a2ce1 | ||
|
|
2ab7b1e762 | ||
|
|
2e93124080 | ||
|
|
2c339bee15 | ||
|
|
41b6e2ed2b | ||
|
|
3867e7c183 | ||
|
|
a7255fa1bd | ||
|
|
fd5e2b7128 | ||
|
|
57097c8204 | ||
|
|
226217ecbf | ||
|
|
daea59cf5d | ||
|
|
bc5437063d | ||
|
|
b9098382b3 | ||
|
|
e5d0654d2a | ||
|
|
3ef353de83 | ||
|
|
726b8a7d24 | ||
|
|
91e5c6e0c5 | ||
|
|
2fbf059c00 | ||
|
|
1cc0b97a72 | ||
|
|
134c5fa4bc | ||
|
|
205f4e2d4c | ||
|
|
f19462dff0 | ||
|
|
3fa3fce5df | ||
|
|
218f5e2dd6 | ||
|
|
e136fbc56a | ||
|
|
41fb82b9e9 | ||
|
|
e7743cdbb5 | ||
|
|
703baa1ead | ||
|
|
e05ddc7aa5 | ||
|
|
6f3a53b9ff | ||
|
|
13859ce7dc | ||
|
|
e15422ed8d | ||
|
|
3f1fcd0d2d | ||
|
|
798f56734a | ||
|
|
77b9e49bf2 | ||
|
|
9e3624e584 | ||
|
|
b7f3507b59 | ||
|
|
cc8085bcfa | ||
|
|
984c6236d3 | ||
|
|
cd82bec414 | ||
|
|
d6a2d05407 | ||
|
|
ae37c9b57a | ||
|
|
86b3dc1e6a | ||
|
|
c499f136fd | ||
|
|
6d8b6daba9 | ||
|
|
d7509421c7 | ||
|
|
d609a41222 | ||
|
|
e710756baf | ||
|
|
ba83038d8d | ||
|
|
f033197850 | ||
|
|
dc0771308a | ||
|
|
83f2951035 | ||
|
|
4362d49770 | ||
|
|
a01b7bc99e | ||
|
|
9b6b970cae | ||
|
|
1849213ce4 | ||
|
|
d694dda320 | ||
|
|
212fdaa5b5 | ||
|
|
0ac648bc26 | ||
|
|
10da210b0a | ||
|
|
b209936f86 | ||
|
|
c64434a131 | ||
|
|
72e4d3eceb | ||
|
|
4cebacadfe | ||
|
|
e8f630f12d | ||
|
|
4550a11460 | ||
|
|
3c6b659f36 | ||
|
|
1a1d2e9d08 | ||
|
|
fed1d5d3f4 | ||
|
|
b61020babd | ||
|
|
379f80cbd0 | ||
|
|
649e3d9127 | ||
|
|
b3e9b99e13 | ||
|
|
586a6db8ff |
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "basic-app",
|
||||
"runtimeExecutable": "bun",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"packages/cli/src/index.ts",
|
||||
"dev",
|
||||
"examples/basic-app",
|
||||
"--port=3520"
|
||||
],
|
||||
"port": 3520
|
||||
},
|
||||
{
|
||||
"name": "component-showcase",
|
||||
"runtimeExecutable": "bun",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"packages/cli/src/index.ts",
|
||||
"dev",
|
||||
"examples/component-showcase",
|
||||
"--port=3400"
|
||||
],
|
||||
"port": 3400
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# Enforce LF in the working tree regardless of a contributor's core.autocrlf.
|
||||
# Without this, Git on Windows smudges every text file to CRLF on clone, stash
|
||||
# pop, or checkout, which fails `bun run format:check` (prettier endOfLine: lf).
|
||||
* text=auto eol=lf
|
||||
|
||||
# Binary assets Git must not touch.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.ico binary
|
||||
*.pdf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.db binary
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Quality
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: editors/vscode/package-lock.json
|
||||
- name: Install framework dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install editor dependencies
|
||||
run: npm ci --prefix editors/vscode
|
||||
- name: Typecheck
|
||||
run: bun run typecheck
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
- name: Formatting
|
||||
run: bun run format:check
|
||||
- name: Package and application tests
|
||||
run: bun run test:all
|
||||
- name: Package contracts
|
||||
run: bun run check:public-api && bun run check:ui-visual && bun run audit:packages && bun run test:package-kits && bun run validate:staging
|
||||
- name: Stage and test publishable packages
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: bun run stage:packages && bun run test:staged-consumers
|
||||
- name: Framework validation
|
||||
run: bun run validate:0.8
|
||||
- name: Dependency audit
|
||||
run: bun audit
|
||||
- name: Editor dependency audit
|
||||
run: npm audit --prefix editors/vscode --audit-level=high
|
||||
@@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.wrnexus/
|
||||
.wrnexus-*/
|
||||
*.log
|
||||
*.db
|
||||
*.db-shm
|
||||
@@ -19,3 +20,8 @@ bun.lockb
|
||||
# Local focused typecheck helpers must never enter the repository.
|
||||
focus-shims.d.ts
|
||||
tsconfig.focus.json
|
||||
|
||||
# Scratch dirs for tests that must dynamically import scaffolded files using
|
||||
# "@wrnexus/*" bare specifiers (resolved via the root tsconfig.json `paths`,
|
||||
# which requires the scaffold to live inside the repo tree).
|
||||
**/test/.tmp-*/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
@wrnexus:registry=https://registry.npmjs.org/
|
||||
audit=true
|
||||
fund=false
|
||||
@@ -1,6 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
**/dist/
|
||||
**/.wrnexus/**
|
||||
**/.wrnexus-*/**
|
||||
.publish/
|
||||
**/.wirefw/
|
||||
coverage/
|
||||
@@ -10,9 +12,14 @@ bun.lockb
|
||||
|
||||
# Generated code (queries.gen.ts, routes.gen.ts, etc.)
|
||||
**/*.gen.ts
|
||||
**/*.generated.d.ts
|
||||
|
||||
# Bundled .wire compiler for the VS Code extension (generated)
|
||||
editors/vscode/src/compiler.cjs
|
||||
editors/vscode/src/language-server.cjs
|
||||
editors/vscode/src/extension.bundle.cjs
|
||||
docs/public-api-0.8.json
|
||||
docs/ui-visual-contract-0.8.json
|
||||
*.svg
|
||||
**/.vscodeignore
|
||||
|
||||
@@ -21,3 +28,6 @@ focus-shims.d.ts
|
||||
**/focus-shims.d.ts
|
||||
tsconfig.focus.json
|
||||
**/tsconfig.focus.json
|
||||
|
||||
# SDD scratch workspace (git-ignored controller artifacts)
|
||||
.superpowers/
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @wrnexus/ai
|
||||
|
||||
Provider-neutral AI orchestration for OpenAI, Anthropic, Google and local OpenAI-compatible models,
|
||||
with streaming, structured output, tools, embeddings, vector search/RAG, conversation persistence,
|
||||
templates, guardrails, usage events, fallback, rate limits and evaluation reports.
|
||||
|
||||
> A tiny, zero-dependency Claude (Anthropic) client for WrNexus apps — generate and stream text with Claude from any server-side code.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
@@ -120,6 +124,34 @@ try {
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-provider client
|
||||
|
||||
`createAIClient` adds named-provider selection and fallback, capability discovery,
|
||||
validated JSON output, validated tool execution, abort-aware exponential retries,
|
||||
and per-provider circuit breakers. Attempt events intentionally contain metadata
|
||||
only: prompts, credentials, and raw model responses are never passed to telemetry.
|
||||
|
||||
```ts
|
||||
import { anthropicProvider, createAIClient } from "@wrnexus/ai";
|
||||
|
||||
const ai = createAIClient({
|
||||
providers: [anthropicProvider()],
|
||||
retry: { attempts: 3, baseDelayMs: 100, maxDelayMs: 2_000 },
|
||||
circuitBreaker: { failureThreshold: 5, resetAfterMs: 30_000 },
|
||||
});
|
||||
|
||||
const result = await ai.generateObject<{ title: string }>("Return a JSON title", {
|
||||
validate: (value): value is { title: string } =>
|
||||
typeof value === "object" && value !== null && "title" in value,
|
||||
});
|
||||
```
|
||||
|
||||
Providers can return normalized `usage` (`inputTokens`, `outputTokens`,
|
||||
`totalTokens`, and `costUsd`) and `toolCalls`. Use `executeTools` with a named,
|
||||
validated tool registry; unknown tools and invalid arguments are rejected before
|
||||
application code runs. `deterministicAIProvider` supplies ordered or computed
|
||||
offline responses for tests and examples without API keys or network calls.
|
||||
|
||||
## Usage
|
||||
|
||||
### Return generated JSON from an API route
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/ai",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/ai"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/ai",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"ai"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -18,9 +34,14 @@
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./platform": {
|
||||
"types": "./dist/platform.d.ts",
|
||||
"import": "./dist/platform.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -149,3 +149,156 @@ app.put(
|
||||
- **Bun-only** — like the rest of WrNexus, this package targets the Bun runtime; Node is not supported.
|
||||
- Works with [`@wrnexus/core`](../core) — the guards return `Middleware` and read the subject from `ctx.user` on the request `Context`. Both types are imported from `@wrnexus/core`.
|
||||
- Policy combinators (`any`, `all`) and `authorize` are async-aware, so policies may return a `Promise<boolean>` (e.g. for a database ownership check).
|
||||
|
||||
## Declaring permissions
|
||||
|
||||
The RBAC/PBAC/ABAC surface above is the low-level toolkit. On top of it sits a
|
||||
declarative **registry + catalog + store + engine**: permissions, roles, and
|
||||
policies are declared once in code, merged into a frozen catalog at boot, and
|
||||
resolved per-request against a pluggable `PermissionStore` that holds who has
|
||||
what.
|
||||
|
||||
Put declarations in `app/authz/<name>.ts`; they are discovered automatically
|
||||
and merged (conflicting declarations of the same permission/role/policy across
|
||||
files fail the boot loudly, naming both source files).
|
||||
|
||||
```ts
|
||||
import { defineAuthz, owner } from "@wrnexus/authz";
|
||||
|
||||
export default defineAuthz({
|
||||
permissions: {
|
||||
"post:read": { title: "View posts", public: true },
|
||||
"post:delete": { title: "Delete posts", risk: "high" },
|
||||
},
|
||||
// "post:*" is a namespace wildcard grant, valid inside a role's list — it is
|
||||
// not itself a registered permission, so it can only ever grant permissions
|
||||
// that ARE declared above (e.g. "post:read", "post:delete").
|
||||
roles: { editor: ["post:*"], admin: ["role:editor"] },
|
||||
policies: { ownsPost: owner("id", "authorId") },
|
||||
bindings: { "post:delete": ["ownsPost"] },
|
||||
});
|
||||
```
|
||||
|
||||
`public: true` means anonymous callers may hold the permission — but any
|
||||
policy bound to it still runs, and can still veto the anonymous caller (e.g. a
|
||||
`notBanned` policy on a public `post:preview` permission).
|
||||
|
||||
## Checking permissions
|
||||
|
||||
Register `authzMiddleware` once, in `app/middleware/`, with the merged
|
||||
catalog and a `PermissionStore`. Like every other `app/middleware/*.ts` file,
|
||||
the registration is an eager, module-scope call — the same shape as
|
||||
`authzMiddleware({ catalog, store })` requires — so it must run after the
|
||||
catalog has been populated. Both the dev server and `wrnexus build`'s
|
||||
generated production entry guarantee `getAuthzCatalog()` is populated before
|
||||
any app middleware module evaluates. Name the file so it sorts after whatever
|
||||
middleware sets `ctx.user` (middleware runs in alphabetical filename order —
|
||||
`authz.ts` after `auth.ts`, for instance).
|
||||
|
||||
```ts
|
||||
// app/middleware/authz.ts
|
||||
import { authzMiddleware, getAuthzCatalog } from "@wrnexus/authz";
|
||||
import { dbPermissionStore } from "@wrnexus/authz/db";
|
||||
import { getDb } from "@wrnexus/db";
|
||||
|
||||
export default authzMiddleware({ catalog: getAuthzCatalog(), store: dbPermissionStore(getDb()) });
|
||||
```
|
||||
|
||||
> **`subject.id` must be a non-empty string.** The engine denies (and logs to
|
||||
> stderr) whenever `ctx.user.id` is present but not a non-empty string — this
|
||||
> includes the common case of an integer primary key. Coerce it before it
|
||||
> reaches `ctx.user`, e.g. `user.id = String(row.id)`, or every request for
|
||||
> that user denies with "Invalid subject" instead of resolving normally.
|
||||
> `owner()` (the built-in ownership policy) compares subject and resource ids
|
||||
> with `Object.is`, so both sides must be the same type too — `owner()` on a
|
||||
> numeric `resource.authorId` against a stringified `subject.id` never
|
||||
> matches even when they represent "the same" id.
|
||||
|
||||
There is no per-route `middleware` export — `app/middleware/*.ts` is the only
|
||||
place middleware is registered. To gate part of the app, branch on the
|
||||
request the same way any other conditional middleware does (compare
|
||||
`app/middleware/captcha-login.ts` in the auth showcase, which branches on
|
||||
method + path the same way):
|
||||
|
||||
```ts
|
||||
// app/middleware/protect-posts.ts
|
||||
import type { Context, Next } from "@wrnexus/core";
|
||||
import { guardPermission } from "@wrnexus/authz";
|
||||
|
||||
const guardPostWrite = guardPermission("post:write");
|
||||
|
||||
export default function protectPosts(ctx: Context, next: Next) {
|
||||
return ctx.url.pathname.startsWith("/api/posts") && ctx.req.method !== "GET"
|
||||
? guardPostWrite(ctx, next)
|
||||
: next();
|
||||
}
|
||||
```
|
||||
|
||||
Or check inline inside a route handler with the free function `can()`:
|
||||
|
||||
```ts
|
||||
// app/api/posts/[id].ts
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { can } from "@wrnexus/authz";
|
||||
|
||||
export const DELETE = async (ctx: Context) => {
|
||||
const post = { id: "1", authorId: "alice" }; // load your own resource here
|
||||
if (!(await can(ctx, "post:delete", post))) {
|
||||
return Response.json({ ok: false, error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return Response.json({ ok: true });
|
||||
};
|
||||
```
|
||||
|
||||
`can()` is a free function taking `ctx`, not `ctx.can` — `@wrnexus/core` must
|
||||
not depend on `@wrnexus/authz`, so the per-request resolver lives in
|
||||
`ctx.locals` instead, reached through `can()` / `decideFor()` /
|
||||
`guardPermission()` / `filterCan()`. Calling any of them before
|
||||
`authzMiddleware` has run for that request throws a `WRN-AUTHZ-SETUP` error
|
||||
naming the missing registration, rather than silently denying.
|
||||
|
||||
See `examples/auth-showcase/app/authz/showcase.ts` and
|
||||
`examples/auth-showcase/app/middleware/authz.ts` for a complete, runnable
|
||||
version of this wiring.
|
||||
|
||||
## Precedence
|
||||
|
||||
1. An explicit deny wins over everything, including `*` — and honours the
|
||||
same namespace-wildcard matching as grants (denying `post:*` blocks
|
||||
`post:comment:delete`, not just `post:*` itself).
|
||||
2. A bound policy can veto a permission a role grants, and runs even for a
|
||||
`public: true` permission — including for an anonymous caller.
|
||||
3. Otherwise the permission must be held via a role or an explicit grant.
|
||||
4. Default deny.
|
||||
|
||||
Every failure — an unknown permission (outside strict/dev mode), a store
|
||||
outage, a thrown policy — denies rather than throwing through to the caller.
|
||||
|
||||
`permissionsFor()` (on the resolver returned by `createAuthzResolver`) is a
|
||||
coarse hint for hiding UI (e.g. a menu section), **never authoritative**. A
|
||||
`Set<string>` cannot represent "granted `post:*` except `post:delete`", so a
|
||||
narrow deny beneath a broad grant is invisible to it — the set still contains
|
||||
`post:*` while `can()` / `decide()` correctly refuse `post:delete`. Gate real
|
||||
actions with `can()`, `decideFor()`, or `filterCan()`; never by matching
|
||||
against `permissionsFor()`'s result.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
wrnexus authz list # every registered permission, role, and policy
|
||||
wrnexus authz generate # app/authz/permissions.gen.ts type unions
|
||||
wrnexus authz init # scaffold the assignment-table migration
|
||||
```
|
||||
|
||||
`wrnexus authz generate`'s output is a plain `Permission | Role` string-literal
|
||||
union — `can()`, `guardPermission()`, and `decideFor()` all take a bare
|
||||
`string` and nothing reads this file automatically, so import it to type your
|
||||
own helpers/constants against the registered catalog, e.g.:
|
||||
|
||||
```ts
|
||||
import type { Permission } from "app/authz/permissions.gen.ts";
|
||||
|
||||
function guard(permission: Permission) {
|
||||
return guardPermission(permission);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/authz — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/authz"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/authz",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"authz"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -18,9 +34,18 @@
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./db": {
|
||||
"types": "./dist/db.d.ts",
|
||||
"import": "./dist/db.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.8.6",
|
||||
"@wrnexus/db": "^0.8.6"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
+54
-2
@@ -1,5 +1,18 @@
|
||||
# @wrnexus/cli
|
||||
|
||||
Production parity commands:
|
||||
|
||||
```bash
|
||||
wrnexus build .
|
||||
wrnexus preview . --port=3000
|
||||
wrnexus dev . --production-runtime
|
||||
```
|
||||
|
||||
`preview` refuses to start without `dist/server.js` and executes that exact
|
||||
artifact with the production profile. Production-runtime development rebuilds
|
||||
the same minified artifact after app, public, or configuration changes and
|
||||
keeps the last good server running when a rebuild fails.
|
||||
|
||||
> The `wrnexus` command-line tool that scaffolds, runs, builds, tests, and manages WrNexus apps.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
@@ -26,6 +39,30 @@ bunx wrnexus dev
|
||||
|
||||
## Commands
|
||||
|
||||
### Local production services
|
||||
|
||||
`wrnexus dev . --services` starts the application and the bounded local database,
|
||||
cache, mail, SMS, webhook, storage, queue, cron, authentication and metrics simulator.
|
||||
It generates a localhost/`*.localhost` development certificate under
|
||||
`.wrnexus/certificates/` and serves both the application and service console over HTTPS.
|
||||
Trust that certificate locally to remove the browser warning. Use `--services-http` only
|
||||
when an external development proxy already terminates TLS.
|
||||
|
||||
### Exact production runtime with live updates
|
||||
|
||||
`wrnexus dev . --production-runtime` rebuilds and executes `dist/server.js` with
|
||||
production resolution, serialization, caching, headers and assets. The supervisor keeps
|
||||
the last good process when a build fails. On a successful rebuild the opt-in production
|
||||
HMR socket reconnects, requests the new document and morphs it into the browser; ordinary
|
||||
`wrnexus preview` and deployed production servers never include that client.
|
||||
|
||||
### API platform
|
||||
|
||||
`wrnexus api generate [app-dir]` (or `api docs`) derives operations from file routes and
|
||||
emits `generated/api/openapi.json`, safe static documentation, Postman collection, curl examples,
|
||||
and TypeScript, JavaScript, Java, Go and Python SDKs. Generate one client with
|
||||
`wrnexus sdk generate <language> [app-dir]`.
|
||||
|
||||
Every command accepts an optional `[app-dir]` (defaults to `.`). Commands that read config or `.env` also accept `--profile=<name>` (see [Profiles](#profiles)).
|
||||
|
||||
| Command | Purpose |
|
||||
@@ -46,10 +83,17 @@ Every command accepts an optional `[app-dir]` (defaults to `.`). Commands that r
|
||||
| `wrnexus db <cmd>` | Database migrations and tooling (see [db](#wrnexus-db)). |
|
||||
| `wrnexus test [app-dir] [--watch]` | Run the app's tests via `bun test` (defaults to the `test` profile). |
|
||||
| `wrnexus profiles [app-dir]` | List config profiles and their `.env` files, marking the active one. |
|
||||
| `wrnexus compatibility check` | Check whether behavior defaults are explicitly pinned and current. |
|
||||
| `wrnexus compatibility explain` | Explain configured, effective, and current compatibility behavior. |
|
||||
| `wrnexus compatibility upgrade` | Back up config and explicitly opt into reviewed current behavior. |
|
||||
| `wrnexus help` | Print usage. |
|
||||
|
||||
`wrnexus g` is an alias for `wrnexus generate`.
|
||||
|
||||
Compatibility upgrades never happen implicitly. New applications pin
|
||||
`compatibilityDate` and `frameworkBehaviour`; existing applications use
|
||||
`wrnexus compatibility explain` before the backed-up, idempotent upgrade command.
|
||||
|
||||
### `wrnexus dev`
|
||||
|
||||
Supervises a child dev-server process (from `@wrnexus/dev-server`). The child owns file watching and HMR: CSS and client-island edits update the live page over a WebSocket with no restart; when a server module changes, the child exits with a restart code and the supervisor respawns it (the browser reconnects and morphs in the new HTML). On startup it regenerates typed DB queries and typed routes (best effort). Use `--port=` to change the port (default `3000`).
|
||||
@@ -76,7 +120,9 @@ bun dist/server.js # PORT env var optional
|
||||
|
||||
### `wrnexus create`
|
||||
|
||||
Scaffolds a new app from an inline (dependency-free) template — `package.json`, `.gitignore`, config, and starter `app/` files. Use `npm run dev` during development, `npm run build && npm start` for production, or `npm run production` to build and start in one command. The generated production server currently requires Bun even when npm is used to manage packages and scripts.
|
||||
Scaffolds a complete v0.8 app from an inline template. The generated project includes strict TypeScript, ESLint and Prettier, editor recommendations, environment templates, database migrations, locales, schemas, tests, API/middleware/realtime examples, Tailwind and Iconify, PWA/mobile defaults, and the framework package kits. Its `wrnexus.config.ts` documents the current imports, types, stores, performance, observability, tenancy, build, navigation, theme, i18n, database, storage, realtime, security, and profile configuration.
|
||||
|
||||
Use `bun run dev` during development, `bun run check` for the complete typecheck/lint/test/format gate, `bun run build && bun run start` for production, or `bun run production` to build and start in one command.
|
||||
|
||||
### `wrnexus update`
|
||||
|
||||
@@ -162,7 +208,7 @@ wrnexus db status --db=analytics
|
||||
|
||||
### `wrnexus workspace` and `wrnexus gateway`
|
||||
|
||||
`workspace <name>` scaffolds a monorepo: several WrNexus apps under `apps/*` and shared libraries under `packages/*`, plus a `wrnexus.workspace.ts` that maps each app to the domains it serves. `gateway` runs every app behind one port and routes by `Host` header, with optional per-app auth and gateway-wide security (trusted hosts, rate limit, security headers, access log).
|
||||
`workspace <name>` scaffolds a monorepo: complete v0.8 apps under `apps/*`, shared libraries under `packages/*`, root TypeScript/lint/format/editor/environment tooling, and a `wrnexus.workspace.ts` that maps each app to the domains it serves. `gateway` runs every app behind one port and routes by `Host` header, with optional per-app auth and gateway-wide security (trusted hosts, rate limit, security headers, access log). Newly added workspace apps use the same current scaffold.
|
||||
|
||||
```bash
|
||||
wrnexus workspace acme
|
||||
@@ -241,6 +287,12 @@ wrnexus update --latest
|
||||
wrnexus doctor
|
||||
```
|
||||
|
||||
Use `wrnexus doctor --fix [app-dir]` to apply conservative repairs before the
|
||||
health check: create missing `app/pages` and a default config, align skewed
|
||||
`@wrnexus/*` dependency ranges, record the current migration marker, and format
|
||||
only syntax-valid `.wrn` files. Invalid JSON or WRN sources are reported/skipped
|
||||
instead of overwritten; repeat runs are idempotent.
|
||||
|
||||
## Profiles
|
||||
|
||||
Pass `--profile=<name>` to `dev`, `build`, `db` (or set `WRNEXUS_PROFILE`) to select a config profile. The CLI publishes `WRNEXUS_PROFILE` so config loaders and the dev child pick it up, and loads that profile's `.env` cascade (`.env`, `.env.local`, `.env.<profile>`, `.env.<profile>.local`) into `process.env`.
|
||||
|
||||
+37
-14
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/cli — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/cli"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/cli",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"cli"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -28,20 +44,27 @@
|
||||
"wrnexus": "./dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.7.0",
|
||||
"@wrnexus/router": "^0.7.0",
|
||||
"@wrnexus/csr": "^0.7.0",
|
||||
"@wrnexus/compiler": "^0.7.0",
|
||||
"@wrnexus/styles": "^0.7.0",
|
||||
"@wrnexus/dev-server": "^0.7.0",
|
||||
"@wrnexus/ui": "^0.7.0",
|
||||
"@wrnexus/validation": "^0.7.0",
|
||||
"@wrnexus/i18n": "^0.7.0",
|
||||
"@wrnexus/db": "^0.7.0",
|
||||
"@wrnexus/plugin": "^0.7.0",
|
||||
"@wrnexus/syntax": "^0.7.0"
|
||||
"@wrnexus/core": "^0.8.6",
|
||||
"@wrnexus/router": "^0.8.6",
|
||||
"@wrnexus/csr": "^0.8.6",
|
||||
"@wrnexus/compiler": "^0.8.6",
|
||||
"@wrnexus/styles": "^0.8.6",
|
||||
"@wrnexus/dev-server": "^0.8.6",
|
||||
"@wrnexus/ui": "^0.8.6",
|
||||
"@wrnexus/validation": "^0.8.6",
|
||||
"@wrnexus/i18n": "^0.8.6",
|
||||
"@wrnexus/mcp": "^0.8.6",
|
||||
"@wrnexus/playground": "^0.8.6",
|
||||
"@wrnexus/db": "^0.8.6",
|
||||
"@wrnexus/authz": "^0.8.6",
|
||||
"@wrnexus/plugin": "^0.8.6",
|
||||
"@wrnexus/syntax": "^0.8.6",
|
||||
"@wrnexus/typecheck": "^0.8.6",
|
||||
"@wrnexus/security": "^0.8.6",
|
||||
"selfsigned": "^5.5.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,44 @@
|
||||
# @wrnexus/compiler
|
||||
|
||||
## Partial-static rendering
|
||||
|
||||
Pages can select `render = "partial-static"` and divide their view with `<Static>` and
|
||||
`<Dynamic>` boundaries. The compiler emits a build-only shell renderer that never evaluates
|
||||
dynamic-boundary children. `wrnexus build` expands static component mounts into
|
||||
`dist/partial-shells.json`, records byte/region evidence in `build-report.json`, and embeds
|
||||
the shell in the production route manifest. At request time the production runtime retains
|
||||
request-aware layouts, locale/theme metadata and security nonces while streaming dynamic
|
||||
regions into stable placeholders.
|
||||
|
||||
> Compiler for the `.wrn` language — tokenizes, parses, and lowers `.wrn` page and component files to TypeScript.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
Production adapters use `analyzeRuntimeImports` before bundling. Edge, worker,
|
||||
service-worker, and browser targets reject Node filesystem, TCP, and process
|
||||
modules with `WRN-RUNTIME-CAPABILITY`. Package manifests can declare supported
|
||||
`wrnexus.runtimes` and required `wrnexus.requires` capabilities; discovery fails
|
||||
when the selected deployment cannot satisfy them.
|
||||
|
||||
## Server actions
|
||||
|
||||
```wrn
|
||||
action createUser using CreateUserSchema {
|
||||
const user = await users.create(input)
|
||||
invalidate("users")
|
||||
return user
|
||||
}
|
||||
|
||||
view {
|
||||
<form @submit="createUser">...</form>
|
||||
}
|
||||
```
|
||||
|
||||
The compiler produces a schema-aware server registry, a fully inferred action
|
||||
client, and progressively enhanced form metadata. The shared runtime performs
|
||||
validation, authentication/permission checks, CSRF verification, serialization,
|
||||
invalidation reporting, and browser lifecycle events.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/compiler` turns `.wrn` source into TypeScript that targets the framework's runtime primitives. A `.wrn` file declares either a `page` (a route) or a `component` (a reusable, prop-driven fragment) with blocks for `state`, `view` (plain HTML), `seo`, `style`, `functions`, `api`, `ssr`/`client` data bindings, and `realtime` websocket handlers. The pipeline is `source → Lexer → parse() → PageAst → generate() → TypeScript`. It is a build/server-side library — the WrNexus dev loader calls it to compile `.wrn` files on the fly, surfacing `ParseError` as a readable error page.
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/compiler — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/compiler"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/compiler",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"compiler"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -21,10 +37,13 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/syntax": "^0.7.0",
|
||||
"@wrnexus/store": "^0.7.0"
|
||||
"@wrnexus/csr": "^0.8.6",
|
||||
"@wrnexus/syntax": "^0.8.6",
|
||||
"@wrnexus/store": "^0.8.6",
|
||||
"@wrnexus/validation": "^0.8.6"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -110,6 +110,37 @@ instances. The default store is process-local memory.
|
||||
(default `console.log`), `requestIdKey` (default `"requestId"`), `now`.
|
||||
`RequestRecord` = `{ time, id, method, path, status, durationMs }`.
|
||||
|
||||
### Resilience — `@wrnexus/core`
|
||||
|
||||
`resilientCall` standardizes cancellation-aware timeouts, controlled retries,
|
||||
fixed or exponential backoff, fallback responses, circuit breaking, and bounded
|
||||
concurrency. Reuse a declarative circuit/bulkhead options object, or an explicit
|
||||
`CircuitBreaker`/`Bulkhead` instance, wherever calls must share health and
|
||||
capacity state.
|
||||
|
||||
```ts
|
||||
import { resilientCall } from "@wrnexus/core";
|
||||
|
||||
const paymentCircuit = { failures: 5, resetAfter: "30s" } as const;
|
||||
|
||||
const status = await resilientCall({
|
||||
timeout: "5s",
|
||||
retries: 3,
|
||||
retryDelay: "100ms",
|
||||
backoff: "exponential",
|
||||
circuitBreaker: paymentCircuit,
|
||||
bulkhead: { concurrency: 20, queue: 100 },
|
||||
run: (signal) => paymentProvider.checkStatus({ signal }),
|
||||
fallback: () => ({ state: "unavailable" }),
|
||||
});
|
||||
```
|
||||
|
||||
`CircuitBreaker.snapshot()` reports `closed`, `open`, or `half-open`, failure
|
||||
and success counts, and the remaining retry delay for health endpoints and
|
||||
development tooling. Fail-fast conditions use stable `WRN-RESILIENCE-*` codes.
|
||||
Core's existing `HealthRegistry`, `withIdempotency`, and pluggable stores/locks
|
||||
cover health reporting, idempotent requests, and distributed coordination.
|
||||
|
||||
### Caching — `@wrnexus/core`
|
||||
|
||||
| Export | Kind | Notes |
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/core — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/core"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/core",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"core"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -29,6 +45,7 @@
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
+44
-10
@@ -1,5 +1,34 @@
|
||||
# @wrnexus/csr
|
||||
|
||||
## Navigation state preservation
|
||||
|
||||
Pages can opt into restoration across client navigation:
|
||||
|
||||
```wrn
|
||||
page Users {
|
||||
navigation {
|
||||
preserve = ["filters", "pagination", "scroll", "tabs", "expanded"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Form-like categories restore named inputs, selects, and textareas. Password,
|
||||
file, hidden, CSRF/token/secret/credential fields, and elements marked
|
||||
`data-no-preserve` are never saved. For tab, expanded, or component UI state,
|
||||
mark stable elements with `data-wrn-preserve="key"`; their value and ARIA
|
||||
selected/expanded state are restored. State is scoped to pathname plus query.
|
||||
|
||||
## Typed server actions
|
||||
|
||||
`createActionClient<Input, Output>(route, name)` supports programmatic calls.
|
||||
Schema-backed WRN actions also export `__wrnexusActionClients`, whose input and
|
||||
output are inferred automatically. Enhanced forms expose
|
||||
`data-wrn-action-state="pending|success|error"` and dispatch bubbling
|
||||
`wrnexus:action:optimistic`, `:pending`, `:success`, and `:error` events.
|
||||
Success details contain returned data and invalidated cache tags; error details
|
||||
contain field errors. Without JavaScript, the same form posts to its page and
|
||||
receives a 303 redirect or accessible validation response.
|
||||
|
||||
> The browser-side client runtime for WrNexus — generic, self-contained JS that hydrates server-rendered pages with reactivity, client-side navigation, and realtime rooms.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
@@ -49,16 +78,21 @@ getRealtimeRuntime(): string // → REALTIME_RUNTIME
|
||||
|
||||
Applied to any subtree containing `data-scope`. Expressions are parsed by a tiny eval-free evaluator, so a strict CSP with no `unsafe-eval` works.
|
||||
|
||||
| Directive | Purpose |
|
||||
| -------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `data-scope="count: 0, name: 'x'"` | Declare reactive state on a subtree |
|
||||
| `data-on-<event>="count++"` | Run a statement in scope on a DOM event |
|
||||
| `data-text="expr"` | Bind an element's `textContent` to an expression |
|
||||
| `data-show="expr"` | Toggle visibility (`display`) on truthiness |
|
||||
| `data-for="item in list"` (opt. index and `key item.id`) | Per-item rendering; stable keys preserve DOM identity during reorder |
|
||||
| `data-key="item.id"` | Alternative key declaration for `data-for` templates |
|
||||
| `{{expr}}` or `{expr}` | Interpolation inside text nodes and attribute values |
|
||||
| `data-wrnexus-csr="id"` | Target for a generated CSR fetch binding (fetches `/__wrnexus/csr?...`) |
|
||||
| Directive | Purpose |
|
||||
| ---------------------------------- | ---------------------------------------------------- |
|
||||
| `data-scope="count: 0, name: 'x'"` | Declare reactive state on a subtree |
|
||||
| `data-on-<event>="count++"` | Run a statement in scope on a DOM event |
|
||||
| `data-text="expr"` | Bind an element's `textContent` to an expression |
|
||||
| `data-show="expr"` | Toggle visibility while preserving interactive state |
|
||||
|
||||
Compiled conditional rendering and dynamic component cases omit inactive elements from the live
|
||||
DOM. `data-show` is a visibility directive for stateful controls and keeps its element mounted.
|
||||
Neither mechanism is authorization: never place secrets in client-rendered branches. Authorize on
|
||||
the server and return only data the current request may access.
|
||||
| `data-for="item in list"` (opt. index and `key item.id`) | Per-item rendering; stable keys preserve DOM identity during reorder |
|
||||
| `data-key="item.id"` | Alternative key declaration for `data-for` templates |
|
||||
| `{{expr}}` or `{expr}` | Interpolation inside text nodes and attribute values |
|
||||
| `data-wrnexus-csr="id"` | Target for a generated CSR fetch binding (fetches `/__wrnexus/csr?...`) |
|
||||
|
||||
Supported expression features: literals, identifiers, member access (`a.b`, `a[b]`), function/method calls, arrays, objects, arithmetic, comparison, equality, logical (`&& ||`), unary (`! - +`), and ternary. Statements support `++`/`--`, assignment operators (`= += -= *= /= %=`), and bare expression/method calls. Rendering is dependency-tracked: a signal change only re-runs the renderers that actually read it.
|
||||
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/csr — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/csr"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/csr",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"csr"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -21,9 +37,10 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.7.0"
|
||||
"@wrnexus/core": "^0.8.6"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
+75
-4
@@ -1,5 +1,14 @@
|
||||
# @wrnexus/db
|
||||
|
||||
## Rollout-safe migrations
|
||||
|
||||
Run `wrnexus db check` in CI before deployment. The analyzer reports stable
|
||||
diagnostics for drops, renames, type changes, new/enforced required columns,
|
||||
and potentially blocking index creation, with an expand/backfill/switch/contract
|
||||
recommendation. `wrnexus db migrate` blocks critical issues in pending
|
||||
migrations. `--allow-breaking` is an explicit operator override; already-applied
|
||||
migrations do not block later releases.
|
||||
|
||||
> The database layer for WrNexus: TS models as the single source of truth for DDL, validation, and result typing, plus a driver-based `Db` client, migrations, and a sqlc-style query generator.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
@@ -85,7 +94,8 @@ A `Driver` (implemented by adapters) exposes `dialect`, `query(sql, params?)`,
|
||||
- `exec(sql, params?)` — `Promise<ExecResult>` (`{ changes, lastInsertId? }`).
|
||||
- `tx(fn)` — run `fn(db)` in a transaction; rolls back on throw. Nested `tx` reuses the current transaction.
|
||||
- `createTable(model)` — runs the model's `CREATE TABLE IF NOT EXISTS` DDL.
|
||||
- `close()`.
|
||||
- `close()` — idempotently rejects new top-level work, drains active queries and
|
||||
transactions, then closes the underlying pool.
|
||||
|
||||
Every query is parameterized (positional params). `createTableSql(model, dialect, ifNotExists?)`
|
||||
renders `CREATE TABLE` directly; `Dialect` is `"sqlite" | "postgres" | "mysql"`.
|
||||
@@ -98,7 +108,9 @@ A process-wide registry the runtime configures at startup from `wrnexus.config.t
|
||||
- `setDb(db)` / `setDb(name, db)` — set the default or a named connection.
|
||||
- `registerDb(name, db)` — alias of `setDb(name, db)`.
|
||||
- `getDb(name = "default")` — the default or a named `Db` (throws if unconfigured).
|
||||
- `hasDb(name?)`, `databaseNames()`, `closeDatabases()`.
|
||||
- `hasDb(name?)`, `databaseNames()`, `closeDatabases()`. Registry shutdown clears
|
||||
registrations first, attempts every open database, and reports close failures
|
||||
together with `AggregateError` instead of leaking later pools.
|
||||
|
||||
```ts
|
||||
const users = await getDb().all("SELECT * FROM users");
|
||||
@@ -121,8 +133,8 @@ Migrations are `.sql` files (in e.g. `app/db/migrations`), each split into
|
||||
- `parseMigration(name, content)` → `Migration` (`{ name, up, down }`).
|
||||
- `loadMigrations(dir)` — parse all `.sql` files, sorted by filename.
|
||||
- `appliedMigrations(db)` — applied names, oldest first.
|
||||
- `migrate(db, dir)` — apply all pending (each in a transaction); returns applied names.
|
||||
- `rollback(db, dir)` — roll back the most recent; returns its name or `null`.
|
||||
- `migrate(db, dir, options?)` — apply all pending (each in a transaction); returns applied names.
|
||||
- `rollback(db, dir, options?)` — roll back the most recent; returns its name or `null`.
|
||||
- `status(db, dir)` — `{ name, applied }[]` for every migration file.
|
||||
- `scaffoldMigration(dir, name, dialect, models?)` — write a new numbered migration; with `models` it generates `CREATE`/`DROP` for every table (referenced tables first via topological sort). Returns the file path.
|
||||
|
||||
@@ -199,6 +211,21 @@ const pageTwo = await paginate(
|
||||
);
|
||||
```
|
||||
|
||||
For deployments, `{ dryRun: true }` reports pending names without applying
|
||||
their SQL, `signal` cancels safely between migrations, and the default
|
||||
database-backed lock prevents concurrent deploy runners. A live lock produces
|
||||
`WRN-DB-MIGRATION-LOCKED`; crash-stale locks expire after `lockTimeoutMs` (five
|
||||
minutes by default). Disable it with `lock: false` only when an external deploy
|
||||
coordinator already guarantees exclusivity.
|
||||
|
||||
```ts
|
||||
const pending = await migrate(db, "app/db/migrations", { dryRun: true });
|
||||
await migrate(db, "app/db/migrations", {
|
||||
signal: shutdownController.signal,
|
||||
lockTimeoutMs: 10 * 60_000,
|
||||
});
|
||||
```
|
||||
|
||||
MongoDB (document API):
|
||||
|
||||
```ts
|
||||
@@ -226,3 +253,47 @@ SQL driver — use `@wrnexus/db/mongo` directly.
|
||||
`wrnexus.config.ts`.
|
||||
- The `mongodb` npm package is an optional, lazily-imported peer — install it
|
||||
only if you use `@wrnexus/db/mongo`. The core package stays dependency-free.
|
||||
|
||||
## Repository and transaction helpers
|
||||
|
||||
Repositories accept an immutable equality `scope`, normally `{ column: "tenant_id", value:
|
||||
ctx.tenant.id }`. The scope is injected into every read, count, update and delete, while create
|
||||
overwrites any caller-supplied tenant value. This makes accidental cross-tenant CRUD through the
|
||||
repository API fail closed.
|
||||
|
||||
```ts
|
||||
import { createRepository, retryTransaction, databaseHealth, batch } from "@wrnexus/db";
|
||||
|
||||
const users = createRepository<User>(db, {
|
||||
table: "users",
|
||||
allowedColumns: ["email", "name", "active"],
|
||||
});
|
||||
|
||||
const user = await users.require(42);
|
||||
await users.update(42, { active: true });
|
||||
```
|
||||
|
||||
Repository identifiers are validated, writes may be restricted to an allowlist, and values always use query parameters. Infrastructure packages remain helper-only and do not add UI dependencies to server code.
|
||||
|
||||
## 0.8 repository and transaction helpers
|
||||
|
||||
```ts
|
||||
import { createRepository, databaseHealth, firstOrThrow, retryTransaction } from "@wrnexus/db";
|
||||
|
||||
const usersRepo = createRepository<User>(db, {
|
||||
table: "users",
|
||||
allowedColumns: ["email", "name", "active"],
|
||||
maxListLimit: 250,
|
||||
});
|
||||
|
||||
const users = await usersRepo.all({
|
||||
orderBy: "name",
|
||||
direction: "asc",
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
```
|
||||
|
||||
Repository SQL identifiers are validated and values remain parameterized. Placeholder generation is dialect-aware: PostgreSQL uses `$1`, `$2`, and SQLite/MySQL use `?`. List limits are bounded.
|
||||
|
||||
`retryTransaction()` retries recognized serialization, deadlock, and database-lock errors by default. Supply `shouldRetry` for application-specific retryable errors; ordinary validation or business errors are not retried automatically.
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/db — part of the WrNexus framework.",
|
||||
"description": "Typed database drivers, migrations, instrumentation, repositories, pagination, and transaction helpers.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/db"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/db",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"db"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -45,6 +61,7 @@
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -66,6 +66,12 @@ interface RunningServer {
|
||||
|
||||
In development, `startServer` also connects `app/db/migrations` (and `app/db/<name>/migrations`) and auto-applies migrations, then starts an in-process file watcher. CSS edits hot-swap live. Page, component, layout, API, middleware, realtime, schema, locale, and public-asset edits invalidate only their cached modules, rescan routes where necessary, and morph fresh HTML through the existing HMR WebSocket. The server process and active gateway stay running.
|
||||
|
||||
`getWrnCompileMetrics()` exposes cumulative content-addressed compiler cache
|
||||
`hits`, `misses`, successful `compilations`, `errors`, `totalDurationMs`, and
|
||||
`lastDurationMs` for the DevToolbar or custom diagnostics. Tests and embedded
|
||||
servers can call `resetWrnCompileMetrics()` to establish a fresh measurement
|
||||
window.
|
||||
|
||||
### `createHandlers(deps)`
|
||||
|
||||
The core runtime shared by dev and prod. It handles CORS preflight, `/healthz` and `/__wrnexus/health`, request-body size limits (413), HMR socket upgrades (`/__wrnexus/hmr`), realtime WebSocket upgrades (`defineRoom` default export or a raw `websocket` export), the middleware pipeline, API routes (`/api/*`), framework assets (`/__wrnexus/*`), public assets, and full SSR page rendering (component mounts, layouts, slots, i18n markers, per-page script selection, ETag/304, gzip).
|
||||
@@ -296,7 +302,9 @@ Pages get only the scripts they use: `nav.js` always, `reactive.js` when a page
|
||||
|
||||
- **Bun-only.** Uses `Bun.serve` (HTTP + WebSocket), `Bun.file`, and `Bun.gzipSync`. The full app also relies on `bun:sqlite` / `Bun.SQL` via `@wrnexus/db`.
|
||||
- Orchestrates the whole framework: `@wrnexus/core` (context, security, realtime registry), `@wrnexus/router`, `@wrnexus/ssr` (`renderDocument`), `@wrnexus/csr` (client runtimes), `@wrnexus/compiler` (`.wrn` → TS), `@wrnexus/styles`, `@wrnexus/ui`, `@wrnexus/validation`, `@wrnexus/i18n`, `@wrnexus/db`, and `@wrnexus/pubsub` (Redis-backed cross-process realtime).
|
||||
- `.wrn` files are compiled to TypeScript into a hidden sibling `.wrnexus/` cache dir and dynamically imported; the module cache means each edited server module needs a fresh process (dev) — hence the restart-on-change model.
|
||||
- `.wrn` files compile into a content-addressed hidden `.wrnexus/` cache. Targeted
|
||||
invalidation gives changed modules a fresh import identity without restarting
|
||||
the development server.
|
||||
- Responses are gzipped when the client accepts it and the body is a buffered, compressible payload ≥ 1 KB; streaming/SSE responses opt out via `Cache-Control: no-transform`.
|
||||
</content>
|
||||
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/dev-server — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/dev-server"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/dev-server",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"dev-server"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -25,25 +41,30 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.7.0",
|
||||
"@wrnexus/dev-toolbar": "^0.7.0",
|
||||
"@wrnexus/router": "^0.7.0",
|
||||
"@wrnexus/ssr": "^0.7.0",
|
||||
"@wrnexus/csr": "^0.7.0",
|
||||
"@wrnexus/compiler": "^0.7.0",
|
||||
"@wrnexus/styles": "^0.7.0",
|
||||
"@wrnexus/ui": "^0.7.0",
|
||||
"@wrnexus/validation": "^0.7.0",
|
||||
"@wrnexus/i18n": "^0.7.0",
|
||||
"@wrnexus/db": "^0.7.0",
|
||||
"@wrnexus/pubsub": "^0.7.0",
|
||||
"@wrnexus/uploader": "^0.7.0",
|
||||
"@wrnexus/plugin": "^0.7.0",
|
||||
"@wrnexus/store": "^0.7.0",
|
||||
"@wrnexus/security": "^0.7.0",
|
||||
"@wrnexus/observability": "^0.7.0"
|
||||
"@wrnexus/authz": "^0.8.6",
|
||||
"@wrnexus/rpc": "^0.8.6",
|
||||
"@wrnexus/core": "^0.8.6",
|
||||
"@wrnexus/dev-toolbar": "^0.8.6",
|
||||
"@wrnexus/router": "^0.8.6",
|
||||
"@wrnexus/ssr": "^0.8.6",
|
||||
"@wrnexus/csr": "^0.8.6",
|
||||
"@wrnexus/compiler": "^0.8.6",
|
||||
"@wrnexus/styles": "^0.8.6",
|
||||
"@wrnexus/ui": "^0.8.6",
|
||||
"@wrnexus/validation": "^0.8.6",
|
||||
"@wrnexus/i18n": "^0.8.6",
|
||||
"@wrnexus/db": "^0.8.6",
|
||||
"@wrnexus/pubsub": "^0.8.6",
|
||||
"@wrnexus/uploader": "^0.8.6",
|
||||
"@wrnexus/plugin": "^0.8.6",
|
||||
"@wrnexus/store": "^0.8.6",
|
||||
"@wrnexus/security": "^0.8.6",
|
||||
"@wrnexus/observability": "^0.8.6",
|
||||
"@wrnexus/cache": "^0.8.6",
|
||||
"@wrnexus/pwa": "^0.8.6"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ Development-only page quality toolbar for WRNexusJS.
|
||||
- Runtime, resource and unhandled promise error capture
|
||||
- Accessibility, SEO, image, media, color, HTML, form, link, responsive and security checks
|
||||
- Performance and network observations
|
||||
- First-class application tabs for runtime, stores, cache, accessibility, SEO, performance,
|
||||
security, images, links, and JavaScript
|
||||
- Plugin-contributed applications with badges, descriptions, issue feeds, and structured data
|
||||
- Element highlighting and issue filtering
|
||||
- Server-side issue collector
|
||||
- Development-only asset strings for direct serving by `@wrnexus/dev-server`
|
||||
@@ -21,3 +24,7 @@ Serve `DEV_TOOLBAR_RUNTIME` at `/__wrnexus/dev-toolbar.js` and `DEV_TOOLBAR_CSS`
|
||||
```
|
||||
|
||||
The browser runtime exposes `window.__wrnexusDevToolbar`.
|
||||
|
||||
Plugin panels returned through `devToolbarPanels()` are automatically added to the application
|
||||
strip. Their issue category is filterable, and structured `data` is rendered as escaped diagnostic
|
||||
content so a plugin never needs to inject toolbar HTML.
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-toolbar",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/dev-toolbar — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/dev-toolbar"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/dev-toolbar",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"dev-toolbar"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -37,6 +53,7 @@
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,80 +1,67 @@
|
||||
# @wrnexus/encryption
|
||||
|
||||
> Dependency-free crypto helpers for WrNexus: authenticated symmetric encryption (AES-256-GCM), hashing, and HMAC signing.
|
||||
Authenticated encryption, hashing, HMAC, key rotation, and optional encrypted HTTP exchanges for WRNexusJS.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
## Core helpers
|
||||
|
||||
## Overview
|
||||
- `generateKey()` — random 256-bit AES key encoded as base64.
|
||||
- `deriveKey(password, salt)` — PBKDF2-derived AES key.
|
||||
- `encrypt(plaintext, key)` / `decrypt(payload, key)` — AES-256-GCM.
|
||||
- `sha256(data)` — SHA-256 digest.
|
||||
- `hmacSign(data, secret)` / `hmacVerify(...)` — HMAC-SHA256.
|
||||
- `createKeyring(keys)` — active/previous key management.
|
||||
- `seal()` / `open()` — versioned ciphertext with key ID.
|
||||
|
||||
This package provides small, focused cryptographic primitives for server-side use: encrypting secrets/tokens/database fields at rest with AES-256-GCM, deriving keys from passwords via PBKDF2, computing SHA-256 digests, and signing/verifying payloads with HMAC-SHA256. It is built entirely on the standard **Web Crypto API** (`crypto.subtle`) plus `btoa`/`atob` and `TextEncoder`/`TextDecoder` — no third-party dependencies. Reach for it whenever you need to protect sensitive values or verify webhook signatures. All functions are `async` (Web Crypto is promise-based).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/encryption
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
## API
|
||||
|
||||
All keys are exchanged as **base64 strings** and all digests/signatures as **hex strings**.
|
||||
|
||||
| Export | Signature | Description |
|
||||
| ------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `generateKey` | `() => Promise<string>` | Generate a random 256-bit AES key, base64-encoded. Store it as a secret. |
|
||||
| `deriveKey` | `(password: string, salt: string) => Promise<string>` | Derive a base64 AES-256 key from a password + salt using PBKDF2 (100,000 iterations, SHA-256). |
|
||||
| `encrypt` | `(plaintext: string, key: string) => Promise<string>` | AES-256-GCM encrypt a string. Returns base64 of `iv(12 bytes) ‖ ciphertext+tag`. A fresh random IV is used each call. |
|
||||
| `decrypt` | `(payload: string, key: string) => Promise<string>` | Decrypt a value produced by `encrypt`. Throws if the key is wrong or the data was tampered with. |
|
||||
| `sha256` | `(data: string) => Promise<string>` | SHA-256 hex digest of a string (e.g. content hashing, dedup keys). |
|
||||
| `hmacSign` | `(data: string, secret: string) => Promise<string>` | HMAC-SHA256 hex signature of `data` with `secret` (e.g. signing webhooks). |
|
||||
| `hmacVerify` | `(data: string, secret: string, signature: string) => Promise<boolean>` | Constant-time verify of an HMAC-SHA256 hex signature. |
|
||||
|
||||
Notes:
|
||||
|
||||
- `generateKey` produces a 32-byte (256-bit) key via `crypto.getRandomValues`.
|
||||
- `encrypt`/`decrypt` require a base64-encoded 256-bit key; anything else throws `"Encryption key must be a base64 256-bit key"`.
|
||||
- `decrypt` throws `"Invalid ciphertext"` if the payload is shorter than the 12-byte IV, and the underlying Web Crypto call throws on any authentication (tag) mismatch.
|
||||
- `hmacVerify` compares in constant time (length check plus XOR accumulation) to avoid timing leaks.
|
||||
|
||||
## Usage
|
||||
|
||||
Symmetric encryption of a secret at rest:
|
||||
## Encrypted HTTP envelope
|
||||
|
||||
```ts
|
||||
import { generateKey, encrypt, decrypt } from "@wrnexus/encryption";
|
||||
import {
|
||||
createEncryptedRequest,
|
||||
createKeyring,
|
||||
createMemoryReplayStore,
|
||||
decryptEncryptedResponse,
|
||||
encryptedExchange,
|
||||
} from "@wrnexus/encryption";
|
||||
|
||||
const key = await generateKey(); // store this safely (env/secret manager)
|
||||
const keyring = createKeyring([{ id: "2026-08", secret: process.env.API_BODY_KEY!, active: true }]);
|
||||
|
||||
const box = await encrypt("card #1234", key); // opaque base64 string, safe to persist
|
||||
const plain = await decrypt(box, key); // "card #1234"
|
||||
const replayStore = createMemoryReplayStore();
|
||||
|
||||
// Server middleware.
|
||||
app.use(
|
||||
encryptedExchange({
|
||||
keyring,
|
||||
replayStore,
|
||||
maxAgeMs: 60_000,
|
||||
maxBodyBytes: 1_048_576,
|
||||
}),
|
||||
);
|
||||
|
||||
// Controlled service/native client.
|
||||
const request = await createEncryptedRequest(
|
||||
"https://api.example.com/private/report",
|
||||
{ reportId: "report-1" },
|
||||
{ method: "POST", keyring },
|
||||
);
|
||||
const response = await fetch(request);
|
||||
const result = await decryptEncryptedResponse(response, request, { keyring });
|
||||
```
|
||||
|
||||
Deriving a key from a user password instead of a random key:
|
||||
The envelope binds authenticated ciphertext to:
|
||||
|
||||
```ts
|
||||
import { deriveKey, encrypt } from "@wrnexus/encryption";
|
||||
- HTTP method
|
||||
- URL path and query
|
||||
- request ID
|
||||
- timestamp and expiry window
|
||||
- encryption key ID
|
||||
- optional replay-store consumption
|
||||
|
||||
const key = await deriveKey("correct horse battery staple", "per-user-salt");
|
||||
const box = await encrypt("secret note", key);
|
||||
```
|
||||
`encryptedBody()` decrypts request bodies only. `encryptedExchange()` also encrypts successful downstream responses while allowing application exceptions to propagate normally. `encryptedFetch()` provides a convenient controlled-client call.
|
||||
|
||||
Hashing and webhook signature verification:
|
||||
## Security boundary
|
||||
|
||||
```ts
|
||||
import { sha256, hmacSign, hmacVerify } from "@wrnexus/encryption";
|
||||
Encrypted HTTP bodies **do not replace TLS/HTTPS**. Always use HTTPS.
|
||||
|
||||
const digest = await sha256("some content"); // 64-char hex string
|
||||
This layer is appropriate for service-to-service traffic, native/mobile applications, controlled agents, and selected fields protected with server-managed keys. It cannot conceal data from an end user when browser JavaScript receives the decryption key. Never ship a long-lived server encryption key to a browser.
|
||||
|
||||
const signature = await hmacSign(rawBody, webhookSecret);
|
||||
const ok = await hmacVerify(rawBody, webhookSecret, incomingSignatureHeader);
|
||||
if (!ok) throw new Error("Invalid webhook signature");
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only.** Relies on the Web Crypto API (`crypto.subtle`, `crypto.getRandomValues`) and the global `btoa`/`atob`, `TextEncoder`/`TextDecoder` — all available in Bun's runtime.
|
||||
- **No dependencies.** The package has an empty dependency set; nothing is bundled beyond standard runtime APIs.
|
||||
- Algorithms: AES-256-GCM (encryption), PBKDF2 with 100k SHA-256 iterations (key derivation), SHA-256 (digest), HMAC-SHA256 (signing).
|
||||
- Keep generated/derived keys and HMAC secrets out of source control; treat them as first-class secrets.
|
||||
Use a shared replay store such as Redis in multi-instance deployments. The memory replay store is process-local.
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/encryption",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/encryption — part of the WrNexus framework.",
|
||||
"description": "Authenticated encryption, key rotation, hashing, and optional application-layer encrypted HTTP envelopes.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/encryption"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/encryption",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"encryption"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -20,7 +36,11 @@
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.8.6"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/helpers",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/helpers"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/helpers",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"helpers"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -21,9 +37,10 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.7.0"
|
||||
"@wrnexus/core": "^0.8.6"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
+60
-134
@@ -1,167 +1,93 @@
|
||||
# @wrnexus/i18n
|
||||
|
||||
> Per-request translations plus locale-aware number, date, and currency formatting for WrNexus apps.
|
||||
Recursive locale loading, fallback resolution, SSR/browser translations, locale formatting, and language UI blocks for WRNexusJS.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
## Locale files
|
||||
|
||||
## Overview
|
||||
Both layouts can be used together:
|
||||
|
||||
`@wrnexus/i18n` loads locale files from `app/locales/<lang>.json`, resolves the
|
||||
active language for each request (cookie → `Accept-Language` → default), and
|
||||
builds a `t(key, params)` translator used both in server code and in `.wrn`
|
||||
views. It also ships Intl-based formatting helpers and a tiny client runtime that
|
||||
wires up a language switcher. Translation lookup, language resolution, and HTML
|
||||
marker rewriting run server-side; only the small `I18N_RUNTIME` snippet runs in
|
||||
the browser.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/i18n
|
||||
```text
|
||||
app/locales/en.json
|
||||
app/locales/en/common.json
|
||||
app/locales/en/auth.json
|
||||
app/locales/mr/common.json
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
## API
|
||||
|
||||
### Loading & resolving
|
||||
|
||||
| Export | Signature | Description |
|
||||
| ------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `loadLocales` | `(dir: string) => Record<string, Messages>` | Reads every `<lang>.json` in `dir` into a `{ lang: messages }` map. Missing dir → `{}`; a bad file is warned and skipped. |
|
||||
| `resolveI18n` | `(messages: Record<string, Messages>, config?: I18nConfig) => ResolvedI18n` | Merges loaded messages + config into a resolved bundle (default lang, supported langs, messages). |
|
||||
| `resolveLang` | `(i18n: ResolvedI18n, cookieValue: string \| undefined, acceptLanguage: string \| null) => string` | Picks the active language: matching cookie → best `Accept-Language` tag (falls back to base tag, e.g. `en-US` → `en`) → `i18n.default`. |
|
||||
| `makeT` | `(i18n: ResolvedI18n, lang: string) => TFunction` | Builds a translator resolving current language → default → the key itself, with `{param}` interpolation. |
|
||||
|
||||
### Types & constants
|
||||
|
||||
| Export | Kind | Notes |
|
||||
| -------------- | ----------- | ------------------------------------------------------------------------------ |
|
||||
| `Messages` | `type` | `Record<string, unknown>` — a locale's messages (supports nested/dotted keys). |
|
||||
| `I18nConfig` | `interface` | `{ default?: string; locales?: string[] }`. |
|
||||
| `ResolvedI18n` | `interface` | `{ default: string; langs: string[]; messages: Record<string, Messages> }`. |
|
||||
| `LANG_COOKIE` | `const` | `"wire-lang"` — the cookie the language is read from / written to. |
|
||||
| `I18N_JS_HREF` | `const` | `"/__wrnexus/i18n.js"` — URL the client runtime is served at. |
|
||||
|
||||
### HTML & client runtime
|
||||
|
||||
| Export | Signature | Description |
|
||||
| ---------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `translateHtml` | `(html: string, t: TFunction) => string` | Rewrites markers in rendered HTML: `t:<attr>="key"` → `<attr>="<translation>"` (attribute-escaped) and `<tag data-t="key">…</tag>` → element text becomes the translation (HTML-escaped). No-op unless a marker is present. |
|
||||
| `renderI18nData` | `(i18n: ResolvedI18n, lang: string) => string` | JS snippet setting `window.__wireI18n = { lang, langs, default }` for the client switcher. |
|
||||
| `I18N_RUNTIME` | `const string` | Browser IIFE that binds `[data-wire-lang-set="es"]` clicks and `select[data-wire-lang]` changes to set the `wire-lang` cookie and reload. Exposes `window.__wireLang.set(lang)`. |
|
||||
|
||||
### Formatting helpers (re-exported from `./format.ts`)
|
||||
|
||||
| Export | Signature | Example |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| `formatNumber` | `(value: number, lang: string, options?: Intl.NumberFormatOptions) => string` | `1234.5 → "1,234.5"` |
|
||||
| `formatCurrency` | `(value: number, currency: string, lang: string) => string` | `9.99, "USD" → "$9.99"` |
|
||||
| `formatDate` | `(value: Date \| number \| string, lang: string, options?: Intl.DateTimeFormatOptions) => string` | defaults to `{ dateStyle: "medium" }` |
|
||||
| `formatRelativeTime` | `(value: number, unit: Intl.RelativeTimeFormatUnit, lang: string) => string` | `-3, "day" → "3 days ago"` (`numeric: "auto"`) |
|
||||
| `plural` | `(count: number, forms: Partial<Record<Intl.LDMLPluralRule, string>>, lang: string) => string` | picks CLDR form; `#` is replaced by `count` |
|
||||
|
||||
## Usage
|
||||
|
||||
### Server: load, resolve, translate
|
||||
Namespaced files become keys such as `common.save` and `auth.signIn`.
|
||||
|
||||
```ts
|
||||
import {
|
||||
loadLocales,
|
||||
resolveI18n,
|
||||
resolveLang,
|
||||
makeT,
|
||||
translateHtml,
|
||||
LANG_COOKIE,
|
||||
} from "@wrnexus/i18n";
|
||||
import { loadLocales, makeT, resolveI18n, resolveLang } from "@wrnexus/i18n";
|
||||
|
||||
// app/locales/en.json, app/locales/es.json
|
||||
const messages = loadLocales("app/locales");
|
||||
const i18n = resolveI18n(messages, { default: "en", locales: ["en", "es"] });
|
||||
const i18n = resolveI18n(loadLocales("app/locales", { strict: true }), {
|
||||
default: "en",
|
||||
locales: ["en", "mr", "hi"],
|
||||
fallbacks: { "mr-IN": ["mr", "en"] },
|
||||
cookie: { name: "wire-lang", sameSite: "Lax", secure: true },
|
||||
});
|
||||
|
||||
// Per request:
|
||||
const lang = resolveLang(i18n, req.cookies?.[LANG_COOKIE], req.headers.get("accept-language"));
|
||||
const lang = resolveLang(i18n, cookieValue, request.headers.get("accept-language"));
|
||||
const t = makeT(i18n, lang);
|
||||
|
||||
t("nav.home"); // dotted key → "Home"
|
||||
t("greeting", { name: "Ada" }); // "Hello, {name}" → "Hello, Ada"
|
||||
|
||||
// After rendering a .wrn view, resolve translation markers in the HTML:
|
||||
const finalHtml = translateHtml(renderedHtml, t);
|
||||
t("common.hello", { name: "Ajay" });
|
||||
```
|
||||
|
||||
`app/locales/en.json`:
|
||||
## Resolution behavior
|
||||
|
||||
```json
|
||||
{
|
||||
"nav": { "home": "Home" },
|
||||
"greeting": "Hello, {name}"
|
||||
}
|
||||
```
|
||||
- normalized BCP-47-style locale names
|
||||
- cookie preference
|
||||
- weighted `Accept-Language`
|
||||
- wildcard language ranges
|
||||
- regional base fallback
|
||||
- explicit fallback chains
|
||||
- configured default language
|
||||
- automatic RTL for Arabic, Hebrew, Persian, Urdu, and related languages
|
||||
|
||||
### Views: translation markers
|
||||
Locale JSON is size-limited and rejects prototype-pollution keys. Recursive namespace collisions are resolved safely.
|
||||
|
||||
## Views and runtime
|
||||
|
||||
```html
|
||||
<h1 data-t="nav.home">Home</h1>
|
||||
<h1 data-t="dashboard.title">Dashboard</h1>
|
||||
<input t:placeholder="search.placeholder" />
|
||||
```
|
||||
|
||||
`translateHtml` replaces the element text for `data-t` and the attribute value for
|
||||
any `t:<attr>` (e.g. `t:placeholder`, `t:aria-label`).
|
||||
Text and translated attributes are resolved during SSR. Active/fallback messages are serialized safely for the language runtime, which rebinds `data-t` markers after client navigation.
|
||||
|
||||
### Client: language switcher
|
||||
Enable `i18nPlugin()` to use:
|
||||
|
||||
```ts
|
||||
import { renderI18nData, I18N_RUNTIME, I18N_JS_HREF } from "@wrnexus/i18n";
|
||||
- `<LanguageSwitcher />`
|
||||
- `<LocaleStatus />`
|
||||
|
||||
// In the document <head>:
|
||||
const head = `
|
||||
<script>${renderI18nData(i18n, lang)}</script>
|
||||
<script src="${I18N_JS_HREF}"></script>
|
||||
`;
|
||||
`LanguageSwitcher` renders a native `select[data-wire-lang]`. The packaged runtime validates the
|
||||
selection against the configured locales, writes the configured language cookie, updates the
|
||||
document `lang`/`dir` attributes, emits `wrnexus:language-change`, and reloads so the next SSR
|
||||
request uses the same cookie. No application-owned browser script is required.
|
||||
|
||||
// Serve I18N_RUNTIME at I18N_JS_HREF; then in markup:
|
||||
// <button data-wire-lang-set="es">Español</button>
|
||||
// <select data-wire-lang>…</select>
|
||||
```
|
||||
## Formatting
|
||||
|
||||
### Formatting
|
||||
- `formatNumber`
|
||||
- `formatCurrency`
|
||||
- `formatDate`
|
||||
- `formatRelativeTime`
|
||||
- `plural`
|
||||
- `createLocaleFormatter`
|
||||
- `translationCoverage`
|
||||
Localization tooling can extract statically discoverable `t("key")`,
|
||||
`i18n.t("key")`, and `data-i18n="key"` usage, compare every locale with a
|
||||
reference, and create layout-stressing pseudo-locales:
|
||||
|
||||
```ts
|
||||
import {
|
||||
formatNumber,
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
formatRelativeTime,
|
||||
plural,
|
||||
auditLocaleKeys,
|
||||
createPseudoLocale,
|
||||
extractTranslationKeysFromFiles,
|
||||
} from "@wrnexus/i18n";
|
||||
|
||||
formatNumber(1234.5, lang); // "1,234.5"
|
||||
formatCurrency(9.99, "USD", lang); // "$9.99"
|
||||
formatDate(Date.now(), lang); // "Jul 4, 2026"
|
||||
formatRelativeTime(-3, "day", lang); // "3 days ago"
|
||||
plural(2, { one: "# item", other: "# items" }, lang); // "2 items"
|
||||
const used = extractTranslationKeysFromFiles(sourceFiles);
|
||||
const coverage = auditLocaleKeys(messages, "en");
|
||||
const enXA = createPseudoLocale(messages.en);
|
||||
const arXB = createPseudoLocale(messages.en, { rtl: true });
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
`resolveI18n` accepts an `I18nConfig`:
|
||||
|
||||
- `default` — fallback language; used when nothing else matches. Ignored if it has
|
||||
no loaded messages, in which case the first supported language is used.
|
||||
- `locales` — explicit supported-language list; defaults to the loaded locale names.
|
||||
|
||||
Language resolution order at request time (`resolveLang`): a supported `wire-lang`
|
||||
cookie value → the first matching `Accept-Language` tag (or its base subtag) → the
|
||||
resolved default.
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only.** Locale loading uses `node:fs` (`existsSync`, `readdirSync`,
|
||||
`readFileSync`) and `node:path`; formatting relies on the platform `Intl` APIs.
|
||||
- Works with [`@wrnexus/core`](../core) — `TFunction` (the `t(key, params)` type)
|
||||
comes from core, and the resolved translator is exposed as `ctx.t` / `ctx.lang`
|
||||
in request handling.
|
||||
- Nested message objects are supported: keys are looked up whole first, then split
|
||||
on `.` to walk the object tree.
|
||||
Pseudo-localization preserves interpolation placeholders and markup tags. RTL
|
||||
pseudo output uses Unicode direction controls, while runtime direction detection
|
||||
continues to derive `rtl` from Arabic and other RTL language subtags.
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/i18n",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/i18n — part of the WrNexus framework.",
|
||||
"description": "Locale loading, fallback resolution, SSR/browser translations, formatters, and WRNexusJS language components.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/i18n"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/i18n",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"i18n"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -18,12 +34,28 @@
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./plugin": {
|
||||
"types": "./dist/plugin.d.ts",
|
||||
"import": "./dist/plugin.js"
|
||||
},
|
||||
"./components/*": "./components/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.7.0"
|
||||
"@wrnexus/core": "^0.8.6",
|
||||
"@wrnexus/plugin": "^0.8.6",
|
||||
"@wrnexus/ui": "^0.8.6"
|
||||
},
|
||||
"wrnexus": {
|
||||
"plugin": {
|
||||
"plugin": "./dist/plugin.js",
|
||||
"export": "default",
|
||||
"factory": true
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md",
|
||||
"components"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -130,3 +130,62 @@ app.use(jwtAuth({ secret: process.env.JWT_SECRET!, required: false }));
|
||||
- Integrates with [`@wrnexus/core`](../core) for `Context`, `Middleware`, and
|
||||
`ctx.user`; it complements the framework's cookie/session auth with a
|
||||
stateless bearer-token flow for API and mobile clients.
|
||||
|
||||
## Access, refresh, scope, and cookie helpers
|
||||
|
||||
```ts
|
||||
import {
|
||||
createAccessToken,
|
||||
createRefreshToken,
|
||||
verifyAccessToken,
|
||||
verifyRefreshToken,
|
||||
extractBearerToken,
|
||||
requireScopes,
|
||||
jwtCookie,
|
||||
} from "@wrnexus/jwt";
|
||||
```
|
||||
|
||||
The helpers add explicit `type: "access" | "refresh"` claims, scope checks, refresh-token family metadata, no-store token responses, and secure cookie defaults. `__Host-` cookies are rejected unless they use `Path=/` and `Secure`; `SameSite=None` is rejected without `Secure`.
|
||||
|
||||
## 0.8 helper kit
|
||||
|
||||
```ts
|
||||
import {
|
||||
createTokenPair,
|
||||
verifyAccessToken,
|
||||
verifyRefreshToken,
|
||||
extractBearerToken,
|
||||
readJwtCookie,
|
||||
jwtCookie,
|
||||
clearJwtCookie,
|
||||
requireScopes,
|
||||
} from "@wrnexus/jwt";
|
||||
|
||||
const pair = await createTokenPair(user.id, {
|
||||
accessSecret: process.env.JWT_ACCESS_SECRET!,
|
||||
refreshSecret: process.env.JWT_REFRESH_SECRET!,
|
||||
scopes: ["profile:read"],
|
||||
family: sessionFamily,
|
||||
});
|
||||
```
|
||||
|
||||
The helper kit validates `__Host-` cookie invariants, cookie names and paths, `SameSite=None` security, typed access/refresh token types, scope requirements, and no-store token responses.
|
||||
In addition to local HS256 secrets/keyrings, the package verifies standards-based
|
||||
RS256 tokens through bounded remote JWKS caches:
|
||||
|
||||
```ts
|
||||
import { createRemoteJwks, verifyJwtWithJwks } from "@wrnexus/jwt";
|
||||
|
||||
const jwks = createRemoteJwks("https://issuer.example/.well-known/jwks.json");
|
||||
const claims = await verifyJwtWithJwks(token, jwks, {
|
||||
issuer: "https://issuer.example",
|
||||
audience: "my-api",
|
||||
maxAge: 300,
|
||||
});
|
||||
```
|
||||
|
||||
JWKS URLs must use HTTPS. Responses have key-count/byte limits, accept only
|
||||
RS256 signing RSA keys, deduplicate concurrent refreshes, cache imported public
|
||||
keys, and force an immediate refresh for an unknown `kid` so issuer rotation
|
||||
does not wait for cache expiry. Never use decoded-but-unverified claims for an
|
||||
authorization decision.
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/jwt",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/jwt — part of the WrNexus framework.",
|
||||
"description": "HS256 JSON Web Tokens, key rotation, access/refresh helpers, scopes, cookies, and auth middleware.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/jwt"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/jwt",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"jwt"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -20,7 +36,11 @@
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.8.6"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -76,6 +76,14 @@ const status = network ? await network.getStatus() : { connected: true, connecti
|
||||
|
||||
Unavailable required plugins throw `MobileUnavailableError` with an actionable message.
|
||||
|
||||
The package also provides portable application-facing primitives:
|
||||
|
||||
- `listenDeepLinks` normalizes initial and live links with an allowed-scheme list.
|
||||
- `PushNotifications` performs permission gating and validates registrations.
|
||||
- `SecureStorage` namespaces and validates keys over an application-supplied encrypted
|
||||
Keychain/Keystore adapter; it does not mislabel browser `localStorage` as secure.
|
||||
- `OfflineQueue` persists bounded sync batches through a pluggable durable store.
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- Capacitor plugin imports must remain in browser-owned modules.
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/mobile",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/mobile — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/mobile"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/mobile",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"mobile"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -21,9 +37,10 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/native": "^0.7.0"
|
||||
"@wrnexus/native": "^0.8.6"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -79,6 +79,9 @@ const position = await native.run(
|
||||
Built-ins include `camera`, `clipboard.write`, `share`, `geolocation`, `network`,
|
||||
`haptics`, storage, filesystem, notifications, and device information.
|
||||
|
||||
`defineNativeManifest` declares required capabilities and typed permissions, while
|
||||
`PermissionManager` normalizes permission query/request flows across platform adapters.
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
Use `supports()` before showing optional controls. Mobile capabilities require their
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/native",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/native — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/native"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/native",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"native"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -29,6 +45,7 @@
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -194,3 +194,24 @@ const gitlab = defineProvider({
|
||||
`verifier` between `startAuth` and `completeAuth` (session or signed cookie).
|
||||
- Pairs with [`@wrnexus/core`](../core) — feed the normalized `OAuthProfile` into
|
||||
`logIn` to establish a session.
|
||||
OIDC integrations can combine strict discovery with the rotating JWKS verifier:
|
||||
|
||||
```ts
|
||||
import { createRemoteJwks } from "@wrnexus/jwt";
|
||||
import { discoverOidc, verifyOidcIdToken } from "@wrnexus/oauth";
|
||||
|
||||
const metadata = await discoverOidc("https://issuer.example");
|
||||
const jwks = createRemoteJwks(metadata.jwks_uri);
|
||||
const claims = await verifyOidcIdToken(idToken, {
|
||||
issuer: metadata.issuer,
|
||||
clientId: "client-id",
|
||||
jwks,
|
||||
nonce: expectedNonce,
|
||||
accessToken,
|
||||
});
|
||||
```
|
||||
|
||||
Discovery requires an exact normalized issuer and HTTPS endpoints without URL
|
||||
credentials/fragments. ID-token verification checks the RS256 signature,
|
||||
expiry/not-before, issuer, audience, required OIDC claims, nonce, multi-audience
|
||||
`azp`, optional token age, and optional `at_hash` binding.
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/oauth",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/oauth — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/oauth"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/oauth",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"oauth"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -20,7 +36,11 @@
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/jwt": "^0.8.6"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,60 @@
|
||||
# @wrnexus/plugin
|
||||
|
||||
## Least-privilege package permissions
|
||||
|
||||
Package manifests declare every framework capability they register:
|
||||
|
||||
```json
|
||||
{
|
||||
"wrnexus": {
|
||||
"permissions": ["routes", "migrations"],
|
||||
"routes": [{ "kind": "api", "path": "/api/example", "entry": "./route.ts" }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Applications can enable fail-closed grants:
|
||||
|
||||
```ts
|
||||
export default {
|
||||
pluginPermissions: {
|
||||
enforce: true,
|
||||
grants: { "example-plugin": ["routes"] },
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Discovery rejects used-but-undeclared capabilities with
|
||||
`WRN-PLUGIN-PERMISSION-UNDECLARED` and ungranted capabilities with
|
||||
`WRN-PLUGIN-PERMISSION-DENIED`. Permissions cover components, browser runtime,
|
||||
assets, styles, routes, middleware, migrations, config, transforms,
|
||||
diagnostics/tooling, and server/build hooks.
|
||||
|
||||
## Compatibility matrices
|
||||
|
||||
Manifests can add `compatibility: { bunMin: "1.3.0", os: ["linux",
|
||||
"darwin"] }` alongside `runtimes` and `requires`. Use
|
||||
`testPluginCompatibility(manifest, targets)` in a package test to exercise the
|
||||
complete support matrix. Runtime discovery enforces the same Bun minimum, OS,
|
||||
runtime, and capability declarations used by the test kit.
|
||||
|
||||
Deterministic WRNexusJS plugin contracts for configuration, AST/code transforms,
|
||||
diagnostics, development servers, production builds, and DevToolbar extensions.
|
||||
|
||||
Use `definePlugin()` and declare `enforce`, `before`, or `after` when ordering matters.
|
||||
Duplicate names and dependency cycles are rejected.
|
||||
|
||||
## Complete lifecycle and contributions
|
||||
|
||||
Plugins may implement `setup`, `configure`, `configResolved`, `transformAst`,
|
||||
`transformCode`, `diagnostics`, `routes`, `configureServer`, `buildStart`,
|
||||
`buildEnd`, `render`, `deploy`, `shutdown`, and `hmrUpdate`. The runner preserves
|
||||
resolved plugin order for every hook and executes `setup` exactly once.
|
||||
|
||||
In addition to components, routes, middleware, assets, styles, runtimes, and
|
||||
migrations, plugins can contribute `directives`, `cliCommands`,
|
||||
`virtualModules`, `deploymentAdapters`, `configSchemas`, `documentation`, and
|
||||
`typeDefinitions`. Names are collision checked. Configuration schemas run after
|
||||
configuration resolution, CLI commands are callable as normal `wrnexus`
|
||||
commands, directives participate in AST transformation, and production builds
|
||||
materialize virtual modules and invoke matching contributed adapters.
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/plugin",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/plugin — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/plugin"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/plugin",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"plugin"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -33,9 +49,10 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/syntax": "^0.7.0"
|
||||
"@wrnexus/syntax": "^0.8.6"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -31,13 +31,15 @@ Creates a bus over a driver. Defaults to `memoryDriver()` (in-process).
|
||||
interface PubSub {
|
||||
publish<T = unknown>(topic: string, message: T): Promise<void>;
|
||||
subscribe<T = unknown>(pattern: string, handler: Handler<T>): () => void;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
type Handler<T = unknown> = (message: T, topic: string) => void | Promise<void>;
|
||||
```
|
||||
|
||||
- `publish(topic, message)` — resolves once the driver has dispatched the message.
|
||||
- `publish(topic, message)` — resolves once the driver and in-memory async handlers finish.
|
||||
- `subscribe(pattern, handler)` — returns an unsubscribe function.
|
||||
- `close()` — idempotently rejects new work, clears local subscriptions, and closes the driver.
|
||||
|
||||
### Pattern matching
|
||||
|
||||
@@ -67,7 +69,7 @@ then `redis://localhost:6379`. The URL may carry a password and a database index
|
||||
(e.g. `redis://:secret@host:6379/2`).
|
||||
|
||||
```ts
|
||||
function redisDriver(url?: string): PubSubDriver & { close(): void };
|
||||
function redisDriver(url?: string, options?: RedisDriverOptions): PubSubDriver & { close(): void };
|
||||
```
|
||||
|
||||
- Exact topics use Redis `SUBSCRIBE`; wildcard patterns (`ns:*`, `*`) use
|
||||
@@ -75,6 +77,9 @@ function redisDriver(url?: string): PubSubDriver & { close(): void };
|
||||
- Messages are JSON-stringified on publish and `JSON.parse`d on receipt; a payload
|
||||
that isn't valid JSON is delivered as the raw string.
|
||||
- `close()` tears down both the subscriber and publisher connections.
|
||||
- Lost sockets reconnect with bounded exponential backoff and active subscriptions
|
||||
are replayed. `maxPending` bounds unavailable-connection writes (default 1000);
|
||||
`reconnectDelayMs` and `reconnectMaxDelayMs` tune recovery (100ms/5000ms).
|
||||
|
||||
### RESP codec (internal)
|
||||
|
||||
@@ -115,8 +120,8 @@ bus.subscribe("order:*", (msg, topic) => {
|
||||
|
||||
await bus.publish("order:created", { id: 7 });
|
||||
|
||||
// on shutdown
|
||||
driver.close();
|
||||
// on shutdown (also closes the driver)
|
||||
await bus.close();
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/pubsub",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/pubsub — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/pubsub"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/pubsub",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"pubsub"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -19,12 +35,17 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./brokers": {
|
||||
"types": "./dist/brokers.d.ts",
|
||||
"import": "./dist/brokers.js"
|
||||
},
|
||||
"./redis": {
|
||||
"types": "./dist/redis.d.ts",
|
||||
"import": "./dist/redis.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
+61
-14
@@ -43,33 +43,45 @@ function createQueue(options?: QueueOptions): Queue;
|
||||
| `backoffMs` | `number` | `1000` | Base retry backoff in ms; doubles per attempt. |
|
||||
| `pollMs` | `number` | `250` | Poll interval used once `start()` is called (ms). |
|
||||
| `onFailed` | `(job: Job, error: unknown) => void` | — | Called when a job exhausts its attempts. |
|
||||
| `concurrency` | `number` | unlimited | Maximum jobs claimed by one `drain()` call. |
|
||||
| `capacity` | `number` | unlimited | Maximum queued plus active jobs before adds reject. |
|
||||
| `now` | `() => number` | `Date.now` | Clock injection for deterministic tests. |
|
||||
|
||||
### `Queue`
|
||||
|
||||
The object returned by `createQueue`.
|
||||
|
||||
| Method | Signature | Description |
|
||||
| --------- | -------------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| `add` | `add<T>(name, data: T, options?: AddOptions): Promise<Job<T>>` | Enqueue a job under a worker name. Returns the created job. |
|
||||
| `process` | `process<T>(name, handler: JobHandler<T>): void` | Register the worker that runs jobs of the given name. |
|
||||
| `drain` | `drain(now?: number): Promise<number>` | Run every job whose `runAt ≤ now`, once. Returns how many ran. |
|
||||
| `start` | `start(): void` | Begin polling every `pollMs`. No-op if already started. |
|
||||
| `stop` | `stop(): void` | Stop the poll timer. |
|
||||
| `size` | `size(): number` | Number of jobs currently queued. |
|
||||
| Method | Signature | Description |
|
||||
| ---------- | -------------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| `add` | `add<T>(name, data: T, options?: AddOptions): Promise<Job<T>>` | Enqueue a job under a worker name. Returns the created job. |
|
||||
| `process` | `process<T>(name, handler: JobHandler<T>): void` | Register the worker that runs jobs of the given name. |
|
||||
| `drain` | `drain(now?: number): Promise<number>` | Run every job whose `runAt ≤ now`, once. Returns how many ran. |
|
||||
| `start` | `start(): void` | Begin polling every `pollMs`. No-op if already started. |
|
||||
| `stop` | `stop(): void` | Stop the poll timer. |
|
||||
| `shutdown` | `shutdown({ force? }): Promise<void>` | Stop accepting jobs and await active work; force aborts it. |
|
||||
| `size` | `size(): number` | Number of jobs currently queued. |
|
||||
| `get/list` | `get(id)` / `list(name?)` | Inspect defensive copies of pending jobs. |
|
||||
| `cancel` | `cancel(id): boolean` | Remove queued work or abort an active handler. |
|
||||
| `failed` | `failed(): Job[]` | Inspect exhausted jobs in the dead-letter collection. |
|
||||
| `retry` | `retry(id): Promise<boolean>` | Reset and requeue a dead-lettered job. |
|
||||
|
||||
#### `AddOptions`
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------------- | -------- | -------------------------------------------------------------------------- |
|
||||
| `delayMs` | `number` | Delay before the job becomes runnable (ms). |
|
||||
| `maxAttempts` | `number` | Max attempts before dead-lettering. Defaults to the queue's `maxAttempts`. |
|
||||
| `repeat` | `number` | Re-enqueue this job this many ms after each successful run (recurring). |
|
||||
| Option | Type | Description |
|
||||
| ---------------- | -------- | -------------------------------------------------------------------------- |
|
||||
| `delayMs` | `number` | Delay before the job becomes runnable (ms). |
|
||||
| `maxAttempts` | `number` | Max attempts before dead-lettering. Defaults to the queue's `maxAttempts`. |
|
||||
| `repeat` | `number` | Re-enqueue this job this many ms after each successful run (recurring). |
|
||||
| `priority` | `number` | Higher values are selected first among due jobs. |
|
||||
| `idempotencyKey` | `string` | Return the matching pending job instead of enqueueing a duplicate. |
|
||||
|
||||
#### `JobHandler<T>`
|
||||
|
||||
```ts
|
||||
type JobHandler<T = unknown> = (job: Job<T>) => void | Promise<void>;
|
||||
type JobHandler<T = unknown> = (
|
||||
job: Job<T>,
|
||||
context: { signal: AbortSignal },
|
||||
) => void | Promise<void>;
|
||||
```
|
||||
|
||||
#### `Job<T>`
|
||||
@@ -106,6 +118,18 @@ await queue.add("email", { to: "a@b.com" }, { delayMs: 5000, maxAttempts: 3 });
|
||||
queue.start(); // begin polling; queue.stop() to halt
|
||||
```
|
||||
|
||||
Use `context.signal` in network/database calls so forced shutdown and active
|
||||
cancellation finish promptly. For process termination, prefer
|
||||
`await queue.shutdown()`; use `{ force: true }` only after your grace period.
|
||||
|
||||
### Durable queue
|
||||
|
||||
`createDurableQueue({ store })` retains jobs until their handler succeeds and
|
||||
supports atomic leases when a driver implements `QueueStore.claim`. It exposes
|
||||
the same cancellation/shutdown behavior plus `list`, `failed`, and `retry`.
|
||||
The included `memoryQueueStore()` is useful for tests; production Redis/SQL
|
||||
drivers should make `claim()` atomic to prevent two workers executing one job.
|
||||
|
||||
### Recurring jobs
|
||||
|
||||
Pass `repeat` to re-enqueue a job a fixed interval after each successful run:
|
||||
@@ -145,6 +169,29 @@ clock = 5000;
|
||||
const ran = await queue.drain(); // => 1
|
||||
```
|
||||
|
||||
### Durable workflows and approvals
|
||||
|
||||
`createWorkflowEngine(store)` executes dependency-ordered steps and persists every transition,
|
||||
result, progress update, failure, cancellation, and approval record. Approval steps pause safely
|
||||
and can resume after a process restart because the snapshot lives in the supplied `WorkflowStore`.
|
||||
|
||||
```ts
|
||||
const workflow = defineDurableWorkflow({
|
||||
name: "publish-report",
|
||||
steps: [
|
||||
{ name: "build", run: buildReport },
|
||||
{ name: "approve", dependsOn: ["build"], approval: true, run: (report) => report },
|
||||
{ name: "publish", dependsOn: ["approve"], run: publishReport },
|
||||
],
|
||||
});
|
||||
|
||||
const run = await engine.start(workflow, input);
|
||||
await engine.approve(workflow, run.id, "approve", currentUser.id);
|
||||
```
|
||||
|
||||
Use `memoryWorkflowStore()` for tests. Production stores implement the small `get`, `put`, and
|
||||
`list` contract using the same transactional database or durable service as the application.
|
||||
|
||||
## Retry & backoff behavior
|
||||
|
||||
- On a thrown handler error, the job is retried while `attempts < maxAttempts`.
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/queue",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/queue — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/queue"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/queue",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"queue"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -20,7 +36,11 @@
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.8.6"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/reactive",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/reactive — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/reactive"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/reactive",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"reactive"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -21,6 +37,7 @@
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/router",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/router — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/router"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/router",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"router"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -21,10 +37,11 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/compiler": "^0.7.0",
|
||||
"@wrnexus/core": "^0.7.0"
|
||||
"@wrnexus/compiler": "^0.8.6",
|
||||
"@wrnexus/core": "^0.8.6"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/ssr",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/ssr — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/ssr"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/ssr",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"ssr"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -29,11 +45,12 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.7.0",
|
||||
"@wrnexus/store": "^0.7.0",
|
||||
"@wrnexus/security": "^0.7.0"
|
||||
"@wrnexus/core": "^0.8.6",
|
||||
"@wrnexus/store": "^0.8.6",
|
||||
"@wrnexus/security": "^0.8.6"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# @wrnexus/styles
|
||||
|
||||
## Reusable layers and presets
|
||||
|
||||
Compose local or package foundations in order; later layers override earlier
|
||||
ones and the application has final base-config precedence:
|
||||
|
||||
```ts
|
||||
export default defineConfig({
|
||||
extends: ["@workroot/wrnexus-enterprise", "./layers/company"],
|
||||
profiles: { production: { port: 8080 } },
|
||||
});
|
||||
```
|
||||
|
||||
A directory layer exports `wrnexus.layer.ts` (JavaScript/MJS are supported).
|
||||
A package can provide that conventional file or declare
|
||||
`wrnexus.layer` in its `package.json`. Layers may extend other layers and carry
|
||||
the complete app configuration, including plugins that contribute layouts,
|
||||
components, routes, middleware, and migrations. `plugins` and `head` compose;
|
||||
other arrays intentionally replace earlier values. Cycles and missing/invalid
|
||||
entries fail with stable `WRN-CONFIG-LAYER-*` diagnostics. `wrnexus config
|
||||
--explain` lists every resolved layer source.
|
||||
|
||||
> Global CSS bundling, the `--wire-*` design-token theme system, and the `wrnexus.config.ts` app-config loader for WrNexus apps.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/styles",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/styles — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/styles"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/styles",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"styles"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -21,11 +37,12 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/uploader": "^0.7.0",
|
||||
"@wrnexus/core": "^0.7.0",
|
||||
"@wrnexus/plugin": "^0.7.0"
|
||||
"@wrnexus/uploader": "^0.8.6",
|
||||
"@wrnexus/core": "^0.8.6",
|
||||
"@wrnexus/plugin": "^0.8.6"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/syntax",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/syntax — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/syntax"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/syntax",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"syntax"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -38,9 +54,14 @@
|
||||
"./spec": {
|
||||
"types": "./dist/spec.d.ts",
|
||||
"import": "./dist/spec.js"
|
||||
},
|
||||
"./formatter": {
|
||||
"types": "./dist/formatter.d.ts",
|
||||
"import": "./dist/formatter.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -103,6 +103,22 @@ Remember to `await app.close()` when done.
|
||||
|
||||
## Usage
|
||||
|
||||
The CLI supports focused suites by file or directory convention:
|
||||
|
||||
```bash
|
||||
wrnexus test unit # *.unit.test.ts or test/unit/**
|
||||
wrnexus test component # *.component.test.ts or test/component/**
|
||||
wrnexus test api # *.api.test.ts or test/api/**
|
||||
wrnexus test accessibility # *.a11y.test.ts / *.accessibility.test.ts
|
||||
wrnexus test performance # *.performance.test.ts / *.benchmark.test.ts
|
||||
wrnexus test browser # Playwright project when configured
|
||||
wrnexus test visual # Playwright tests tagged @visual
|
||||
```
|
||||
|
||||
Pass the application directory after the level, for example
|
||||
`wrnexus test component examples/basic-app`. A focused command fails clearly when no matching
|
||||
suite exists instead of silently running unrelated tests.
|
||||
|
||||
```ts
|
||||
import { test, expect, renderComponent, mountHtml, createHarness } from "@wrnexus/test";
|
||||
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/test",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/test — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/test"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/test",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"test"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -21,6 +37,7 @@
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/tracking",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/tracking — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/tracking"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/tracking",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"tracking"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -21,6 +37,7 @@
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
+49
-49
@@ -229,7 +229,7 @@ Present structured responsive linked or status items with icons, descriptions, a
|
||||
- Mount: `data-component="List"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "List"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
- Outputs: `select({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### ListGroup
|
||||
|
||||
@@ -247,7 +247,7 @@ Continuously present responsive labels, partners, notices, or capabilities with
|
||||
- Mount: `data-component="Marquee"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Marquee"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
- Outputs: `pause({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `resume({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### Progress
|
||||
|
||||
@@ -301,7 +301,7 @@ Present responsive chronological activity, milestones, or workflow status with r
|
||||
- Mount: `data-component="Timeline"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Timeline"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
- Outputs: `select({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### Toast
|
||||
|
||||
@@ -359,6 +359,15 @@ Reusable preference switcher component.
|
||||
- Slots: None
|
||||
- Outputs: `theme({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `color({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `language({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`
|
||||
|
||||
### Toaster
|
||||
|
||||
Reusable toaster component.
|
||||
|
||||
- Mount: `data-component="Toaster"`
|
||||
- Props: `color: string = "info"`, `size: string = "default"`, `position: string = "bottom-right"`, `duration: number = 4500`, `max: number = 4`, `pauseOnHover: boolean = true`, `showIcon: boolean = true`, `successIcon: string = ""`, `dangerIcon: string = ""`, `warningIcon: string = ""`, `infoIcon: string = ""`, `closable: boolean = true`, `showProgress: boolean = true`, `closeLabel: string = "Dismiss notification"`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `show({ id: number; message: string; tone: string })`, `dismiss({ id: number; reason: string })`, `action({ id: number; sourceEvent: Event })`
|
||||
|
||||
## Data
|
||||
|
||||
### MetricCard
|
||||
@@ -460,7 +469,7 @@ Provide an accessible responsive search field with labels, validation states, si
|
||||
- Mount: `data-component="SearchBox"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Search Box"`, `name: string = ""`, `value: string = ""`, `placeholder: string = ""`, `type: string = "search"`, `min: string = ""`, `max: string = ""`, `step: string = ""`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: None
|
||||
- Outputs: `search({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `clear({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### Select
|
||||
|
||||
@@ -554,15 +563,6 @@ Theme-aware, responsive data map component.
|
||||
- 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)`
|
||||
|
||||
### DataTable
|
||||
|
||||
Theme-aware, responsive data table component.
|
||||
|
||||
- Mount: `data-component="DataTable"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `caption: string = "Data Table"`, `columns: unknown[] = []`, `rows: unknown[] = []`, `striped: boolean = true`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `sort({ 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)`, `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)`, `rowClick({ 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)`, `pageChange({ 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)`
|
||||
|
||||
### DragAndDrop
|
||||
|
||||
Theme-aware, responsive drag and drop component.
|
||||
@@ -588,7 +588,7 @@ Present responsive location information and markers with map-ready metadata and
|
||||
- Mount: `data-component="Map"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Map"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
- 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 })`
|
||||
|
||||
### ToastNotifications
|
||||
|
||||
@@ -624,7 +624,7 @@ Create responsive balanced content columns with configurable count, gap, density
|
||||
Constrain and align page content with responsive gutters and compact, wide, or full width options.
|
||||
|
||||
- Mount: `data-component="Container"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `columns: number = 2`, `gap: string = "md"`, `maxWidth: string = "xl"`, `class: string = ""`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `columns: number = 2`, `gap: string = "md"`, `maxWidth: string = "xl"`, `centered: boolean = true`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
|
||||
@@ -633,16 +633,16 @@ Constrain and align page content with responsive gutters and compact, wide, or f
|
||||
Theme-aware, responsive custom scrollbar component.
|
||||
|
||||
- Mount: `data-component="CustomScrollbar"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `columns: number = 2`, `gap: string = "md"`, `maxWidth: string = "xl"`, `class: string = ""`
|
||||
- Props: `color: string = "primary"`, `size: string = "default"`, `axis: string = "vertical"`, `thickness: number = 8`, `maxHeight: string = "20rem"`, `radius: string = "999px"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `scroll({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`
|
||||
- Outputs: None
|
||||
|
||||
### Divider
|
||||
|
||||
Separate related horizontal or vertical content with optional labels, sizes, and semantic colors.
|
||||
|
||||
- Mount: `data-component="Divider"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = ""`, `orientation: string = "horizontal"`, `class: string = ""`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = ""`, `orientation: string = "horizontal"`, `variant: string = "solid"`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: None
|
||||
|
||||
@@ -669,7 +669,7 @@ Render structured responsive footer navigation, pre and post content, copyright
|
||||
Arrange arbitrary content in a responsive configurable CSS grid with stable columns, gaps, alignment, and width.
|
||||
|
||||
- Mount: `data-component="Grid"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `columns: number = 2`, `gap: string = "md"`, `maxWidth: string = "xl"`, `class: string = ""`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `columns: number = 2`, `gap: string = "md"`, `maxWidth: string = "xl"`, `minItemWidth: string = ""`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
|
||||
@@ -678,7 +678,7 @@ Arrange arbitrary content in a responsive configurable CSS grid with stable colu
|
||||
Render a responsive image with explicit dimensions, loading behavior, alternative text, sizing, and rounded treatment.
|
||||
|
||||
- Mount: `data-component="Image"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `src: string = ""`, `alt: string = ""`, `width: string = ""`, `height: string = ""`, `loading: string = "lazy"`, `rounded: boolean = false`, `class: string = ""`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `src: string = ""`, `alt: string = ""`, `width: string = ""`, `height: string = ""`, `loading: string = "lazy"`, `fit: string = "cover"`, `rounded: boolean = false`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
|
||||
@@ -687,7 +687,7 @@ Render a responsive image with explicit dimensions, loading behavior, alternativ
|
||||
Theme-aware, responsive kbd component.
|
||||
|
||||
- Mount: `data-component="Kbd"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "⌘ K"`, `class: string = ""`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "K"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
|
||||
@@ -696,16 +696,16 @@ Theme-aware, responsive kbd component.
|
||||
Theme-aware, responsive layout splitter component.
|
||||
|
||||
- Mount: `data-component="LayoutSplitter"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `columns: number = 2`, `gap: string = "md"`, `maxWidth: string = "xl"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `resizeStart({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `resize({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `resizeEnd({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`
|
||||
- Props: `color: string = "primary"`, `size: number = 50`, `orientation: string = "horizontal"`, `minSize: number = 15`, `step: number = 5`, `label: string = "Resize panels"`, `class: string = ""`
|
||||
- Slots: `start`, `end`, `default`
|
||||
- Outputs: `sizeChange({ size: number })`
|
||||
|
||||
### Link
|
||||
|
||||
Render an accessible internal or external link with target, relation, size, color, and public focus or click events.
|
||||
|
||||
- Mount: `data-component="Link"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Link"`, `href: string = "#"`, `target: string = ""`, `rel: string = ""`, `external: boolean = false`, `class: string = ""`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Link"`, `href: string = "#"`, `target: string = ""`, `rel: string = ""`, `underline: string = "hover"`, `external: boolean = false`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
|
||||
@@ -862,18 +862,18 @@ Show responsive hierarchical navigation with home support, separators, current-p
|
||||
Theme-aware, responsive mega menu component.
|
||||
|
||||
- Mount: `data-component="MegaMenu"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Mega Menu"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""`
|
||||
- Props: `color: string = "primary"`, `size: string = "default"`, `label: string = "Menu"`, `icon: string = ""`, `columns: unknown[] = []`, `footer: string = ""`, `defaultOpen: boolean = false`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `open({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `close({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `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)`
|
||||
- Outputs: `open({ sourceEvent: Event })`, `close({ reason: string })`, `select({ item: object; value: string })`
|
||||
|
||||
### Nav
|
||||
|
||||
Theme-aware, responsive nav component.
|
||||
|
||||
- Mount: `data-component="Nav"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Nav"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""`
|
||||
- Props: `color: string = "primary"`, `size: string = "default"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `label: string = "Main"`, `collapsible: boolean = true`, `toggleLabel: string = "Menu"`, `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)`
|
||||
- Outputs: `select({ item: object; value: string })`
|
||||
|
||||
### Navbar
|
||||
|
||||
@@ -889,45 +889,45 @@ Theme-aware, responsive navbar component.
|
||||
Theme-aware, responsive pagination component.
|
||||
|
||||
- Mount: `data-component="Pagination"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Pagination"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""`
|
||||
- Props: `color: string = "primary"`, `size: string = "default"`, `page: number = 1`, `pageSize: number = 10`, `total: number = 0`, `variant: string = "compact"`, `siblingCount: number = 1`, `showSummary: boolean = true`, `label: string = "Pagination"`, `previousLabel: string = "Previous"`, `nextLabel: string = "Next"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `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)`, `previous({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `next({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
- Outputs: `change({ page: number; pageSize: number })`, `previous({ page: number })`, `next({ page: number })`
|
||||
|
||||
### Scrollspy
|
||||
|
||||
Theme-aware, responsive scrollspy component.
|
||||
|
||||
- Mount: `data-component="Scrollspy"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Scrollspy"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""`
|
||||
- Props: `color: string = "primary"`, `size: string = "default"`, `items: unknown[] = []`, `active: string = ""`, `label: string = "On this page"`, `heading: string = ""`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `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)`
|
||||
- Outputs: `change({ href: string; label: string })`
|
||||
|
||||
### Sidebar
|
||||
|
||||
Theme-aware, responsive sidebar component.
|
||||
|
||||
- Mount: `data-component="Sidebar"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Sidebar"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `mobileLabel: string = "Open navigation"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `toggle({ open: boolean })`, `open({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `close({ source: string })`, `select({ item: string | number | boolean | null | object; value: string | number | boolean; level: string })`
|
||||
- Props: `color: string = "primary"`, `size: string = "default"`, `label: string = "Sidebar"`, `items: unknown[] = []`, `active: string = ""`, `mobileLabel: string = "Open navigation"`, `drawerTitle: string = "Navigation"`, `class: string = ""`
|
||||
- Slots: `default`, `drawer`
|
||||
- Outputs: `toggle({ open: boolean })`, `open({ sourceEvent: Event })`, `close({ source: string })`, `select({ item: object; value: string; level: number })`
|
||||
|
||||
### Stepper
|
||||
|
||||
Theme-aware, responsive stepper component.
|
||||
|
||||
- Mount: `data-component="Stepper"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Stepper"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `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)`, `previous({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `next({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `complete({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
- 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 = ""`
|
||||
- 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 })`
|
||||
|
||||
### Tabs
|
||||
|
||||
Switch between related responsive content panels with horizontal or vertical orientation and selection events.
|
||||
|
||||
- Mount: `data-component="Tabs"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Tabs"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
- Props: `color: string = "primary"`, `size: string = "default"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `mode: string = "client"`, `param: string = "tab"`, `label: string = "Tabs"`, `class: string = ""`
|
||||
- Slots: `panel-{valueOf(item, index)}`, `default`
|
||||
- Outputs: `change({ value: string; item: object; index: number })`, `select({ value: string; item: object; index: number })`
|
||||
|
||||
## Overlays
|
||||
|
||||
@@ -945,7 +945,7 @@ Open an accessible keyboard-aware action menu from pointer or keyboard context i
|
||||
Present responsive modal side or bottom content with focus management, backdrop behavior, slots, and close events.
|
||||
|
||||
- Mount: `data-component="Drawer"`
|
||||
- Props: `open: boolean = false`, `defaultOpen: boolean = false`, `placement: string = "right"`, `size: string = "md"`, `color: string = "primary"`, `variant: string = "default"`, `title: string = "Drawer"`, `description: string = ""`, `icon: string = ""`, `label: string = "Drawer"`, `closeLabel: string = "Close drawer"`, `showClose: boolean = true`, `closeOnBackdrop: boolean = true`, `closeOnEscape: boolean = true`, `overlay: boolean = true`, `scrollable: boolean = true`, `triggerLabel: string = ""`, `triggerIcon: string = ""`, `class: string = ""`
|
||||
- Props: `open: boolean = false`, `defaultOpen: boolean = false`, `placement: string = "right"`, `size: string = "md"`, `color: string = "primary"`, `variant: string = "default"`, `title: string = "Drawer"`, `description: string = ""`, `icon: string = ""`, `label: string = "Drawer"`, `closeLabel: string = "Close drawer"`, `showClose: boolean = true`, `closeOnBackdrop: boolean = true`, `closeOnEscape: boolean = true`, `duration: number = 260`, `overlay: boolean = true`, `scrollable: boolean = true`, `triggerLabel: string = ""`, `triggerIcon: string = ""`, `class: string = ""`
|
||||
- Slots: `trigger`, `header`, `default`, `footer`
|
||||
- Outputs: `open({ placement: string; sourceEvent: Event })`, `close({ reason: string; placement: string; sourceEvent: Event })`, `cancel({ placement: string; sourceEvent: Event })`
|
||||
|
||||
@@ -963,7 +963,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`, `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"`, `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 })`
|
||||
|
||||
@@ -987,11 +987,11 @@ Show concise accessible contextual help on hover, focus, click, or controlled op
|
||||
|
||||
## Tables
|
||||
|
||||
### Table
|
||||
### DataTable
|
||||
|
||||
Theme-aware, responsive table component.
|
||||
Sortable, filterable, paginated data table with row selection.
|
||||
|
||||
- Mount: `data-component="Table"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `caption: string = "Table"`, `columns: unknown[] = []`, `rows: unknown[] = []`, `striped: boolean = true`, `class: string = ""`
|
||||
- Mount: `data-component="DataTable"`
|
||||
- Props: `color: string = "primary"`, `size: string = "default"`, `columns: unknown[] = []`, `rows: unknown[] = []`, `rowKey: string = "id"`, `remote: boolean = false`, `loadingLabel: string = "Loading"`, `errorLabel: string = "Could not load this data"`, `retryLabel: string = "Try again"`, `caption: string = ""`, `description: string = ""`, `searchable: boolean = true`, `searchPlaceholder: string = "Search"`, `paginated: boolean = true`, `pageSize: number = 10`, `paginationStyle: string = "compact"`, `pageSizes: number[] = [10, 25, 50]`, `selectable: boolean = false`, `actions: unknown[] = []`, `striped: boolean = true`, `bordered: boolean = true`, `gridlines: string = "rows"`, `density: string = "default"`, `emptyLabel: string = "No records to show"`, `noResultsLabel: string = "No records match your search"`, `clearSearchLabel: string = "Clear search"`, `stickyFirstColumn: boolean = false`, `layout: string = "rows"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `sort({ 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)`, `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)`, `rowClick({ 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)`
|
||||
- Outputs: `sort({ key: string; direction: string })`, `search({ query: string })`, `pageChange({ page: number; pageSize: number })`, `select({ selected: Array<string | number>; all: boolean })`, `change({ page: number; pageSize: number; total: number; query: string; sortKey: string; sortDirection: string })`, `rowClick({ row: object; sourceEvent: Event })`, `action({ id: string; selected: Array<string | number>; rows: object[]; sourceEvent: Event })`, `request({ instanceId: number; page: number; pageSize: number; sortKey: string; sortDirection: string; query: string })`
|
||||
|
||||
@@ -168,8 +168,8 @@
|
||||
},
|
||||
{
|
||||
"name": "DataTable",
|
||||
"category": "integrations",
|
||||
"purpose": "Theme-aware, responsive data table component."
|
||||
"category": "tables",
|
||||
"purpose": "Sortable, filterable, paginated data table with row selection."
|
||||
},
|
||||
{
|
||||
"name": "DatePicker",
|
||||
@@ -471,11 +471,6 @@
|
||||
"category": "forms",
|
||||
"purpose": "Theme-aware, responsive switch component."
|
||||
},
|
||||
{
|
||||
"name": "Table",
|
||||
"category": "tables",
|
||||
"purpose": "Theme-aware, responsive table component."
|
||||
},
|
||||
{
|
||||
"name": "Tabs",
|
||||
"category": "navigation",
|
||||
@@ -511,6 +506,11 @@
|
||||
"category": "integrations",
|
||||
"purpose": "Theme-aware, responsive toast notifications component."
|
||||
},
|
||||
{
|
||||
"name": "Toaster",
|
||||
"category": "core",
|
||||
"purpose": "Reusable toaster component."
|
||||
},
|
||||
{
|
||||
"name": "ToggleCount",
|
||||
"category": "advanced-forms",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,22 +49,24 @@ component Accordion {
|
||||
return multiple || alwaysOpen
|
||||
}
|
||||
|
||||
client function dispatchAccordionEvent(sourceEvent, eventName, value, item, root, customEvent) {
|
||||
root = sourceEvent.currentTarget.closest("[data-wrn-accordion]")
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent(eventName, true, false, {
|
||||
// Named rather than computed: an output is resolved as a property name, so
|
||||
// output[eventName] would not reach a parent binding.
|
||||
client function dispatchAccordionEvent(sourceEvent, eventName, value, item, payload) {
|
||||
payload = {
|
||||
component: "Accordion",
|
||||
value: value,
|
||||
item: item,
|
||||
open: isOpen(value),
|
||||
openValues: openValues
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
}
|
||||
|
||||
if (eventName === "open") {
|
||||
output.open(payload)
|
||||
} else if (eventName === "close") {
|
||||
output.close(payload)
|
||||
} else {
|
||||
output.change(payload)
|
||||
}
|
||||
}
|
||||
|
||||
client function toggleItem(sourceEvent, value, item, wasOpen) {
|
||||
|
||||
@@ -30,17 +30,13 @@ component AvatarGroup {
|
||||
return items.slice(Number(maxVisible))
|
||||
}
|
||||
|
||||
client function toggleOverflow(sourceEvent, root, customEvent) {
|
||||
client function toggleOverflow() {
|
||||
overflowOpen = !overflowOpen
|
||||
root = sourceEvent.currentTarget.closest("[data-wrn-avatar-group]")
|
||||
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent("overflow", true, false, {
|
||||
output.overflow({
|
||||
component: "AvatarGroup",
|
||||
open: overflowOpen,
|
||||
hiddenCount: hiddenMembers().length
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ label: string = "Breadcrumb"
|
||||
<a
|
||||
href='{homeHref || "/"}'
|
||||
class="wire-breadcrumb__link wire-breadcrumb__home"
|
||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: { label: homeLabel, href: homeHref || "/", value: "home" }, itemIndex: -1 } }))'
|
||||
@click='output.select({ item: { label: homeLabel, href: homeHref || "/", value: "home" }, itemIndex: -1 })'
|
||||
>
|
||||
{#if homeIcon === "icon-[lucide--house]"}
|
||||
<span class="icon-[lucide--house] wire-breadcrumb__icon" aria-hidden="true"></span>
|
||||
@@ -72,7 +72,7 @@ label: string = "Breadcrumb"
|
||||
target='{item.target || ""}'
|
||||
rel='{item.external ? "noopener noreferrer" : (item.rel || "")}'
|
||||
class="wire-breadcrumb__link"
|
||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: item, itemIndex: itemIndex } }))'
|
||||
@click='output.select({ item: item, itemIndex: itemIndex })'
|
||||
>
|
||||
{#if item.icon}
|
||||
<span class='{item.icon} wire-breadcrumb__icon' aria-hidden="true"></span>
|
||||
@@ -87,7 +87,7 @@ label: string = "Breadcrumb"
|
||||
{:else}
|
||||
<span
|
||||
class="wire-breadcrumb__current"
|
||||
aria-current='{item.active || item.current || (active && active === (item.value || item.label || item.title)) || (!active && itemIndex === items.length - 1) ? "page" : ""}'
|
||||
aria-current='{item.active || item.current || (active && active === (item.value || item.label || item.title)) || (!active && itemIndex === items.length - 1) ? "page" : "false"}'
|
||||
aria-disabled='{item.disabled ? "true" : "false"}'
|
||||
>
|
||||
{#if item.icon}
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
// Columns -- a simple multi-column split for page content.
|
||||
//
|
||||
// <Columns columns={2} gap="lg">...</Columns>
|
||||
//
|
||||
// Columns and Grid overlap deliberately: Columns is the coarse two or three
|
||||
// way split of a page, and its gaps are wider for that reason. Grid is the one
|
||||
// to reach for when the items are cards and the count matters.
|
||||
//
|
||||
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
|
||||
// and silently swallows the rule that follows it.
|
||||
component Columns {
|
||||
props {
|
||||
size: string = "default"
|
||||
@@ -8,28 +18,92 @@ component Columns {
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
functions {
|
||||
shared function columnCount() {
|
||||
var value = Number(columns)
|
||||
if (!value || value < 1) {
|
||||
return 1
|
||||
}
|
||||
return Math.min(4, value)
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<div
|
||||
{...attrs}
|
||||
data-ui-component="Columns"
|
||||
class='wire-columns {class}'
|
||||
data-size='{size}'
|
||||
data-color='{color}'
|
||||
data-columns='{columns}'
|
||||
data-gap='{gap}'
|
||||
class='grid w-full grid-cols-1 items-start {class}'
|
||||
class:max-w-3xl='maxWidth === "md"'
|
||||
class:max-w-5xl='maxWidth === "lg"'
|
||||
class:max-w-7xl='maxWidth === "xl"'
|
||||
class:max-w-screen-2xl='maxWidth === "2xl"'
|
||||
class:max-w-none='maxWidth === "full"'
|
||||
class:md:grid-cols-2='columns >= 2'
|
||||
class:lg:grid-cols-3='columns === 3'
|
||||
class:lg:grid-cols-4='columns === 4'
|
||||
class:gap-3='gap === "sm"'
|
||||
class:gap-6='gap === "md"'
|
||||
class:gap-10='gap === "lg"'
|
||||
class:gap-14='gap === "xl"'
|
||||
data-max-width='{maxWidth}'
|
||||
data-columns='{columnCount()}'
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wire-columns {
|
||||
--columns-gap: 1.5rem;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: var(--columns-gap);
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.wire-columns[data-max-width="md"] {
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
.wire-columns[data-max-width="lg"] {
|
||||
max-width: 64rem;
|
||||
}
|
||||
|
||||
.wire-columns[data-max-width="xl"] {
|
||||
max-width: 80rem;
|
||||
}
|
||||
|
||||
.wire-columns[data-max-width="2xl"] {
|
||||
max-width: 96rem;
|
||||
}
|
||||
|
||||
.wire-columns[data-max-width="full"] {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.wire-columns[data-gap="sm"] {
|
||||
--columns-gap: 0.75rem;
|
||||
}
|
||||
|
||||
.wire-columns[data-gap="lg"] {
|
||||
--columns-gap: 2.5rem;
|
||||
}
|
||||
|
||||
.wire-columns[data-gap="xl"] {
|
||||
--columns-gap: 3.5rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* Splits at the tablet breakpoint rather than the phone one: a page split
|
||||
* is worth keeping single-column for longer than a card deck is.
|
||||
*/
|
||||
@media (min-width: 768px) {
|
||||
.wire-columns:not([data-columns="1"]) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.wire-columns[data-columns="3"] {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wire-columns[data-columns="4"] {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +45,12 @@ component ContextMenu {
|
||||
sourceEvent.preventDefault()
|
||||
}
|
||||
if (placement === "pointer" && sourceEvent) {
|
||||
positionX = Math.max(12, Math.min(sourceEvent.clientX || 12, window.innerWidth - 340))
|
||||
positionY = Math.max(12, Math.min(sourceEvent.clientY || 12, window.innerHeight - 420))
|
||||
// Place the menu at the pointer and let the anchored clamp in the
|
||||
// runtime pull it back on screen once it has been laid out and can
|
||||
// actually be measured. Subtracting a guessed 340x420 here instead
|
||||
// pushed every menu that was not that size away from the pointer.
|
||||
positionX = Math.max(12, sourceEvent.clientX || 12)
|
||||
positionY = Math.max(12, sourceEvent.clientY || 12)
|
||||
}
|
||||
visible = true
|
||||
output.open({
|
||||
@@ -161,6 +165,7 @@ component ContextMenu {
|
||||
|
||||
<div
|
||||
class="wire-context-menu__panel"
|
||||
data-wrn-anchored="true"
|
||||
data-show='{open || visible}'
|
||||
role="menu"
|
||||
aria-label='{label}'
|
||||
|
||||
@@ -1,17 +1,122 @@
|
||||
// CustomScrollbar -- a scrolling region with a themed scrollbar.
|
||||
//
|
||||
// <CustomScrollbar maxHeight="20rem">...long content...</CustomScrollbar>
|
||||
//
|
||||
// Scrollbar appearance is CSS, not script: scrollbar-width and scrollbar-color
|
||||
// are the standard properties, and the ::-webkit-scrollbar rules cover the
|
||||
// browsers that still need them.
|
||||
//
|
||||
// This component used to declare a scroll output it never emitted, and its
|
||||
// props were columns, gap and maxWidth copied from a grid scaffold. The output
|
||||
// is removed rather than left unimplemented -- a caller can listen for a plain
|
||||
// scroll event on the element, which is what it would have been anyway.
|
||||
//
|
||||
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
|
||||
// and silently swallows the rule that follows it.
|
||||
component CustomScrollbar {
|
||||
outputs {
|
||||
scroll(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
columns: number = 2
|
||||
gap: string = "md"
|
||||
maxWidth: string = "xl"
|
||||
size: string = "default"
|
||||
axis: string = "vertical"
|
||||
// Track thickness in pixels. Clamped, because it arrives as an attribute
|
||||
// and a scrollbar wider than the content is not useful to anyone.
|
||||
thickness: number = 8
|
||||
maxHeight: string = "20rem"
|
||||
radius: string = "999px"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
functions {
|
||||
shared function trackSize() {
|
||||
var value = Number(thickness)
|
||||
if (!value || value < 2) {
|
||||
return 8
|
||||
}
|
||||
return Math.min(24, value)
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<div class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--custom-scrollbar wire-next--gap-{gap} wire-next--columns-{columns} wire-next--max-{maxWidth} {class}"><slot /></div>
|
||||
<div
|
||||
{...attrs}
|
||||
data-ui-component="CustomScrollbar"
|
||||
class='wire-scrollbar {class}'
|
||||
data-color='{color}'
|
||||
data-size='{size}'
|
||||
data-axis='{axis}'
|
||||
style='--scrollbar-thickness:{trackSize()}px;--scrollbar-radius:{radius};max-height:{maxHeight};'
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wire-scrollbar {
|
||||
--scrollbar-accent: var(--wire-color-primary);
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
/*
|
||||
* The standard properties. Firefox and current Chrome honour these; the
|
||||
* webkit rules below are only for the engines that still ignore them.
|
||||
*/
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--scrollbar-accent) 55%, transparent) transparent;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.wire-scrollbar[data-color="secondary"] {
|
||||
--scrollbar-accent: var(--wire-color-secondary);
|
||||
}
|
||||
|
||||
.wire-scrollbar[data-color="success"] {
|
||||
--scrollbar-accent: var(--wire-color-success);
|
||||
}
|
||||
|
||||
.wire-scrollbar[data-color="danger"] {
|
||||
--scrollbar-accent: var(--wire-color-danger);
|
||||
}
|
||||
|
||||
.wire-scrollbar[data-color="info"] {
|
||||
--scrollbar-accent: var(--wire-color-info);
|
||||
}
|
||||
|
||||
.wire-scrollbar[data-axis="vertical"] {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wire-scrollbar[data-axis="horizontal"] {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.wire-scrollbar::-webkit-scrollbar {
|
||||
width: var(--scrollbar-thickness, 8px);
|
||||
height: var(--scrollbar-thickness, 8px);
|
||||
}
|
||||
|
||||
.wire-scrollbar::-webkit-scrollbar-track {
|
||||
background: var(--wire-color-surface-soft);
|
||||
border-radius: var(--scrollbar-radius, 999px);
|
||||
}
|
||||
|
||||
.wire-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--scrollbar-accent) 45%, transparent);
|
||||
border-radius: var(--scrollbar-radius, 999px);
|
||||
}
|
||||
|
||||
.wire-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-accent);
|
||||
}
|
||||
|
||||
/*
|
||||
* A pointer-less device paints its own overlay scrollbar and ignores the
|
||||
* width above, so the region keeps its own padding instead.
|
||||
*/
|
||||
@media (hover: none) {
|
||||
.wire-scrollbar {
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,8 @@ open: boolean = false
|
||||
showClose: boolean = true
|
||||
closeOnBackdrop: boolean = true
|
||||
closeOnEscape: boolean = true
|
||||
// Open/close animation length in ms. 0 disables the animation entirely.
|
||||
duration: number = 260
|
||||
overlay: boolean = true
|
||||
scrollable: boolean = true
|
||||
triggerLabel: string = ""
|
||||
@@ -79,7 +81,9 @@ open: boolean = false
|
||||
data-overlay='{overlay ? "true" : "false"}'
|
||||
data-scrollable='{scrollable ? "true" : "false"}'
|
||||
class='wire-drawer {class}'
|
||||
style='--drawer-duration: {duration}ms'
|
||||
>
|
||||
|
||||
{#if triggerLabel}
|
||||
<button
|
||||
type="button"
|
||||
@@ -101,7 +105,6 @@ open: boolean = false
|
||||
|
||||
<div
|
||||
class="wire-drawer__layer"
|
||||
data-show='{open || visible}'
|
||||
role="presentation"
|
||||
@keydown='handleKeydown(event)'
|
||||
>
|
||||
@@ -218,12 +221,32 @@ open: boolean = false
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/*
|
||||
* The layer stays in the layout and is revealed by [data-open]; it used to
|
||||
* be toggled with data-show, which sets display:none, and display cannot
|
||||
* be transitioned -- the drawer simply snapped in and out. visibility is
|
||||
* delayed by the duration on the way out so the panel can finish sliding
|
||||
* before the layer is taken out of the hit-testing tree.
|
||||
*/
|
||||
.wire-drawer__layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--drawer-duration, 260ms) ease,
|
||||
visibility 0s linear var(--drawer-duration, 260ms);
|
||||
}
|
||||
|
||||
.wire-drawer[data-open="true"] .wire-drawer__layer {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transition:
|
||||
opacity var(--drawer-duration, 260ms) ease,
|
||||
visibility 0s linear 0s;
|
||||
}
|
||||
|
||||
.wire-drawer__backdrop {
|
||||
@@ -262,6 +285,38 @@ open: boolean = false
|
||||
box-shadow: -28px 0 80px color-mix(in srgb, black 28%, transparent);
|
||||
pointer-events: auto;
|
||||
overflow: hidden;
|
||||
/* Slides in from whichever edge the placement puts it on. */
|
||||
transform: translateX(100%);
|
||||
transition: transform var(--drawer-duration, 260ms) cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.wire-drawer[data-open="true"] .wire-drawer__panel {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.wire-drawer[data-placement="left"] .wire-drawer__panel {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.wire-drawer[data-placement="top"] .wire-drawer__panel {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.wire-drawer[data-placement="bottom"] .wire-drawer__panel {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.wire-drawer[data-open="true"][data-placement="left"] .wire-drawer__panel,
|
||||
.wire-drawer[data-open="true"][data-placement="top"] .wire-drawer__panel,
|
||||
.wire-drawer[data-open="true"][data-placement="bottom"] .wire-drawer__panel {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wire-drawer__layer,
|
||||
.wire-drawer__panel {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wire-drawer[data-size="sm"] .wire-drawer__panel {
|
||||
@@ -395,12 +450,19 @@ open: boolean = false
|
||||
color: color-mix(in srgb, currentColor 76%, transparent);
|
||||
}
|
||||
|
||||
/*
|
||||
* padding is reset explicitly: an app-level `button { padding: ... }` rule
|
||||
* outranks the browser default and leaves this fixed-size button with a
|
||||
* content box of a couple of pixels, which squeezes the icon to a sliver
|
||||
* and reads as "the close button has no icon". Same trap as Modal.
|
||||
*/
|
||||
.wire-drawer__close {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
padding: 0;
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
color: var(--wire-color-text-muted);
|
||||
@@ -417,10 +479,30 @@ open: boolean = false
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Never let the glyph be shrunk by the flex container. */
|
||||
.wire-drawer__close svg {
|
||||
flex: 0 0 auto;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* Slot content is authored by the host app, so the app global stylesheet
|
||||
* styles it too. A bare element selector there (p { color: ... }) beats
|
||||
* anything the panel merely *inherits*, which is how modal body copy ended
|
||||
* up muted grey on a saturated background. State the colour explicitly;
|
||||
* :where() keeps the specificity low enough that any class the app puts on
|
||||
* its own slot content still wins.
|
||||
*/
|
||||
.wire-drawer__body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 1.35rem;
|
||||
color: var(--wire-color-text);
|
||||
}
|
||||
|
||||
.wire-drawer__body :where(p, li, dd, dt, h1, h2, h3, h4, h5, h6, span, label, code) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.wire-drawer[data-scrollable="true"] .wire-drawer__body {
|
||||
|
||||
@@ -171,6 +171,7 @@ items: unknown[] = []
|
||||
|
||||
<div
|
||||
class="wire-dropdown__panel"
|
||||
data-wrn-anchored="true"
|
||||
data-show='{open || visible}'
|
||||
role="menu"
|
||||
aria-label='{menuLabel}'
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
// Image -- a framed image with a reserved aspect ratio.
|
||||
//
|
||||
// <Image src="/hero.jpg" alt="Dashboard" size="video" rounded={true} />
|
||||
//
|
||||
// The frame keeps its ratio whether or not the image has loaded, so the page
|
||||
// does not jump when it arrives. Without a src it renders a labelled
|
||||
// placeholder of the same shape rather than collapsing.
|
||||
//
|
||||
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
|
||||
// and silently swallows the rule that follows it.
|
||||
component Image {
|
||||
props {
|
||||
size: string = "default"
|
||||
@@ -7,41 +17,111 @@ component Image {
|
||||
width: string = ""
|
||||
height: string = ""
|
||||
loading: string = "lazy"
|
||||
fit: string = "cover"
|
||||
rounded: boolean = false
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<figure
|
||||
{...attrs}
|
||||
data-ui-component="Image"
|
||||
class='relative m-0 overflow-hidden bg-[var(--wire-color-surface-soft)] {class}'
|
||||
class:rounded-2xl='rounded'
|
||||
class='wire-image {class}'
|
||||
data-size='{size}'
|
||||
data-color='{color}'
|
||||
data-fit='{fit}'
|
||||
data-rounded='{rounded}'
|
||||
>
|
||||
{#if src}
|
||||
<img
|
||||
src='{src}'
|
||||
alt='{alt}'
|
||||
width='{width}'
|
||||
height='{height}'
|
||||
loading='{loading}'
|
||||
decoding="async"
|
||||
class="block h-auto w-full object-cover transition duration-300"
|
||||
class:aspect-square='size === "square"'
|
||||
class:aspect-video='size === "video"'
|
||||
class:aspect-[4/3]='size === "landscape"'
|
||||
class:aspect-[3/4]='size === "portrait"'
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex min-h-48 w-full items-center justify-center text-[var(--wire-color-text-muted)]"
|
||||
role="img"
|
||||
aria-label='{alt || "Image placeholder"}'
|
||||
>
|
||||
<span class="icon-[lucide--image] size-8" aria-hidden="true"></span>
|
||||
</div>
|
||||
{/if}
|
||||
<img
|
||||
class="wire-image__img"
|
||||
data-show="src"
|
||||
src='{src}'
|
||||
alt='{alt}'
|
||||
width='{width}'
|
||||
height='{height}'
|
||||
loading='{loading}'
|
||||
decoding="async"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="wire-image__placeholder"
|
||||
data-show="!src"
|
||||
role="img"
|
||||
aria-label='{alt || "Image placeholder"}'
|
||||
>
|
||||
<span class="icon-[lucide--image] wire-image__placeholder-icon" aria-hidden="true"></span>
|
||||
</div>
|
||||
|
||||
<slot></slot>
|
||||
</figure>
|
||||
}
|
||||
|
||||
style {
|
||||
.wire-image {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
background: var(--wire-color-surface-soft);
|
||||
}
|
||||
|
||||
.wire-image[data-rounded="true"] {
|
||||
border-radius: var(--wire-radius-lg);
|
||||
}
|
||||
|
||||
/*
|
||||
* The ratio sits on the frame rather than the image, so the space is
|
||||
* reserved before the file arrives and the page does not jump.
|
||||
*/
|
||||
.wire-image[data-size="square"] {
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.wire-image[data-size="video"] {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.wire-image[data-size="landscape"] {
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.wire-image[data-size="portrait"] {
|
||||
aspect-ratio: 3 / 4;
|
||||
}
|
||||
|
||||
.wire-image__img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.wire-image[data-fit="contain"] .wire-image__img {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.wire-image[data-fit="fill"] .wire-image__img {
|
||||
object-fit: fill;
|
||||
}
|
||||
|
||||
.wire-image[data-size="default"] .wire-image__img {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.wire-image__placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 12rem;
|
||||
height: 100%;
|
||||
color: var(--wire-color-text-muted);
|
||||
}
|
||||
|
||||
.wire-image__placeholder-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,26 +232,17 @@ component InputNumber {
|
||||
)
|
||||
}
|
||||
|
||||
// Outputs are resolved by name, so each one is written out. Raw
|
||||
// CustomEvents dispatched on the root -- what this did before -- never
|
||||
// reach a parent @binding.
|
||||
client function dispatchInputNumberEvent(
|
||||
sourceEvent,
|
||||
eventName,
|
||||
action,
|
||||
previousValue,
|
||||
root,
|
||||
customEvent
|
||||
payload
|
||||
) {
|
||||
root = sourceEvent.currentTarget.closest("[data-wrn-input-number]")
|
||||
|
||||
if (!root && sourceEvent.target) {
|
||||
root = sourceEvent.target.closest("[data-wrn-input-number]")
|
||||
}
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent(eventName, true, false, {
|
||||
payload = {
|
||||
component: "InputNumber",
|
||||
name: name,
|
||||
value: currentValue,
|
||||
@@ -261,8 +252,17 @@ component InputNumber {
|
||||
max: hasMax() ? Number(max) : null,
|
||||
step: normalizedStep(),
|
||||
valid: !isInvalid()
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
}
|
||||
|
||||
if (eventName === "input") {
|
||||
output.input(payload)
|
||||
} else if (eventName === "change") {
|
||||
output.change(payload)
|
||||
} else if (eventName === "increment") {
|
||||
output.increment(payload)
|
||||
} else if (eventName === "decrement") {
|
||||
output.decrement(payload)
|
||||
}
|
||||
}
|
||||
|
||||
client function applyControlValue(nextValue, action, sourceEvent, previousValue) {
|
||||
|
||||
@@ -1,9 +1,55 @@
|
||||
// Kbd -- a keyboard key.
|
||||
//
|
||||
// <Kbd label="Ctrl" />
|
||||
//
|
||||
// Deliberately small. A kbd element is a kbd element; the work here is making
|
||||
// it look like a key and follow the theme.
|
||||
//
|
||||
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
|
||||
// and silently swallows the rule that follows it.
|
||||
component Kbd {
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
label: string = "⌘ K"
|
||||
label: string = "K"
|
||||
class: string = ""
|
||||
}
|
||||
view { <kbd class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--kbd {class}"><slot>{label}</slot></kbd> }
|
||||
|
||||
view {
|
||||
<kbd {...attrs} class='wire-kbd {class}' data-size='{size}' data-color='{color}'>
|
||||
<slot>{label}</slot>
|
||||
</kbd>
|
||||
}
|
||||
|
||||
style {
|
||||
.wire-kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.6rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border: 1px solid var(--wire-color-border);
|
||||
/* The lower edge is what reads as a physical key. */
|
||||
border-bottom-width: 2px;
|
||||
border-radius: var(--wire-radius-sm);
|
||||
background: var(--wire-color-surface-soft);
|
||||
color: var(--wire-color-text);
|
||||
font-family: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.wire-kbd[data-size="sm"] {
|
||||
min-width: 1.4rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.wire-kbd[data-size="lg"] {
|
||||
min-width: 2rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,225 @@
|
||||
// LayoutSplitter -- two panes with a divider the reader can move.
|
||||
//
|
||||
// <LayoutSplitter size={40} minSize={20}>
|
||||
// <div data-slot="start">...</div>
|
||||
// <div data-slot="end">...</div>
|
||||
// </LayoutSplitter>
|
||||
//
|
||||
// The dragging lives in the reactive runtime behind data-wrn-splitter. Pointer
|
||||
// moves fire far too often to route through a client function, and a state
|
||||
// write made inside a pointermove callback is dropped, so the resolved size is
|
||||
// held in the DOM as the --wrn-split custom property and these styles read it.
|
||||
//
|
||||
// This component previously declared resizeStart, resize and resizeEnd with no
|
||||
// pointer handling whatsoever: a caller wired up @resize and received nothing,
|
||||
// for ever, with no error.
|
||||
//
|
||||
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
|
||||
// and silently swallows the rule that follows it.
|
||||
component LayoutSplitter {
|
||||
outputs {
|
||||
resizeStart(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
|
||||
resize(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
|
||||
resizeEnd(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
|
||||
// Named sizeChange rather than resize so it cannot be confused with the
|
||||
// native window event a caller may already be listening for. An earlier
|
||||
// comment here claimed a natively-named output could never reach a parent
|
||||
// binding; that was wrong, and the rename was never what fixed anything.
|
||||
sizeChange(payload: { size: number })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
columns: number = 2
|
||||
gap: string = "md"
|
||||
maxWidth: string = "xl"
|
||||
size: number = 50
|
||||
orientation: string = "horizontal"
|
||||
// Smallest share either pane may take, as a percentage. It also fixes the
|
||||
// upper bound at 100 - minSize, so neither pane can be dragged away to
|
||||
// nothing and left unrecoverable by pointer.
|
||||
minSize: number = 15
|
||||
step: number = 5
|
||||
label: string = "Resize panels"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
functions {
|
||||
// The runtime resolves the size and announces it; this turns that into the
|
||||
// declared output so a parent @resize binding receives it.
|
||||
client function reportResize(sourceEvent) {
|
||||
var detail = sourceEvent ? sourceEvent.detail : null
|
||||
if (!detail) {
|
||||
return
|
||||
}
|
||||
output.sizeChange({ size: detail.size })
|
||||
}
|
||||
|
||||
shared function isVertical() {
|
||||
return orientation === "vertical"
|
||||
}
|
||||
|
||||
shared function lowerBound() {
|
||||
var value = Number(minSize)
|
||||
if (!value || value < 0) {
|
||||
return 15
|
||||
}
|
||||
return Math.min(45, value)
|
||||
}
|
||||
|
||||
shared function upperBound() {
|
||||
return 100 - lowerBound()
|
||||
}
|
||||
|
||||
// Sizes arrive as HTML attributes, so a value outside the bounds is
|
||||
// routine rather than exceptional. Clamp instead of rendering something
|
||||
// the reader cannot undo.
|
||||
shared function currentSize() {
|
||||
var value = Number(size)
|
||||
if (!value || value < 0) {
|
||||
return 50
|
||||
}
|
||||
return Math.min(upperBound(), Math.max(lowerBound(), value))
|
||||
}
|
||||
|
||||
shared function stepSize() {
|
||||
var value = Number(step)
|
||||
return value && value > 0 ? value : 5
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<div class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--layout-splitter wire-next--gap-{gap} wire-next--columns-{columns} wire-next--max-{maxWidth} {class}"><slot /></div>
|
||||
<div
|
||||
{...attrs}
|
||||
data-ui-component="LayoutSplitter"
|
||||
class='wire-splitter {class}'
|
||||
data-color='{color}'
|
||||
data-orientation='{orientation}'
|
||||
data-wrn-splitter='{isVertical() ? "vertical" : "horizontal"}'
|
||||
data-wrn-splitter-min='{lowerBound()}'
|
||||
data-wrn-splitter-step='{stepSize()}'
|
||||
style='--wrn-split:{currentSize()}%;'
|
||||
@wrnexus:splitter:resize='reportResize(event)'
|
||||
>
|
||||
<div class="wire-splitter__pane wire-splitter__pane--start">
|
||||
<slot name="start"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="wire-splitter__handle"
|
||||
data-wrn-splitter-handle="true"
|
||||
role="separator"
|
||||
tabindex="0"
|
||||
aria-label='{label}'
|
||||
aria-orientation='{isVertical() ? "horizontal" : "vertical"}'
|
||||
aria-valuenow='{currentSize()}'
|
||||
aria-valuemin='{lowerBound()}'
|
||||
aria-valuemax='{upperBound()}'
|
||||
>
|
||||
<span class="wire-splitter__grip" aria-hidden="true"></span>
|
||||
</div>
|
||||
|
||||
<div class="wire-splitter__pane wire-splitter__pane--end">
|
||||
<slot name="end"></slot>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wire-splitter {
|
||||
--splitter-accent: var(--wire-color-primary);
|
||||
display: grid;
|
||||
/* The first track follows the size the runtime resolves. */
|
||||
grid-template-columns: var(--wrn-split, 50%) auto minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wire-splitter[data-color="secondary"] {
|
||||
--splitter-accent: var(--wire-color-secondary);
|
||||
}
|
||||
|
||||
.wire-splitter[data-color="success"] {
|
||||
--splitter-accent: var(--wire-color-success);
|
||||
}
|
||||
|
||||
.wire-splitter[data-color="danger"] {
|
||||
--splitter-accent: var(--wire-color-danger);
|
||||
}
|
||||
|
||||
.wire-splitter[data-color="info"] {
|
||||
--splitter-accent: var(--wire-color-info);
|
||||
}
|
||||
|
||||
.wire-splitter[data-orientation="vertical"] {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: var(--wrn-split, 50%) auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.wire-splitter__pane {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.wire-splitter__handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
padding: 0 0.25rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: col-resize;
|
||||
/* Without this the pointer drag selects the text in both panes. */
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.wire-splitter[data-orientation="vertical"] .wire-splitter__handle {
|
||||
padding: 0.25rem 0;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.wire-splitter__grip {
|
||||
display: block;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
min-height: 1.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--wire-color-border);
|
||||
transition: background 140ms ease;
|
||||
}
|
||||
|
||||
.wire-splitter[data-orientation="vertical"] .wire-splitter__grip {
|
||||
width: 100%;
|
||||
min-width: 1.5rem;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.wire-splitter__handle:hover .wire-splitter__grip,
|
||||
.wire-splitter[data-wrn-splitter-dragging="true"] .wire-splitter__grip {
|
||||
background: var(--splitter-accent);
|
||||
}
|
||||
|
||||
.wire-splitter__handle:focus-visible {
|
||||
outline: 2px solid var(--splitter-accent);
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--wire-radius-sm);
|
||||
}
|
||||
|
||||
/*
|
||||
* Two panes side by side stop making sense on a phone. Stacking them keeps
|
||||
* both readable, and the divider stops being draggable because the grid
|
||||
* no longer has a second track to trade against.
|
||||
*/
|
||||
@media (max-width: 639px) {
|
||||
.wire-splitter,
|
||||
.wire-splitter[data-orientation="vertical"] {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto auto auto;
|
||||
}
|
||||
|
||||
.wire-splitter__handle {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+114
-24
@@ -1,3 +1,13 @@
|
||||
// Link -- a text link that follows the theme.
|
||||
//
|
||||
// <Link href="/docs" label="Documentation" />
|
||||
// <Link href="https://example.com" label="Spec" external={true} />
|
||||
//
|
||||
// external adds rel="noopener noreferrer" and an outbound icon, so the reader
|
||||
// is told the link leaves the site before they follow it.
|
||||
//
|
||||
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
|
||||
// and silently swallows the rule that follows it.
|
||||
component Link {
|
||||
props {
|
||||
size: string = "default"
|
||||
@@ -6,42 +16,122 @@ component Link {
|
||||
href: string = "#"
|
||||
target: string = ""
|
||||
rel: string = ""
|
||||
underline: string = "hover"
|
||||
external: boolean = false
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<a
|
||||
{...attrs}
|
||||
data-ui-component="Link"
|
||||
class='wire-link {class}'
|
||||
href='{href}'
|
||||
target='{target}'
|
||||
rel='{external ? (rel || "noopener noreferrer") : rel}'
|
||||
class='inline-flex min-w-0 items-center gap-1.5 rounded-md font-semibold underline-offset-4 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--wire-color-background)] {class}'
|
||||
class:text-xs='size === "xs"'
|
||||
class:text-sm='size === "sm" || size === "default"'
|
||||
class:text-base='size === "md"'
|
||||
class:text-lg='size === "lg"'
|
||||
class:text-[var(--wire-color-primary)]='color === "primary"'
|
||||
class:hover:text-[var(--wire-color-primary-hover)]='color === "primary"'
|
||||
class:text-[var(--wire-color-secondary)]='color === "secondary"'
|
||||
class:hover:text-[var(--wire-color-secondary-hover)]='color === "secondary"'
|
||||
class:text-[var(--wire-color-success)]='color === "success"'
|
||||
class:text-[var(--wire-color-warning-text)]='color === "warning"'
|
||||
class:text-[var(--wire-color-danger)]='color === "danger"'
|
||||
class:text-[var(--wire-color-info)]='color === "info"'
|
||||
class:text-[var(--wire-color-text)]='color === "neutral"'
|
||||
class:hover:underline='external === false'
|
||||
data-size='{size}'
|
||||
data-color='{color}'
|
||||
data-underline='{underline}'
|
||||
data-external='{external}'
|
||||
>
|
||||
<span class="truncate">{label}</span>
|
||||
|
||||
{#if external}
|
||||
<span
|
||||
class="icon-[lucide--external-link] size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{/if}
|
||||
|
||||
<span class="wire-link__label">{label}</span>
|
||||
<span
|
||||
class="icon-[lucide--external-link] wire-link__icon"
|
||||
data-show="external"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<slot></slot>
|
||||
</a>
|
||||
}
|
||||
|
||||
style {
|
||||
.wire-link {
|
||||
--link-tone: var(--wire-color-primary);
|
||||
--link-tone-hover: var(--wire-color-primary-hover);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
border-radius: var(--wire-radius-sm);
|
||||
color: var(--link-tone);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
text-underline-offset: 4px;
|
||||
transition: color 140ms ease;
|
||||
}
|
||||
|
||||
.wire-link[data-color="secondary"] {
|
||||
--link-tone: var(--wire-color-secondary);
|
||||
--link-tone-hover: var(--wire-color-secondary-hover);
|
||||
}
|
||||
|
||||
.wire-link[data-color="success"] {
|
||||
--link-tone: var(--wire-color-success);
|
||||
--link-tone-hover: var(--wire-color-success);
|
||||
}
|
||||
|
||||
.wire-link[data-color="warning"] {
|
||||
--link-tone: var(--wire-color-warning-text);
|
||||
--link-tone-hover: var(--wire-color-warning-text);
|
||||
}
|
||||
|
||||
.wire-link[data-color="danger"] {
|
||||
--link-tone: var(--wire-color-danger);
|
||||
--link-tone-hover: var(--wire-color-danger);
|
||||
}
|
||||
|
||||
.wire-link[data-color="info"] {
|
||||
--link-tone: var(--wire-color-info);
|
||||
--link-tone-hover: var(--wire-color-info);
|
||||
}
|
||||
|
||||
.wire-link[data-color="neutral"] {
|
||||
--link-tone: var(--wire-color-text);
|
||||
--link-tone-hover: var(--wire-color-text);
|
||||
}
|
||||
|
||||
.wire-link[data-size="xs"] {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.wire-link[data-size="md"] {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.wire-link[data-size="lg"] {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.wire-link[data-underline="always"] {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.wire-link[data-underline="hover"]:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.wire-link:hover {
|
||||
color: var(--link-tone-hover);
|
||||
}
|
||||
|
||||
.wire-link:focus-visible {
|
||||
outline: 2px solid var(--wire-color-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* The label truncates; the outbound icon must not shrink with it. */
|
||||
.wire-link__label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wire-link__icon {
|
||||
flex: 0 0 auto;
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
component List {
|
||||
outputs {
|
||||
select(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
@@ -52,7 +56,7 @@ component List {
|
||||
class:py-2.5='size === "sm"'
|
||||
class:px-5='size === "lg"'
|
||||
class:py-4='size === "lg"'
|
||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: item, index: index } }))'
|
||||
@click='output.select({ item: item, index: index })'
|
||||
>
|
||||
{#if item.icon}
|
||||
<span class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-[var(--wire-color-primary-soft)] text-[var(--wire-color-primary)]">
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
component Map {
|
||||
outputs {
|
||||
// markerClick and select both fire for a marker press; select is the
|
||||
// generic name callers reach for, markerClick the explicit one.
|
||||
markerClick(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
select(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
zoom(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
@@ -54,7 +62,7 @@ component Map {
|
||||
aria-label='{item.label || item.title || "Map marker"}'
|
||||
class="absolute inline-flex size-10 items-center justify-center rounded-full border-4 border-[var(--wire-color-surface-raised)] bg-[var(--wire-color-primary)] text-[var(--wire-color-on-primary)] shadow-lg transition hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]"
|
||||
style='left: {item.x || (20 + index * 12)}%; top: {item.y || (30 + (index % 3) * 18)}%;'
|
||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("markerClick", { bubbles: true, detail: item })); event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: item }))'
|
||||
@click='output.markerClick(item); output.select(item)'
|
||||
>
|
||||
<span class='{item.icon || "icon-[lucide--map-pin]"}' aria-hidden="true"></span>
|
||||
</button>
|
||||
@@ -66,7 +74,7 @@ component Map {
|
||||
type="button"
|
||||
aria-label="Zoom in"
|
||||
class="inline-flex size-10 items-center justify-center rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-raised)] text-[var(--wire-color-text)] shadow-sm transition hover:bg-[var(--wire-color-surface-soft)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]"
|
||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("zoom", { bubbles: true, detail: { direction: "in" } }))'
|
||||
@click='output.zoom({ direction: "in" })'
|
||||
>
|
||||
<span class="icon-[lucide--plus] size-4" aria-hidden="true"></span>
|
||||
</button>
|
||||
@@ -74,7 +82,7 @@ component Map {
|
||||
type="button"
|
||||
aria-label="Zoom out"
|
||||
class="inline-flex size-10 items-center justify-center rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-raised)] text-[var(--wire-color-text)] shadow-sm transition hover:bg-[var(--wire-color-surface-soft)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]"
|
||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("zoom", { bubbles: true, detail: { direction: "out" } }))'
|
||||
@click='output.zoom({ direction: "out" })'
|
||||
>
|
||||
<span class="icon-[lucide--minus] size-4" aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
component Marquee {
|
||||
outputs {
|
||||
// Fired when the reader pauses the scroll, by hover, focus or the button.
|
||||
pause(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
resume(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
@@ -27,8 +33,8 @@ component Marquee {
|
||||
|
||||
<div
|
||||
class="group relative min-w-0 flex-1 overflow-hidden"
|
||||
@mouseenter='paused = true; event.currentTarget.dispatchEvent(new CustomEvent("pause", { bubbles: true }))'
|
||||
@mouseleave='paused = false; event.currentTarget.dispatchEvent(new CustomEvent("resume", { bubbles: true }))'
|
||||
@mouseenter='paused = true; output.pause({})'
|
||||
@mouseleave='paused = false; output.resume({})'
|
||||
@focusin='paused = true'
|
||||
@focusout='paused = false'
|
||||
>
|
||||
@@ -77,7 +83,7 @@ component Marquee {
|
||||
type="button"
|
||||
aria-label='{paused ? "Resume announcements" : "Pause announcements"}'
|
||||
class="flex shrink-0 items-center justify-center border-l border-[var(--wire-color-border)] px-4 text-[var(--wire-color-text-muted)] transition hover:bg-[var(--wire-color-surface-soft)] hover:text-[var(--wire-color-text)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--wire-color-focus)]"
|
||||
@click='paused = !paused; event.currentTarget.dispatchEvent(new CustomEvent(paused ? "pause" : "resume", { bubbles: true }))'
|
||||
@click='paused = !paused; paused ? output.pause({}) : output.resume({})'
|
||||
>
|
||||
<span class="icon-[lucide--pause] size-4" data-show='!paused' aria-hidden="true"></span>
|
||||
<span class="icon-[lucide--play] size-4" data-show='paused' aria-hidden="true"></span>
|
||||
|
||||
@@ -1,23 +1,344 @@
|
||||
// MegaMenu -- a trigger and a wide panel of grouped links.
|
||||
//
|
||||
// <MegaMenu label="Products" columns='[{"heading":"Platform",
|
||||
// "items":[{"label":"Runtime","href":"/runtime"}]}]' />
|
||||
//
|
||||
// Deliberately one level deep. A mega menu exists to show breadth flat, so
|
||||
// everything is one click away; nesting inside the panel buries content behind
|
||||
// hover-within-hover and is close to unusable by keyboard and touch. Use Nav
|
||||
// when you actually want cascading submenus.
|
||||
//
|
||||
// The panel carries data-wrn-anchored so the runtime clamp keeps it inside the
|
||||
// viewport instead of hanging off the edge of a wide layout.
|
||||
//
|
||||
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
|
||||
// and silently swallows the rule that follows it.
|
||||
component MegaMenu {
|
||||
outputs {
|
||||
open(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
close(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
select(payload: { 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)
|
||||
open(payload: { sourceEvent: Event })
|
||||
close(payload: { reason: string })
|
||||
select(payload: { item: object; value: string })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
label: string = "Mega Menu"
|
||||
items: unknown[] = []
|
||||
active: string = ""
|
||||
orientation: string = "horizontal"
|
||||
size: string = "default"
|
||||
label: string = "Menu"
|
||||
icon: string = ""
|
||||
columns: unknown[] = []
|
||||
footer: string = ""
|
||||
defaultOpen: boolean = false
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
state visible = defaultOpen
|
||||
|
||||
functions {
|
||||
shared function columnList() {
|
||||
return Array.isArray(columns) ? columns : []
|
||||
}
|
||||
|
||||
shared function itemsOf(column) {
|
||||
return column && Array.isArray(column.items) ? column.items : []
|
||||
}
|
||||
|
||||
client function showPanel(sourceEvent) {
|
||||
visible = true
|
||||
output.open({ sourceEvent: sourceEvent })
|
||||
}
|
||||
|
||||
client function hidePanel(reason) {
|
||||
visible = false
|
||||
output.close({ reason: reason })
|
||||
}
|
||||
|
||||
client function togglePanel(sourceEvent) {
|
||||
if (visible) {
|
||||
hidePanel("toggle")
|
||||
} else {
|
||||
showPanel(sourceEvent)
|
||||
}
|
||||
}
|
||||
|
||||
client function handleKeydown(sourceEvent) {
|
||||
if (sourceEvent.key === "Escape" && visible) {
|
||||
sourceEvent.preventDefault()
|
||||
hidePanel("escape")
|
||||
}
|
||||
}
|
||||
|
||||
client function choose(item) {
|
||||
if (item.disabled) {
|
||||
return
|
||||
}
|
||||
output.select({ item: item, value: item.value || item.label || "" })
|
||||
hidePanel("select")
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--mega-menu wire-next--{orientation} {class}" aria-label="{label}">
|
||||
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each}
|
||||
<slot />
|
||||
</nav>
|
||||
<div
|
||||
{...attrs}
|
||||
data-ui-component="MegaMenu"
|
||||
class='wire-mega {class}'
|
||||
data-color='{color}'
|
||||
data-size='{size}'
|
||||
data-open='{visible}'
|
||||
@keydown='handleKeydown(event)'
|
||||
@mouseleave='hidePanel("pointer-leave")'
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="wire-mega__trigger"
|
||||
aria-haspopup="true"
|
||||
aria-expanded='{visible}'
|
||||
@click='togglePanel(event)'
|
||||
@mouseenter='showPanel(event)'
|
||||
@focus='showPanel(event)'
|
||||
>
|
||||
<span class='wire-mega__trigger-icon {icon}' data-show="icon" aria-hidden="true"></span>
|
||||
<span class="wire-mega__trigger-label">{label}</span>
|
||||
<span class="wire-mega__arrow" aria-hidden="true">›</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="wire-mega__panel"
|
||||
data-wrn-anchored="true"
|
||||
data-show="visible"
|
||||
data-wrn-roving="both"
|
||||
>
|
||||
<div class="wire-mega__columns">
|
||||
{#each columnList() as column}
|
||||
<section class="wire-mega__column">
|
||||
<h3 class="wire-mega__heading" data-show="column.heading">{column.heading}</h3>
|
||||
<ul class="wire-mega__list">
|
||||
{#each itemsOf(column) as item}
|
||||
<li>
|
||||
<a
|
||||
class="wire-mega__link"
|
||||
href='{item.href || "#"}'
|
||||
data-wrn-roving-item="true"
|
||||
aria-disabled='{item.disabled ? "true" : "false"}'
|
||||
@click='choose(item)'
|
||||
>
|
||||
<span
|
||||
class='wire-mega__link-icon {item.icon}'
|
||||
data-show="item.icon"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="wire-mega__link-body">
|
||||
<span class="wire-mega__link-label">{item.label}</span>
|
||||
<span class="wire-mega__link-description" data-show="item.description">
|
||||
{item.description}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="wire-mega__footer" data-show="footer">{footer}</p>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wire-mega {
|
||||
--mega-accent: var(--wire-color-primary);
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.wire-mega[data-color="secondary"] {
|
||||
--mega-accent: var(--wire-color-secondary);
|
||||
}
|
||||
|
||||
.wire-mega[data-color="success"] {
|
||||
--mega-accent: var(--wire-color-success);
|
||||
}
|
||||
|
||||
.wire-mega[data-color="danger"] {
|
||||
--mega-accent: var(--wire-color-danger);
|
||||
}
|
||||
|
||||
.wire-mega[data-color="info"] {
|
||||
--mega-accent: var(--wire-color-info);
|
||||
}
|
||||
|
||||
.wire-mega[data-size="sm"] {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.wire-mega[data-size="lg"] {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.wire-mega__trigger {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--wire-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--wire-color-text-muted);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wire-mega__trigger:hover,
|
||||
.wire-mega[data-open="true"] .wire-mega__trigger {
|
||||
background: var(--wire-color-surface-soft);
|
||||
color: var(--wire-color-text);
|
||||
}
|
||||
|
||||
.wire-mega__trigger:focus-visible {
|
||||
outline: 2px solid var(--mega-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.wire-mega__arrow {
|
||||
display: inline-block;
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.wire-mega[data-open="true"] .wire-mega__arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/*
|
||||
* The panel is offset below the trigger for looks, which used to close the
|
||||
* menu on the way to it: that offset is dead space belonging to neither
|
||||
* element, so crossing it fired mouseleave on the root. The bridge below
|
||||
* covers the gap with a descendant, so the pointer never actually leaves.
|
||||
*/
|
||||
.wire-mega__panel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 100%;
|
||||
height: 0.6rem;
|
||||
}
|
||||
|
||||
.wire-mega__panel {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
top: calc(100% + 0.4rem);
|
||||
left: 0;
|
||||
width: max-content;
|
||||
max-width: min(60rem, calc(100vw - 2rem));
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: var(--wire-radius-lg);
|
||||
background: var(--wire-color-surface);
|
||||
box-shadow: var(--wire-shadow-1);
|
||||
}
|
||||
|
||||
.wire-mega__columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.wire-mega__column {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wire-mega__heading {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--wire-color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wire-mega__list {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.wire-mega__link {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.5rem;
|
||||
border-radius: var(--wire-radius-sm);
|
||||
color: var(--wire-color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wire-mega__link:hover {
|
||||
background: var(--wire-color-surface-soft);
|
||||
}
|
||||
|
||||
.wire-mega__link:focus-visible {
|
||||
outline: 2px solid var(--mega-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.wire-mega__link[aria-disabled="true"] {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wire-mega__link-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wire-mega__link-label {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wire-mega__link-description {
|
||||
color: var(--wire-color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.wire-mega__footer {
|
||||
margin: 1rem 0 0;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--wire-color-border);
|
||||
color: var(--wire-color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* On a phone the panel stops floating and becomes part of the flow. A
|
||||
* pointer-opened overlay pinned to a trigger is unreachable on touch, and
|
||||
* a 60rem grid has nowhere to go on a 360px screen.
|
||||
*/
|
||||
@media (max-width: 767px) {
|
||||
.wire-mega {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wire-mega__panel {
|
||||
position: static;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.wire-mega__columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ component Modal {
|
||||
triggerLabel: string = ""
|
||||
triggerIcon: string = ""
|
||||
scrollable: boolean = true
|
||||
scrollBehavior: string = "inside"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
@@ -50,6 +51,16 @@ component Modal {
|
||||
output.open({ sourceEvent: sourceEvent })
|
||||
}
|
||||
|
||||
// Slot content can close the modal it sits in by dispatching a bubbling
|
||||
// wrnexus:modal:close event, e.g. from a form success handler:
|
||||
//
|
||||
// event.target.dispatchEvent(
|
||||
// new CustomEvent("wrnexus:modal:close", { bubbles: true })
|
||||
// )
|
||||
//
|
||||
// The listener is on the modal root, so the event only ever closes the
|
||||
// modal the dispatching element is actually inside -- no ids to wire up
|
||||
// and no way to close somebody else's modal by accident.
|
||||
client function hideModal(reason, sourceEvent) {
|
||||
visible = false
|
||||
output.close({
|
||||
@@ -87,21 +98,23 @@ component Modal {
|
||||
<div
|
||||
{...attrs}
|
||||
data-ui-component="Modal"
|
||||
data-open='{open || visible ? "true" : "false"}'
|
||||
data-open='{isOpen() ? "true" : "false"}'
|
||||
data-size='{size}'
|
||||
data-placement='{placement}'
|
||||
data-color='{color}'
|
||||
data-variant='{variant}'
|
||||
data-scrollable='{scrollable ? "true" : "false"}'
|
||||
data-scroll='{scrollBehavior}'
|
||||
data-destructive='{destructive ? "true" : "false"}'
|
||||
class='wire-modal {class}'
|
||||
@wrnexus:modal:close='hideModal("api", event)'
|
||||
>
|
||||
{#if triggerLabel}
|
||||
<button
|
||||
type="button"
|
||||
class="wire-modal__trigger"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded='{open || visible ? "true" : "false"}'
|
||||
aria-expanded='{isOpen() ? "true" : "false"}'
|
||||
@click='showModal(event)'
|
||||
>
|
||||
{#if triggerIcon}
|
||||
@@ -127,7 +140,7 @@ component Modal {
|
||||
|
||||
<div
|
||||
class="wire-modal__layer"
|
||||
data-show='{open || visible}'
|
||||
data-show="isOpen()"
|
||||
role="presentation"
|
||||
@keydown='handleKeydown(event)'
|
||||
>
|
||||
@@ -183,11 +196,20 @@ component Modal {
|
||||
aria-label='{closeLabel}'
|
||||
@click='hideModal("close-button", event)'
|
||||
>
|
||||
<span
|
||||
class="icon-[lucide--x]"
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</span>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="M6 6 18 18" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
@@ -277,16 +299,26 @@ component Modal {
|
||||
--modal-contrast: var(--wire-color-secondary-contrast);
|
||||
}
|
||||
|
||||
/*
|
||||
* Fallbacks below (the second var() argument): the theme token generator
|
||||
* (packages/styles/src/theme.ts) only emits a real -contrast token for
|
||||
* primary and secondary. info, success, and danger have no contrast
|
||||
* token defined at all, so var(--wire-color-info-contrast) with no
|
||||
* fallback resolves to nothing, and --modal-contrast becomes invalid --
|
||||
* which made confirm-button and solid-panel text unreadable. White is a
|
||||
* safe default against these saturated colors until the theme package
|
||||
* defines real tokens for them.
|
||||
*/
|
||||
.wire-modal[data-color="info"] {
|
||||
--modal-accent: var(--wire-color-info);
|
||||
--modal-soft: var(--wire-color-info-soft);
|
||||
--modal-contrast: var(--wire-color-info-contrast);
|
||||
--modal-contrast: var(--wire-color-info-contrast, white);
|
||||
}
|
||||
|
||||
.wire-modal[data-color="success"] {
|
||||
--modal-accent: var(--wire-color-success);
|
||||
--modal-soft: var(--wire-color-success-soft);
|
||||
--modal-contrast: var(--wire-color-success-contrast);
|
||||
--modal-contrast: var(--wire-color-success-contrast, white);
|
||||
}
|
||||
|
||||
.wire-modal[data-color="warning"] {
|
||||
@@ -299,7 +331,7 @@ component Modal {
|
||||
.wire-modal[data-destructive="true"] {
|
||||
--modal-accent: var(--wire-color-danger);
|
||||
--modal-soft: var(--wire-color-danger-soft);
|
||||
--modal-contrast: var(--wire-color-on-danger);
|
||||
--modal-contrast: var(--wire-color-on-danger, white);
|
||||
}
|
||||
|
||||
.wire-modal__trigger,
|
||||
@@ -321,8 +353,35 @@ component Modal {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms ease, transform 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.wire-modal__trigger:hover {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.wire-modal__trigger:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.wire-modal__trigger:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--modal-accent) 40%, transparent);
|
||||
}
|
||||
|
||||
/*
|
||||
* Hidden by default so the SSR-rendered HTML never paints the layer before
|
||||
* hydration runs. data-open on the wire-modal root is evaluated and
|
||||
* serialized to a real true or false string at render time -- a plain
|
||||
* bind, not the raw-expression data-show directive -- so this selector
|
||||
* is correct on first paint with zero flash, with no dependency on
|
||||
* client JS having run yet. The data-show attribute and client directive
|
||||
* still run after hydration to keep things in sync for state changes,
|
||||
* but visibility itself is driven by CSS here. visibility (rather than
|
||||
* display) is used so the open and close transitions below can actually
|
||||
* animate -- a box that starts at display: none has no prior frame to
|
||||
* transition from.
|
||||
*/
|
||||
.wire-modal__layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -331,6 +390,15 @@ component Modal {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 180ms ease, visibility 0s linear 180ms;
|
||||
}
|
||||
|
||||
.wire-modal[data-open="true"] .wire-modal__layer {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transition: opacity 180ms ease, visibility 0s linear 0s;
|
||||
}
|
||||
|
||||
.wire-modal[data-placement="top"] .wire-modal__layer {
|
||||
@@ -338,6 +406,12 @@ component Modal {
|
||||
padding-top: clamp(1rem, 8vh, 5rem);
|
||||
}
|
||||
|
||||
.wire-modal[data-scroll="page"] .wire-modal__layer {
|
||||
align-items: flex-start;
|
||||
overflow-y: auto;
|
||||
padding: 2.5rem 1rem;
|
||||
}
|
||||
|
||||
.wire-modal__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -370,6 +444,18 @@ component Modal {
|
||||
0 1px 0 color-mix(in srgb, white 5%, transparent) inset,
|
||||
0 40px 110px color-mix(in srgb, black 38%, transparent);
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: scale(0.96) translateY(10px);
|
||||
transition: opacity 180ms ease, transform 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.wire-modal[data-open="true"] .wire-modal__panel {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.wire-modal[data-size="xs"] .wire-modal__panel {
|
||||
width: min(19rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.wire-modal[data-size="sm"] .wire-modal__panel {
|
||||
@@ -390,6 +476,10 @@ component Modal {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.wire-modal[data-scroll="page"] .wire-modal__panel {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.wire-modal[data-variant="soft"] .wire-modal__panel {
|
||||
background:
|
||||
linear-gradient(145deg, var(--modal-soft), transparent 68%),
|
||||
@@ -447,6 +537,7 @@ component Modal {
|
||||
}
|
||||
|
||||
.wire-modal__heading-copy h2 {
|
||||
color: inherit;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.3;
|
||||
@@ -462,32 +553,100 @@ component Modal {
|
||||
color: color-mix(in srgb, currentColor 76%, transparent);
|
||||
}
|
||||
|
||||
/*
|
||||
* padding is reset explicitly: an app-level `button { padding: … }` rule
|
||||
* outranks the browser default, and 1rem of horizontal padding left this
|
||||
* 2.15rem button with a ~2px content box -- which squeezed the icon to
|
||||
* 0.4px wide and read as "the close button has no icon".
|
||||
*/
|
||||
.wire-modal__close {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
padding: 0;
|
||||
width: 2.15rem;
|
||||
height: 2.15rem;
|
||||
color: var(--wire-color-text-muted);
|
||||
background: var(--wire-color-surface-soft);
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, color 150ms ease, border-color 150ms ease, transform 150ms ease;
|
||||
}
|
||||
|
||||
/*
|
||||
* On a solid panel the background is the accent color, so the muted-grey
|
||||
* default is close to invisible -- the dismiss affordance reads as
|
||||
* missing rather than subtle. Derive it from the panel contrast color
|
||||
* instead, and give it a faint ring so it is unmistakably a control.
|
||||
*/
|
||||
.wire-modal[data-variant="solid"] .wire-modal__close {
|
||||
color: color-mix(in srgb, currentColor 82%, transparent);
|
||||
border-color: color-mix(in srgb, currentColor 35%, transparent);
|
||||
}
|
||||
|
||||
.wire-modal[data-variant="solid"] .wire-modal__close:hover {
|
||||
color: currentColor;
|
||||
background: color-mix(in srgb, black 18%, transparent);
|
||||
border-color: color-mix(in srgb, currentColor 55%, transparent);
|
||||
}
|
||||
|
||||
.wire-modal__close:hover {
|
||||
color: var(--wire-color-text);
|
||||
background: var(--wire-color-surface-soft);
|
||||
}
|
||||
|
||||
.wire-modal__close:hover,
|
||||
.wire-modal__close:focus-visible {
|
||||
color: var(--modal-accent);
|
||||
border-color: color-mix(in srgb, var(--modal-accent) 34%, var(--wire-color-border));
|
||||
border-color: color-mix(in srgb, var(--modal-accent) 45%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.wire-modal__close:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
/* Never let the glyph be shrunk by the flex container. */
|
||||
.wire-modal__close svg {
|
||||
flex: 0 0 auto;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* Slot content is authored by the host app, so the app global stylesheet
|
||||
* styles it too. A bare element selector there (p { color: ... }) beats
|
||||
* anything the panel merely *inherits*, which is how solid-variant modals
|
||||
* ended up with muted grey body copy on a saturated accent background --
|
||||
* unreadable, and worst exactly where contrast matters most (the
|
||||
* destructive confirm). Setting the color on the body makes the panel
|
||||
* choice explicit instead of leaving it to inheritance.
|
||||
*
|
||||
* NOTE: apostrophes are avoided in .wrn style comments on purpose -- the
|
||||
* block scanner treats a quote as a string delimiter while it counts
|
||||
* braces, so a stray one breaks parsing of the whole component.
|
||||
*/
|
||||
.wire-modal__body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 1.4rem;
|
||||
color: var(--wire-color-text);
|
||||
}
|
||||
|
||||
/*
|
||||
* :where() keeps this at the specificity of .wire-modal__body alone, so it
|
||||
* outranks a global element selector but still yields to any class the app
|
||||
* puts on its own slot content (an error message, a muted caption). A
|
||||
* plain .wire-modal__body p list would have quietly overridden those.
|
||||
*/
|
||||
.wire-modal__body :where(p, li, dd, dt, h1, h2, h3, h4, h5, h6, span, label, code) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.wire-modal[data-variant="solid"] .wire-modal__body {
|
||||
color: var(--modal-contrast);
|
||||
}
|
||||
|
||||
.wire-modal[data-scrollable="true"] .wire-modal__body {
|
||||
@@ -548,6 +707,18 @@ component Modal {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
/*
|
||||
* On a solid-variant panel the panel background is also --modal-accent,
|
||||
* so a plain primary button (same color) has no visible edge against it.
|
||||
* Darken the fill slightly and add a light border so the button still
|
||||
* reads as a distinct, clickable pill instead of blending into the panel.
|
||||
*/
|
||||
.wire-modal[data-variant="solid"] .wire-modal__button--primary {
|
||||
background: color-mix(in srgb, black 18%, var(--modal-accent));
|
||||
border-color: color-mix(in srgb, white 32%, transparent);
|
||||
box-shadow: 0 1px 0 color-mix(in srgb, white 12%, transparent) inset;
|
||||
}
|
||||
|
||||
.wire-modal__button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
@@ -575,6 +746,7 @@ component Modal {
|
||||
}
|
||||
|
||||
.wire-modal__panel,
|
||||
.wire-modal[data-size="xs"] .wire-modal__panel,
|
||||
.wire-modal[data-size="sm"] .wire-modal__panel,
|
||||
.wire-modal[data-size="lg"] .wire-modal__panel,
|
||||
.wire-modal[data-size="xl"] .wire-modal__panel {
|
||||
@@ -599,10 +771,17 @@ component Modal {
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wire-modal__button,
|
||||
.wire-modal__spinner {
|
||||
.wire-modal__spinner,
|
||||
.wire-modal__layer,
|
||||
.wire-modal__panel,
|
||||
.wire-modal__close {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.wire-modal__panel {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,354 @@
|
||||
// Nav -- a navigation link list, flat or with submenus.
|
||||
//
|
||||
// <Nav items='[{"label":"Home","href":"/","value":"home"}]' active="home" />
|
||||
//
|
||||
// An item carrying its own items array becomes a submenu. Depth is capped at
|
||||
// three levels: this template language has no component recursion, so each
|
||||
// level is written out, and three covers any realistic navigation.
|
||||
//
|
||||
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
|
||||
// and silently swallows the rule that follows it.
|
||||
component Nav {
|
||||
outputs {
|
||||
select(payload: { 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(payload: { 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)
|
||||
select(payload: { item: object; value: string })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
label: string = "Nav"
|
||||
size: string = "default"
|
||||
items: unknown[] = []
|
||||
active: string = ""
|
||||
orientation: string = "horizontal"
|
||||
label: string = "Main"
|
||||
collapsible: boolean = true
|
||||
toggleLabel: string = "Menu"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
state expanded = false
|
||||
|
||||
functions {
|
||||
shared function itemList() {
|
||||
return Array.isArray(items) ? items : []
|
||||
}
|
||||
|
||||
shared function childrenOf(item) {
|
||||
return item && Array.isArray(item.items) ? item.items : []
|
||||
}
|
||||
|
||||
shared function isActive(item) {
|
||||
return Boolean(item.value) && item.value === active
|
||||
}
|
||||
|
||||
shared function rovingAxis() {
|
||||
return orientation === "vertical" ? "vertical" : "horizontal"
|
||||
}
|
||||
|
||||
client function choose(item) {
|
||||
if (item.disabled) {
|
||||
return
|
||||
}
|
||||
output.select({ item: item, value: item.value || "" })
|
||||
}
|
||||
|
||||
client function toggleMenu() {
|
||||
expanded = !expanded
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--nav wire-next--{orientation} {class}" aria-label="{label}">
|
||||
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each}
|
||||
<nav
|
||||
{...attrs}
|
||||
data-ui-component="Nav"
|
||||
class='wire-nav {class}'
|
||||
data-orientation='{orientation}'
|
||||
data-color='{color}'
|
||||
data-size='{size}'
|
||||
data-expanded='{expanded}'
|
||||
role="navigation"
|
||||
aria-label='{label}'
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="wire-nav__toggle"
|
||||
data-show="collapsible"
|
||||
aria-expanded='{expanded}'
|
||||
aria-label='{toggleLabel}'
|
||||
@click='toggleMenu()'
|
||||
>
|
||||
<span class="wire-nav__toggle-bar" aria-hidden="true"></span>
|
||||
<span class="wire-nav__toggle-text">{toggleLabel}</span>
|
||||
</button>
|
||||
|
||||
<ul class="wire-nav__list" data-wrn-roving='{rovingAxis()}'>
|
||||
{#each itemList() as item}
|
||||
<li class="wire-nav__item" data-has-children='{childrenOf(item).length > 0}'>
|
||||
<a
|
||||
class="wire-nav__link"
|
||||
href='{item.href || "#"}'
|
||||
data-wrn-roving-item="true"
|
||||
data-active='{isActive(item)}'
|
||||
aria-current='{isActive(item) ? "page" : "false"}'
|
||||
aria-disabled='{item.disabled ? "true" : "false"}'
|
||||
@click='choose(item)'
|
||||
>
|
||||
<span
|
||||
class='wire-nav__icon {item.icon}'
|
||||
data-show="item.icon"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="wire-nav__label">{item.label}</span>
|
||||
<span class="wire-nav__badge" data-show="item.badge">{item.badge}</span>
|
||||
<span
|
||||
class="wire-nav__arrow"
|
||||
data-show="childrenOf(item).length > 0"
|
||||
aria-hidden="true"
|
||||
>›</span>
|
||||
</a>
|
||||
|
||||
<ul
|
||||
class="wire-nav__submenu"
|
||||
data-wrn-anchored="true"
|
||||
data-show="childrenOf(item).length > 0"
|
||||
>
|
||||
{#each childrenOf(item) as child}
|
||||
<li class="wire-nav__item" data-has-children='{childrenOf(child).length > 0}'>
|
||||
<a
|
||||
class="wire-nav__link"
|
||||
href='{child.href || "#"}'
|
||||
data-active='{isActive(child)}'
|
||||
aria-current='{isActive(child) ? "page" : "false"}'
|
||||
aria-disabled='{child.disabled ? "true" : "false"}'
|
||||
@click='choose(child)'
|
||||
>
|
||||
<span
|
||||
class='wire-nav__icon {child.icon}'
|
||||
data-show="child.icon"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="wire-nav__label">{child.label}</span>
|
||||
<span class="wire-nav__badge" data-show="child.badge">{child.badge}</span>
|
||||
<span
|
||||
class="wire-nav__arrow"
|
||||
data-show="childrenOf(child).length > 0"
|
||||
aria-hidden="true"
|
||||
>›</span>
|
||||
</a>
|
||||
|
||||
<ul
|
||||
class="wire-nav__submenu wire-nav__submenu--level3"
|
||||
data-wrn-anchored="true"
|
||||
data-show="childrenOf(child).length > 0"
|
||||
>
|
||||
{#each childrenOf(child) as leaf}
|
||||
<li class="wire-nav__item">
|
||||
<a
|
||||
class="wire-nav__link"
|
||||
href='{leaf.href || "#"}'
|
||||
data-active='{isActive(leaf)}'
|
||||
aria-current='{isActive(leaf) ? "page" : "false"}'
|
||||
aria-disabled='{leaf.disabled ? "true" : "false"}'
|
||||
@click='choose(leaf)'
|
||||
>
|
||||
<span
|
||||
class='wire-nav__icon {leaf.icon}'
|
||||
data-show="leaf.icon"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="wire-nav__label">{leaf.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<slot />
|
||||
</nav>
|
||||
}
|
||||
|
||||
style {
|
||||
.wire-nav {
|
||||
--nav-accent: var(--wire-color-primary);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.wire-nav[data-color="secondary"] {
|
||||
--nav-accent: var(--wire-color-secondary);
|
||||
}
|
||||
|
||||
.wire-nav[data-color="success"] {
|
||||
--nav-accent: var(--wire-color-success);
|
||||
}
|
||||
|
||||
.wire-nav[data-color="danger"] {
|
||||
--nav-accent: var(--wire-color-danger);
|
||||
}
|
||||
|
||||
.wire-nav[data-color="info"] {
|
||||
--nav-accent: var(--wire-color-info);
|
||||
}
|
||||
|
||||
.wire-nav[data-size="sm"] {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.wire-nav[data-size="lg"] {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.wire-nav__list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.wire-nav[data-orientation="vertical"] .wire-nav__list {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.wire-nav__item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wire-nav__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border-radius: var(--wire-radius-sm);
|
||||
color: var(--wire-color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wire-nav__link:hover {
|
||||
background: var(--wire-color-surface-soft);
|
||||
color: var(--wire-color-text);
|
||||
}
|
||||
|
||||
.wire-nav__link:focus-visible {
|
||||
outline: 2px solid var(--nav-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.wire-nav__link[data-active="true"] {
|
||||
background: color-mix(in srgb, var(--nav-accent) 16%, transparent);
|
||||
color: var(--nav-accent);
|
||||
}
|
||||
|
||||
.wire-nav__link[aria-disabled="true"] {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wire-nav__badge {
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--nav-accent) 16%, transparent);
|
||||
color: var(--nav-accent);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.wire-nav__arrow {
|
||||
display: inline-block;
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.wire-nav__item:hover > .wire-nav__link > .wire-nav__arrow,
|
||||
.wire-nav__item:focus-within > .wire-nav__link > .wire-nav__arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.wire-nav__submenu {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 12rem;
|
||||
margin: 0;
|
||||
padding: 0.35rem;
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: var(--wire-radius-md);
|
||||
background: var(--wire-color-surface);
|
||||
box-shadow: var(--wire-shadow-1);
|
||||
list-style: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.wire-nav__item:hover > .wire-nav__submenu,
|
||||
.wire-nav__item:focus-within > .wire-nav__submenu {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.wire-nav__submenu--level3 {
|
||||
top: 0;
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.wire-nav__toggle {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: var(--wire-radius-sm);
|
||||
background: var(--wire-color-surface);
|
||||
color: var(--wire-color-text);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wire-nav__toggle-bar {
|
||||
width: 1rem;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
box-shadow:
|
||||
0 -5px 0 currentColor,
|
||||
0 5px 0 currentColor;
|
||||
}
|
||||
|
||||
/*
|
||||
* On a phone the bar becomes a disclosure: submenus stop being floating
|
||||
* overlays and stack inline, because a hover-opened overlay is
|
||||
* unreachable on touch.
|
||||
*/
|
||||
@media (max-width: 767px) {
|
||||
.wire-nav__toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.wire-nav__list {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.wire-nav[data-expanded="false"] .wire-nav__list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wire-nav__submenu,
|
||||
.wire-nav__submenu--level3 {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,11 +86,11 @@ size: string = "default"
|
||||
</button>
|
||||
|
||||
<div class="wire-navbar__collapse {mobileOpen ? 'is-open' : ''}">
|
||||
<nav class="wire-navbar__menus" aria-label="{label}">
|
||||
<nav class="wire-navbar__menus" aria-label="{label}" data-wrn-roving="horizontal">
|
||||
{#each items as item}
|
||||
{#if item.children && item.children.length}
|
||||
<details class="wire-navbar__dropdown wire-navbar__dropdown--{item.type || 'dropdown'}" name="wire-navbar-menu" @toggle="toggleDropdown(event, item)">
|
||||
<summary aria-current="{isItemActive(item) ? 'page' : ''}">
|
||||
<summary data-wrn-roving-item="true" aria-current="{isItemActive(item) ? 'page' : 'false'}">
|
||||
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
|
||||
<span>{item.label}</span>
|
||||
<span class="wire-navbar__chevron" aria-hidden="true"></span>
|
||||
@@ -103,13 +103,13 @@ size: string = "default"
|
||||
{#if child.label}<strong class="wire-navbar__group-title">{child.label}</strong>{/if}
|
||||
{#if child.description}<small>{child.description}</small>{/if}
|
||||
{#each child.children as nested}
|
||||
<a href="{nested.href || '#'}" target="{nested.target || ''}" rel="{nested.rel || ''}" aria-current="{nested.value === active ? 'page' : ''}" @click="selectItem(nested, 3)">
|
||||
<a href="{nested.href || '#'}" target="{nested.target || ''}" rel="{nested.rel || ''}" aria-current="{nested.value === active ? 'page' : 'false'}" @click="selectItem(nested, 3)">
|
||||
{#if nested.icon}<span class="{nested.icon}" aria-hidden="true"></span>{/if}
|
||||
<span><strong>{nested.label}</strong>{#if nested.description}<small>{nested.description}</small>{/if}</span>
|
||||
</a>
|
||||
{/each}
|
||||
{:else}
|
||||
<a href="{child.href || '#'}" target="{child.target || ''}" rel="{child.rel || ''}" aria-current="{child.value === active ? 'page' : ''}" @click="selectItem(child, 2)">
|
||||
<a href="{child.href || '#'}" target="{child.target || ''}" rel="{child.rel || ''}" aria-current="{child.value === active ? 'page' : 'false'}" @click="selectItem(child, 2)">
|
||||
{#if child.icon}<span class="{child.icon}" aria-hidden="true"></span>{/if}
|
||||
<span><strong>{child.label}</strong>{#if child.description}<small>{child.description}</small>{/if}</span>
|
||||
</a>
|
||||
@@ -119,7 +119,7 @@ size: string = "default"
|
||||
</div>
|
||||
</details>
|
||||
{:else}
|
||||
<a class="wire-navbar__menu-link" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" aria-current="{item.value === active ? 'page' : ''}" @click="selectItem(item, 1)">
|
||||
<a class="wire-navbar__menu-link" data-wrn-roving-item="true" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" aria-current="{item.value === active ? 'page' : 'false'}" @click="selectItem(item, 1)">
|
||||
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
@@ -140,4 +140,440 @@ size: string = "default"
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
|
||||
style {
|
||||
/* Full application navigation */
|
||||
.wire-navbar {
|
||||
position: relative;
|
||||
z-index: 40;
|
||||
width: 100%;
|
||||
color: var(--wire-color-text);
|
||||
background: color-mix(in srgb, var(--wire-color-surface) 96%, transparent);
|
||||
border-bottom: 1px solid var(--wire-color-border);
|
||||
}
|
||||
|
||||
.wire-navbar--sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.wire-navbar__topbar:empty,
|
||||
.wire-navbar__topbar:not(:has(*)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wire-navbar__topbar:not(:empty) {
|
||||
display: flex;
|
||||
min-height: 2.25rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.35rem clamp(1rem, 4vw, 3rem);
|
||||
color: var(--wire-color-muted);
|
||||
background: var(--wire-color-surface-2);
|
||||
border-bottom: 1px solid var(--wire-color-border);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.wire-navbar__main {
|
||||
display: flex;
|
||||
min-height: 4.25rem;
|
||||
width: min(100%, 90rem);
|
||||
margin-inline: auto;
|
||||
padding: 0.75rem clamp(1rem, 4vw, 3rem);
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.wire-navbar--width-full .wire-navbar__main {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.wire-navbar--width-compact .wire-navbar__main {
|
||||
width: min(86%, 80rem);
|
||||
}
|
||||
|
||||
.wire-navbar__brand,
|
||||
.wire-navbar__menu-link,
|
||||
.wire-navbar__dropdown summary,
|
||||
.wire-navbar__action,
|
||||
.wire-navbar__panel a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wire-navbar__brand {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.wire-navbar__brand-logo {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 11rem;
|
||||
height: 2.5rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.wire-navbar__brand-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
color: var(--wire-component-color, var(--wire-color-primary));
|
||||
}
|
||||
|
||||
.wire-navbar__brand-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.wire-navbar__brand-copy strong {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wire-navbar__brand-copy small {
|
||||
margin-top: 0.2rem;
|
||||
color: var(--wire-color-muted);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.wire-navbar__collapse,
|
||||
.wire-navbar__menus,
|
||||
.wire-navbar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wire-navbar__collapse {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.wire-navbar__menus {
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.wire-navbar__actions {
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.wire-navbar__menu-link,
|
||||
.wire-navbar__dropdown > summary,
|
||||
.wire-navbar__action {
|
||||
display: inline-flex;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.65rem 0.8rem;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border-radius: 0.65rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wire-navbar__menu-link:hover,
|
||||
.wire-navbar__dropdown > summary:hover {
|
||||
color: var(--wire-color-text);
|
||||
background: var(--wire-color-surface-2);
|
||||
}
|
||||
|
||||
.wire-navbar__menu-link[aria-current="page"],
|
||||
.wire-navbar__dropdown > summary[aria-current="page"] {
|
||||
color: var(--wire-component-color, var(--wire-color-primary));
|
||||
font-weight: 600;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--wire-component-color, var(--wire-color-primary)) 12%,
|
||||
transparent
|
||||
);
|
||||
box-shadow: inset 0 -2px 0 var(--wire-component-color, var(--wire-color-primary));
|
||||
}
|
||||
|
||||
.wire-navbar__menu-link[aria-current="page"]:hover,
|
||||
.wire-navbar__dropdown > summary[aria-current="page"]:hover {
|
||||
color: var(--wire-component-color, var(--wire-color-primary));
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--wire-component-color, var(--wire-color-primary)) 16%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.wire-navbar__dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wire-navbar__dropdown > summary {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.wire-navbar__dropdown > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wire-navbar__chevron {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-right: 1.5px solid currentColor;
|
||||
border-bottom: 1.5px solid currentColor;
|
||||
transform: rotate(45deg) translateY(-0.15rem);
|
||||
transition: transform var(--wire-motion-fast, 150ms) ease;
|
||||
}
|
||||
|
||||
.wire-navbar__dropdown[open] .wire-navbar__chevron {
|
||||
transform: rotate(225deg) translate(-0.1rem, -0.1rem);
|
||||
}
|
||||
|
||||
.wire-navbar__panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.55rem);
|
||||
left: 50%;
|
||||
display: grid;
|
||||
width: max-content;
|
||||
min-width: 15rem;
|
||||
max-width: min(90vw, 64rem);
|
||||
padding: 0.65rem;
|
||||
gap: 0.45rem;
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: 0.9rem;
|
||||
color: var(--wire-color-text);
|
||||
background: var(--wire-color-surface);
|
||||
box-shadow: 0 18px 48px rgb(15 23 42 / 0.16);
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0) scale(1);
|
||||
transform-origin: top center;
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 180ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
display 180ms allow-discrete;
|
||||
}
|
||||
|
||||
.wire-navbar__panel::before {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
height: 0.65rem;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.wire-navbar__dropdown:not([open]) > .wire-navbar__panel {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-0.5rem) scale(0.98);
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
.wire-navbar__dropdown[open] > .wire-navbar__panel {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-0.5rem) scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.wire-navbar__dropdown--mega {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.wire-navbar__dropdown--mega .wire-navbar__panel {
|
||||
right: auto;
|
||||
left: 50%;
|
||||
width: min(calc(100vw - 2rem), 64rem);
|
||||
}
|
||||
|
||||
.wire-navbar__panel--columns-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wire-navbar__panel--columns-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wire-navbar__panel--columns-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wire-navbar__panel-intro {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.65rem;
|
||||
color: var(--wire-color-muted);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.wire-navbar__group {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.wire-navbar__group-title {
|
||||
padding: 0.55rem 0.65rem 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wire-navbar__group > small {
|
||||
padding: 0 0.65rem 0.4rem;
|
||||
color: var(--wire-color-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.wire-navbar__panel a {
|
||||
display: flex;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.65rem;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
border-radius: 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 450;
|
||||
}
|
||||
|
||||
.wire-navbar__panel a:hover {
|
||||
background: var(--wire-color-surface-2);
|
||||
}
|
||||
|
||||
.wire-navbar__panel a span:last-child {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.wire-navbar__panel a strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.wire-navbar__panel a small {
|
||||
color: var(--wire-color-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.wire-navbar__action {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.wire-navbar__action--primary {
|
||||
color: var(--wire-color-on-primary, #fff);
|
||||
background: var(--wire-component-color, var(--wire-color-primary));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wire-navbar__action--outline {
|
||||
border-color: var(--wire-color-border);
|
||||
}
|
||||
|
||||
.wire-navbar__toggle {
|
||||
display: none;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
margin-left: auto;
|
||||
padding: 0.65rem;
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: 0.65rem;
|
||||
color: inherit;
|
||||
background: var(--wire-color-surface);
|
||||
}
|
||||
|
||||
.wire-navbar__toggle span {
|
||||
display: block;
|
||||
height: 2px;
|
||||
margin: 0.25rem 0;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.wire-navbar--width-compact .wire-navbar__main {
|
||||
width: 100%;
|
||||
}
|
||||
.wire-navbar__toggle {
|
||||
display: block;
|
||||
}
|
||||
.wire-navbar__collapse {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: none;
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
background: var(--wire-color-surface);
|
||||
border-bottom: 1px solid var(--wire-color-border);
|
||||
box-shadow: 0 16px 32px rgb(15 23 42 / 0.12);
|
||||
max-height: calc(100dvh - 4rem);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.wire-navbar__collapse.is-open {
|
||||
display: flex;
|
||||
}
|
||||
.wire-navbar__menus,
|
||||
.wire-navbar__actions {
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.wire-navbar__menu-link,
|
||||
.wire-navbar__dropdown > summary,
|
||||
.wire-navbar__action {
|
||||
width: 100%;
|
||||
}
|
||||
.wire-navbar__panel,
|
||||
.wire-navbar__dropdown--mega .wire-navbar__panel {
|
||||
position: static;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin-top: 0.25rem;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
overflow: hidden;
|
||||
animation: wire-navbar-mobile-panel 180ms ease both;
|
||||
}
|
||||
.wire-navbar__dropdown:not([open]) > .wire-navbar__panel,
|
||||
.wire-navbar__dropdown[open] > .wire-navbar__panel {
|
||||
transform: none;
|
||||
}
|
||||
.wire-navbar__panel[class*="wire-navbar__panel--columns-"] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.wire-navbar__panel::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.wire-navbar__brand-copy small {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wire-navbar-mobile-panel {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.35rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,291 @@
|
||||
// Pagination -- page controls over a known total.
|
||||
//
|
||||
// <Pagination page={2} pageSize={10} total={137} variant="numbered" />
|
||||
//
|
||||
// The component owns no data. It reports the requested page through its
|
||||
// change output and lets the caller fetch or slice.
|
||||
//
|
||||
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
|
||||
// and silently swallows the rule that follows it.
|
||||
component Pagination {
|
||||
outputs {
|
||||
change(payload: { 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)
|
||||
previous(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
next(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
change(payload: { page: number; pageSize: number })
|
||||
previous(payload: { page: number })
|
||||
next(payload: { page: number })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
size: string = "default"
|
||||
page: number = 1
|
||||
pageSize: number = 10
|
||||
total: number = 0
|
||||
variant: string = "compact"
|
||||
siblingCount: number = 1
|
||||
showSummary: boolean = true
|
||||
label: string = "Pagination"
|
||||
items: unknown[] = []
|
||||
active: string = ""
|
||||
orientation: string = "horizontal"
|
||||
previousLabel: string = "Previous"
|
||||
nextLabel: string = "Next"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
functions {
|
||||
shared function lastPage() {
|
||||
var size = Number(pageSize) > 0 ? Number(pageSize) : 10
|
||||
var count = Number(total) > 0 ? Number(total) : 0
|
||||
return Math.max(1, Math.ceil(count / size))
|
||||
}
|
||||
|
||||
// Out-of-range values arrive routinely: page travels as an HTML attribute
|
||||
// and callers compute it from data that may have shrunk underneath them.
|
||||
shared function currentPage() {
|
||||
var value = Number(page)
|
||||
if (!value || value < 1) {
|
||||
return 1
|
||||
}
|
||||
return Math.min(value, lastPage())
|
||||
}
|
||||
|
||||
shared function firstShown() {
|
||||
if (Number(total) < 1) {
|
||||
return 0
|
||||
}
|
||||
return (currentPage() - 1) * (Number(pageSize) || 10) + 1
|
||||
}
|
||||
|
||||
shared function lastShown() {
|
||||
return Math.min(currentPage() * (Number(pageSize) || 10), Number(total) || 0)
|
||||
}
|
||||
|
||||
shared function pageNumbers() {
|
||||
var last = lastPage()
|
||||
var current = currentPage()
|
||||
var siblings = Math.max(0, Number(siblingCount) || 0)
|
||||
var start = Math.max(1, current - siblings)
|
||||
var end = Math.min(last, current + siblings)
|
||||
var pages = []
|
||||
if (start > 1) {
|
||||
pages.push({ value: 1, label: "1", gap: false })
|
||||
if (start > 2) {
|
||||
pages.push({ value: 0, label: "...", gap: true })
|
||||
}
|
||||
}
|
||||
for (var index = start; index <= end; index += 1) {
|
||||
pages.push({ value: index, label: String(index), gap: false })
|
||||
}
|
||||
if (end < last) {
|
||||
if (end < last - 1) {
|
||||
pages.push({ value: 0, label: "...", gap: true })
|
||||
}
|
||||
pages.push({ value: last, label: String(last), gap: false })
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
client function goToPage(target) {
|
||||
var next = Math.min(Math.max(1, Number(target) || 1), lastPage())
|
||||
output.change({ page: next, pageSize: Number(pageSize) || 10 })
|
||||
}
|
||||
|
||||
client function goPrevious() {
|
||||
var target = Math.max(1, currentPage() - 1)
|
||||
output.previous({ page: target })
|
||||
output.change({ page: target, pageSize: Number(pageSize) || 10 })
|
||||
}
|
||||
|
||||
client function goNext() {
|
||||
var target = Math.min(lastPage(), currentPage() + 1)
|
||||
output.next({ page: target })
|
||||
output.change({ page: target, pageSize: Number(pageSize) || 10 })
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--pagination wire-next--{orientation} {class}" aria-label="{label}">
|
||||
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each}
|
||||
<nav
|
||||
{...attrs}
|
||||
data-ui-component="Pagination"
|
||||
class='wire-pagination {class}'
|
||||
data-variant='{variant}'
|
||||
data-color='{color}'
|
||||
data-size='{size}'
|
||||
role="navigation"
|
||||
aria-label='{label}'
|
||||
>
|
||||
<p class="wire-pagination__summary" data-show="showSummary">
|
||||
{firstShown()} to {lastShown()} of {total}
|
||||
</p>
|
||||
|
||||
<div class="wire-pagination__controls">
|
||||
<button
|
||||
type="button"
|
||||
class="wire-pagination__step"
|
||||
aria-label='{previousLabel}'
|
||||
@click='goPrevious()'
|
||||
>
|
||||
<span class="wire-pagination__step-icon" aria-hidden="true">‹</span>
|
||||
<span class="wire-pagination__step-label">{previousLabel}</span>
|
||||
</button>
|
||||
|
||||
<ol class="wire-pagination__pages" data-show="variant === 'numbered'">
|
||||
{#each pageNumbers() as entry}
|
||||
<li class="wire-pagination__slot">
|
||||
<span class="wire-pagination__gap" data-show="entry.gap">{entry.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="wire-pagination__page"
|
||||
data-show="!entry.gap"
|
||||
data-active='{entry.value === currentPage()}'
|
||||
aria-current='{entry.value === currentPage() ? "page" : "false"}'
|
||||
@click='goToPage(entry.value)'
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<p class="wire-pagination__compact" data-show="variant !== 'numbered'">
|
||||
{currentPage()} / {lastPage()}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="wire-pagination__step"
|
||||
aria-label='{nextLabel}'
|
||||
@click='goNext()'
|
||||
>
|
||||
<span class="wire-pagination__step-label">{nextLabel}</span>
|
||||
<span class="wire-pagination__step-icon" aria-hidden="true">›</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
</nav>
|
||||
}
|
||||
|
||||
style {
|
||||
.wire-pagination {
|
||||
--pagination-accent: var(--wire-color-primary);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.wire-pagination[data-color="secondary"] {
|
||||
--pagination-accent: var(--wire-color-secondary);
|
||||
}
|
||||
|
||||
.wire-pagination[data-color="success"] {
|
||||
--pagination-accent: var(--wire-color-success);
|
||||
}
|
||||
|
||||
.wire-pagination[data-color="danger"] {
|
||||
--pagination-accent: var(--wire-color-danger);
|
||||
}
|
||||
|
||||
.wire-pagination[data-color="info"] {
|
||||
--pagination-accent: var(--wire-color-info);
|
||||
}
|
||||
|
||||
.wire-pagination[data-size="sm"] {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.wire-pagination[data-size="lg"] {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.wire-pagination__summary {
|
||||
margin: 0;
|
||||
color: var(--wire-color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.wire-pagination__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wire-pagination__pages {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.wire-pagination__slot {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.wire-pagination__page,
|
||||
.wire-pagination__step {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-width: 2.25rem;
|
||||
justify-content: center;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: var(--wire-radius-sm);
|
||||
background: var(--wire-color-surface);
|
||||
color: var(--wire-color-text);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wire-pagination__page:hover,
|
||||
.wire-pagination__step:hover {
|
||||
background: var(--wire-color-surface-soft);
|
||||
}
|
||||
|
||||
.wire-pagination__page:focus-visible,
|
||||
.wire-pagination__step:focus-visible {
|
||||
outline: 2px solid var(--pagination-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.wire-pagination__page[data-active="true"] {
|
||||
border-color: var(--pagination-accent);
|
||||
background: var(--pagination-accent);
|
||||
color: var(--wire-color-primary-contrast);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wire-pagination__gap {
|
||||
padding: 0 0.35rem;
|
||||
color: var(--wire-color-text-muted);
|
||||
}
|
||||
|
||||
.wire-pagination__compact {
|
||||
margin: 0;
|
||||
padding: 0 0.5rem;
|
||||
color: var(--wire-color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Below the small breakpoint the word labels crowd the arrows out. */
|
||||
@media (max-width: 639px) {
|
||||
.wire-pagination {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wire-pagination__step-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wire-pagination__summary {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,12 +123,13 @@ component Popover {
|
||||
|
||||
<section
|
||||
class="wire-popover__panel"
|
||||
data-wrn-anchored="true"
|
||||
data-show='{open || visible}'
|
||||
role="dialog"
|
||||
aria-label='{title || triggerLabel}'
|
||||
>
|
||||
{#if showArrow}
|
||||
<span class="wire-popover__arrow" aria-hidden="true"></span>
|
||||
<span class="wire-popover__arrow" data-wrn-anchor-arrow="true" aria-hidden="true"></span>
|
||||
{/if}
|
||||
|
||||
{#if title || description || icon || showClose}
|
||||
@@ -471,7 +472,13 @@ component Popover {
|
||||
color: color-mix(in srgb, currentColor 76%, transparent);
|
||||
}
|
||||
|
||||
/*
|
||||
* padding is reset explicitly: an app-level `button { padding: ... }` rule
|
||||
* outranks the browser default and crushes the icon inside this
|
||||
* fixed-size button. Same trap as Modal and Drawer.
|
||||
*/
|
||||
.wire-popover__close {
|
||||
padding: 0;
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,21 +1,172 @@
|
||||
// Scrollspy -- a table of contents that follows the reader.
|
||||
//
|
||||
// <Scrollspy items='[{"label":"Overview","href":"#overview"}]' />
|
||||
//
|
||||
// Each href points at an element on the page. The runtime observes those
|
||||
// elements and moves aria-current to the link for whichever one is in view.
|
||||
//
|
||||
// The runtime writes the marker straight onto the links rather than into
|
||||
// component state. An IntersectionObserver callback fires long after the
|
||||
// client function that registered it has returned, and a state write made
|
||||
// there is dropped -- so the DOM is the only place the answer can live.
|
||||
//
|
||||
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
|
||||
// and silently swallows the rule that follows it.
|
||||
component Scrollspy {
|
||||
outputs {
|
||||
change(payload: { 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(payload: { href: string; label: string })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
label: string = "Scrollspy"
|
||||
size: string = "default"
|
||||
items: unknown[] = []
|
||||
active: string = ""
|
||||
orientation: string = "horizontal"
|
||||
label: string = "On this page"
|
||||
heading: string = ""
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
functions {
|
||||
shared function itemList() {
|
||||
return Array.isArray(items) ? items : []
|
||||
}
|
||||
|
||||
shared function isActive(item) {
|
||||
return Boolean(item.href) && item.href === active
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--scrollspy wire-next--{orientation} {class}" aria-label="{label}">
|
||||
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each}
|
||||
<nav
|
||||
{...attrs}
|
||||
data-ui-component="Scrollspy"
|
||||
class='wire-scrollspy {class}'
|
||||
data-color='{color}'
|
||||
data-size='{size}'
|
||||
data-wrn-scrollspy="true"
|
||||
role="navigation"
|
||||
aria-label='{label}'
|
||||
>
|
||||
<p class="wire-scrollspy__heading" data-show="heading">{heading}</p>
|
||||
|
||||
<ul class="wire-scrollspy__list">
|
||||
{#each itemList() as item}
|
||||
<li class="wire-scrollspy__item">
|
||||
<a
|
||||
class="wire-scrollspy__link"
|
||||
href='{item.href || "#"}'
|
||||
data-active='{isActive(item)}'
|
||||
aria-current='{isActive(item) ? "location" : "false"}'
|
||||
>
|
||||
<span class="wire-scrollspy__label">{item.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<slot />
|
||||
</nav>
|
||||
}
|
||||
|
||||
style {
|
||||
.wire-scrollspy {
|
||||
--scrollspy-accent: var(--wire-color-primary);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.wire-scrollspy[data-color="secondary"] {
|
||||
--scrollspy-accent: var(--wire-color-secondary);
|
||||
}
|
||||
|
||||
.wire-scrollspy[data-color="success"] {
|
||||
--scrollspy-accent: var(--wire-color-success);
|
||||
}
|
||||
|
||||
.wire-scrollspy[data-color="danger"] {
|
||||
--scrollspy-accent: var(--wire-color-danger);
|
||||
}
|
||||
|
||||
.wire-scrollspy[data-color="info"] {
|
||||
--scrollspy-accent: var(--wire-color-info);
|
||||
}
|
||||
|
||||
.wire-scrollspy[data-size="sm"] {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.wire-scrollspy[data-size="lg"] {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.wire-scrollspy__heading {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--wire-color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wire-scrollspy__list {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border-left: 1px solid var(--wire-color-border);
|
||||
}
|
||||
|
||||
.wire-scrollspy__link {
|
||||
display: block;
|
||||
padding: 0.3rem 0.75rem;
|
||||
margin-left: -1px;
|
||||
border-left: 2px solid transparent;
|
||||
color: var(--wire-color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wire-scrollspy__link:hover {
|
||||
color: var(--wire-color-text);
|
||||
}
|
||||
|
||||
.wire-scrollspy__link:focus-visible {
|
||||
outline: 2px solid var(--scrollspy-accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.wire-scrollspy__link[data-active="true"] {
|
||||
border-left-color: var(--scrollspy-accent);
|
||||
color: var(--scrollspy-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/*
|
||||
* A table of contents is a sidebar affordance. On a phone it stops being
|
||||
* a rail and becomes a horizontal strip that scrolls, so it costs one
|
||||
* line rather than a screenful.
|
||||
*/
|
||||
@media (max-width: 767px) {
|
||||
.wire-scrollspy__list {
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: max-content;
|
||||
overflow-x: auto;
|
||||
border-left: 0;
|
||||
border-bottom: 1px solid var(--wire-color-border);
|
||||
}
|
||||
|
||||
.wire-scrollspy__link {
|
||||
margin-left: 0;
|
||||
border-left: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wire-scrollspy__link[data-active="true"] {
|
||||
border-left-color: transparent;
|
||||
border-bottom-color: var(--scrollspy-accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
component SearchBox {
|
||||
outputs {
|
||||
search(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
clear(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
@@ -22,7 +27,7 @@ component SearchBox {
|
||||
data-ui-component="SearchBox"
|
||||
role="search"
|
||||
class='w-full {class}'
|
||||
@submit='event.preventDefault(); event.currentTarget.dispatchEvent(new CustomEvent("search", { bubbles: true, detail: { value: query, name: name } }))'
|
||||
@submit='event.preventDefault(); output.search({ value: query, name: name })'
|
||||
>
|
||||
<label
|
||||
class="mb-2 block text-sm font-semibold text-[var(--wire-color-text)]"
|
||||
@@ -61,7 +66,7 @@ component SearchBox {
|
||||
aria-label="Clear search"
|
||||
data-show='query.length > 0 && !disabled'
|
||||
class="absolute right-12 inline-flex size-8 items-center justify-center rounded-lg text-[var(--wire-color-text-muted)] transition hover:bg-[var(--wire-color-surface-soft)] hover:text-[var(--wire-color-text)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]"
|
||||
@click='query = ""; event.currentTarget.parentElement.querySelector("input")?.focus(); event.currentTarget.dispatchEvent(new CustomEvent("clear", { bubbles: true, detail: { value: "", name: name } }))'
|
||||
@click='query = ""; event.currentTarget.parentElement.querySelector("input")?.focus(); output.clear({ value: "", name: name })'
|
||||
>
|
||||
<span class="icon-[lucide--x] size-4" aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
@@ -1,82 +1,335 @@
|
||||
// Sidebar -- a vertical navigation rail that becomes a drawer on small screens.
|
||||
//
|
||||
// <Sidebar items='[{"label":"Dashboard","href":"/","value":"dash"}]' active="dash" />
|
||||
//
|
||||
// An entry is one of three shapes:
|
||||
// { label, href, value } a single link
|
||||
// { heading, items: [...] } a labelled group
|
||||
// { label, items: [...] } a collapsible branch, nested to 3 levels
|
||||
//
|
||||
// The off-canvas presentation is Drawer rather than a second implementation,
|
||||
// so the focus trap and the body scroll lock come from one place.
|
||||
//
|
||||
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
|
||||
// and silently swallows the rule that follows it.
|
||||
import Drawer from "./Drawer.wrn"
|
||||
|
||||
component Sidebar {
|
||||
outputs {
|
||||
toggle(payload: { open: boolean })
|
||||
open(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
open(payload: { sourceEvent: Event })
|
||||
close(payload: { source: string })
|
||||
select(payload: { item: string | number | boolean | null | object; value: string | number | boolean; level: string })
|
||||
select(payload: { item: object; value: string; level: number })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
size: string = "default"
|
||||
label: string = "Sidebar"
|
||||
items: unknown[] = []
|
||||
active: string = ""
|
||||
orientation: string = "horizontal"
|
||||
mobileLabel: string = "Open navigation"
|
||||
drawerTitle: string = "Navigation"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
state mobileOpen: boolean = false
|
||||
state drawerOpen = false
|
||||
|
||||
functions {
|
||||
client function toggleSidebar() {
|
||||
mobileOpen = !mobileOpen
|
||||
output.toggle({ open: mobileOpen })
|
||||
output[mobileOpen ? "open" : "close"]({ source: "mobile" })
|
||||
shared function entryList() {
|
||||
return Array.isArray(items) ? items : []
|
||||
}
|
||||
|
||||
client function closeSidebar() {
|
||||
mobileOpen = false
|
||||
output.close({ source: "mobile" })
|
||||
// Accepts children as well as items: children is what shipped in 0.8.5
|
||||
// and existing callers should keep working.
|
||||
shared function childrenOf(entry) {
|
||||
if (!entry) {
|
||||
return []
|
||||
}
|
||||
if (Array.isArray(entry.items)) {
|
||||
return entry.items
|
||||
}
|
||||
return Array.isArray(entry.children) ? entry.children : []
|
||||
}
|
||||
|
||||
client function selectItem(item, level) {
|
||||
mobileOpen = false
|
||||
output.select({ item: item, value: item.value || "", level: level })
|
||||
shared function isGroup(entry) {
|
||||
return Boolean(entry.heading)
|
||||
}
|
||||
|
||||
shared function isActive(entry) {
|
||||
return Boolean(entry.value) && entry.value === active
|
||||
}
|
||||
|
||||
client function choose(entry, level) {
|
||||
if (entry.disabled) {
|
||||
return
|
||||
}
|
||||
output.select({ item: entry, value: entry.value || "", level: level })
|
||||
if (drawerOpen) {
|
||||
drawerOpen = false
|
||||
output.close({ source: "select" })
|
||||
}
|
||||
}
|
||||
|
||||
client function openDrawer(sourceEvent) {
|
||||
drawerOpen = true
|
||||
output.open({ sourceEvent: sourceEvent })
|
||||
output.toggle({ open: true })
|
||||
}
|
||||
|
||||
client function closeDrawer() {
|
||||
drawerOpen = false
|
||||
output.close({ source: "drawer" })
|
||||
output.toggle({ open: false })
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<div class="wire-sidebar-shell wire-next--color-{color} wire-next--size-{size} {mobileOpen ? 'is-open' : ''} {class}">
|
||||
<button type="button" class="wire-sidebar-toggle" aria-label="{mobileLabel}" aria-expanded="{mobileOpen}" @click="toggleSidebar()">
|
||||
<span class="icon-[lucide--panel-left-open]" aria-hidden="true"></span>
|
||||
<span>{label}</span>
|
||||
<div
|
||||
{...attrs}
|
||||
data-ui-component="Sidebar"
|
||||
class='wire-sidebar {class}'
|
||||
data-color='{color}'
|
||||
data-size='{size}'
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="wire-sidebar__launcher"
|
||||
aria-label='{mobileLabel}'
|
||||
aria-expanded='{drawerOpen}'
|
||||
@click='openDrawer(event)'
|
||||
>
|
||||
<span class="wire-sidebar__launcher-bar" aria-hidden="true"></span>
|
||||
<span class="wire-sidebar__launcher-text">{mobileLabel}</span>
|
||||
</button>
|
||||
<button type="button" class="wire-sidebar-backdrop" aria-label="Close navigation" @click="closeSidebar()"></button>
|
||||
<nav class="wire-next wire-next--sidebar wire-next--{orientation} wire-sidebar-panel" aria-label="{label}">
|
||||
<div class="wire-sidebar-panel__head">
|
||||
<strong>{label}</strong>
|
||||
<button type="button" aria-label="Close navigation" @click="closeSidebar()"><span class="icon-[lucide--x]" aria-hidden="true"></span></button>
|
||||
</div>
|
||||
<div class="wire-sidebar-items">
|
||||
{#each items as item}
|
||||
{#if item.children && item.children.length}
|
||||
<details class="wire-sidebar-group" name="wire-sidebar-group">
|
||||
<summary>
|
||||
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
|
||||
<span>{item.label}</span>
|
||||
<span class="icon-[lucide--chevron-down]" aria-hidden="true"></span>
|
||||
</summary>
|
||||
<div class="wire-sidebar-group__children">
|
||||
{#each item.children as child}
|
||||
<a href="{child.href || '#'}" aria-current="{child.value === active ? 'page' : ''}" @click="selectItem(child, 2)">
|
||||
{#if child.icon}<span class="{child.icon}" aria-hidden="true"></span>{/if}
|
||||
<span>{child.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
{:else}
|
||||
<a href="{item.href || '#'}" aria-current="{item.value === active ? 'page' : ''}" @click="selectItem(item, 1)">
|
||||
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
|
||||
<span>{item.label}</span>
|
||||
|
||||
<nav class="wire-sidebar__rail" aria-label='{label}'>
|
||||
<ul class="wire-sidebar__list" data-wrn-roving="vertical">
|
||||
{#each entryList() as entry}
|
||||
<li class="wire-sidebar__entry" data-group='{isGroup(entry)}'>
|
||||
<p class="wire-sidebar__heading" data-show="entry.heading">{entry.heading}</p>
|
||||
|
||||
<a
|
||||
class="wire-sidebar__link"
|
||||
data-show="!entry.heading"
|
||||
href='{entry.href || "#"}'
|
||||
data-wrn-roving-item="true"
|
||||
data-active='{isActive(entry)}'
|
||||
aria-current='{isActive(entry) ? "page" : "false"}'
|
||||
aria-disabled='{entry.disabled ? "true" : "false"}'
|
||||
@click='choose(entry, 1)'
|
||||
>
|
||||
<span
|
||||
class='wire-sidebar__icon {entry.icon}'
|
||||
data-show="entry.icon"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="wire-sidebar__label">{entry.label}</span>
|
||||
<span class="wire-sidebar__badge" data-show="entry.badge">{entry.badge}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<ul class="wire-sidebar__sublist" data-show="childrenOf(entry).length > 0">
|
||||
{#each childrenOf(entry) as child}
|
||||
<li class="wire-sidebar__entry">
|
||||
<a
|
||||
class="wire-sidebar__link"
|
||||
href='{child.href || "#"}'
|
||||
data-active='{isActive(child)}'
|
||||
aria-current='{isActive(child) ? "page" : "false"}'
|
||||
aria-disabled='{child.disabled ? "true" : "false"}'
|
||||
@click='choose(child, 2)'
|
||||
>
|
||||
<span
|
||||
class='wire-sidebar__icon {child.icon}'
|
||||
data-show="child.icon"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="wire-sidebar__label">{child.label}</span>
|
||||
<span class="wire-sidebar__badge" data-show="child.badge">{child.badge}</span>
|
||||
</a>
|
||||
|
||||
<ul
|
||||
class="wire-sidebar__sublist wire-sidebar__sublist--level3"
|
||||
data-show="childrenOf(child).length > 0"
|
||||
>
|
||||
{#each childrenOf(child) as leaf}
|
||||
<li class="wire-sidebar__entry">
|
||||
<a
|
||||
class="wire-sidebar__link"
|
||||
href='{leaf.href || "#"}'
|
||||
data-active='{isActive(leaf)}'
|
||||
aria-current='{isActive(leaf) ? "page" : "false"}'
|
||||
@click='choose(leaf, 3)'
|
||||
>
|
||||
<span class="wire-sidebar__label">{leaf.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</div>
|
||||
</ul>
|
||||
<slot />
|
||||
</nav>
|
||||
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
placement="left"
|
||||
title={drawerTitle}
|
||||
label={label}
|
||||
@close="closeDrawer()"
|
||||
>
|
||||
<slot name="drawer"></slot>
|
||||
</Drawer>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wire-sidebar {
|
||||
--sidebar-accent: var(--wire-color-primary);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.wire-sidebar[data-color="secondary"] {
|
||||
--sidebar-accent: var(--wire-color-secondary);
|
||||
}
|
||||
|
||||
.wire-sidebar[data-color="success"] {
|
||||
--sidebar-accent: var(--wire-color-success);
|
||||
}
|
||||
|
||||
.wire-sidebar[data-color="danger"] {
|
||||
--sidebar-accent: var(--wire-color-danger);
|
||||
}
|
||||
|
||||
.wire-sidebar[data-color="info"] {
|
||||
--sidebar-accent: var(--wire-color-info);
|
||||
}
|
||||
|
||||
.wire-sidebar[data-size="sm"] {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.wire-sidebar[data-size="lg"] {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.wire-sidebar__rail {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wire-sidebar__list,
|
||||
.wire-sidebar__sublist {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.wire-sidebar__sublist {
|
||||
margin-left: 0.85rem;
|
||||
padding-left: 0.5rem;
|
||||
border-left: 1px solid var(--wire-color-border);
|
||||
}
|
||||
|
||||
.wire-sidebar__entry[data-group="true"] + .wire-sidebar__entry {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.wire-sidebar__heading {
|
||||
margin: 0.9rem 0 0.35rem;
|
||||
color: var(--wire-color-text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wire-sidebar__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: var(--wire-radius-sm);
|
||||
color: var(--wire-color-text-muted);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wire-sidebar__link:hover {
|
||||
background: var(--wire-color-surface-soft);
|
||||
color: var(--wire-color-text);
|
||||
}
|
||||
|
||||
.wire-sidebar__link:focus-visible {
|
||||
outline: 2px solid var(--sidebar-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.wire-sidebar__link[data-active="true"] {
|
||||
background: color-mix(in srgb, var(--sidebar-accent) 16%, transparent);
|
||||
color: var(--sidebar-accent);
|
||||
}
|
||||
|
||||
.wire-sidebar__link[aria-disabled="true"] {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wire-sidebar__label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wire-sidebar__badge {
|
||||
margin-left: auto;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--sidebar-accent) 16%, transparent);
|
||||
color: var(--sidebar-accent);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.wire-sidebar__launcher {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: var(--wire-radius-sm);
|
||||
background: var(--wire-color-surface);
|
||||
color: var(--wire-color-text);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wire-sidebar__launcher-bar {
|
||||
width: 1rem;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
box-shadow:
|
||||
0 -5px 0 currentColor,
|
||||
0 5px 0 currentColor;
|
||||
}
|
||||
|
||||
/*
|
||||
* Below the tablet breakpoint the rail is replaced by the drawer launcher.
|
||||
* A permanent rail eats most of a phone screen, and Drawer already handles
|
||||
* the focus trap and the scroll lock.
|
||||
*/
|
||||
@media (max-width: 767px) {
|
||||
.wire-sidebar__launcher {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.wire-sidebar__rail {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,442 @@
|
||||
// Stepper -- ordered progress through a sequence.
|
||||
//
|
||||
// <Stepper steps='[{"label":"Account"},{"label":"Billing"}]' active={1} />
|
||||
//
|
||||
// Each step can be authored by hand instead of using the built-in body, by
|
||||
// passing a slot named for its index:
|
||||
//
|
||||
// <Stepper steps={steps} active={1}>
|
||||
// <div data-slot="step-1">...anything...</div>
|
||||
// </Stepper>
|
||||
//
|
||||
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
|
||||
// and silently swallows the rule that follows it.
|
||||
component Stepper {
|
||||
outputs {
|
||||
change(payload: { 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)
|
||||
previous(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
next(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
complete(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
change(payload: { index: number; step: object })
|
||||
back(payload: { index: number; step: object })
|
||||
next(payload: { index: number; step: object })
|
||||
skip(payload: { index: number; step: object })
|
||||
finish(payload: { index: number; step: object })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
label: string = "Stepper"
|
||||
items: unknown[] = []
|
||||
active: string = ""
|
||||
size: string = "default"
|
||||
steps: unknown[] = []
|
||||
active: number = 0
|
||||
orientation: string = "horizontal"
|
||||
clickable: boolean = false
|
||||
label: string = "Progress"
|
||||
// Render each step body, showing only the active one -- same contract as
|
||||
// Tabs, so a wizard does not have to hand-roll the panel switching.
|
||||
showPanel: boolean = false
|
||||
controls: boolean = false
|
||||
allowSkip: boolean = false
|
||||
// Lets a form hold the step. The component never validates anything
|
||||
// itself: the page owns the form, so it sets this while the step is
|
||||
// incomplete and clears it once the step passes.
|
||||
nextDisabled: boolean = false
|
||||
backLabel: string = "Back"
|
||||
nextLabel: string = "Next"
|
||||
skipLabel: string = "Skip"
|
||||
finishLabel: string = "Finish"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
functions {
|
||||
shared function stepList() {
|
||||
return Array.isArray(steps) ? steps : []
|
||||
}
|
||||
|
||||
shared function activeIndex() {
|
||||
var count = stepList().length
|
||||
if (count < 1) {
|
||||
return 0
|
||||
}
|
||||
var value = Number(active)
|
||||
if (!value || value < 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.min(value, count - 1)
|
||||
}
|
||||
|
||||
shared function statusFor(index) {
|
||||
if (index < activeIndex()) {
|
||||
return "complete"
|
||||
}
|
||||
if (index === activeIndex()) {
|
||||
return "current"
|
||||
}
|
||||
return "upcoming"
|
||||
}
|
||||
|
||||
// An empty orientation is how the roving runtime is told to stay out of
|
||||
// the way, so a read-only stepper never takes arrow-key focus.
|
||||
shared function rovingAxis() {
|
||||
if (!clickable) {
|
||||
return ""
|
||||
}
|
||||
return orientation === "vertical" ? "vertical" : "horizontal"
|
||||
}
|
||||
|
||||
shared function isActiveStep(index) {
|
||||
return index === activeIndex()
|
||||
}
|
||||
|
||||
shared function lastIndex() {
|
||||
return Math.max(0, stepList().length - 1)
|
||||
}
|
||||
|
||||
shared function onLastStep() {
|
||||
return activeIndex() >= lastIndex()
|
||||
}
|
||||
|
||||
shared function activeStep() {
|
||||
var list = stepList()
|
||||
return list.length ? list[activeIndex()] : {}
|
||||
}
|
||||
|
||||
client function goBack() {
|
||||
var target = Math.max(0, activeIndex() - 1)
|
||||
output.back({ index: target, step: stepList()[target] || {} })
|
||||
output.change({ index: target, step: stepList()[target] || {} })
|
||||
}
|
||||
|
||||
client function goNext() {
|
||||
if (nextDisabled) {
|
||||
return
|
||||
}
|
||||
if (onLastStep()) {
|
||||
output.finish({ index: activeIndex(), step: activeStep() })
|
||||
return
|
||||
}
|
||||
var target = Math.min(lastIndex(), activeIndex() + 1)
|
||||
output.next({ index: target, step: stepList()[target] || {} })
|
||||
output.change({ index: target, step: stepList()[target] || {} })
|
||||
}
|
||||
|
||||
client function goSkip() {
|
||||
var target = Math.min(lastIndex(), activeIndex() + 1)
|
||||
output.skip({ index: target, step: stepList()[target] || {} })
|
||||
output.change({ index: target, step: stepList()[target] || {} })
|
||||
}
|
||||
|
||||
client function selectStep(index, step) {
|
||||
if (!clickable) {
|
||||
return
|
||||
}
|
||||
output.change({ index: index, step: step })
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--stepper wire-next--{orientation} {class}" aria-label="{label}">
|
||||
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each}
|
||||
<div
|
||||
{...attrs}
|
||||
data-ui-component="Stepper"
|
||||
class='wire-stepper {class}'
|
||||
data-orientation='{orientation}'
|
||||
data-color='{color}'
|
||||
data-size='{size}'
|
||||
data-clickable='{clickable}'
|
||||
>
|
||||
<ol
|
||||
class="wire-stepper__list"
|
||||
data-wrn-roving='{rovingAxis()}'
|
||||
aria-label='{label}'
|
||||
>
|
||||
{#each stepList() as step, index}
|
||||
<li
|
||||
class="wire-stepper__step"
|
||||
data-status='{statusFor(index)}'
|
||||
aria-current='{statusFor(index) === "current" ? "step" : "false"}'
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="wire-stepper__button"
|
||||
data-wrn-roving-item='{clickable ? "true" : "false"}'
|
||||
@click='selectStep(index, step)'
|
||||
>
|
||||
<span class="wire-stepper__marker" aria-hidden="true">
|
||||
<span class='wire-stepper__icon {step.icon}' data-show="step.icon"></span>
|
||||
<span class="wire-stepper__number" data-show="!step.icon">{index + 1}</span>
|
||||
</span>
|
||||
<span class="wire-stepper__body">
|
||||
<span class="wire-stepper__label">{step.label}</span>
|
||||
<span class="wire-stepper__description" data-show="step.description">
|
||||
{step.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<span class="wire-stepper__custom">
|
||||
<slot name="step-{index}"></slot>
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<div class="wire-stepper__panels" data-show="showPanel">
|
||||
{#each stepList() as step, index}
|
||||
<div
|
||||
class="wire-stepper__panel"
|
||||
data-show='isActiveStep(index)'
|
||||
aria-hidden='{index === activeIndex() ? "false" : "true"}'
|
||||
>
|
||||
<h4 class="wire-stepper__panel-title" data-show="step.title">{step.title}</h4>
|
||||
<p class="wire-stepper__panel-body" data-show="step.content">{step.content}</p>
|
||||
<slot name="panel-{index}"></slot>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="wire-stepper__controls" data-show="controls">
|
||||
<button
|
||||
type="button"
|
||||
class="wire-stepper__button-control wire-stepper__back"
|
||||
disabled='{activeIndex() === 0}'
|
||||
@click='goBack()'
|
||||
>
|
||||
{backLabel}
|
||||
</button>
|
||||
<span class="wire-stepper__controls-spacer"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="wire-stepper__button-control wire-stepper__skip"
|
||||
data-show="allowSkip && !onLastStep()"
|
||||
@click='goSkip()'
|
||||
>
|
||||
{skipLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="wire-stepper__button-control wire-stepper__next"
|
||||
data-primary="true"
|
||||
disabled='{nextDisabled}'
|
||||
@click='goNext()'
|
||||
>
|
||||
{onLastStep() ? finishLabel : nextLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
</nav>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wire-stepper {
|
||||
--stepper-accent: var(--wire-color-primary);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.wire-stepper__list {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.wire-stepper[data-color="secondary"] {
|
||||
--stepper-accent: var(--wire-color-secondary);
|
||||
}
|
||||
|
||||
.wire-stepper[data-color="success"] {
|
||||
--stepper-accent: var(--wire-color-success);
|
||||
}
|
||||
|
||||
.wire-stepper[data-color="danger"] {
|
||||
--stepper-accent: var(--wire-color-danger);
|
||||
}
|
||||
|
||||
.wire-stepper[data-color="info"] {
|
||||
--stepper-accent: var(--wire-color-info);
|
||||
}
|
||||
|
||||
.wire-stepper[data-size="sm"] {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.wire-stepper[data-size="lg"] {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.wire-stepper[data-orientation="vertical"] .wire-stepper__list {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wire-stepper__step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.wire-stepper__button {
|
||||
appearance: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 0;
|
||||
border-radius: var(--wire-radius-sm);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wire-stepper[data-clickable="true"] .wire-stepper__button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wire-stepper[data-clickable="true"] .wire-stepper__button:hover {
|
||||
background: var(--wire-color-surface-soft);
|
||||
}
|
||||
|
||||
.wire-stepper__button:focus-visible {
|
||||
outline: 2px solid var(--stepper-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.wire-stepper__marker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--wire-color-surface);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wire-stepper__step[data-status="complete"] .wire-stepper__marker {
|
||||
border-color: var(--stepper-accent);
|
||||
background: var(--stepper-accent);
|
||||
color: var(--wire-color-primary-contrast);
|
||||
}
|
||||
|
||||
.wire-stepper__step[data-status="current"] .wire-stepper__marker {
|
||||
border-color: var(--stepper-accent);
|
||||
color: var(--stepper-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--stepper-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
.wire-stepper__step[data-status="upcoming"] .wire-stepper__marker {
|
||||
color: var(--wire-color-text-muted);
|
||||
}
|
||||
|
||||
.wire-stepper__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wire-stepper__label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wire-stepper__description {
|
||||
color: var(--wire-color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.wire-stepper__step[data-status="upcoming"] .wire-stepper__label {
|
||||
color: var(--wire-color-text-muted);
|
||||
}
|
||||
|
||||
.wire-stepper__panels {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.wire-stepper__panel {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: var(--wire-radius-md);
|
||||
background: var(--wire-color-surface);
|
||||
animation: wire-stepper-in 200ms ease both;
|
||||
}
|
||||
|
||||
.wire-stepper__panel-title {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wire-stepper__panel-body {
|
||||
margin: 0;
|
||||
color: var(--wire-color-text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.wire-stepper__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
.wire-stepper__controls-spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.wire-stepper__button-control {
|
||||
appearance: none;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: var(--wire-radius-sm);
|
||||
background: var(--wire-color-surface);
|
||||
color: var(--wire-color-text);
|
||||
font: inherit;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wire-stepper__button-control:hover:not(:disabled) {
|
||||
background: var(--wire-color-surface-soft);
|
||||
}
|
||||
|
||||
.wire-stepper__button-control:focus-visible {
|
||||
outline: 2px solid var(--stepper-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.wire-stepper__button-control[data-primary="true"] {
|
||||
border-color: var(--stepper-accent);
|
||||
background: var(--stepper-accent);
|
||||
color: var(--wire-color-primary-contrast);
|
||||
}
|
||||
|
||||
/* A held step has to look held, or the button reads as broken. */
|
||||
.wire-stepper__button-control:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@keyframes wire-stepper-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(0.75rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* A horizontal stepper cannot stay side by side on a phone. */
|
||||
@media (max-width: 639px) {
|
||||
.wire-stepper__list {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+325
-53
@@ -1,90 +1,362 @@
|
||||
// Tabs -- a tablist over panels.
|
||||
//
|
||||
// <Tabs items='[{"label":"Overview","value":"overview"}]' active="overview" />
|
||||
//
|
||||
// With mode="url" the selection is mirrored into a query parameter using
|
||||
// history.pushState, so the panel swaps without a page load and the back
|
||||
// button works. The query parameter is used rather than the hash because it
|
||||
// survives a reload and does not collide with in-page anchors or Scrollspy.
|
||||
//
|
||||
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
|
||||
// and silently swallows the rule that follows it.
|
||||
component Tabs {
|
||||
outputs {
|
||||
change(payload: { value: string; item: object; index: number })
|
||||
select(payload: { value: string; item: object; index: number })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
label: string = "Tabs"
|
||||
size: string = "default"
|
||||
items: unknown[] = []
|
||||
active: string = ""
|
||||
orientation: string = "horizontal"
|
||||
mode: string = "client"
|
||||
param: string = "tab"
|
||||
label: string = "Tabs"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
state activeValue = active || (items[0] ? (items[0].value || items[0].id || "0") : "")
|
||||
state activeValue = ""
|
||||
// Which way the panel should slide in. Set on every change so the animation
|
||||
// follows the direction of travel rather than always coming from one side.
|
||||
state slideFrom = "forward"
|
||||
|
||||
functions {
|
||||
shared function itemList() {
|
||||
return Array.isArray(items) ? items : []
|
||||
}
|
||||
|
||||
shared function valueOf(item, index) {
|
||||
return String(item.value || item.id || index)
|
||||
}
|
||||
|
||||
/*
|
||||
* In url mode the query parameter is the source of truth, not local state.
|
||||
*
|
||||
* The client router owns popstate and swaps the whole page on back and
|
||||
* forward, which discards component state anyway. Reading the URL means
|
||||
* the right tab simply falls out of whatever render happens next, with no
|
||||
* listener to lose and nothing to keep in step.
|
||||
*/
|
||||
shared function currentValue() {
|
||||
if (mode === "url" && typeof window !== "undefined" && window.location) {
|
||||
var fromUrl = new URLSearchParams(window.location.search).get(param || "tab")
|
||||
return fromUrl ? fromUrl : defaultValue()
|
||||
}
|
||||
if (activeValue) {
|
||||
return activeValue
|
||||
}
|
||||
return defaultValue()
|
||||
}
|
||||
|
||||
// The selection this instance started with. The runtime falls back to it
|
||||
// when the back button lands on a URL that has no tab parameter at all.
|
||||
shared function defaultValue() {
|
||||
if (active) {
|
||||
return active
|
||||
}
|
||||
var list = itemList()
|
||||
return list.length ? valueOf(list[0], 0) : ""
|
||||
}
|
||||
|
||||
shared function isSelected(item, index) {
|
||||
return valueOf(item, index) === currentValue()
|
||||
}
|
||||
|
||||
shared function rovingAxis() {
|
||||
return orientation === "vertical" ? "vertical" : "horizontal"
|
||||
}
|
||||
|
||||
client function selectTab(item, index, sourceEvent) {
|
||||
if (item.disabled) {
|
||||
return
|
||||
}
|
||||
var value = valueOf(item, index)
|
||||
var list = itemList()
|
||||
var previous = -1
|
||||
for (var scan = 0; scan < list.length; scan += 1) {
|
||||
if (valueOf(list[scan], scan) === currentValue()) {
|
||||
previous = scan
|
||||
}
|
||||
}
|
||||
slideFrom = previous > index ? "back" : "forward"
|
||||
activeValue = value
|
||||
|
||||
if (mode === "url" && window.history && window.history.pushState) {
|
||||
var url = new URL(window.location.href)
|
||||
url.searchParams.set(param || "tab", value)
|
||||
window.history.pushState({}, "", url.toString())
|
||||
}
|
||||
|
||||
output.change({ value: value, item: item, index: index })
|
||||
output.select({ value: value, item: item, index: index })
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<section
|
||||
{...attrs}
|
||||
data-ui-component="Tabs"
|
||||
class='w-full {class}'
|
||||
class:flex='orientation === "vertical"'
|
||||
class:items-start='orientation === "vertical"'
|
||||
class:gap-6='orientation === "vertical"'
|
||||
class='wire-tabs {class}'
|
||||
data-orientation='{orientation}'
|
||||
data-color='{color}'
|
||||
data-size='{size}'
|
||||
data-slide='{slideFrom}'
|
||||
data-mode='{mode}'
|
||||
data-param='{param}'
|
||||
>
|
||||
<div
|
||||
class="wire-tabs__list"
|
||||
role="tablist"
|
||||
aria-label='{label}'
|
||||
aria-orientation='{orientation}'
|
||||
class="flex min-w-0 gap-1 overflow-x-auto rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-soft)] p-1"
|
||||
class:flex-col='orientation === "vertical"'
|
||||
class:w-56='orientation === "vertical"'
|
||||
class:shrink-0='orientation === "vertical"'
|
||||
data-wrn-roving='{rovingAxis()}'
|
||||
>
|
||||
{#each items as item, index}
|
||||
{#each itemList() as item, index}
|
||||
<button
|
||||
type="button"
|
||||
class="wire-tabs__tab"
|
||||
role="tab"
|
||||
id='tab-{item.value || item.id || index}'
|
||||
aria-selected='{activeValue === (item.value || item.id || String(index))}'
|
||||
aria-controls='panel-{item.value || item.id || index}'
|
||||
tabindex='{activeValue === (item.value || item.id || String(index)) ? "0" : "-1"}'
|
||||
disabled='{item.disabled || false}'
|
||||
class="inline-flex min-w-max flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold outline-none transition disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class:bg-[var(--wire-color-surface-raised)]='activeValue === (item.value || item.id || String(index))'
|
||||
class:text-[var(--wire-color-primary)]='activeValue === (item.value || item.id || String(index))'
|
||||
class:shadow-sm='activeValue === (item.value || item.id || String(index))'
|
||||
class:text-[var(--wire-color-text-muted)]='activeValue !== (item.value || item.id || String(index))'
|
||||
class:hover:text-[var(--wire-color-text)]='activeValue !== (item.value || item.id || String(index))'
|
||||
class:justify-start='orientation === "vertical"'
|
||||
class:px-3='size === "sm"'
|
||||
class:py-2='size === "sm"'
|
||||
class:px-5='size === "lg"'
|
||||
class:py-3='size === "lg"'
|
||||
@click='activeValue = item.value || item.id || String(index); event.currentTarget.dispatchEvent(new CustomEvent("change", { bubbles: true, detail: { item: item, index: index, value: activeValue } })); event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: item, index: index, value: activeValue } }))'
|
||||
data-value='{valueOf(item, index)}'
|
||||
data-wrn-roving-item="true"
|
||||
id='wire-tab-{valueOf(item, index)}'
|
||||
aria-controls='wire-panel-{valueOf(item, index)}'
|
||||
aria-selected='{isSelected(item, index) ? "true" : "false"}'
|
||||
aria-disabled='{item.disabled ? "true" : "false"}'
|
||||
@click='selectTab(item, index, event)'
|
||||
>
|
||||
{#if item.icon}
|
||||
<span class='{item.icon + " size-4"}' aria-hidden="true"></span>
|
||||
{/if}
|
||||
<span>{item.label || item.title}</span>
|
||||
{#if item.badge}
|
||||
<span class="rounded-full bg-[var(--wire-color-primary-soft)] px-2 py-0.5 text-xs text-[var(--wire-color-primary)]">{item.badge}</span>
|
||||
{/if}
|
||||
<span class='wire-tabs__icon {item.icon}' data-show="item.icon" aria-hidden="true"></span>
|
||||
<span class="wire-tabs__label">{item.label || item.title}</span>
|
||||
<span class="wire-tabs__badge" data-show="item.badge">{item.badge}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 pt-4" class:pt-0='orientation === "vertical"'>
|
||||
{#each items as item, index}
|
||||
<div class="wire-tabs__panels">
|
||||
{#each itemList() as item, index}
|
||||
<div
|
||||
class="wire-tabs__panel"
|
||||
role="tabpanel"
|
||||
id='panel-{item.value || item.id || index}'
|
||||
aria-labelledby='tab-{item.value || item.id || index}'
|
||||
id='wire-panel-{valueOf(item, index)}'
|
||||
aria-labelledby='wire-tab-{valueOf(item, index)}'
|
||||
tabindex="0"
|
||||
data-show='activeValue === (item.value || item.id || String(index))'
|
||||
class="rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]"
|
||||
data-show='isSelected(item, index)'
|
||||
>
|
||||
{#if item.title && item.title !== item.label}
|
||||
<h3 class="text-lg font-bold text-[var(--wire-color-text)]">{item.title}</h3>
|
||||
{/if}
|
||||
{#if item.description}
|
||||
<p class="mt-2 leading-7 text-[var(--wire-color-text-muted)]">{item.description}</p>
|
||||
{/if}
|
||||
{#if item.content}
|
||||
<div class="mt-4 text-[var(--wire-color-text)]">{item.content}</div>
|
||||
{/if}
|
||||
<h3 class="wire-tabs__title" data-show="item.title && item.title !== item.label">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p class="wire-tabs__description" data-show="item.description">{item.description}</p>
|
||||
<div class="wire-tabs__content" data-show="item.content">{item.content}</div>
|
||||
<slot name="panel-{valueOf(item, index)}"></slot>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<slot></slot>
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
style {
|
||||
.wire-tabs {
|
||||
--tabs-accent: var(--wire-color-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.wire-tabs[data-color="secondary"] {
|
||||
--tabs-accent: var(--wire-color-secondary);
|
||||
}
|
||||
|
||||
.wire-tabs[data-color="success"] {
|
||||
--tabs-accent: var(--wire-color-success);
|
||||
}
|
||||
|
||||
.wire-tabs[data-color="danger"] {
|
||||
--tabs-accent: var(--wire-color-danger);
|
||||
}
|
||||
|
||||
.wire-tabs[data-color="info"] {
|
||||
--tabs-accent: var(--wire-color-info);
|
||||
}
|
||||
|
||||
.wire-tabs[data-size="sm"] {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.wire-tabs[data-size="lg"] {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.wire-tabs[data-orientation="vertical"] {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.wire-tabs__list {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: var(--wire-radius-md);
|
||||
background: var(--wire-color-surface-soft);
|
||||
/* A long tablist scrolls rather than wrapping into an unusable stack. */
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.wire-tabs[data-orientation="vertical"] .wire-tabs__list {
|
||||
flex-direction: column;
|
||||
flex: 0 0 auto;
|
||||
width: 14rem;
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
.wire-tabs__tab {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
flex: 1 0 auto;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border: 0;
|
||||
border-radius: var(--wire-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--wire-color-text-muted);
|
||||
font: inherit;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
color 160ms ease;
|
||||
}
|
||||
|
||||
.wire-tabs[data-orientation="vertical"] .wire-tabs__tab {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.wire-tabs__tab:hover {
|
||||
color: var(--wire-color-text);
|
||||
}
|
||||
|
||||
.wire-tabs__tab:focus-visible {
|
||||
outline: 2px solid var(--tabs-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.wire-tabs__tab[aria-selected="true"] {
|
||||
background: var(--wire-color-surface);
|
||||
color: var(--tabs-accent);
|
||||
box-shadow: var(--wire-shadow-1);
|
||||
}
|
||||
|
||||
.wire-tabs__tab[aria-disabled="true"] {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wire-tabs__badge {
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--tabs-accent) 16%, transparent);
|
||||
color: var(--tabs-accent);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.wire-tabs__panels {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.wire-tabs__panel {
|
||||
outline: none;
|
||||
animation: wire-tabs-slide-forward 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.wire-tabs[data-slide="back"] .wire-tabs__panel {
|
||||
animation-name: wire-tabs-slide-back;
|
||||
}
|
||||
|
||||
/* The panels clip their own slide so it never widens the page. */
|
||||
.wire-tabs__panels {
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.wire-tabs__panel:focus-visible {
|
||||
outline: 2px solid var(--tabs-accent);
|
||||
outline-offset: 4px;
|
||||
border-radius: var(--wire-radius-sm);
|
||||
}
|
||||
|
||||
.wire-tabs__title {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wire-tabs__description {
|
||||
margin: 0;
|
||||
color: var(--wire-color-text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.wire-tabs__content {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
@keyframes wire-tabs-slide-forward {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(1.25rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wire-tabs-slide-back {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-1.25rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Respect a reduced-motion preference: swap instantly instead. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wire-tabs__panel {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.wire-tabs[data-orientation="vertical"] {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wire-tabs[data-orientation="vertical"] .wire-tabs__list {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
component Timeline {
|
||||
outputs {
|
||||
select(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
@@ -30,7 +34,7 @@ component Timeline {
|
||||
{#each items as item, index}
|
||||
<li
|
||||
class="relative pb-8 pl-8 last:pb-0"
|
||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: item, index: index } }))'
|
||||
@click='output.select({ item: item, index: index })'
|
||||
>
|
||||
<span
|
||||
class="absolute -left-[1.05rem] top-0 flex size-8 items-center justify-center rounded-full border-4 border-[var(--wire-color-background)] bg-[var(--wire-color-primary)] text-[var(--wire-color-on-primary)] shadow-sm"
|
||||
|
||||
@@ -57,34 +57,17 @@ component ToggleCount {
|
||||
: emptyValue
|
||||
}
|
||||
|
||||
client function dispatchToggleEvent(sourceEvent, previousValue, root, customEvent) {
|
||||
root = sourceEvent.currentTarget.closest("[data-wrn-toggle-count]")
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
client function dispatchToggleEvent(sourceEvent, previousValue, payload) {
|
||||
payload = {
|
||||
component: "ToggleCount",
|
||||
name: name,
|
||||
value: selectedValue,
|
||||
previousValue: previousValue,
|
||||
firstValue: firstValue,
|
||||
secondValue: secondValue
|
||||
}
|
||||
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent("change", true, false, {
|
||||
component: "ToggleCount",
|
||||
name: name,
|
||||
value: selectedValue,
|
||||
previousValue: previousValue,
|
||||
firstValue: firstValue,
|
||||
secondValue: secondValue
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent("toggle", true, false, {
|
||||
component: "ToggleCount",
|
||||
name: name,
|
||||
value: selectedValue,
|
||||
previousValue: previousValue,
|
||||
firstValue: firstValue,
|
||||
secondValue: secondValue
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
output.change(payload)
|
||||
output.toggle(payload)
|
||||
}
|
||||
|
||||
client function selectValue(sourceEvent, nextValue, previousValue) {
|
||||
|
||||
@@ -11,26 +11,74 @@ component Typography {
|
||||
view {
|
||||
<div
|
||||
data-ui-component="Typography"
|
||||
class='wire-typography text-[var(--wire-color-text)] {class}'
|
||||
class:max-w-3xl='maxWidth === "md"'
|
||||
class:max-w-5xl='maxWidth === "lg"'
|
||||
class:max-w-7xl='maxWidth === "xl"'
|
||||
class:max-w-none='maxWidth === "full"'
|
||||
class:text-sm='size === "sm"'
|
||||
class:text-base='size === "default" || size === "md"'
|
||||
class:text-lg='size === "lg"'
|
||||
class:columns-1='columns === 1'
|
||||
class:md:columns-2='columns === 2'
|
||||
class:lg:columns-3='columns === 3'
|
||||
class:gap-4='gap === "sm"'
|
||||
class:gap-8='gap === "md"'
|
||||
class:gap-12='gap === "lg"'
|
||||
class='wire-typography {class}'
|
||||
data-size='{size}'
|
||||
data-columns='{columns}'
|
||||
data-gap='{gap}'
|
||||
data-max-width='{maxWidth}'
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wire-typography {
|
||||
color: var(--wire-color-text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.wire-typography[data-size="sm"] {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.wire-typography[data-size="lg"] {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.wire-typography[data-max-width="md"] {
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
.wire-typography[data-max-width="lg"] {
|
||||
max-width: 64rem;
|
||||
}
|
||||
|
||||
.wire-typography[data-max-width="xl"] {
|
||||
max-width: 80rem;
|
||||
}
|
||||
|
||||
.wire-typography[data-max-width="full"] {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Multi-column prose only below a comfortable reading width; a narrow
|
||||
* screen split into columns is unreadable.
|
||||
*/
|
||||
.wire-typography[data-gap="sm"] {
|
||||
column-gap: 1rem;
|
||||
}
|
||||
|
||||
.wire-typography[data-gap="md"] {
|
||||
column-gap: 2rem;
|
||||
}
|
||||
|
||||
.wire-typography[data-gap="lg"] {
|
||||
column-gap: 3rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.wire-typography[data-columns="2"] {
|
||||
column-count: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.wire-typography[data-columns="3"] {
|
||||
column-count: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.wire-typography :where(h1, h2, h3, h4) {
|
||||
color: var(--wire-color-text);
|
||||
font-weight: 700;
|
||||
|
||||
@@ -35,22 +35,23 @@ component Alert {
|
||||
state visible: boolean = true
|
||||
|
||||
functions {
|
||||
client function dispatchAlertEvent(sourceEvent, eventName, action, root, customEvent) {
|
||||
root = sourceEvent.currentTarget.closest("[data-wrn-alert]")
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent(eventName, true, false, {
|
||||
// The output name has to be written out rather than computed: outputs are
|
||||
// resolved as named properties, so a dynamic key would not reach a parent
|
||||
// binding. Only two names exist here, so a branch is honest and cheap.
|
||||
client function dispatchAlertEvent(sourceEvent, eventName, action, payload) {
|
||||
payload = {
|
||||
component: "Alert",
|
||||
title: title,
|
||||
color: color,
|
||||
variant: variant,
|
||||
action: action
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
}
|
||||
|
||||
if (eventName === "dismiss") {
|
||||
output.dismiss(payload)
|
||||
} else {
|
||||
output.action(payload)
|
||||
}
|
||||
}
|
||||
|
||||
client function dismissAlert(sourceEvent) {
|
||||
|
||||
@@ -35,15 +35,13 @@ component Badge {
|
||||
state visible: boolean = true
|
||||
|
||||
functions {
|
||||
client function dismissBadge(sourceEvent, root, customEvent) {
|
||||
// output.dismiss reaches a parent @dismiss binding; a raw dispatchEvent on
|
||||
// the root does not. The runtime registers parent handlers in a registry
|
||||
// that only the output proxy consults, so the CustomEvent this used to
|
||||
// build bubbled past every binding and was never seen by anyone.
|
||||
client function dismissBadge() {
|
||||
visible = false
|
||||
root = sourceEvent.currentTarget.closest("[data-wrn-badge]")
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent("dismiss", true, false, {
|
||||
component: "Badge",
|
||||
label: label
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
output.dismiss({ component: "Badge", label: label })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,67 +45,27 @@ component Card {
|
||||
state dismissed: boolean = false
|
||||
|
||||
functions {
|
||||
client function dispatchCardNavigation(sourceEvent, item, index, root, customEvent) {
|
||||
root = sourceEvent.currentTarget.closest("[data-wrn-card]")
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent("navigate", true, false, {
|
||||
component: "Card",
|
||||
item: item,
|
||||
index: index
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
// Outputs must go through output.*; a CustomEvent dispatched on the root
|
||||
// bubbles past every parent @binding without being seen, because the
|
||||
// runtime keeps parent handlers in a registry only the output proxy reads.
|
||||
client function dispatchCardNavigation(sourceEvent, item, index) {
|
||||
output.navigate({ component: "Card", item: item, index: index })
|
||||
}
|
||||
|
||||
client function dispatchCardNavigationValue(sourceEvent, root, customEvent) {
|
||||
root = sourceEvent.currentTarget.closest("[data-wrn-card]")
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent("navigate", true, false, {
|
||||
client function dispatchCardNavigationValue(sourceEvent) {
|
||||
output.navigate({
|
||||
component: "Card",
|
||||
value: sourceEvent.currentTarget.value
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
}
|
||||
|
||||
client function dispatchCardHeaderAction(sourceEvent, action, index, root, customEvent) {
|
||||
root = sourceEvent.currentTarget.closest("[data-wrn-card]")
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent("action", true, false, {
|
||||
component: "Card",
|
||||
action: action,
|
||||
index: index
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
client function dispatchCardHeaderAction(sourceEvent, action, index) {
|
||||
output.action({ component: "Card", action: action, index: index })
|
||||
}
|
||||
|
||||
client function dismissCard(sourceEvent, root, customEvent) {
|
||||
client function dismissCard() {
|
||||
dismissed = true
|
||||
root = sourceEvent.currentTarget.closest("[data-wrn-card]")
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
customEvent = document.createEvent("CustomEvent")
|
||||
customEvent.initCustomEvent("dismiss", true, false, {
|
||||
component: "Card",
|
||||
title: title
|
||||
})
|
||||
root.dispatchEvent(customEvent)
|
||||
output.dismiss({ component: "Card", title: title })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,8 +97,8 @@ component Card {
|
||||
alt='{item.imageAlt || item.alt || ""}'
|
||||
loading='{item.loading || "lazy"}'
|
||||
decoding="async"
|
||||
@load='event.currentTarget.dispatchEvent(new CustomEvent("load", { bubbles: true, detail: { item: item, index: itemIndex } }))'
|
||||
@error='event.currentTarget.dispatchEvent(new CustomEvent("error", { bubbles: true, detail: { item: item, index: itemIndex } }))'
|
||||
@load='output.load({ item: item, index: itemIndex })'
|
||||
@error='output.error({ item: item, index: itemIndex })'
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -157,7 +117,7 @@ component Card {
|
||||
<a
|
||||
href='{item.actionHref || item.href || "#"}'
|
||||
class="wire-next__card-action"
|
||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("action", { bubbles: true, detail: { item: item, index: itemIndex } }))'
|
||||
@click='output.action({ item: item, index: itemIndex })'
|
||||
>
|
||||
<span>{item.actionLabel || "Learn more"}</span>
|
||||
<span class="icon-[lucide--arrow-right]" aria-hidden="true"></span>
|
||||
@@ -180,8 +140,8 @@ component Card {
|
||||
alt='{imageAlt}'
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@load='event.currentTarget.dispatchEvent(new CustomEvent("load", { bubbles: true, detail: { src: imageSrc } }))'
|
||||
@error='event.currentTarget.dispatchEvent(new CustomEvent("error", { bubbles: true, detail: { src: imageSrc } }))'
|
||||
@load='output.load({ src: imageSrc })'
|
||||
@error='output.error({ src: imageSrc })'
|
||||
/>
|
||||
<div class="wire-next__card-overlay-shade" aria-hidden="true"></div>
|
||||
</div>
|
||||
@@ -194,8 +154,8 @@ component Card {
|
||||
alt='{imageAlt}'
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@load='event.currentTarget.dispatchEvent(new CustomEvent("load", { bubbles: true, detail: { src: imageSrc } }))'
|
||||
@error='event.currentTarget.dispatchEvent(new CustomEvent("error", { bubbles: true, detail: { src: imageSrc } }))'
|
||||
@load='output.load({ src: imageSrc })'
|
||||
@error='output.error({ src: imageSrc })'
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -323,7 +283,7 @@ component Card {
|
||||
<a
|
||||
href='{actionHref || "#"}'
|
||||
class="wire-next__card-action"
|
||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("action", { bubbles: true, detail: { href: actionHref, label: actionLabel } }))'
|
||||
@click='output.action({ href: actionHref, label: actionLabel })'
|
||||
>
|
||||
<span>{actionLabel}</span>
|
||||
<span class="icon-[lucide--arrow-right]" aria-hidden="true"></span>
|
||||
@@ -340,8 +300,8 @@ component Card {
|
||||
alt='{imageAlt}'
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@load='event.currentTarget.dispatchEvent(new CustomEvent("load", { bubbles: true, detail: { src: imageSrc } }))'
|
||||
@error='event.currentTarget.dispatchEvent(new CustomEvent("error", { bubbles: true, detail: { src: imageSrc } }))'
|
||||
@load='output.load({ src: imageSrc })'
|
||||
@error='output.error({ src: imageSrc })'
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,46 +1,162 @@
|
||||
// Container -- a centred, width-limited page wrapper.
|
||||
//
|
||||
// <Container maxWidth="lg">...</Container>
|
||||
//
|
||||
// It also accepts columns and gap, because it always has and applications
|
||||
// depend on it. Reach for Grid when a grid is the point; reach for Container
|
||||
// when the point is the reading width.
|
||||
//
|
||||
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
|
||||
// and silently swallows the rule that follows it.
|
||||
component Container {
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
// Stays 2 to match what shipped. A published default cannot be changed
|
||||
// without silently reflowing every Container already in use.
|
||||
columns: number = 2
|
||||
gap: string = "md"
|
||||
maxWidth: string = "xl"
|
||||
centered: boolean = true
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
functions {
|
||||
shared function columnCount() {
|
||||
var value = Number(columns)
|
||||
if (!value || value < 1) {
|
||||
return 1
|
||||
}
|
||||
return Math.min(6, value)
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<div
|
||||
{...attrs}
|
||||
data-ui-component="Container"
|
||||
class='wire-container {class}'
|
||||
data-size='{size}'
|
||||
data-color='{color}'
|
||||
data-columns='{columns}'
|
||||
data-gap='{gap}'
|
||||
data-max-width='{maxWidth}'
|
||||
class='mx-auto w-full {class}'
|
||||
class:max-w-3xl='maxWidth === "md"'
|
||||
class:max-w-5xl='maxWidth === "lg"'
|
||||
class:max-w-7xl='maxWidth === "xl" || maxWidth === "wide"'
|
||||
class:max-w-screen-2xl='maxWidth === "2xl"'
|
||||
class:max-w-none='maxWidth === "full"'
|
||||
class:px-4='size === "compact" || size === "default"'
|
||||
class:px-5='size === "comfortable"'
|
||||
class:px-6='size === "spacious"'
|
||||
class:sm:px-6='size !== "compact"'
|
||||
class:lg:px-8='size === "default" || size === "comfortable" || size === "spacious"'
|
||||
class:grid='columns > 1'
|
||||
class:grid-cols-1='columns > 1'
|
||||
class:sm:grid-cols-2='columns === 2 || columns === 3 || columns === 4 || columns === 5 || columns === 6'
|
||||
class:lg:grid-cols-3='columns === 3'
|
||||
class:lg:grid-cols-4='columns === 4'
|
||||
class:lg:grid-cols-5='columns === 5'
|
||||
class:lg:grid-cols-6='columns === 6'
|
||||
class:gap-2='gap === "xs"'
|
||||
class:gap-3='gap === "sm"'
|
||||
class:gap-5='gap === "md"'
|
||||
class:gap-8='gap === "lg"'
|
||||
class:gap-10='gap === "xl"'
|
||||
data-centered='{centered}'
|
||||
data-columns='{columnCount()}'
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wire-container {
|
||||
--container-gap: 1.25rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.wire-container[data-centered="true"] {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* Reading widths, not breakpoints: the cap is about line length. */
|
||||
.wire-container[data-max-width="md"] {
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
.wire-container[data-max-width="lg"] {
|
||||
max-width: 64rem;
|
||||
}
|
||||
|
||||
.wire-container[data-max-width="xl"],
|
||||
.wire-container[data-max-width="wide"] {
|
||||
max-width: 80rem;
|
||||
}
|
||||
|
||||
.wire-container[data-max-width="2xl"] {
|
||||
max-width: 96rem;
|
||||
}
|
||||
|
||||
.wire-container[data-max-width="full"] {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.wire-container[data-size="compact"] {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.wire-container[data-size="comfortable"] {
|
||||
padding-inline: 1.25rem;
|
||||
}
|
||||
|
||||
.wire-container[data-size="spacious"] {
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
|
||||
.wire-container[data-gap="xs"] {
|
||||
--container-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.wire-container[data-gap="sm"] {
|
||||
--container-gap: 0.75rem;
|
||||
}
|
||||
|
||||
.wire-container[data-gap="lg"] {
|
||||
--container-gap: 2rem;
|
||||
}
|
||||
|
||||
.wire-container[data-gap="xl"] {
|
||||
--container-gap: 2.5rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* Columns are opt-in. One column stays plain flow so a container does not
|
||||
* quietly turn every page into a grid.
|
||||
*/
|
||||
.wire-container:not([data-columns="1"]) {
|
||||
display: grid;
|
||||
gap: var(--container-gap);
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.wire-container[data-columns="2"],
|
||||
.wire-container[data-columns="3"],
|
||||
.wire-container[data-columns="4"],
|
||||
.wire-container[data-columns="5"],
|
||||
.wire-container[data-columns="6"] {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wire-container[data-size="default"],
|
||||
.wire-container[data-size="comfortable"],
|
||||
.wire-container[data-size="spacious"] {
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.wire-container[data-columns="3"] {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wire-container[data-columns="4"] {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wire-container[data-columns="5"] {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wire-container[data-columns="6"] {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wire-container[data-size="default"],
|
||||
.wire-container[data-size="comfortable"],
|
||||
.wire-container[data-size="spacious"] {
|
||||
padding-inline: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +1,141 @@
|
||||
// Divider -- a rule between sections, optionally labelled.
|
||||
//
|
||||
// <Divider />
|
||||
// <Divider label="or" />
|
||||
// <Divider orientation="vertical" />
|
||||
//
|
||||
// The label sits between two rules rather than on top of one, so the text
|
||||
// never overlaps the line at any width.
|
||||
//
|
||||
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
|
||||
// and silently swallows the rule that follows it.
|
||||
component Divider {
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
label: string = ""
|
||||
orientation: string = "horizontal"
|
||||
variant: string = "solid"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
functions {
|
||||
shared function isVertical() {
|
||||
return orientation === "vertical"
|
||||
}
|
||||
|
||||
// The rule only takes the component colour when there is a label. A plain
|
||||
// divider is chrome and should stay the border token.
|
||||
shared function tone() {
|
||||
return label ? color : "border"
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<div
|
||||
{...attrs}
|
||||
data-ui-component="Divider"
|
||||
class='wire-divider {class}'
|
||||
data-size='{size}'
|
||||
data-color='{color}'
|
||||
data-tone='{tone()}'
|
||||
data-variant='{variant}'
|
||||
data-orientation='{orientation}'
|
||||
class='w-full {class}'
|
||||
class:h-full='orientation === "vertical"'
|
||||
class:w-auto='orientation === "vertical"'
|
||||
data-labelled='{label ? "true" : "false"}'
|
||||
role="separator"
|
||||
aria-orientation='{isVertical() ? "vertical" : "horizontal"}'
|
||||
>
|
||||
{#if orientation === "vertical"}
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
class="mx-3 h-full min-h-6 border-l border-[var(--wire-color-border)]"
|
||||
class:border-l-2='size === "lg"'
|
||||
class:border-[var(--wire-color-primary)]='color === "primary"'
|
||||
class:border-[var(--wire-color-secondary)]='color === "secondary"'
|
||||
class:border-[var(--wire-color-success)]='color === "success"'
|
||||
class:border-[var(--wire-color-warning)]='color === "warning"'
|
||||
class:border-[var(--wire-color-danger)]='color === "danger"'
|
||||
></div>
|
||||
{:else}
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
class="flex w-full items-center gap-4"
|
||||
>
|
||||
<span
|
||||
class="h-px flex-1 bg-[var(--wire-color-border)]"
|
||||
class:h-0.5='size === "lg"'
|
||||
class:bg-[var(--wire-color-primary)]='color === "primary" && label !== ""'
|
||||
class:bg-[var(--wire-color-secondary)]='color === "secondary" && label !== ""'
|
||||
class:bg-[var(--wire-color-success)]='color === "success" && label !== ""'
|
||||
class:bg-[var(--wire-color-warning)]='color === "warning" && label !== ""'
|
||||
class:bg-[var(--wire-color-danger)]='color === "danger" && label !== ""'
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
|
||||
{#if label}
|
||||
<span class="shrink-0 text-xs font-bold uppercase tracking-[0.16em] text-[var(--wire-color-text-muted)]">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
class="h-px flex-1 bg-[var(--wire-color-border)]"
|
||||
class:h-0.5='size === "lg"'
|
||||
class:bg-[var(--wire-color-primary)]='color === "primary"'
|
||||
class:bg-[var(--wire-color-secondary)]='color === "secondary"'
|
||||
class:bg-[var(--wire-color-success)]='color === "success"'
|
||||
class:bg-[var(--wire-color-warning)]='color === "warning"'
|
||||
class:bg-[var(--wire-color-danger)]='color === "danger"'
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="wire-divider__rule" aria-hidden="true"></span>
|
||||
<span class="wire-divider__label" data-show="label">{label}</span>
|
||||
<span class="wire-divider__rule" data-show="label" aria-hidden="true"></span>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wire-divider {
|
||||
--divider-tone: var(--wire-color-border);
|
||||
--divider-thickness: 1px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wire-divider[data-tone="primary"] {
|
||||
--divider-tone: var(--wire-color-primary);
|
||||
}
|
||||
|
||||
.wire-divider[data-tone="secondary"] {
|
||||
--divider-tone: var(--wire-color-secondary);
|
||||
}
|
||||
|
||||
.wire-divider[data-tone="success"] {
|
||||
--divider-tone: var(--wire-color-success);
|
||||
}
|
||||
|
||||
.wire-divider[data-tone="warning"] {
|
||||
--divider-tone: var(--wire-color-warning);
|
||||
}
|
||||
|
||||
.wire-divider[data-tone="danger"] {
|
||||
--divider-tone: var(--wire-color-danger);
|
||||
}
|
||||
|
||||
.wire-divider[data-size="lg"] {
|
||||
--divider-thickness: 2px;
|
||||
}
|
||||
|
||||
.wire-divider__rule {
|
||||
flex: 1 1 auto;
|
||||
height: var(--divider-thickness);
|
||||
background: var(--divider-tone);
|
||||
}
|
||||
|
||||
.wire-divider[data-variant="dashed"] .wire-divider__rule {
|
||||
height: 0;
|
||||
background: transparent;
|
||||
border-top: var(--divider-thickness) dashed var(--divider-tone);
|
||||
}
|
||||
|
||||
.wire-divider[data-variant="dotted"] .wire-divider__rule {
|
||||
height: 0;
|
||||
background: transparent;
|
||||
border-top: var(--divider-thickness) dotted var(--divider-tone);
|
||||
}
|
||||
|
||||
.wire-divider__label {
|
||||
flex: 0 0 auto;
|
||||
color: var(--wire-color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wire-divider[data-orientation="vertical"] {
|
||||
flex-direction: column;
|
||||
width: auto;
|
||||
height: 100%;
|
||||
min-height: 1.5rem;
|
||||
margin-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.wire-divider[data-orientation="vertical"] .wire-divider__rule {
|
||||
width: var(--divider-thickness);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.wire-divider[data-orientation="vertical"][data-variant="dashed"] .wire-divider__rule {
|
||||
width: 0;
|
||||
border-top: 0;
|
||||
border-left: var(--divider-thickness) dashed var(--divider-tone);
|
||||
}
|
||||
|
||||
.wire-divider[data-orientation="vertical"][data-variant="dotted"] .wire-divider__rule {
|
||||
width: 0;
|
||||
border-top: 0;
|
||||
border-left: var(--divider-thickness) dotted var(--divider-tone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+117
-19
@@ -1,3 +1,13 @@
|
||||
// Grid -- an equal-track grid that collapses on small screens.
|
||||
//
|
||||
// <Grid columns={3} gap="lg">...</Grid>
|
||||
//
|
||||
// Pass minItemWidth to let the browser decide the count instead: the track
|
||||
// list becomes auto-fit, which reflows continuously rather than at fixed
|
||||
// breakpoints and suits card decks whose item count is not known.
|
||||
//
|
||||
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
|
||||
// and silently swallows the rule that follows it.
|
||||
component Grid {
|
||||
props {
|
||||
size: string = "default"
|
||||
@@ -5,36 +15,124 @@ component Grid {
|
||||
columns: number = 2
|
||||
gap: string = "md"
|
||||
maxWidth: string = "xl"
|
||||
// When set, the count is derived from the available width instead of the
|
||||
// columns prop. Accepts any CSS length.
|
||||
minItemWidth: string = ""
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
functions {
|
||||
shared function columnCount() {
|
||||
var value = Number(columns)
|
||||
if (!value || value < 1) {
|
||||
return 1
|
||||
}
|
||||
return Math.min(6, value)
|
||||
}
|
||||
|
||||
shared function autoTrack() {
|
||||
return minItemWidth ? "repeat(auto-fit, minmax(" + minItemWidth + ", 1fr))" : ""
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<div
|
||||
{...attrs}
|
||||
data-ui-component="Grid"
|
||||
class='wire-grid {class}'
|
||||
data-size='{size}'
|
||||
data-color='{color}'
|
||||
data-columns='{columns}'
|
||||
data-gap='{gap}'
|
||||
class='grid w-full grid-cols-1 {class}'
|
||||
class:max-w-3xl='maxWidth === "md"'
|
||||
class:max-w-5xl='maxWidth === "lg"'
|
||||
class:max-w-7xl='maxWidth === "xl"'
|
||||
class:max-w-screen-2xl='maxWidth === "2xl"'
|
||||
class:max-w-none='maxWidth === "full"'
|
||||
class:sm:grid-cols-2='columns >= 2'
|
||||
class:lg:grid-cols-3='columns === 3'
|
||||
class:lg:grid-cols-4='columns === 4'
|
||||
class:lg:grid-cols-5='columns === 5'
|
||||
class:lg:grid-cols-6='columns === 6'
|
||||
class:gap-2='gap === "xs"'
|
||||
class:gap-3='gap === "sm"'
|
||||
class:gap-5='gap === "md"'
|
||||
class:gap-8='gap === "lg"'
|
||||
class:gap-10='gap === "xl"'
|
||||
class:items-start='size === "compact"'
|
||||
class:items-stretch='size !== "compact"'
|
||||
data-max-width='{maxWidth}'
|
||||
data-columns='{columnCount()}'
|
||||
data-auto='{minItemWidth ? "true" : "false"}'
|
||||
style='--grid-auto-track:{autoTrack()};'
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wire-grid {
|
||||
--grid-gap: 1.25rem;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: var(--grid-gap);
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.wire-grid[data-size="compact"] {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.wire-grid[data-max-width="md"] {
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
.wire-grid[data-max-width="lg"] {
|
||||
max-width: 64rem;
|
||||
}
|
||||
|
||||
.wire-grid[data-max-width="xl"] {
|
||||
max-width: 80rem;
|
||||
}
|
||||
|
||||
.wire-grid[data-max-width="2xl"] {
|
||||
max-width: 96rem;
|
||||
}
|
||||
|
||||
.wire-grid[data-max-width="full"] {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.wire-grid[data-gap="xs"] {
|
||||
--grid-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.wire-grid[data-gap="sm"] {
|
||||
--grid-gap: 0.75rem;
|
||||
}
|
||||
|
||||
.wire-grid[data-gap="lg"] {
|
||||
--grid-gap: 2rem;
|
||||
}
|
||||
|
||||
.wire-grid[data-gap="xl"] {
|
||||
--grid-gap: 2.5rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* auto-fit needs no breakpoints, so it wins over the fixed counts below
|
||||
* and applies at every width.
|
||||
*/
|
||||
.wire-grid[data-auto="true"] {
|
||||
grid-template-columns: var(--grid-auto-track);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.wire-grid[data-auto="false"]:not([data-columns="1"]) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.wire-grid[data-auto="false"][data-columns="3"] {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wire-grid[data-auto="false"][data-columns="4"] {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wire-grid[data-auto="false"][data-columns="5"] {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wire-grid[data-auto="false"][data-columns="6"] {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
component Table {
|
||||
outputs {
|
||||
sort(payload: { 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)
|
||||
select(payload: { 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)
|
||||
rowClick(payload: { 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)
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
caption: string = "Table"
|
||||
columns: unknown[] = []
|
||||
rows: unknown[] = []
|
||||
striped: boolean = true
|
||||
class: string = ""
|
||||
}
|
||||
view {
|
||||
<div class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--table {class}"><table><caption>{caption}</caption><thead><tr>{#each columns as column}<th>{column.label}</th>{/each}</tr></thead><tbody>{#each rows as row}<tr>{#each columns as column}<td>{row[column.key]}</td>{/each}</tr>{/each}</tbody></table><slot /></div>
|
||||
}
|
||||
}
|
||||
@@ -97,11 +97,12 @@ id: string = ""
|
||||
<span
|
||||
id='{id}'
|
||||
class="wire-tooltip__content"
|
||||
data-wrn-anchored="true"
|
||||
data-show='{open || visible}'
|
||||
role="tooltip"
|
||||
>
|
||||
{#if showArrow}
|
||||
<span class="wire-tooltip__arrow" aria-hidden="true"></span>
|
||||
<span class="wire-tooltip__arrow" data-wrn-anchor-arrow="true" aria-hidden="true"></span>
|
||||
{/if}
|
||||
|
||||
{#if title}
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/ui",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/ui — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/ui"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/ui",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"ui"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -19,6 +35,10 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./registry": {
|
||||
"types": "./dist/registry.d.ts",
|
||||
"import": "./dist/registry.js"
|
||||
},
|
||||
"./components/*": "./components/*",
|
||||
"./component-catalog.json": "./component-catalog.json",
|
||||
"./component-migrations.json": "./component-migrations.json",
|
||||
@@ -26,10 +46,11 @@
|
||||
"./ui.css": "./ui.css"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.7.0"
|
||||
"@wrnexus/core": "^0.8.6"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"components",
|
||||
"ui.css",
|
||||
"component-catalog.json",
|
||||
|
||||
-2034
File diff suppressed because it is too large
Load Diff
@@ -108,6 +108,11 @@ export const GET = serveFromStore("docs"); // your middleware decides who gets i
|
||||
|
||||
## API
|
||||
|
||||
Uploads can participate in security and media pipelines without changing storage drivers. Pass a
|
||||
`scan` hook to reject malware/DLP findings before storage, and `afterStore` to enqueue image/video
|
||||
processing or indexing. If post-processing throws, WRNexus deletes the newly written object so a
|
||||
partially accepted upload is never left behind.
|
||||
|
||||
| Export | What |
|
||||
| --------------------------------------- | --------------------------------------------------------------- |
|
||||
| `handleUpload(opts)` | POST route handler → JSON `{ ok, files }` |
|
||||
@@ -123,3 +128,36 @@ export const GET = serveFromStore("docs"); // your middleware decides who gets i
|
||||
- SigV4 signing is implemented from scratch (no `@aws-sdk`); tested against local S3 semantics.
|
||||
Live AWS/R2 connectivity depends on your credentials + bucket policy.
|
||||
- v1 buffers each file in memory up to its size cap (fine for images/docs up to tens of MB).
|
||||
|
||||
## Helper and component kit
|
||||
|
||||
Use `formatFileSize`, `uploadAccept`, `uploadedFileMap`, `uploaderAttributes`, and `assertUploadedFiles` to keep upload forms and server validation consistent.
|
||||
|
||||
Enable `uploaderPlugin()` for:
|
||||
|
||||
- `<UploadDropzone />`
|
||||
- `<UploadStatus />`
|
||||
|
||||
The complete blocks compose `Card`, `Alert`, and `Badge` from `@wrnexus/ui`; the specialized upload runtime remains responsible for the native file input and secure transport behavior.
|
||||
Large files can use `createResumableUploadManager`. Sessions are bounded and
|
||||
expiring; chunks may arrive out of order, carry SHA-256 checksums, and are
|
||||
idempotent when retried. Conflicting retries reject, and the object is assembled
|
||||
only after every exact-sized chunk is present.
|
||||
|
||||
```ts
|
||||
const uploads = createResumableUploadManager({
|
||||
driver: getStore("documents").driver,
|
||||
sessions: redisUploadSessionStore,
|
||||
chunkSize: 5 * 1024 * 1024,
|
||||
maxBytes: 500 * 1024 * 1024,
|
||||
accept: ["application/pdf"],
|
||||
});
|
||||
|
||||
const session = await uploads.create({ name: "report.pdf", size, type });
|
||||
await uploads.uploadChunk(session.id, index, bytes, sha256);
|
||||
```
|
||||
|
||||
The included memory session store is intended for one-process apps and tests.
|
||||
Multi-instance production deployments should implement `ResumableSessionStore`
|
||||
with shared durable storage and atomic session updates, and periodically call
|
||||
`prune()` for abandoned uploads.
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/uploader",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/uploader — part of the WrNexus framework.",
|
||||
"description": "Secure upload drivers, policies, client runtime, helper functions, and reusable upload components.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/uploader"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/uploader",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"uploader"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -18,12 +34,28 @@
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./plugin": {
|
||||
"types": "./dist/plugin.d.ts",
|
||||
"import": "./dist/plugin.js"
|
||||
},
|
||||
"./components/*": "./components/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.7.0"
|
||||
"@wrnexus/core": "^0.8.6",
|
||||
"@wrnexus/plugin": "^0.8.6",
|
||||
"@wrnexus/ui": "^0.8.6"
|
||||
},
|
||||
"wrnexus": {
|
||||
"plugin": {
|
||||
"plugin": "./dist/plugin.js",
|
||||
"export": "default",
|
||||
"factory": true
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md",
|
||||
"components"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,31 @@
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Boundary contracts
|
||||
|
||||
Use `ContractRegistry` with `defineContract` or `defineEvent` to publish the
|
||||
same schema descriptors for APIs, actions, webhooks, realtime, queues, cron,
|
||||
pub/sub, plugins, configuration, and environment variables.
|
||||
|
||||
```ts
|
||||
import { ContractRegistry, defineEvent, v } from "@wrnexus/validation";
|
||||
|
||||
export const contracts = new ContractRegistry().register(
|
||||
defineEvent({
|
||||
name: "user.created",
|
||||
version: 1,
|
||||
consumers: ["notification-worker", "audit-service"],
|
||||
payload: v.object({ userId: v.string().uuid(), createdAt: v.string().date() }),
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
Export the registry from `app/contracts.ts`, then accept a baseline with
|
||||
`wrnexus contracts snapshot`. CI can run `wrnexus contracts check`; removed
|
||||
contracts/fields, required-field additions, type changes, narrowed enums, and
|
||||
tighter validation fail with stable `WRN-CONTRACT-*` diagnostics and list known
|
||||
consumers. A generated `wrnexus.contracts.json` can be used instead of a module.
|
||||
|
||||
## Overview
|
||||
|
||||
Define a schema once with the fluent `v` builder, then reuse it in three places: `.parse()` runs server-side and returns coerced values plus per-field errors; `.describe()` emits a plain-JSON `SchemaDescriptor` that the browser runtime interprets (no `eval`, no bundled validator); and helpers like `parseBody` and `parseEnv` wire schemas straight into API routes and startup config. The server rule logic (`applyRule`/`checkField`) and the client runtime (`VALIDATE_RUNTIME`) mirror each other exactly, so a form validates identically in both places. Schemas are conventionally kept in `app/schemas/`.
|
||||
@@ -178,3 +203,55 @@ const head = `<script>${renderSchemasScript({ signup: signupSchema.describe() })
|
||||
- Refinements (`refine`) run only server-side and are never serialized — client and server agree on every other rule because both interpret the same `RuleDescriptor` list.
|
||||
- No runtime dependencies. Ships as TypeScript source (`src/index.ts`) executed directly by Bun.
|
||||
- Pairs with the WrNexus server (`@wrnexus/core`) for route handlers and the SSR layer that injects `renderSchemasScript` / `VALIDATE_RUNTIME`.
|
||||
|
||||
## Helper and component kit
|
||||
|
||||
The public helper API includes `parseOrThrow`, `ValidationError`, `validationResponse`, `firstValidationError`, `validationSummary`, and `schemaFieldNames`.
|
||||
|
||||
Schema output is inferred automatically by `ObjectSchema`, `parseOrThrow`, `parseBody`, `parseEnv`, and `asyncSchema`. Use `InferSchema<typeof schema>` when a named output type is useful:
|
||||
|
||||
```ts
|
||||
const accountSchema = v.object({
|
||||
email: v.string().email(),
|
||||
attempts: v.number().integer(),
|
||||
});
|
||||
|
||||
type AccountInput = InferSchema<typeof accountSchema>;
|
||||
const account = parseOrThrow(accountSchema, input);
|
||||
// account.email: string
|
||||
// account.attempts: number
|
||||
```
|
||||
|
||||
Enable `validationPlugin()` for:
|
||||
|
||||
- `<ValidationSummary />`
|
||||
- `<FieldError />`
|
||||
|
||||
The summary block composes `Alert` from `@wrnexus/ui`, while `FieldError` remains a lightweight accessible field-level primitive.
|
||||
Schemas can drive external contracts without maintaining a second definition:
|
||||
|
||||
```ts
|
||||
import {
|
||||
localizeDescriptor,
|
||||
openApiRequestBody,
|
||||
parseDescriptor,
|
||||
toJsonSchema,
|
||||
} from "@wrnexus/validation";
|
||||
|
||||
const jsonSchema = toJsonSchema(contactSchema, {
|
||||
id: "urn:example:contact",
|
||||
title: "Contact request",
|
||||
});
|
||||
const requestBody = openApiRequestBody(contactSchema);
|
||||
|
||||
const mr = localizeDescriptor(contactSchema, (key, params) =>
|
||||
translations.t(`validation.${key}`, params),
|
||||
);
|
||||
const result = parseDescriptor(mr, input);
|
||||
```
|
||||
|
||||
JSON Schema output targets draft 2020-12, closes unknown object properties, and
|
||||
maps lengths/ranges/formats/enums/patterns/integer rules. OpenAPI request bodies
|
||||
reuse the same properties. Localized descriptors preserve explicit custom
|
||||
messages and fill default required, type-coercion, and rule messages; the same
|
||||
descriptor is consumable by server parsing and the eval-free browser runtime.
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/validation",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.6",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/validation — part of the WrNexus framework.",
|
||||
"description": "Shared server/browser schemas, validation helpers, form runtime, and reusable error components.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
|
||||
"directory": "packages/validation"
|
||||
},
|
||||
"homepage": "https://wrnexusjs.dev/packages/validation",
|
||||
"bugs": {
|
||||
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"wrnexus",
|
||||
"bun",
|
||||
"typescript",
|
||||
"validation"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -18,9 +34,28 @@
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./plugin": {
|
||||
"types": "./dist/plugin.d.ts",
|
||||
"import": "./dist/plugin.js"
|
||||
},
|
||||
"./components/*": "./components/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.8.6",
|
||||
"@wrnexus/plugin": "^0.8.6",
|
||||
"@wrnexus/ui": "^0.8.6"
|
||||
},
|
||||
"wrnexus": {
|
||||
"plugin": {
|
||||
"plugin": "./dist/plugin.js",
|
||||
"export": "default",
|
||||
"factory": true
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md",
|
||||
"components"
|
||||
]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user