Compare commits
305
Commits
v0.2.40
...
60e38b983c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60e38b983c | ||
|
|
5afb2d1875 | ||
|
|
2e42be7b31 | ||
|
|
b508b49058 | ||
|
|
e638c6be6a | ||
|
|
082da38f67 | ||
|
|
5de792f359 | ||
|
|
a0dab308f0 | ||
|
|
dd9b7a289a | ||
|
|
effed1c3cf | ||
|
|
6133d33b8c | ||
|
|
32187a425c | ||
|
|
531bf7b2da | ||
|
|
afe1c413cc | ||
|
|
f68f79786f | ||
|
|
d47c57a953 | ||
|
|
7a968977f6 | ||
|
|
fe9bb9ede0 | ||
|
|
7ba7c8a73f | ||
|
|
8e6dca0751 | ||
|
|
b76053ae93 | ||
|
|
13b198326f | ||
|
|
2c960fc1dc | ||
|
|
ec23dd4c93 | ||
|
|
f73fbb7aba | ||
|
|
0f201c9767 | ||
|
|
9637a47ec7 | ||
|
|
1719059fa0 | ||
|
|
cf06a60f2b | ||
|
|
8fa3d7ecd1 | ||
|
|
bf0d866793 | ||
|
|
e189b2b62f | ||
|
|
6c49991700 | ||
|
|
4d6db0d887 | ||
|
|
78ca2b85d9 | ||
|
|
d3fe0a71aa | ||
|
|
794a7b3378 | ||
|
|
b1086d14e9 | ||
|
|
743275e6fa | ||
|
|
904127687b | ||
|
|
b72f517fa4 | ||
|
|
52842f149d | ||
|
|
1cea44aaf2 | ||
|
|
c54a69532c | ||
|
|
8d044565e3 | ||
|
|
feff30a2b5 | ||
|
|
816594a58b | ||
|
|
db612df6cd | ||
|
|
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 | ||
|
|
87507edf59 | ||
|
|
c54144f2e4 | ||
|
|
2b06b00d35 | ||
|
|
28d931dfff | ||
|
|
e2f7bea299 | ||
|
|
d5e834bc78 | ||
|
|
687d345882 | ||
|
|
3e565e8d03 | ||
|
|
358a520bc5 | ||
|
|
9dffe83f32 | ||
|
|
d64e899993 | ||
|
|
f37303b22d | ||
|
|
d1b0c55b53 | ||
|
|
8fc6f15402 | ||
|
|
6afe32f63f | ||
|
|
76c768099d | ||
|
|
30e5721e84 | ||
|
|
8b728a3e5d | ||
|
|
d0aded0392 | ||
|
|
c81dedff17 | ||
|
|
44ba847210 | ||
|
|
798f06d4c0 | ||
|
|
173e230d7f | ||
|
|
bbec4cc7bb | ||
|
|
504bd4358f | ||
|
|
e543fd026d | ||
|
|
d51662826d | ||
|
|
e2d9ea1ece | ||
|
|
58ba2f2046 | ||
|
|
304819dfe9 | ||
|
|
07d8fb59d6 | ||
|
|
13dfa31d19 | ||
|
|
c6fc0f1f63 | ||
|
|
7ff5b3e8c5 | ||
|
|
569365143b | ||
|
|
69b6cd431f | ||
|
|
2ca2d02b22 | ||
|
|
0013c0771d | ||
|
|
0b8856b3b5 | ||
|
|
45e6fd3cb9 | ||
|
|
54f5309fca | ||
|
|
944f83d3f4 | ||
|
|
a75779fa4d | ||
|
|
2b4083c6db | ||
|
|
aa2595ec01 | ||
|
|
b3e5e5b999 | ||
|
|
3888453e0d | ||
|
|
b95bdc6e77 | ||
|
|
1745d8d676 | ||
|
|
f72aec7c8c | ||
|
|
33730c68ab | ||
|
|
5ce45b4973 | ||
|
|
dcdb5e766d | ||
|
|
a3e21163c1 | ||
|
|
c5326f7622 | ||
|
|
eaea2c5f72 | ||
|
|
ca8f746f41 | ||
|
|
94ae40d8bd | ||
|
|
0d3ec79ee4 | ||
|
|
c0c09426ff | ||
|
|
14e2878417 | ||
|
|
180239ef4c | ||
|
|
12beb6f4ba | ||
|
|
6dbc75c370 | ||
|
|
7cee13526e | ||
|
|
3bb096057e | ||
|
|
646d16f83d | ||
|
|
df4c1d3e7d | ||
|
|
17dc7c1a06 | ||
|
|
c72cbbd780 | ||
|
|
1cda2a01b3 | ||
|
|
9ca1cc6b0f | ||
|
|
aa6badbd31 | ||
|
|
4a3ee3baa7 | ||
|
|
95ca1c0625 | ||
|
|
d22824fa62 | ||
|
|
973d580bc5 | ||
|
|
8a8328a308 | ||
|
|
0f915c3c98 | ||
|
|
9aa13fb4db | ||
|
|
9070a1ef9c | ||
|
|
4230de7065 | ||
|
|
0006aef2c4 |
@@ -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
|
||||
+18
@@ -1,9 +1,27 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.wrnexus/
|
||||
.wrnexus-*/
|
||||
*.log
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.DS_Store
|
||||
bun.lockb
|
||||
|
||||
.publish/
|
||||
|
||||
# Environment files may contain secrets. Commit only templates.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
|
||||
# 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
|
||||
+18
-2
@@ -1,8 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
**/dist/
|
||||
**/.wrnexus/**
|
||||
**/.wrnexus-*/**
|
||||
.publish/
|
||||
**/.wirefw/
|
||||
**/.wrnfw/
|
||||
coverage/
|
||||
bun.lock
|
||||
bun.lockb
|
||||
@@ -10,8 +12,22 @@ 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)
|
||||
# Bundled .wrn 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
|
||||
|
||||
# Local focused typecheck helpers (never part of a release)
|
||||
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,14 +1,30 @@
|
||||
{
|
||||
"name": "@wrnexus/ai",
|
||||
"version": "0.2.39",
|
||||
"version": "0.8.9",
|
||||
"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",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
"bun": ">=1.3.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
@@ -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,14 +1,30 @@
|
||||
{
|
||||
"name": "@wrnexus/authz",
|
||||
"version": "0.2.39",
|
||||
"version": "0.8.9",
|
||||
"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",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
"bun": ">=1.3.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
@@ -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.9",
|
||||
"@wrnexus/db": "^0.8.9"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
+69
-5
@@ -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 |
|
||||
@@ -36,19 +73,27 @@ Every command accepts an optional `[app-dir]` (defaults to `.`). Commands that r
|
||||
| `wrnexus workspace <name>` | Scaffold a monorepo (`apps/*` + shared `packages/*`). |
|
||||
| `wrnexus workspace add <name>` | Add and register an app in the current workspace. |
|
||||
| `wrnexus gateway [--port=3000]` | Serve every workspace app behind one port, routed by domain. |
|
||||
| `wrnexus production [workspace-dir]` | Build, migrate, and serve every workspace app in production. |
|
||||
| `wrnexus generate <type> <name>` | Scaffold a `page` \| `component` \| `api` \| `schema`. |
|
||||
| `wrnexus generate routes` | Regenerate the typed routes file (`app/routes.gen.ts`). |
|
||||
| `wrnexus generate docker` | Scaffold `Dockerfile`, `.dockerignore`, and `docker-compose.yml`. |
|
||||
| `wrnexus generate mobile` | Scaffold a Capacitor shell for iOS and Android. |
|
||||
| `wrnexus mobile add <package...>` | Install Capacitor plugins and sync native projects. |
|
||||
| `wrnexus eject <name...>` | Copy Wire UI component `.wrn` sources into `app/components/`. |
|
||||
| `wrnexus eject <name...>` | Copy WrNexus UI component `.wrn` sources into `app/components/`. |
|
||||
| `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`).
|
||||
@@ -75,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`
|
||||
|
||||
@@ -132,7 +179,7 @@ WrNexus response pages.
|
||||
|
||||
### `wrnexus eject`
|
||||
|
||||
Copies a Wire UI component's `.wrn` source out of `@wrnexus/ui` into `app/components/`, so the app owns and can edit it (the app copy shadows the library one by name). Run with no names to list available components. It skips components that already exist in the app.
|
||||
Copies a WrNexus UI component's `.wrn` source out of `@wrnexus/ui` into `app/components/`, so the app owns and can edit it (the app copy shadows the library one by name). Run with no names to list available components. It skips components that already exist in the app.
|
||||
|
||||
```bash
|
||||
wrnexus eject button card modal
|
||||
@@ -161,13 +208,24 @@ 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
|
||||
wrnexus gateway --port=3000
|
||||
```
|
||||
|
||||
For a complete production start, use the first-class workspace orchestrator:
|
||||
|
||||
```bash
|
||||
wrnexus production --host=0.0.0.0 --port=3000
|
||||
```
|
||||
|
||||
It builds every registered app, applies default and named-database SQL migrations
|
||||
when present, and starts the production gateway only after preparation succeeds.
|
||||
Use `--prepare-only`, `--no-build`, or `--no-migrate` when deployment stages are
|
||||
managed separately; `--environment=<name>` selects another workspace environment.
|
||||
|
||||
From a workspace root, add and register another app in one command:
|
||||
|
||||
```bash
|
||||
@@ -229,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`.
|
||||
@@ -258,5 +322,5 @@ export default config;
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only.** The CLI runs on Bun, spawns the Bun binary for the dev child and `bun test`, and the production build uses `Bun.build`. Node is not supported.
|
||||
- Orchestrates the rest of the framework: `@wrnexus/dev-server` (dev/prod server + gateway), `@wrnexus/router` (route + typed-routes codegen), `@wrnexus/compiler` (`.wrn` → `.ts`), `@wrnexus/db` (migrations, typed queries), `@wrnexus/styles` (config, profiles, `.env`, themes, styles), `@wrnexus/ui` (ejectable Wire UI components), `@wrnexus/validation`, `@wrnexus/csr`, and `@wrnexus/i18n`.
|
||||
- Orchestrates the rest of the framework: `@wrnexus/dev-server` (dev/prod server + gateway), `@wrnexus/router` (route + typed-routes codegen), `@wrnexus/compiler` (`.wrn` → `.ts`), `@wrnexus/db` (migrations, typed queries), `@wrnexus/styles` (config, profiles, `.env`, themes, styles), `@wrnexus/ui` (ejectable WrNexus UI components), `@wrnexus/validation`, `@wrnexus/csr`, and `@wrnexus/i18n`.
|
||||
- Reads `wrnexus.config.ts` for `db` / `databases`, `theme`, `styles`, `seo`, `security`, `i18n`, and `profiles`, and `wrnexus.workspace.ts` for the gateway.
|
||||
|
||||
+38
-13
@@ -1,14 +1,30 @@
|
||||
{
|
||||
"name": "@wrnexus/cli",
|
||||
"version": "0.2.39",
|
||||
"version": "0.8.17",
|
||||
"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",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
"bun": ">=1.3.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
@@ -28,18 +44,27 @@
|
||||
"wrnexus": "./dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.2.39",
|
||||
"@wrnexus/router": "^0.2.39",
|
||||
"@wrnexus/csr": "^0.2.39",
|
||||
"@wrnexus/compiler": "^0.2.39",
|
||||
"@wrnexus/styles": "^0.2.39",
|
||||
"@wrnexus/dev-server": "^0.2.39",
|
||||
"@wrnexus/ui": "^0.2.39",
|
||||
"@wrnexus/validation": "^0.2.39",
|
||||
"@wrnexus/i18n": "^0.2.39",
|
||||
"@wrnexus/db": "^0.2.39"
|
||||
"@wrnexus/core": "^0.8.9",
|
||||
"@wrnexus/router": "^0.8.9",
|
||||
"@wrnexus/csr": "^0.8.16",
|
||||
"@wrnexus/compiler": "^0.8.9",
|
||||
"@wrnexus/styles": "^0.8.10",
|
||||
"@wrnexus/dev-server": "^0.8.16",
|
||||
"@wrnexus/ui": "^0.8.12",
|
||||
"@wrnexus/validation": "^0.8.9",
|
||||
"@wrnexus/i18n": "^0.8.9",
|
||||
"@wrnexus/mcp": "^0.8.8",
|
||||
"@wrnexus/playground": "^0.8.8",
|
||||
"@wrnexus/db": "^0.8.9",
|
||||
"@wrnexus/authz": "^0.8.9",
|
||||
"@wrnexus/plugin": "^0.8.8",
|
||||
"@wrnexus/syntax": "^0.8.8",
|
||||
"@wrnexus/typecheck": "^0.8.8",
|
||||
"@wrnexus/security": "^0.8.8",
|
||||
"selfsigned": "^5.5.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
+76
-24
@@ -1,13 +1,61 @@
|
||||
# @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.
|
||||
|
||||
Static ES module imports may appear before the root declaration. Imported values are
|
||||
available to server-rendered expressions, including component props:
|
||||
|
||||
```wrn
|
||||
import { appUrl } from "@wrnexus/helpers";
|
||||
|
||||
layout PublicLayout {
|
||||
view {
|
||||
<PublicHeader signInHref="{appUrl('sso', '/sign-in')}" />
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
@@ -21,7 +69,7 @@ bun add @wrnexus/compiler
|
||||
|
||||
All exports come from the package root (`@wrnexus/compiler`).
|
||||
|
||||
### `compileWireFile(source: string): string`
|
||||
### `compileWrnFile(source: string): string`
|
||||
|
||||
Compile `.wrn` source to a TypeScript module string. Throws `ParseError` on invalid input. The output is prefixed with a `// compiled from .wrn` comment.
|
||||
|
||||
@@ -67,36 +115,37 @@ class Lexer {
|
||||
|
||||
### Errors
|
||||
|
||||
| Class | Thrown by | Meaning |
|
||||
| ------------ | ------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| `ParseError` | `parse`, `compile`, `compileWireFile`, `generate` | Invalid `.wrn` grammar or (rewrapped) lex failure. |
|
||||
| `LexError` | `Lexer` | Unexpected character / unterminated string / unbalanced braces. |
|
||||
| Class | Thrown by | Meaning |
|
||||
| ------------ | ------------------------------------------------ | --------------------------------------------------------------- |
|
||||
| `ParseError` | `parse`, `compile`, `compileWrnFile`, `generate` | Invalid `.wrn` grammar or (rewrapped) lex failure. |
|
||||
| `LexError` | `Lexer` | Unexpected character / unterminated string / unbalanced braces. |
|
||||
|
||||
### AST types
|
||||
|
||||
Exported type-only symbols describing the parsed tree:
|
||||
|
||||
| Type | Description |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PageAst` | Root node: `kind` (`"page" \| "component"`), `name`, optional `layout`, `props`, `states`, `seo`, `view`, `styles`, `functions`, `dataApis`, `modeFunctions`, `apis`, `realtimes`. |
|
||||
| `ViewNode` | `{ type: "text"; value }` or `{ type: "element"; tag; attrs; children }`. |
|
||||
| `Attr` | `{ name; value; event; boolean? }` — `event` marks `@event` bindings. |
|
||||
| `StateDecl` | `{ name; expr }` — a `state x = <expr>` declaration. |
|
||||
| `SeoBlock` | `Record<string, string>` from the `seo { ... }` block. |
|
||||
| `ApiBlock` | `{ method; path; body }` — a top-level `api METHOD /path { ... }`. |
|
||||
| `DataApiBlock` | `{ mode; name; method; path; body }` — an `api` inside an `ssr`/`client` block. |
|
||||
| `DataMode` | `"ssr" \| "client"`. |
|
||||
| `ModeFunctionsBlock` | `{ mode; body }` — a `functions { ... }` inside an `ssr`/`client` block. |
|
||||
| `RealtimeBlock` | `{ name; handlers }` — a `realtime <name> { on evt(args) { ... } }` block. |
|
||||
| Type | Description |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PageAst` | Root node including top-level `imports`, `kind`, `name`, `types`, typed `props`, typed `states`, `view`, styles, functions, data APIs, lifecycle, and routes. |
|
||||
| `ViewNode` | `{ type: "text"; value }` or `{ type: "element"; tag; attrs; children }`. |
|
||||
| `Attr` | `{ name; value; event; boolean? }` — `event` marks `@event` bindings. |
|
||||
| `StateDecl` | `{ name; valueType?; expr }` — a typed `state x: Type = <expr>` declaration. |
|
||||
| `PropDecl` | `{ name; valueType?; required; default }` — a typed prop declaration. |
|
||||
| `SeoBlock` | `Record<string, string>` from the `seo { ... }` block. |
|
||||
| `ApiBlock` | `{ method; path; body }` — a top-level `api METHOD /path { ... }`. |
|
||||
| `DataApiBlock` | `{ mode; name; method; path; body }` — an `api` inside an `ssr`/`client` block. |
|
||||
| `DataMode` | `"ssr" \| "client"`. |
|
||||
| `ModeFunctionsBlock` | `{ mode; body }` — a `functions { ... }` inside an `ssr`/`client` block. |
|
||||
| `RealtimeBlock` | `{ name; handlers }` — a `realtime <name> { on evt(args) { ... } }` block. |
|
||||
|
||||
## Usage
|
||||
|
||||
Compile a page:
|
||||
|
||||
```ts
|
||||
import { compileWireFile } from "@wrnexus/compiler";
|
||||
import { compileWrnFile } from "@wrnexus/compiler";
|
||||
|
||||
const ts = compileWireFile(`
|
||||
const ts = compileWrnFile(`
|
||||
page Home {
|
||||
state count = 0
|
||||
seo { title = "Home" description = "Welcome" }
|
||||
@@ -147,12 +196,15 @@ lx.next(); // { type: "lbrace", value: "{", pos: 10 }
|
||||
A file opens with `page <Name>` or `component <Name>` followed by a `{ ... }` body containing zero or more members:
|
||||
|
||||
- `layout = "<name>"` — selects `app/layouts/<name>.wrn` (pages only).
|
||||
- `props { name = <default> ... }` — component props; each default's type drives coercion.
|
||||
- `state <ident> = <expr>` — reactive state seeded from a raw JS expression.
|
||||
- `view { <html> }` — plain HTML with `{expr}` interpolation in text and attributes, hyphenated attributes, boolean attributes, `@event="..."` client bindings, and `<!-- comments -->`. Attribute expressions that reference `state` keep an SSR value and update reactively in the browser.
|
||||
- `types { <TypeScript declarations> }` — reusable interfaces and aliases for the current file.
|
||||
- `props { name: Type = <default> ... }` — typed component props. Omit `= <default>` to make a prop required. Legacy inferred props remain supported.
|
||||
- `@event name = function` inside `props` — declares a public component event. Emit it from component behavior with `name(detail)` or `$emit("name", detail)`, and consume it with `<Component @name="handler(event)" />`.
|
||||
- `state <ident>: Type = <expr>` — typed reactive state seeded from a raw JS expression, including native array and object literals. The annotation is optional for backward compatibility.
|
||||
- `view { <html> }` — plain HTML with `{expr}` interpolation in text and attributes, JSX-style component props such as `items={items}`, `items={[...]}`, and `options={{...}}`, hyphenated attributes, boolean attributes, `@event="..."` client bindings, and `<!-- comments -->`. Structured component props are serialized safely for SSR; expressions that reference `state` retain their initial value and update reactively in the browser.
|
||||
- Client functions automatically commit state changed by `setTimeout` callbacks. For other deferred callbacks (observers, third-party APIs, or detached promise callbacks), call the injected `commit()` function after changing local state; returning/awaiting a promise also commits through the normal function boundary.
|
||||
- `seo { key = "value" ... }` — metadata merged into the generated `meta`.
|
||||
- `style { <raw css> }` — inlined page/component stylesheet (repeatable).
|
||||
- `functions { <raw js> }` — shared server-side helpers (repeatable).
|
||||
- `functions { <TypeScript> }` — helpers with typed parameters and return values. Types remain in server output and are safely erased from browser behavior code.
|
||||
- `api <METHOD> <path> { <raw js> }` — route handler, lowered to a `METHOD` export (repeatable).
|
||||
- `ssr { ... }` / `client { ... }` — data blocks holding `api <name> <METHOD> <path> { ... }` bindings and their own `functions { ... }`.
|
||||
- `realtime <name> { on <evt>(<args>) { <raw js> } ... }` — websocket handlers, lowered to a `websocket` export.
|
||||
@@ -162,4 +214,4 @@ A file opens with `page <Name>` or `component <Name>` followed by a `{ ... }` bo
|
||||
## Requirements / Notes
|
||||
|
||||
- Pure TypeScript with no runtime dependencies; runs under **Bun** as part of the WrNexus toolchain (Node is not supported).
|
||||
- Generated modules target WrNexus runtime primitives (`data-scope`, `data-text`, `data-on-*`, `data-for`, `data-component`, `__wrnexus*`/`__wire*` helpers) — consume the output within a WrNexus app, e.g. via `@wrnexus/core`'s dev loader.
|
||||
- Generated modules target WrNexus runtime primitives (`data-scope`, `data-text`, `data-on-*`, `data-for`, `data-component`, `__wrnexus*`/`__wrn*` helpers) — consume the output within a WrNexus app, e.g. via `@wrnexus/core`'s dev loader.
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
{
|
||||
"name": "@wrnexus/compiler",
|
||||
"version": "0.2.39",
|
||||
"version": "0.8.9",
|
||||
"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",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
"bun": ">=1.3.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
@@ -20,7 +36,14 @@
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/csr": "^0.8.16",
|
||||
"@wrnexus/syntax": "^0.8.8",
|
||||
"@wrnexus/store": "^0.8.8",
|
||||
"@wrnexus/validation": "^0.8.9"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
+39
-8
@@ -37,7 +37,7 @@ The `Context` (`ctx`) is the single value passed to middleware and handlers.
|
||||
| `Context` | type | Per-request object: `req`, `url`, `lang`, `t`, `params`, `locals`, `user?`, `ip?`, `cookies`, `session`, `localStorage`. |
|
||||
| `Next` | type | `() => Promise<Response> \| Response` — invokes the next middleware/handler. |
|
||||
| `Middleware` | type | `(ctx, next) => Promise<Response> \| Response`. Return `next()` to continue, or a `Response` to short-circuit. |
|
||||
| `createContext(req, url)` | fn | Build a fresh `Context` for an incoming request (wires up cookies, session, localStorage snapshot). |
|
||||
| `createContext(req, url)` | fn | Build a fresh `Context` for an incoming request (connects up cookies, session, localStorage snapshot). |
|
||||
| `withContextHeaders(ctx, res)` | fn | Apply accumulated headers (e.g. `Set-Cookie`) from the context onto a response. |
|
||||
| `PageComponent` | type | `(ctx) => string \| Promise<string>` — a page module's default export. |
|
||||
| `PageMeta` / `SeoConfig` | type | `<head>` metadata: `title`, `description`, `canonical`, `robots`, `image`, `twitterCard`, `themeColor`, … |
|
||||
@@ -70,15 +70,15 @@ cookie-backed `SessionStore`.
|
||||
|
||||
### CSRF — `@wrnexus/core`
|
||||
|
||||
Double-submit cookie pattern: a readable `wire-csrf` cookie is echoed in an
|
||||
Double-submit cookie pattern: a readable `wrn-csrf` cookie is echoed in an
|
||||
`x-csrf-token` header on unsafe requests.
|
||||
|
||||
| Export | Signature | Notes |
|
||||
| ----------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `csrfToken(ctx)` | `(Context) => string` | Ensures the CSRF cookie exists and returns its token. |
|
||||
| `verifyCsrf(ctx)` | `(Context) => boolean` | Safe methods (GET/HEAD/OPTIONS) pass; otherwise header/`ctx.locals._csrf` must match the cookie (constant-time). |
|
||||
| `csrfProtection()` | `() => Middleware` | 403s unsafe requests with a missing/mismatched token. |
|
||||
| `CSRF_COOKIE` / `CSRF_HEADER` | `"wire-csrf"` / `"x-csrf-token"` | Cookie & header names. |
|
||||
| Export | Signature | Notes |
|
||||
| ----------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `csrfToken(ctx)` | `(Context) => string` | Ensures the CSRF cookie exists and returns its token. |
|
||||
| `verifyCsrf(ctx)` | `(Context) => boolean` | Safe methods (GET/HEAD/OPTIONS) pass; otherwise header/`ctx.locals._csrf` must match the cookie (constant-time). |
|
||||
| `csrfProtection()` | `() => Middleware` | 403s unsafe requests with a missing/mismatched token. |
|
||||
| `CSRF_COOKIE` / `CSRF_HEADER` | `"wrn-csrf"` / `"x-csrf-token"` | Cookie & header names. |
|
||||
|
||||
### Rate limiting — `@wrnexus/core`
|
||||
|
||||
@@ -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,14 +1,30 @@
|
||||
{
|
||||
"name": "@wrnexus/core",
|
||||
"version": "0.2.39",
|
||||
"version": "0.8.9",
|
||||
"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",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
"bun": ">=1.3.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
@@ -29,6 +45,7 @@
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
+48
-13
@@ -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,15 +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. `item, i in list`) | Per-item list rendering template |
|
||||
| `{{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.
|
||||
|
||||
@@ -76,11 +111,11 @@ Intercepts same-origin `<a>` clicks, fetches the target page, and swaps the `#ap
|
||||
|
||||
Connects to `/realtime/<name>` over WebSocket (`ws`/`wss` chosen from `location.protocol`). Two usage modes.
|
||||
|
||||
Programmatic API via `window.wire`:
|
||||
Programmatic API via `window.wrn`:
|
||||
|
||||
```ts
|
||||
wire.room(name): Room // open (or reuse) a room connection
|
||||
wire.bindRooms(root?) // (re)bind declarative [data-room] containers
|
||||
wrn.room(name): Room // open (or reuse) a room connection
|
||||
wrn.bindRooms(root?) // (re)bind declarative [data-room] containers
|
||||
|
||||
interface Room {
|
||||
name: string;
|
||||
@@ -176,7 +211,7 @@ A realtime chat, fully declarative:
|
||||
Or drive a room from code:
|
||||
|
||||
```ts
|
||||
const room = wire.room("lobby");
|
||||
const room = wrn.room("lobby");
|
||||
room.on("chat", (msg) => console.log(msg.user, msg.text));
|
||||
room.send({ type: "chat", user: "ada", text: "hi" });
|
||||
```
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.2.39",
|
||||
"version": "0.8.16",
|
||||
"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",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
"bun": ">=1.3.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
@@ -21,9 +37,10 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.2.39"
|
||||
"@wrnexus/core": "^0.8.9"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
+77
-6
@@ -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");
|
||||
@@ -116,13 +128,13 @@ const events = await getDb("analytics").all("SELECT * FROM hits");
|
||||
|
||||
Migrations are `.sql` files (in e.g. `app/db/migrations`), each split into
|
||||
`-- +up` and `-- +down` sections. A file with no markers is treated entirely as
|
||||
`up`. Applied names are recorded in a `_wire_migrations` table so each runs once.
|
||||
`up`. Applied names are recorded in a `_wrn_migrations` table so each runs once.
|
||||
|
||||
- `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
|
||||
@@ -222,7 +249,51 @@ SQL driver — use `@wrnexus/db/mongo` directly.
|
||||
- **Bun-only.** Uses `bun:sqlite` (SQLite adapter + session store) and `Bun.SQL`
|
||||
(Postgres/MySQL). Migrations/scaffolding use `node:fs`/`node:path`.
|
||||
- Works with `@wrnexus/core` — `sqliteSessionStore` implements its
|
||||
`SessionBackend`; `getDb`/`setDb` are wired by the WrNexus runtime from
|
||||
`SessionBackend`; `getDb`/`setDb` are connected by the WrNexus runtime from
|
||||
`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,14 +1,30 @@
|
||||
{
|
||||
"name": "@wrnexus/db",
|
||||
"version": "0.2.39",
|
||||
"version": "0.8.9",
|
||||
"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",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
"bun": ">=1.3.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
@@ -45,6 +61,7 @@
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web fr
|
||||
|
||||
## Overview
|
||||
|
||||
This package is the server runtime that powers a WrNexus app in both development and production. A single **request runtime** (`createHandlers`) owns HTTP/WebSocket dispatch and SSR document assembly; it knows nothing about _how_ modules and assets are produced, so the dev and prod entry points wire in different backends: dev uses dynamic module loading plus on-the-fly bundling and injects a live-reload client; prod uses a static, pre-built manifest with cache-immutable assets. The package also ships a multi-app **gateway** (route several apps by `Host` header behind one port) and a portable `node:http` adapter for WinterCG hosts. It is entirely server-side and Bun-native (`Bun.serve`, `Bun.file`, `Bun.gzipSync`).
|
||||
This package is the server runtime that powers a WrNexus app in both development and production. A single **request runtime** (`createHandlers`) owns HTTP/WebSocket dispatch and SSR document assembly; it knows nothing about _how_ modules and assets are produced, so the dev and prod entry points wrn in different backends: dev uses dynamic module loading plus on-the-fly bundling and injects a live-reload client; prod uses a static, pre-built manifest with cache-immutable assets. The package also ships a multi-app **gateway** (route several apps by `Host` header behind one port) and a portable `node:http` adapter for WinterCG hosts. It is entirely server-side and Bun-native (`Bun.serve`, `Bun.file`, `Bun.gzipSync`).
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -21,16 +21,16 @@ bun add @wrnexus/dev-server
|
||||
|
||||
### Main entry (`@wrnexus/dev-server`)
|
||||
|
||||
| Export | Kind | Purpose |
|
||||
| --------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `startServer(opts: ServeOptions)` | `Promise<RunningServer>` | Start the dev server on `Bun.serve`: builds the router, connects/migrates databases, wires assets + HMR, and starts the file watcher. |
|
||||
| `createHandlers(deps: RuntimeDeps)` | `Handlers` | The shared request runtime (fetch + websocket handlers). Re-exported from `runtime.ts`. |
|
||||
| `createProductionServer(manifest, opts)` | `Bun.Server` | Start the production server from a precompiled manifest. |
|
||||
| `createProductionHandlers(manifest, opts)` | `Handlers` | Build the portable prod fetch/websocket handlers with no server bound (the deployment-adapter seam). |
|
||||
| `startGateway(opts: GatewayOptions)` | `Promise<RunningGateway>` | Boot multiple apps as child processes and route by `Host`. |
|
||||
| `toRequest`, `writeResponse`, `nodeListener`, `serveNode` | functions | `node:http` ↔ WinterCG `Request`/`Response` adapter. |
|
||||
| `RESTART_EXIT_CODE` | `number` (`97`) | Exit code the dev child uses to ask the supervisor for a fresh process. |
|
||||
| `STYLES_HREF`, `HMR_CLIENT_JS` | constants | The global stylesheet URL and the inline HMR client script. |
|
||||
| Export | Kind | Purpose |
|
||||
| --------------------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `startServer(opts: ServeOptions)` | `Promise<RunningServer>` | Start the dev server on `Bun.serve`: builds the router, connects/migrates databases, connects assets + HMR, and starts the file watcher. |
|
||||
| `createHandlers(deps: RuntimeDeps)` | `Handlers` | The shared request runtime (fetch + websocket handlers). Re-exported from `runtime.ts`. |
|
||||
| `createProductionServer(manifest, opts)` | `Bun.Server` | Start the production server from a precompiled manifest. |
|
||||
| `createProductionHandlers(manifest, opts)` | `Handlers` | Build the portable prod fetch/websocket handlers with no server bound (the deployment-adapter seam). |
|
||||
| `startGateway(opts: GatewayOptions)` | `Promise<RunningGateway>` | Boot multiple apps as child processes and route by `Host`. |
|
||||
| `toRequest`, `writeResponse`, `nodeListener`, `serveNode` | functions | `node:http` ↔ WinterCG `Request`/`Response` adapter. |
|
||||
| `RESTART_EXIT_CODE` | `number` (`97`) | Exit code the dev child uses to ask the supervisor for a fresh process. |
|
||||
| `STYLES_HREF`, `HMR_CLIENT_JS` | constants | The global stylesheet URL and the inline HMR client script. |
|
||||
|
||||
Exported types: `ServeOptions`, `RunningServer`, `RuntimeDeps`, `AssetServer`, `WsData`, `GatewayApp`, `GatewayOptions`, `GatewayAuth`, `GatewaySecurity`, `RunningGateway`, `FetchHandler`.
|
||||
|
||||
@@ -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).
|
||||
@@ -79,7 +85,7 @@ interface RuntimeDeps {
|
||||
getMiddleware(): Promise<Middleware[]>;
|
||||
assets: AssetServer; // serves /__wrnexus/* (islands, reactive, hmr)
|
||||
hasStyles?: boolean; // inject the global stylesheet link
|
||||
hasUi?: boolean; // inject the Wire UI stylesheet (/__wrnexus/ui.css)
|
||||
hasUi?: boolean; // inject the WrNexus UI stylesheet (/__wrnexus/ui.css)
|
||||
theme?: ResolvedTheme; // enables /__wrnexus/theme.css + <html data-theme>
|
||||
i18n?: ResolvedI18n; // enables ctx.t, <html lang>, {t:key} markers
|
||||
inlineStyles?: string; // inline small prod stylesheets into <head>
|
||||
@@ -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,14 +1,30 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-server",
|
||||
"version": "0.2.39",
|
||||
"version": "0.8.16",
|
||||
"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",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
"bun": ">=1.3.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
@@ -25,20 +41,30 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.2.39",
|
||||
"@wrnexus/router": "^0.2.39",
|
||||
"@wrnexus/ssr": "^0.2.39",
|
||||
"@wrnexus/csr": "^0.2.39",
|
||||
"@wrnexus/compiler": "^0.2.39",
|
||||
"@wrnexus/styles": "^0.2.39",
|
||||
"@wrnexus/ui": "^0.2.39",
|
||||
"@wrnexus/validation": "^0.2.39",
|
||||
"@wrnexus/i18n": "^0.2.39",
|
||||
"@wrnexus/db": "^0.2.39",
|
||||
"@wrnexus/pubsub": "^0.2.39",
|
||||
"@wrnexus/uploader": "^0.2.39"
|
||||
"@wrnexus/authz": "^0.8.9",
|
||||
"@wrnexus/rpc": "^0.8.9",
|
||||
"@wrnexus/core": "^0.8.9",
|
||||
"@wrnexus/dev-toolbar": "^0.8.9",
|
||||
"@wrnexus/router": "^0.8.9",
|
||||
"@wrnexus/ssr": "^0.8.9",
|
||||
"@wrnexus/csr": "^0.8.16",
|
||||
"@wrnexus/compiler": "^0.8.9",
|
||||
"@wrnexus/styles": "^0.8.10",
|
||||
"@wrnexus/ui": "^0.8.12",
|
||||
"@wrnexus/validation": "^0.8.9",
|
||||
"@wrnexus/i18n": "^0.8.9",
|
||||
"@wrnexus/db": "^0.8.9",
|
||||
"@wrnexus/pubsub": "^0.8.9",
|
||||
"@wrnexus/uploader": "^0.8.9",
|
||||
"@wrnexus/plugin": "^0.8.8",
|
||||
"@wrnexus/store": "^0.8.8",
|
||||
"@wrnexus/security": "^0.8.8",
|
||||
"@wrnexus/observability": "^0.8.8",
|
||||
"@wrnexus/cache": "^0.8.8",
|
||||
"@wrnexus/pwa": "^0.8.8"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# @wrnexus/dev-toolbar
|
||||
|
||||
Development-only page quality toolbar for WRNexusJS.
|
||||
|
||||
## Features
|
||||
|
||||
- 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`
|
||||
- Safe open-in-editor helper
|
||||
|
||||
## Dev-server integration
|
||||
|
||||
Serve `DEV_TOOLBAR_RUNTIME` at `/__wrnexus/dev-toolbar.js` and `DEV_TOOLBAR_CSS` at `/__wrnexus/dev-toolbar.css`, then inject this before `</body>` in development:
|
||||
|
||||
```html
|
||||
<script type="module" src="/__wrnexus/dev-toolbar.js" data-wrnexus-dev-toolbar></script>
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@wrnexus/dev-toolbar",
|
||||
"version": "0.8.9",
|
||||
"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",
|
||||
"engines": {
|
||||
"bun": ">=1.3.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "restricted"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./dist/client/index.d.ts",
|
||||
"import": "./dist/client/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/types.d.ts",
|
||||
"import": "./dist/types.js"
|
||||
},
|
||||
"./rules": {
|
||||
"types": "./dist/rules/index.d.ts",
|
||||
"import": "./dist/rules/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
# @wrnexus/encryption
|
||||
|
||||
> Dependency-free crypto helpers for WrNexus: authenticated symmetric encryption (AES-256-GCM), hashing, and HMAC signing.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Overview
|
||||
|
||||
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:
|
||||
|
||||
```ts
|
||||
import { generateKey, encrypt, decrypt } from "@wrnexus/encryption";
|
||||
|
||||
const key = await generateKey(); // store this safely (env/secret manager)
|
||||
|
||||
const box = await encrypt("card #1234", key); // opaque base64 string, safe to persist
|
||||
const plain = await decrypt(box, key); // "card #1234"
|
||||
```
|
||||
|
||||
Deriving a key from a user password instead of a random key:
|
||||
|
||||
```ts
|
||||
import { deriveKey, encrypt } from "@wrnexus/encryption";
|
||||
|
||||
const key = await deriveKey("correct horse battery staple", "per-user-salt");
|
||||
const box = await encrypt("secret note", key);
|
||||
```
|
||||
|
||||
Hashing and webhook signature verification:
|
||||
|
||||
```ts
|
||||
import { sha256, hmacSign, hmacVerify } from "@wrnexus/encryption";
|
||||
|
||||
const digest = await sha256("some content"); // 64-char hex string
|
||||
|
||||
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.
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "@wrnexus/encryption",
|
||||
"version": "0.2.39",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/encryption — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "restricted"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
# @wrnexus/helpers
|
||||
|
||||
Safe convenience helpers for common WrNexus application flows. The package uses
|
||||
standard `Context`, `URL`, and `Response` values and has no runtime dependency beyond
|
||||
`@wrnexus/core`.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/helpers
|
||||
```
|
||||
|
||||
The package is private, so the machine must be authenticated to the `wrnexus` npm
|
||||
organization.
|
||||
|
||||
## Usage
|
||||
|
||||
### Redirect an unauthenticated forward-auth request
|
||||
|
||||
The gateway calls an SSO verifier on a different URL from the original application.
|
||||
These helpers reconstruct the original URL from the gateway headers and safely place it
|
||||
in the login redirect:
|
||||
|
||||
```ts
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { redirectToLogin } from "@wrnexus/helpers";
|
||||
|
||||
export const GET = async (ctx: Context) => {
|
||||
if (await hasValidSession(ctx)) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
return redirectToLogin(ctx, "/login", {
|
||||
allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"],
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
This creates a response such as:
|
||||
|
||||
```text
|
||||
Location: http://sso.localhost:3000/login?returnTo=http%3A%2F%2Fadmin.localhost%3A3000%2F
|
||||
```
|
||||
|
||||
Always list the application hosts that are valid redirect destinations. Forwarded host
|
||||
headers are rejected when `allowedHosts` is absent or does not match, preventing an open
|
||||
redirect.
|
||||
|
||||
The SSO hostname is the login destination, not an `allowedHosts` entry. For example,
|
||||
when protecting `admin.localhost:3000`, keep `admin.localhost:3000` in the allowlist even
|
||||
though the verifier runs at `sso.localhost:3000`. WRNexus preserves both hosts across a
|
||||
nested gateway request.
|
||||
|
||||
### Support dynamic tenant domains
|
||||
|
||||
```ts
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { getOriginalRequestOrigin, redirectToLogin } from "@wrnexus/helpers";
|
||||
|
||||
export const GET = async (ctx: Context) => {
|
||||
const allowedHosts = (host: string) => host === "example.test" || host.endsWith(".example.test");
|
||||
|
||||
console.info("Authentication requested by", getOriginalRequestOrigin(ctx, { allowedHosts }));
|
||||
return redirectToLogin(ctx, "https://auth.example.test/login", {
|
||||
allowedHosts,
|
||||
returnToParam: "continue",
|
||||
status: 303,
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
- `getOriginalRequestUrl(ctx, options): URL` — reconstruct the gateway URL.
|
||||
- `getOriginalRequestOrigin(ctx, options): string` — return only its origin.
|
||||
- `getOriginalRequestPath(ctx): string` — return its path and query string.
|
||||
- `getOriginalRequestMethod(ctx): string` — return its HTTP method.
|
||||
- `redirectToLogin(ctx, loginUrl, options): Response` — create a login redirect with an
|
||||
encoded `returnTo` parameter.
|
||||
|
||||
For direct requests without gateway headers, URL helpers use `ctx.url`.
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"name": "@wrnexus/helpers",
|
||||
"version": "0.2.39",
|
||||
"type": "module",
|
||||
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
|
||||
"license": "MIT",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "restricted"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.2.39"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
+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: "wrn-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-wrn-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,14 +1,30 @@
|
||||
{
|
||||
"name": "@wrnexus/i18n",
|
||||
"version": "0.2.39",
|
||||
"version": "0.8.9",
|
||||
"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",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
"bun": ">=1.3.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
@@ -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.2.39"
|
||||
"@wrnexus/core": "^0.8.9",
|
||||
"@wrnexus/plugin": "^0.8.8",
|
||||
"@wrnexus/ui": "^0.8.12"
|
||||
},
|
||||
"wrnexus": {
|
||||
"plugin": {
|
||||
"plugin": "./dist/plugin.js",
|
||||
"export": "default",
|
||||
"factory": true
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md",
|
||||
"components"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
# @wrnexus/jwt
|
||||
|
||||
> Dependency-free JSON Web Tokens (HS256) via Web Crypto, plus a bearer-token auth middleware for WrNexus.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/jwt` signs and verifies stateless JSON Web Tokens using the **HS256**
|
||||
(HMAC-SHA-256) algorithm. It has no runtime dependencies — signing and
|
||||
verification are implemented directly on the standard **Web Crypto** API
|
||||
(`crypto.subtle`), which Bun provides natively. It runs server-side and pairs
|
||||
with the session-based auth in `@wrnexus/core`, giving you a stateless option
|
||||
for API and mobile clients. Reach for it when you need bearer-token auth rather
|
||||
than cookie sessions.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/jwt
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
## API
|
||||
|
||||
Single entry point (`@wrnexus/jwt`). All functions are async and return Promises.
|
||||
|
||||
| Export | Kind | Description |
|
||||
| --------------------------------------- | --------- | --------------------------------------------------------------- |
|
||||
| `signJwt(payload, secret, options?)` | function | Sign claims into an HS256 token string. |
|
||||
| `verifyJwt<T>(token, secret, options?)` | function | Verify a token and return its claims, or throw. |
|
||||
| `jwtAuth(options)` | function | Middleware that verifies a bearer JWT and sets `ctx.user`. |
|
||||
| `JwtError` | class | Error thrown on any signature/payload/expiry failure. |
|
||||
| `JwtClaims` | interface | Claims shape (`sub`, `iat`, `exp`, `nbf`, plus arbitrary keys). |
|
||||
| `SignOptions` | interface | Options for `signJwt`. |
|
||||
| `JwtAuthOptions` | interface | Options for `jwtAuth`. |
|
||||
|
||||
### `signJwt(payload, secret, options?)`
|
||||
|
||||
```ts
|
||||
function signJwt(payload: JwtClaims, secret: string, options?: SignOptions): Promise<string>;
|
||||
```
|
||||
|
||||
Signs `payload` with `secret` using HS256 and returns the encoded token
|
||||
(`header.body.signature`). An `iat` (issued-at) claim is always added.
|
||||
|
||||
`SignOptions`:
|
||||
|
||||
- `expiresIn?: number` — seconds until expiry; sets the `exp` claim.
|
||||
- `now?: number` — override the issued-at time (seconds), useful for testing.
|
||||
|
||||
### `verifyJwt<T>(token, secret, options?)`
|
||||
|
||||
```ts
|
||||
function verifyJwt<T extends JwtClaims = JwtClaims>(
|
||||
token: string,
|
||||
secret: string,
|
||||
options?: { now?: number },
|
||||
): Promise<T>;
|
||||
```
|
||||
|
||||
Verifies the HS256 signature and returns the decoded claims typed as `T`.
|
||||
Throws `JwtError` when the token is malformed, the signature is invalid, the
|
||||
payload is not valid JSON, the token is expired (`exp`), or not yet valid
|
||||
(`nbf`). Pass `now` (seconds) to override the reference time for the `exp`/`nbf`
|
||||
checks.
|
||||
|
||||
### `jwtAuth(options)`
|
||||
|
||||
```ts
|
||||
function jwtAuth(options: JwtAuthOptions): Middleware;
|
||||
```
|
||||
|
||||
Returns a WrNexus `Middleware` that reads a token, verifies it, and assigns the
|
||||
claims to `ctx.user`.
|
||||
|
||||
`JwtAuthOptions`:
|
||||
|
||||
- `secret: string` — the HMAC secret used to verify tokens.
|
||||
- `getToken?: (ctx: Context) => string | undefined` — how to extract the token.
|
||||
Defaults to reading `Authorization: Bearer <token>`.
|
||||
- `required?: boolean` — when `true` (default), a missing or invalid token
|
||||
responds with `401 { ok: false, error: "Unauthorized" }`. When `false`,
|
||||
requests pass through and `ctx.user` is only set if a valid token is present.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { signJwt, verifyJwt, jwtAuth, JwtError } from "@wrnexus/jwt";
|
||||
|
||||
const secret = process.env.JWT_SECRET!;
|
||||
|
||||
// Sign a token that expires in one hour
|
||||
const token = await signJwt({ sub: user.id, role: "admin" }, secret, {
|
||||
expiresIn: 3600,
|
||||
});
|
||||
|
||||
// Verify it later
|
||||
try {
|
||||
const claims = await verifyJwt<{ sub: string; role: string }>(token, secret);
|
||||
console.log(claims.sub, claims.role);
|
||||
} catch (err) {
|
||||
if (err instanceof JwtError) {
|
||||
// invalid signature, expired, malformed, etc.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Protecting routes with the middleware:
|
||||
|
||||
```ts
|
||||
import { jwtAuth } from "@wrnexus/jwt";
|
||||
|
||||
// Require a valid bearer token; ctx.user holds the verified claims
|
||||
app.use(jwtAuth({ secret: process.env.JWT_SECRET! }));
|
||||
|
||||
// Optional auth — populate ctx.user when present, but don't 401
|
||||
app.use(jwtAuth({ secret: process.env.JWT_SECRET!, required: false }));
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only.** Uses the standard Web Crypto API (`crypto.subtle.importKey`,
|
||||
`sign`, `verify`) plus `btoa`/`atob` and `TextEncoder`/`TextDecoder` — all
|
||||
provided by Bun. No third-party crypto dependency.
|
||||
- **Algorithm:** HS256 (HMAC with SHA-256) only. Asymmetric algorithms (RS/ES)
|
||||
are not supported.
|
||||
- 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.
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "@wrnexus/jwt",
|
||||
"version": "0.2.39",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/jwt — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "restricted"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
# @wrnexus/mobile
|
||||
|
||||
> SSR-safe access to Capacitor plugins from WRNexusJS browser code.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/mobile` keeps optional native imports out of server rendering while giving
|
||||
browser-owned modules one consistent registry for Capacitor plugins. During SSR,
|
||||
`mobile.isNative()` is `false` and `mobile.platform()` is `"web"`.
|
||||
|
||||
## Installation
|
||||
|
||||
Install a plugin through the WRNexusJS CLI so the web and native projects stay aligned:
|
||||
|
||||
```bash
|
||||
wrnexus mobile add @capacitor/camera @capacitor/haptics
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Register and invoke a Capacitor plugin
|
||||
|
||||
Import Capacitor packages only from browser-owned code, never from API routes or SSR
|
||||
helpers.
|
||||
|
||||
```ts
|
||||
import { Camera, CameraResultType } from "@capacitor/camera";
|
||||
import { mobile } from "@wrnexus/mobile";
|
||||
|
||||
mobile.registerPlugin("Camera", Camera);
|
||||
|
||||
export async function takePhoto() {
|
||||
if (!mobile.isNative()) return null;
|
||||
return mobile.invoke("Camera", "getPhoto", {
|
||||
quality: 85,
|
||||
resultType: CameraResultType.Uri,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Provide a browser fallback
|
||||
|
||||
`whenNative` runs the first callback only in a Capacitor WebView and can return a
|
||||
web/SSR-safe fallback everywhere else.
|
||||
|
||||
```ts
|
||||
import { Haptics, ImpactStyle } from "@capacitor/haptics";
|
||||
import { mobile } from "@wrnexus/mobile";
|
||||
|
||||
mobile.registerPlugin("Haptics", Haptics);
|
||||
|
||||
export const confirmAction = () =>
|
||||
mobile.whenNative(
|
||||
() => mobile.invoke("Haptics", "impact", { style: ImpactStyle.Medium }),
|
||||
() => navigator.vibrate?.(30),
|
||||
);
|
||||
```
|
||||
|
||||
### Read an optional plugin without throwing
|
||||
|
||||
```ts
|
||||
import type { NetworkPlugin } from "@capacitor/network";
|
||||
import { mobile } from "@wrnexus/mobile";
|
||||
|
||||
const network = mobile.plugin<NetworkPlugin>("Network");
|
||||
const status = network ? await network.getStatus() : { connected: true, connectionType: "unknown" };
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
- `registerPlugin(name, instance)` registers a browser-imported plugin.
|
||||
- `plugin(name)` returns a plugin or `undefined`; `requirePlugin(name)` throws when absent.
|
||||
- `invoke(plugin, method, options?)` calls a registered method and returns its result.
|
||||
- `whenNative(native, fallback?)` selects native behavior without breaking SSR.
|
||||
- `isNative()` and `platform()` report the current Capacitor environment.
|
||||
|
||||
Unavailable required plugins throw `MobileUnavailableError` with an actionable message.
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- Capacitor plugin imports must remain in browser-owned modules.
|
||||
- `@wrnexus/mobile` re-exports `native` from `@wrnexus/native` for applications that
|
||||
prefer the higher-level cross-platform capability API.
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"name": "@wrnexus/mobile",
|
||||
"version": "0.2.39",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/mobile — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "restricted"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/native": "^0.2.39"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
# @wrnexus/native
|
||||
|
||||
> Cross-platform capabilities for browsers, Capacitor WebViews, and compiled native apps.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/native` exposes capabilities by name so application code can ask what the
|
||||
current platform supports before presenting an action. Browser capabilities use Web
|
||||
APIs; mobile capabilities use installed Capacitor plugins. `platform()` returns
|
||||
`"server"` during SSR, `"browser"` on the web, and the Capacitor platform in a native
|
||||
WebView.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/native
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Share a page when the platform supports it
|
||||
|
||||
```ts
|
||||
import { native } from "@wrnexus/native";
|
||||
|
||||
export async function shareCurrentPage() {
|
||||
if (!native.supports("share")) return false;
|
||||
await native.run("share", {
|
||||
title: document.title,
|
||||
url: location.href,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### Register an application-specific capability
|
||||
|
||||
`register` returns an unregister function, which is useful for tests and temporary
|
||||
feature modules.
|
||||
|
||||
```ts
|
||||
import { native } from "@wrnexus/native";
|
||||
|
||||
const unregister = native.register("orders.scan", {
|
||||
browser: {
|
||||
supported: () => typeof window !== "undefined",
|
||||
run: async ({ orderId }: { orderId: string }) => {
|
||||
const code = window.prompt(`Scan code for order ${orderId}`);
|
||||
return { code };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await native.run<{ code: string | null }>("orders.scan", { orderId: "ord_42" });
|
||||
unregister();
|
||||
```
|
||||
|
||||
### Target browser or mobile behavior explicitly
|
||||
|
||||
```ts
|
||||
import { native } from "@wrnexus/native";
|
||||
|
||||
const canUseMobileCamera = native.supports("camera", "mobile");
|
||||
const position = await native.run(
|
||||
"geolocation",
|
||||
{ enableHighAccuracy: true },
|
||||
{ target: "browser" },
|
||||
);
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
- `supports(name, target?)` checks availability without running the capability.
|
||||
- `run(name, options?, runOptions?)` executes it or rejects with `NativeUnavailableError`.
|
||||
- `register(name, capability)` adds or overrides a capability and returns cleanup.
|
||||
- `registered()` lists capability names; `clearRegistry()` resets the registry.
|
||||
- `isMobile()` and `platform()` report the current target safely during SSR.
|
||||
|
||||
Built-ins include `camera`, `clipboard.write`, `share`, `geolocation`, `network`,
|
||||
`haptics`, storage, filesystem, notifications, and device information.
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
Use `supports()` before showing optional controls. Mobile capabilities require their
|
||||
matching Capacitor plugins to be installed and registered by the application.
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"name": "@wrnexus/native",
|
||||
"version": "0.2.39",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/native — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "restricted"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./browser": {
|
||||
"types": "./dist/browser.d.ts",
|
||||
"import": "./dist/browser.js"
|
||||
},
|
||||
"./mobile": {
|
||||
"types": "./dist/mobile.d.ts",
|
||||
"import": "./dist/mobile.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
# @wrnexus/oauth
|
||||
|
||||
> Dependency-free OAuth 2.0 sign-in for any provider, with PKCE and presets for Google, GitHub, and Discord.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/oauth` implements the OAuth 2.0 Authorization Code flow (with PKCE) for
|
||||
server-side sign-in. It ships ready-made provider presets and a `defineProvider`
|
||||
helper for custom providers, then gives you two flow functions — `startAuth`
|
||||
(build the redirect) and `completeAuth` (exchange the code and fetch the user's
|
||||
profile). It has no runtime dependencies: it uses the platform `fetch` and
|
||||
WebCrypto only. Pairs naturally with `@wrnexus/core`'s `logIn` to establish a
|
||||
session once you have a normalized profile.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/oauth
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
## API
|
||||
|
||||
### Providers
|
||||
|
||||
Each preset takes `ProviderCredentials` and returns an `OAuthProvider`.
|
||||
|
||||
```ts
|
||||
interface ProviderCredentials {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
scopes?: string[]; // override the preset's default scopes
|
||||
}
|
||||
```
|
||||
|
||||
| Export | Default scopes | Notes |
|
||||
| ------------------------ | ---------------------------- | ------------------------------------------------------------------ |
|
||||
| `google(creds)` | `openid`, `email`, `profile` | Sets `access_type: offline` for refresh tokens. |
|
||||
| `github(creds)` | `read:user`, `user:email` | Maps `name` (falls back to `login`) and `avatar_url`. |
|
||||
| `discord(creds)` | `identify`, `email` | Builds the avatar CDN URL from the user id + hash. |
|
||||
| `defineProvider(config)` | — | Pass a full `OAuthProvider` to define a custom OAuth 2.0 provider. |
|
||||
|
||||
An `OAuthProvider` describes the endpoints, scopes, credentials, optional extra
|
||||
authorize params, and a `mapProfile` normalizer:
|
||||
|
||||
```ts
|
||||
interface OAuthProvider {
|
||||
name: string;
|
||||
authorizeUrl: string;
|
||||
tokenUrl: string;
|
||||
userInfoUrl: string;
|
||||
scopes: string[];
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
authorizeParams?: Record<string, string>; // e.g. access_type, prompt
|
||||
mapProfile: (raw: Record<string, unknown>) => OAuthProfile;
|
||||
}
|
||||
```
|
||||
|
||||
### Flow
|
||||
|
||||
#### `startAuth(provider, options): Promise<StartAuthResult>`
|
||||
|
||||
Builds the authorize redirect URL with a generated PKCE challenge and CSRF
|
||||
`state`. Store the returned `state` and `verifier` (session/cookie), then 302 the
|
||||
user to `url`.
|
||||
|
||||
```ts
|
||||
interface StartAuthOptions {
|
||||
redirectUri: string;
|
||||
state?: string; // reuse a state instead of generating one
|
||||
params?: Record<string, string>; // extra authorize params, merged last
|
||||
}
|
||||
|
||||
interface StartAuthResult {
|
||||
url: string; // authorize URL to redirect to
|
||||
state: string; // CSRF state — verify on callback
|
||||
verifier: string; // PKCE code verifier — pass to completeAuth
|
||||
}
|
||||
```
|
||||
|
||||
#### `completeAuth(provider, options): Promise<{ tokens, profile }>`
|
||||
|
||||
On the callback: exchanges the authorization `code` for tokens, then fetches and
|
||||
normalizes the user profile. Convenience wrapper over `exchangeCode` +
|
||||
`fetchProfile`.
|
||||
|
||||
```ts
|
||||
interface CompleteAuthOptions {
|
||||
code: string;
|
||||
redirectUri: string;
|
||||
verifier?: string; // the PKCE verifier from startAuth
|
||||
fetch?: typeof fetch; // inject a fetch implementation (tests)
|
||||
}
|
||||
```
|
||||
|
||||
#### Lower-level helpers
|
||||
|
||||
| Export | Signature | Purpose |
|
||||
| ---------------------------------------- | ------------------------- | --------------------------------------------------------------- |
|
||||
| `exchangeCode(provider, options)` | `→ Promise<OAuthTokens>` | Exchange an authorization code for tokens. |
|
||||
| `fetchProfile(provider, tokens, fetch?)` | `→ Promise<OAuthProfile>` | Fetch + normalize the user's profile. |
|
||||
| `randomToken(bytes?)` | `→ string` | Random URL-safe token (default 32 bytes) for `state`/verifiers. |
|
||||
|
||||
### Types
|
||||
|
||||
```ts
|
||||
interface OAuthTokens {
|
||||
access_token: string;
|
||||
token_type?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
id_token?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
interface OAuthProfile {
|
||||
id: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { google, startAuth, completeAuth } from "@wrnexus/oauth";
|
||||
import { logIn } from "@wrnexus/core";
|
||||
|
||||
const provider = google({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
});
|
||||
|
||||
const redirectUri = "https://example.com/auth/callback";
|
||||
|
||||
// 1. Kick off sign-in: redirect the user to the provider.
|
||||
async function beginLogin(ctx) {
|
||||
const { url, state, verifier } = await startAuth(provider, { redirectUri });
|
||||
// Persist state + verifier in the session, then redirect.
|
||||
ctx.session.set("oauth_state", state);
|
||||
ctx.session.set("oauth_verifier", verifier);
|
||||
return Response.redirect(url, 302);
|
||||
}
|
||||
|
||||
// 2. Handle the callback.
|
||||
async function handleCallback(ctx, code: string, state: string) {
|
||||
if (state !== ctx.session.get("oauth_state")) throw new Error("bad state");
|
||||
|
||||
const { profile } = await completeAuth(provider, {
|
||||
code,
|
||||
redirectUri,
|
||||
verifier: ctx.session.get("oauth_verifier"),
|
||||
});
|
||||
|
||||
logIn(ctx, { id: profile.id, email: profile.email });
|
||||
}
|
||||
```
|
||||
|
||||
Custom provider with `defineProvider`:
|
||||
|
||||
```ts
|
||||
import { defineProvider, startAuth } from "@wrnexus/oauth";
|
||||
|
||||
const gitlab = defineProvider({
|
||||
name: "gitlab",
|
||||
authorizeUrl: "https://gitlab.com/oauth/authorize",
|
||||
tokenUrl: "https://gitlab.com/oauth/token",
|
||||
userInfoUrl: "https://gitlab.com/api/v4/user",
|
||||
scopes: ["read_user"],
|
||||
clientId: process.env.GITLAB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITLAB_CLIENT_SECRET!,
|
||||
mapProfile: (raw) => ({
|
||||
id: String(raw.id),
|
||||
email: raw.email as string | undefined,
|
||||
name: raw.name as string | undefined,
|
||||
avatar: raw.avatar_url as string | undefined,
|
||||
raw,
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only.** Relies on the global `fetch` and WebCrypto (`crypto.getRandomValues`,
|
||||
`crypto.subtle.digest`) — no other runtime dependencies.
|
||||
- The flow is stateless by design: you are responsible for storing `state` and
|
||||
`verifier` between `startAuth` and `completeAuth` (session or signed cookie).
|
||||
- Pairs with [`@wrnexus/core`](../core) — feed the normalized `OAuthProfile` into
|
||||
`logIn` to establish a session.
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "@wrnexus/oauth",
|
||||
"version": "0.2.39",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/oauth — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "restricted"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -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,14 +1,30 @@
|
||||
{
|
||||
"name": "@wrnexus/pubsub",
|
||||
"version": "0.2.39",
|
||||
"version": "0.8.9",
|
||||
"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",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
"bun": ">=1.3.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
@@ -19,12 +35,21 @@
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.8.9",
|
||||
"@wrnexus/rpc": "^0.8.9"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
# @wrnexus/queue
|
||||
|
||||
> A background job queue with delays, retries + exponential backoff, recurring jobs, and concurrent workers.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/queue` is a server-side in-process job queue. You register named
|
||||
workers, enqueue jobs (optionally delayed or recurring), and let the queue poll
|
||||
and run them on a timer — with per-job retry limits and doubling backoff between
|
||||
attempts. The default store lives in memory; the design allows a pluggable driver
|
||||
to back it with Redis/SQL for durability across restarts. Reach for it when you
|
||||
need to defer work (emails, webhooks, cleanup) off the request path without a
|
||||
heavyweight external broker. Tests can drive it deterministically via `drain()`.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/queue
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
## API
|
||||
|
||||
The package exports a single factory plus its supporting types.
|
||||
|
||||
### `createQueue(options?): Queue`
|
||||
|
||||
Creates a new queue instance.
|
||||
|
||||
```ts
|
||||
function createQueue(options?: QueueOptions): Queue;
|
||||
```
|
||||
|
||||
#### `QueueOptions`
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ------------- | ------------------------------------ | ---------- | -------------------------------------------------------- |
|
||||
| `maxAttempts` | `number` | `3` | Default max attempts per job before it is dead-lettered. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
||||
#### `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). |
|
||||
|
||||
#### `JobHandler<T>`
|
||||
|
||||
```ts
|
||||
type JobHandler<T = unknown> = (job: Job<T>) => void | Promise<void>;
|
||||
```
|
||||
|
||||
#### `Job<T>`
|
||||
|
||||
```ts
|
||||
interface Job<T = unknown> {
|
||||
id: string; // e.g. "job_1"
|
||||
name: string;
|
||||
data: T;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
runAt: number; // epoch ms; job runs when now ≥ runAt
|
||||
repeat?: number; // if set, re-enqueue this many ms after each success
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Register workers, enqueue jobs, then start the poller:
|
||||
|
||||
```ts
|
||||
import { createQueue } from "@wrnexus/queue";
|
||||
|
||||
const queue = createQueue({ maxAttempts: 3, backoffMs: 1000 });
|
||||
|
||||
// Register a worker for the "email" job name.
|
||||
queue.process<{ to: string }>("email", async (job) => {
|
||||
await send(job.data.to);
|
||||
});
|
||||
|
||||
// Enqueue a delayed job with up to 3 attempts.
|
||||
await queue.add("email", { to: "a@b.com" }, { delayMs: 5000, maxAttempts: 3 });
|
||||
|
||||
queue.start(); // begin polling; queue.stop() to halt
|
||||
```
|
||||
|
||||
### Recurring jobs
|
||||
|
||||
Pass `repeat` to re-enqueue a job a fixed interval after each successful run:
|
||||
|
||||
```ts
|
||||
queue.process("heartbeat", async () => ping());
|
||||
await queue.add("heartbeat", {}, { repeat: 60_000 }); // runs ~every minute
|
||||
```
|
||||
|
||||
### Handling permanent failures
|
||||
|
||||
When a job's `attempts` reaches `maxAttempts`, it is dropped and `onFailed`
|
||||
fires instead of retrying:
|
||||
|
||||
```ts
|
||||
const queue = createQueue({
|
||||
onFailed: (job, error) => {
|
||||
console.error(`job ${job.id} (${job.name}) gave up`, error);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Deterministic testing
|
||||
|
||||
Instead of `start()`, inject a clock and drive the queue with `drain()`:
|
||||
|
||||
```ts
|
||||
let clock = 0;
|
||||
const queue = createQueue({ now: () => clock });
|
||||
|
||||
queue.process("task", async () => {
|
||||
/* ... */
|
||||
});
|
||||
await queue.add("task", {}, { delayMs: 5000 });
|
||||
|
||||
clock = 5000;
|
||||
const ran = await queue.drain(); // => 1
|
||||
```
|
||||
|
||||
## Retry & backoff behavior
|
||||
|
||||
- On a thrown handler error, the job is retried while `attempts < maxAttempts`.
|
||||
- The next `runAt` is set to `now + backoffMs * 2^(attempts - 1)` (exponential
|
||||
backoff): with `backoffMs: 1000` the delays are 1s, 2s, 4s, …
|
||||
- A job whose worker name has no registered handler stays queued until one is
|
||||
registered (it is not counted as runnable by `drain`).
|
||||
- `drain` is re-entrant-safe: overlapping calls are skipped while one is running.
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only** runtime (Node is not supported), consistent with the rest of the
|
||||
WrNexus framework. The queue itself relies only on standard timers
|
||||
(`setInterval`/`clearInterval`) and has no runtime dependencies.
|
||||
- The default store is in-process, so queued jobs do not survive a restart; a
|
||||
pluggable driver is intended for backing it with Redis/SQL for durability.
|
||||
- Works alongside `@wrnexus/core` for offloading work from the request path.
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "@wrnexus/queue",
|
||||
"version": "0.2.39",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/queue — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "restricted"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
# @wrnexus/reactive
|
||||
|
||||
> Tiny, type-safe reactive primitives (signals) with zero dependencies.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/reactive` is the seed of WrNexus's reactivity layer: a minimal `signal`
|
||||
primitive that holds a value, notifies subscribers when it changes, and hands back
|
||||
an unsubscribe function. It is deliberately small and framework-agnostic — it powers
|
||||
nothing on its own, but is shaped so client islands (and later the `.wrn` compiler's
|
||||
`state` blocks) can build reactive bindings on top of it. Reach for it when you need
|
||||
observable state without pulling in a full reactivity library.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/reactive
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
## API
|
||||
|
||||
The package has a single entry point (`.`) exporting one function and three types.
|
||||
|
||||
### `signal<T>(initial: T): Signal<T>`
|
||||
|
||||
Creates a reactive signal seeded with `initial`. Returns a `Signal<T>`:
|
||||
|
||||
| Member | Signature | Description |
|
||||
| ----------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `get` | `(): T` | Read the current value. |
|
||||
| `set` | `(next: T): void` | Write a new value. Subscribers run **only when the value actually changes** (compared with `Object.is`). |
|
||||
| `update` | `(fn: (current: T) => T): void` | Apply a function to the current value; equivalent to `set(fn(get()))`. |
|
||||
| `subscribe` | `(fn: Subscriber<T>): Unsubscribe` | Register a subscriber; returns a function that removes it. |
|
||||
|
||||
### Types
|
||||
|
||||
```ts
|
||||
type Subscriber<T> = (value: T) => void;
|
||||
type Unsubscribe = () => void;
|
||||
|
||||
interface Signal<T> {
|
||||
get(): T;
|
||||
set(next: T): void;
|
||||
update(fn: (current: T) => T): void;
|
||||
subscribe(fn: Subscriber<T>): Unsubscribe;
|
||||
}
|
||||
```
|
||||
|
||||
Notes on semantics:
|
||||
|
||||
- **No-op updates are skipped.** `set` compares the incoming value to the current
|
||||
one with `Object.is`; identical values do not notify subscribers.
|
||||
- **Safe unsubscribe during notification.** Subscribers are iterated over a copy of
|
||||
the subscriber set, so a subscriber may call its own (or another's) unsubscribe
|
||||
while a notification is in flight.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { signal } from "@wrnexus/reactive";
|
||||
|
||||
const count = signal(0);
|
||||
|
||||
count.get(); // 0
|
||||
|
||||
// Subscribe; the returned function unsubscribes.
|
||||
const off = count.subscribe((value) => {
|
||||
console.log("count is now", value);
|
||||
});
|
||||
|
||||
count.set(1); // logs: count is now 1
|
||||
count.set(1); // no-op — value unchanged, no notification
|
||||
count.update((n) => n + 1); // logs: count is now 2
|
||||
|
||||
off(); // stop listening
|
||||
count.set(3); // nothing logged
|
||||
```
|
||||
|
||||
Typed signals infer `T` from the initial value, or can be annotated explicitly:
|
||||
|
||||
```ts
|
||||
import { signal, type Signal } from "@wrnexus/reactive";
|
||||
|
||||
const user: Signal<{ name: string } | null> = signal(null);
|
||||
user.set({ name: "Ada" });
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- **Bun-only.** Distributed as TypeScript source (`main`/`exports` point at
|
||||
`src/index.ts`); consume it under Bun, which runs `.ts` directly.
|
||||
- **Zero dependencies.** The only runtime API used is the standard `Object.is`.
|
||||
- Foundational primitive for WrNexus client islands and the forthcoming `.wrn`
|
||||
compiler `state` blocks.
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "@wrnexus/reactive",
|
||||
"version": "0.2.39",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/reactive — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "restricted"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -1,14 +1,30 @@
|
||||
{
|
||||
"name": "@wrnexus/router",
|
||||
"version": "0.2.39",
|
||||
"version": "0.8.9",
|
||||
"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",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
"bun": ">=1.3.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
@@ -21,10 +37,11 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/compiler": "^0.2.39",
|
||||
"@wrnexus/core": "^0.2.39"
|
||||
"@wrnexus/compiler": "^0.8.9",
|
||||
"@wrnexus/core": "^0.8.9"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ const html = renderDocument({
|
||||
},
|
||||
url: new URL("https://acme.example/about"),
|
||||
body: "<h1>About Us</h1>",
|
||||
scripts: ["/_wire/runtime.js", "/_wire/islands/about.js"],
|
||||
scripts: ["/_wrn/runtime.js", "/_wrn/islands/about.js"],
|
||||
htmlAttrs: ' data-theme="dark"',
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
{
|
||||
"name": "@wrnexus/ssr",
|
||||
"version": "0.2.39",
|
||||
"version": "0.8.9",
|
||||
"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",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
"bun": ">=1.3.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
@@ -18,12 +34,23 @@
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./rpc": {
|
||||
"types": "./dist/rpc.d.ts",
|
||||
"import": "./dist/rpc.js"
|
||||
},
|
||||
"./store-context": {
|
||||
"types": "./dist/store-context.d.ts",
|
||||
"import": "./dist/store-context.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "^0.2.39"
|
||||
"@wrnexus/core": "^0.8.9",
|
||||
"@wrnexus/store": "^0.8.8",
|
||||
"@wrnexus/security": "^0.8.8"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
+60
-14
@@ -1,6 +1,27 @@
|
||||
# @wrnexus/styles
|
||||
|
||||
> Global CSS bundling, the `--wire-*` design-token theme system, and the `wrnexus.config.ts` app-config loader for WrNexus apps.
|
||||
## 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 `--wrn-*` 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.
|
||||
|
||||
@@ -9,7 +30,7 @@ Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web fr
|
||||
This package owns three server-side concerns that shape every page a WrNexus app renders:
|
||||
|
||||
1. **Global stylesheet pipeline** — finds `app/styles/global.css` (or aggregates `app/styles/*.css`), bundles it with Bun's CSS bundler (which resolves `@import`, including from `node_modules`), and produces one stylesheet that is `<link>`ed into every page's `<head>`. Because it is a plain global sheet, it styles server-rendered markup and hydrated client islands identically. A custom `process` hook lets you swap in Tailwind / PostCSS / Sass.
|
||||
2. **Theme system** — design tokens exposed as CSS custom properties (`--wire-<key>`), with built-in `light`/`dark` sets, deep-merged user overrides, an SSR `<html data-theme>` render (no flash), and a tiny client runtime to toggle/persist the choice.
|
||||
2. **Theme system** — design tokens exposed as CSS custom properties (`--wrn-<key>`), with built-in `light`/`dark` sets, deep-merged user overrides, an SSR `<html data-theme>` render (no flash), and a tiny client runtime to toggle/persist the choice.
|
||||
3. **App config** — loads `wrnexus.config.ts` (the `AppConfig` type), applies named profile overrides, and loads the `.env` cascade.
|
||||
|
||||
It runs server-side / at build time. Reach for it when configuring an app, defining themes, or customising how global CSS is produced.
|
||||
@@ -90,7 +111,7 @@ interface StyleProcessContext {
|
||||
| Export | Type / Signature | Purpose |
|
||||
| -------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `DEFAULT_THEMES` | `Record<string, ThemeTokens>` | Built-in `light` and `dark` token maps. |
|
||||
| `THEME_COOKIE` | `"wire-theme"` | Cookie the resolved theme is read from / persisted to. |
|
||||
| `THEME_COOKIE` | `"wrn-theme"` | Cookie the resolved theme is read from / persisted to. |
|
||||
| `THEME_CSS_HREF` | `"/__wrnexus/theme.css"` | URL the generated theme stylesheet is served at. |
|
||||
| `THEME_JS_HREF` | `"/__wrnexus/theme.js"` | URL the client theme runtime is served at. |
|
||||
| `resolveThemeConfig` | `(config?: ThemeConfig) => ResolvedTheme` | Deep-merge the user's `theme` config over the defaults; pick the default theme (config's `default` if valid, else `dark`, else the first). |
|
||||
@@ -98,7 +119,7 @@ interface StyleProcessContext {
|
||||
| `renderThemeCss` | `(theme: ResolvedTheme) => string` | Generate the theme stylesheet: a `:root{…}` default plus one `[data-theme="<name>"]{…}` block per theme. |
|
||||
| `renderThemeRuntime` | `(theme: ResolvedTheme) => string` | Generate the client runtime (see below). |
|
||||
|
||||
Tokens are emitted as `--wire-<key>` custom properties, **except** the reserved key `color-scheme`, which is emitted as the native `color-scheme` CSS property so form controls and scrollbars match the theme.
|
||||
Tokens are emitted as `--wrn-<key>` custom properties, **except** the reserved key `color-scheme`, which is emitted as the native `color-scheme` CSS property so form controls and scrollbars match the theme.
|
||||
|
||||
`ThemeConfig` / `ThemeTokens` / `ResolvedTheme`:
|
||||
|
||||
@@ -106,6 +127,7 @@ Tokens are emitted as `--wire-<key>` custom properties, **except** the reserved
|
||||
type ThemeTokens = Record<string, string>;
|
||||
|
||||
interface ThemeConfig {
|
||||
palette?: ThemePaletteName | CustomThemePalette;
|
||||
default?: string; // theme used when no cookie is present
|
||||
themes?: Record<string, ThemeTokens>; // deep-merged over built-in light/dark
|
||||
}
|
||||
@@ -117,9 +139,32 @@ interface ResolvedTheme {
|
||||
}
|
||||
```
|
||||
|
||||
Built-in token keys (both `light` and `dark`): `color-scheme`, `color-bg`, `color-surface`, `color-surface-2`, `color-text`, `color-muted`, `color-border`, `color-primary`, `color-primary-hover`, `color-primary-contrast`, `color-danger`, `color-success`, `color-warning`, `radius`, `radius-sm`, `font-sans`, `shadow-1`.
|
||||
Built-in palettes are `blue`, `indigo`, `violet`, `emerald`, `cyan`, `rose`,
|
||||
`amber`, and `slate`. Each supplies primary, secondary, info, success, warning,
|
||||
danger/error, hover, and contrast colors to every light/dark theme. Built-in
|
||||
token keys also include surfaces, text, borders, radii, fonts, and shadows.
|
||||
|
||||
The client runtime (`renderThemeRuntime`) exposes `window.wireTheme` with `{ get, set, toggle, bind, themes }`, wires up any `[data-wire-theme-toggle]` and `[data-wire-theme-set]` elements on load, and persists the choice to the `wire-theme` cookie (`max-age` 1 year, `samesite=lax`). `toggle()` cycles through the configured theme names in order.
|
||||
A custom palette is intentionally complete, so components never fall back to an
|
||||
unrelated blue status or action color:
|
||||
|
||||
```ts
|
||||
theme: {
|
||||
palette: {
|
||||
primary: "#7c3aed",
|
||||
primaryHover: "#6d28d9",
|
||||
primaryContrast: "#ffffff",
|
||||
secondary: "#db2777",
|
||||
secondaryHover: "#be185d",
|
||||
secondaryContrast: "#ffffff",
|
||||
info: "#2563eb",
|
||||
success: "#059669",
|
||||
warning: "#d97706",
|
||||
danger: "#dc2626",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The client runtime (`renderThemeRuntime`) exposes `window.wrnTheme` with `{ get, set, toggle, bind, themes }`, connects up any `[data-wrn-theme-toggle]` and `[data-wrn-theme-set]` elements on load, and persists the choice to the `wrn-theme` cookie (`max-age` 1 year, `samesite=lax`). `toggle()` cycles through the configured theme names in order.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -135,6 +180,7 @@ export default {
|
||||
port: 3000,
|
||||
db: { driver: "sqlite", url: "app.db" },
|
||||
theme: {
|
||||
palette: "violet",
|
||||
default: "dark",
|
||||
themes: {
|
||||
light: { "color-primary": "#7c3aed" }, // override one token; rest inherited
|
||||
@@ -206,17 +252,17 @@ In templates, consume tokens via the custom properties:
|
||||
|
||||
```css
|
||||
.card {
|
||||
background: var(--wire-color-surface);
|
||||
color: var(--wire-color-text);
|
||||
border: 1px solid var(--wire-color-border);
|
||||
border-radius: var(--wire-radius);
|
||||
box-shadow: var(--wire-shadow-1);
|
||||
background: var(--wrn-color-surface);
|
||||
color: var(--wrn-color-text);
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius);
|
||||
box-shadow: var(--wrn-shadow-1);
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<button data-wire-theme-toggle>Toggle theme</button>
|
||||
<button data-wire-theme-set="brand">Brand theme</button>
|
||||
<button data-wrn-theme-toggle>Toggle theme</button>
|
||||
<button data-wrn-theme-set="brand">Brand theme</button>
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
@@ -224,4 +270,4 @@ In templates, consume tokens via the custom properties:
|
||||
- **Bun-only.** `bundleCss` uses `Bun.build`'s CSS bundler for `@import` resolution, nesting, and minification. Node is not supported.
|
||||
- Config and env loading use `node:fs` / `node:path` / `node:url` and read from `process.env`.
|
||||
- Peer package: `@wrnexus/core` supplies the `SeoConfig` and `SecurityConfig` types referenced by `AppConfig`.
|
||||
- The bundled global stylesheet, the theme stylesheet (`THEME_CSS_HREF`), and the theme runtime (`THEME_JS_HREF`) are wired into pages by the framework's server; this package only produces their contents.
|
||||
- The bundled global stylesheet, the theme stylesheet (`THEME_CSS_HREF`), and the theme runtime (`THEME_JS_HREF`) are connected into pages by the framework's server; this package only produces their contents.
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
{
|
||||
"name": "@wrnexus/styles",
|
||||
"version": "0.2.39",
|
||||
"version": "0.8.10",
|
||||
"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",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
"bun": ">=1.3.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
@@ -21,9 +37,12 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/uploader": "^0.2.39"
|
||||
"@wrnexus/uploader": "^0.8.9",
|
||||
"@wrnexus/core": "^0.8.9",
|
||||
"@wrnexus/plugin": "^0.8.8"
|
||||
},
|
||||
"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,14 +1,30 @@
|
||||
{
|
||||
"name": "@wrnexus/test",
|
||||
"version": "0.2.39",
|
||||
"version": "0.8.9",
|
||||
"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",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
"bun": ">=1.3.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
@@ -21,6 +37,7 @@
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
# @wrnexus/tracking
|
||||
|
||||
> Error tracking for WrNexus apps: capture exceptions manually or via middleware and fan them out to pluggable sinks.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
## Overview
|
||||
|
||||
`@wrnexus/tracking` is a small, server-side error-capture layer. You create a
|
||||
tracker with one or more **sinks**, then feed it errors — either manually with
|
||||
`tracker.capture(err, context)` or automatically by mounting `tracker.middleware()`
|
||||
in your request pipeline. A `consoleSink` is included; forwarding to Sentry,
|
||||
Datadog, or any other backend is just a matter of writing a tiny sink. Reach for
|
||||
it when you want a single, sink-agnostic place to route application errors. Sinks
|
||||
run best-effort — a throwing sink never breaks the request.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @wrnexus/tracking
|
||||
```
|
||||
|
||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
## API
|
||||
|
||||
### `createTracker(options?): Tracker`
|
||||
|
||||
Creates a tracker. `TrackerOptions`:
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------------ | ------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `sinks` | `ErrorSink[]` | Initial sinks to fan events out to. Defaults to `[]`. |
|
||||
| `now` | `() => number` | Clock used for `event.timestamp` (epoch ms). Defaults to `Date.now`. |
|
||||
| `beforeSend` | `(event: ErrorEvent) => ErrorEvent \| null` | Scrub/enrich an event before it reaches any sink. Return `null` to drop it. |
|
||||
|
||||
The returned `Tracker`:
|
||||
|
||||
| Member | Signature | Description |
|
||||
| ------------ | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `capture` | `(error: unknown, context?: Record<string, unknown>) => Promise<void>` | Normalizes any thrown value into an `Error`, builds an `ErrorEvent`, runs `beforeSend`, then dispatches to all sinks. Non-`Error` values are wrapped in an `Error` named `NonError`. |
|
||||
| `addSink` | `(sink: ErrorSink) => void` | Registers an additional sink at runtime. |
|
||||
| `middleware` | `() => Middleware` | Returns a WrNexus `Middleware` that captures any error thrown downstream, then re-throws it so the framework's error handler still produces the response. |
|
||||
|
||||
The middleware attaches this context to captured events:
|
||||
|
||||
```ts
|
||||
{ method: ctx.req.method, path: ctx.url.pathname, requestId: ctx.locals.requestId }
|
||||
```
|
||||
|
||||
### `consoleSink: ErrorSink`
|
||||
|
||||
A built-in sink that logs a compact one-line message via `console.error`, e.g.
|
||||
`[error] TypeError: cannot read x {"userId":42}`.
|
||||
|
||||
### Types
|
||||
|
||||
```ts
|
||||
interface ErrorEvent {
|
||||
error: Error;
|
||||
context: Record<string, unknown>; // request info, user id, tags…
|
||||
timestamp: number; // epoch ms
|
||||
}
|
||||
|
||||
interface ErrorSink {
|
||||
name?: string;
|
||||
capture(event: ErrorEvent): void | Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Manual capture:
|
||||
|
||||
```ts
|
||||
import { createTracker, consoleSink } from "@wrnexus/tracking";
|
||||
|
||||
const tracker = createTracker({ sinks: [consoleSink] });
|
||||
|
||||
try {
|
||||
await doWork();
|
||||
} catch (err) {
|
||||
await tracker.capture(err, { userId: 42, op: "doWork" });
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
As request middleware:
|
||||
|
||||
```ts
|
||||
import { createTracker, consoleSink } from "@wrnexus/tracking";
|
||||
|
||||
const tracker = createTracker({ sinks: [consoleSink] });
|
||||
|
||||
app.use(tracker.middleware()); // captures + re-throws downstream errors
|
||||
```
|
||||
|
||||
A custom sink with `beforeSend` scrubbing:
|
||||
|
||||
```ts
|
||||
import { createTracker, type ErrorSink } from "@wrnexus/tracking";
|
||||
|
||||
const sentrySink: ErrorSink = {
|
||||
name: "sentry",
|
||||
async capture(event) {
|
||||
await Sentry.captureException(event.error, { extra: event.context });
|
||||
},
|
||||
};
|
||||
|
||||
const tracker = createTracker({
|
||||
sinks: [sentrySink],
|
||||
beforeSend(event) {
|
||||
delete event.context.password; // scrub secrets
|
||||
return event; // return null to drop the event entirely
|
||||
},
|
||||
});
|
||||
|
||||
tracker.addSink(anotherSink); // add more sinks later
|
||||
```
|
||||
|
||||
## Requirements / Notes
|
||||
|
||||
- Runs on **Bun** only (Node is not supported).
|
||||
- Peer package: [`@wrnexus/core`](../core) — the `Context` and `Middleware` types
|
||||
used by `tracker.middleware()` come from there.
|
||||
- Sink dispatch is fire-and-forget-safe: all sinks run via `Promise.all`, and a
|
||||
sink that throws is swallowed so it can never break the app.
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "@wrnexus/tracking",
|
||||
"version": "0.2.39",
|
||||
"type": "module",
|
||||
"description": "@wrnexus/tracking — part of the WrNexus framework.",
|
||||
"license": "MIT",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"engines": {
|
||||
"bun": ">=1.1.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "restricted"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,943 @@
|
||||
# WRNexus UI component reference
|
||||
|
||||
This reference is generated from the 102 packaged `.wrn` component sources. Props marked required have no default; all others show their runtime default.
|
||||
|
||||
## Advanced-forms
|
||||
|
||||
### AdvancedSelect
|
||||
|
||||
Theme-aware, responsive advanced select component.
|
||||
|
||||
- Mount: `data-component="AdvancedSelect"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Advanced Select"`, `name: string = ""`, `value: string = ""`, `values: unknown[] = []`, `options: unknown[] = []`, `groups: unknown[] = []`, `placeholder: string = "Select an option"`, `placeholderIcon: string = ""`, `searchPlaceholder: string = "Search options…"`, `multiple: boolean = false`, `searchable: boolean = true`, `defaultOpen: boolean = false`, `clearable: boolean = true`, `allowEmpty: boolean = true`, `tags: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `invalid: boolean = false`, `validationMessage: string = ""`, `helpText: string = ""`, `loading: boolean = false`, `loadingLabel: string = "Loading options…"`, `emptyLabel: string = "No options found"`, `selectedOptionsLabel: string = "Selected options"`, `clearLabel: string = "Clear selection"`, `createLabel: string = "Create"`, `loadMoreLabel: string = "Load more"`, `searchMode: string = "contains"`, `searchFields: string = "label,description"`, `minSearchLength: number = 0`, `searchResultLimit: number = 0`, `maxSelections: number = 0`, `showCounter: boolean = false`, `counterTemplate: string = "{selected} selected"`, `optionTemplate: string = "default"`, `selectedTemplate: string = "default"`, `closeOnSelect: boolean = true`, `scrollToSelected: boolean = true`, `fixed: boolean = false`, `placement: string = "bottom"`, `remote: boolean = false`, `remoteUrl: string = ""`, `remoteQueryParam: string = "q"`, `remoteDebounce: number = 250`, `remoteAutoLoad: boolean = true`, `infinite: boolean = false`, `hasMore: boolean = false`, `page: number = 1`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `search({ 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)`, `clear({ 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({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `close({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `load({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `error({ error: Error | string; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | Error | string)`
|
||||
|
||||
### ComboBox
|
||||
|
||||
Editable autocomplete combobox with local and remote suggestions.
|
||||
|
||||
- Mount: `data-component="ComboBox"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "ComboBox"`, `name: string = ""`, `value: string = ""`, `options: unknown[] = []`, `groups: unknown[] = []`, `placeholder: string = "Search or select an option"`, `searchPlaceholder: string = "Start typing…"`, `clearable: boolean = true`, `allowCustomValue: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `invalid: boolean = false`, `validationMessage: string = ""`, `helpText: string = ""`, `loading: boolean = false`, `loadingLabel: string = "Loading suggestions…"`, `emptyLabel: string = "No matching options"`, `clearLabel: string = "Clear value"`, `toggleLabel: string = "Toggle suggestions"`, `searchMode: string = "contains"`, `searchFields: string = "label,description"`, `minSearchLength: number = 0`, `searchResultLimit: number = 0`, `optionTemplate: string = "default"`, `defaultOpen: boolean = false`, `closeOnSelect: boolean = true`, `fixed: boolean = false`, `placement: string = "bottom"`, `autocomplete: string = "off"`, `remote: boolean = false`, `remoteUrl: string = ""`, `remoteQueryParam: string = "q"`, `remoteDebounce: number = 250`, `remoteAutoLoad: boolean = true`, `infinite: boolean = false`, `hasMore: boolean = false`, `page: number = 1`, `loadMoreLabel: string = "Load more"`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `search({ 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)`, `clear({ 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({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `close({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `load({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `error({ error: Error | string; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | Error | string)`
|
||||
|
||||
### InputNumber
|
||||
|
||||
Theme-aware, responsive input number component.
|
||||
|
||||
- Mount: `data-component="InputNumber"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `variant: string = "default"`, `class: string = ""`, `id: string = ""`, `name: string = "quantity"`, `value: number = 0`, `min: string = ""`, `max: string = ""`, `step: number = 1`, `precision: string = "auto"`, `label: string = ""`, `description: string = ""`, `helpText: string = ""`, `error: string = ""`, `invalid: boolean = false`, `prefix: string = ""`, `suffix: string = ""`, `placeholder: string = ""`, `autocomplete: string = "off"`, `inputMode: string = "decimal"`, `ariaLabel: string = ""`, `required: boolean = false`, `disabled: boolean = false`, `inputDisabled: boolean = false`, `buttonsDisabled: boolean = false`, `readonly: boolean = false`, `allowInput: boolean = true`, `keyboard: boolean = true`, `wheel: boolean = false`, `clamp: boolean = true`, `fullWidth: boolean = false`, `showButtons: boolean = true`, `showValidationMessage: boolean = true`, `decrementLabel: string = "Decrease value"`, `incrementLabel: string = "Increase value"`, `controlsLabel: string = "Quantity controls"`, `requiredMessage: string = "A value is required."`, `minMessage: string = "Value is below the minimum."`, `maxMessage: string = "Value is above the maximum."`
|
||||
- Slots: None
|
||||
- Outputs: `input({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `increment({ value: number; previousValue?: number; sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `decrement({ value: number; previousValue?: number; sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### PinInput
|
||||
|
||||
Secure multi-cell PIN and verification-code input with regex and paste support.
|
||||
|
||||
- Mount: `data-component="PinInput"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Verification code"`, `name: string = "pin"`, `value: string = ""`, `length: number = 4`, `pattern: string = "[0-9]"`, `type: string = "text"`, `inputMode: string = "numeric"`, `placeholder: string = "○"`, `autocomplete: string = "one-time-code"`, `masked: boolean = false`, `disabled: boolean = false`, `readonly: boolean = false`, `required: boolean = false`, `autoFocus: boolean = false`, `autoSubmit: boolean = false`, `allowPaste: boolean = true`, `clearable: boolean = true`, `clearLabel: string = "Clear code"`, `separator: string = ""`, `groupSize: number = 0`, `helpText: string = ""`, `invalid: boolean = false`, `validationMessage: string = ""`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `input({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `complete({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `paste({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `clear({ 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)`, `error({ error: Error | string; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | Error | string)`
|
||||
|
||||
### StrongPassword
|
||||
|
||||
Theme-aware, responsive strong password component.
|
||||
|
||||
- Mount: `data-component="StrongPassword"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Password"`, `name: string = "password"`, `value: string = ""`, `placeholder: string = "Create a strong password"`, `autocomplete: string = "new-password"`, `minLength: number = 8`, `specialCharactersSet: string = "!@#$%^&*()_+-=[]{}|;:,.<>?"`, `requireLowercase: boolean = true`, `requireUppercase: boolean = true`, `requireNumber: boolean = true`, `requireSpecialCharacter: boolean = true`, `showRequirements: boolean = true`, `presentation: string = "inline"`, `hintText: string = "Use a unique password you do not use elsewhere."`, `emptyLabel: string = "Enter a password"`, `weakLabel: string = "Weak"`, `fairLabel: string = "Fair"`, `goodLabel: string = "Good"`, `strongLabel: string = "Strong"`, `disabled: boolean = false`, `readonly: boolean = false`, `required: boolean = false`, `invalid: boolean = false`, `validationMessage: string = ""`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `input({ value: string; score: number; maximumScore: number; percent: number; level: string })`, `change({ value: string; score: number; maximumScore: number; percent: number; level: string })`, `strength({ value: string; score: number; maximumScore: number; percent: number; level: string })`
|
||||
|
||||
### ToggleCount
|
||||
|
||||
Theme-aware, responsive toggle count component.
|
||||
|
||||
- Mount: `data-component="ToggleCount"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `variant: string = "segmented"`, `class: string = ""`, `name: string = "billing-cycle"`, `value: string = "monthly"`, `firstValue: string = "monthly"`, `firstLabel: string = "Monthly"`, `secondValue: string = "annual"`, `secondLabel: string = "Annual"`, `ariaLabel: string = "Billing frequency"`, `items: unknown[] = []`, `currency: string = "$"`, `suffix: string = ""`, `firstValueKey: string = "monthly"`, `secondValueKey: string = "annual"`, `emptyValue: string = "—"`, `align: string = "end"`, `fullWidth: boolean = true`, `disabled: boolean = false`, `animate: boolean = true`, `animationDuration: number = 450`, `animationSteps: number = 18`
|
||||
- Slots: None
|
||||
- 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)`, `toggle({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### TogglePassword
|
||||
|
||||
Accessible password field with optional show and hide controls.
|
||||
|
||||
- Mount: `data-component="TogglePassword"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Password"`, `name: string = "password"`, `value: string = ""`, `placeholder: string = "Enter your password"`, `autocomplete: string = "current-password"`, `minlength: string = ""`, `maxlength: string = ""`, `pattern: string = "(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9]).{8,}"`, `fields: unknown[] = []`, `visible: boolean = false`, `toggleable: boolean = true`, `toggleMode: string = "button"`, `checkboxLabel: string = "Show password"`, `showLabel: string = "Show password"`, `hideLabel: string = "Hide password"`, `disabled: boolean = false`, `readonly: boolean = false`, `required: boolean = false`, `invalid: boolean = false`, `helpText: string = ""`, `validationMessage: string = ""`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `input({ name?: string; value: string | number | boolean | null | object; visible: boolean })`, `change({ name?: string; value: string | number | boolean | null | object; visible: boolean })`, `toggle({ visible: boolean })`
|
||||
|
||||
## Base
|
||||
|
||||
### Accordion
|
||||
|
||||
Theme-aware, responsive accordion component.
|
||||
|
||||
- Mount: `data-component="Accordion"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `variant: string = "default"`, `class: string = ""`, `id: string = "accordion"`, `items: unknown[] = []`, `defaultOpen: unknown[] = []`, `multiple: boolean = false`, `alwaysOpen: boolean = false`, `disabled: boolean = false`, `indicator: string = "plus"`, `indicatorPosition: string = "start"`, `showIndicator: boolean = true`, `bordered: boolean = false`, `separated: boolean = false`, `flush: boolean = false`, `contentItalic: boolean = false`
|
||||
- Slots: None
|
||||
- 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)`, `open({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `close({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### Alert
|
||||
|
||||
Theme-aware, responsive alert component.
|
||||
|
||||
- Mount: `data-component="Alert"`
|
||||
- Props: `size: string = "default"`, `color: string = "info"`, `variant: string = "soft"`, `class: string = ""`, `radius: string = "md"`, `shadow: string = "sm"`, `title: string = "Alert"`, `description: string = ""`, `items: unknown[] = []`, `actions: unknown[] = []`, `showIcon: boolean = false`, `icon: string = ""`, `dismissible: boolean = false`, `dismissLabel: string = "Dismiss alert"`, `role: string = "alert"`, `live: string = "polite"`, `linkLabel: string = ""`, `linkHref: string = ""`, `actionLabel: string = ""`, `actionHref: string = ""`, `compact: boolean = false`
|
||||
- Slots: None
|
||||
- Outputs: `dismiss({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `action({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### Avatar
|
||||
|
||||
Theme-aware, responsive avatar component.
|
||||
|
||||
- Mount: `data-component="Avatar"`
|
||||
- Props: `src: string = ""`, `alt: string = ""`, `initials: string = ""`, `size: string = "md"`, `color: string = "primary"`, `variant: string = "solid"`, `shape: string = "circle"`, `status: string = ""`, `statusLabel: string = ""`, `statusPosition: string = "bottom"`, `badge: string = ""`, `badgeIcon: string = ""`, `badgeLabel: string = ""`, `tooltip: string = ""`, `name: string = ""`, `description: string = ""`, `loading: string = "lazy"`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `load({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `error({ error: Error | string; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | Error | string)`, `click({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### AvatarGroup
|
||||
|
||||
Theme-aware, responsive avatar group component.
|
||||
|
||||
- Mount: `data-component="AvatarGroup"`
|
||||
- Props: `items: unknown[] = []`, `size: string = "md"`, `color: string = "primary"`, `variant: string = "solid"`, `shape: string = "circle"`, `layout: string = "stack"`, `maxVisible: number = 4`, `columns: number = 3`, `borderColor: string = ""`, `showTooltips: boolean = true`, `overflowLabel: string = "Show remaining members"`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `overflow({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`
|
||||
|
||||
### Badge
|
||||
|
||||
Theme-aware, responsive badge component.
|
||||
|
||||
- Mount: `data-component="Badge"`
|
||||
- Props: `label: string = "Badge"`, `size: string = "md"`, `color: string = "primary"`, `variant: string = "solid"`, `shape: string = "pill"`, `class: string = ""`, `icon: string = ""`, `iconPosition: string = "start"`, `dot: boolean = false`, `dotOnly: boolean = false`, `dotLabel: string = "Status"`, `animated: boolean = false`, `avatarSrc: string = ""`, `avatarAlt: string = ""`, `dismissible: boolean = false`, `dismissLabel: string = "Remove badge"`, `truncate: boolean = false`, `maxWidth: string = "12rem"`, `anchorLabel: string = ""`, `anchorIcon: string = ""`, `placement: string = "inline"`, `anchorLabelText: string = "Badge anchor"`
|
||||
- Slots: None
|
||||
- Outputs: `dismiss({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### Blockquote
|
||||
|
||||
Theme-aware, responsive blockquote component.
|
||||
|
||||
- Mount: `data-component="Blockquote"`
|
||||
- Props: `quote: string = "I just wanted to say that I'm very happy with my purchase so far. The documentation is outstanding - clear and detailed."`, `citation: string = ""`, `citationTitle: string = ""`, `citationUrl: string = ""`, `avatarSrc: string = ""`, `avatarAlt: string = ""`, `size: string = "md"`, `color: string = "primary"`, `align: string = "left"`, `variant: string = "default"`, `quoteMark: boolean = true`, `italic: boolean = true`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
|
||||
### Button
|
||||
|
||||
Theme-aware, responsive button component.
|
||||
|
||||
- Mount: `data-component="Button"`
|
||||
- Props: `label: string = "Button"`, `loadingLabel: string = "Loading…"`, `description: string = ""`, `as: string = ""`, `href: string = ""`, `target: string = ""`, `rel: string = ""`, `type: string = "button"`, `variant: string = "default"`, `color: string = "primary"`, `size: string = "default"`, `disabled: boolean = false`, `loading: boolean = false`, `pill: boolean = false`, `fullWidth: boolean = false`, `icon: string = ""`, `iconPosition: string = "start"`, `ariaLabel: string = ""`, `ariaPressed: string = ""`, `ariaExpanded: string = ""`, `ariaControls: string = ""`, `title: string = ""`, `autofocus: boolean = false`, `controlClass: string = ""`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `click({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `focus({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `blur({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### ButtonGroup
|
||||
|
||||
Theme-aware, responsive button group component.
|
||||
|
||||
- Mount: `data-component="ButtonGroup"`
|
||||
- Props: `items: unknown[] = []`, `value: string = ""`, `size: string = "md"`, `color: string = "primary"`, `variant: string = "default"`, `orientation: string = "horizontal"`, `responsive: boolean = false`, `attached: boolean = true`, `selectable: boolean = false`, `toolbar: boolean = false`, `disabled: boolean = false`, `ariaLabel: string = "Button group"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `click({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `select({ value: string | number | boolean | null | object; previousValue: string | number | boolean | null | object; item: string | number | boolean | null | object; index: number })`, `change({ value: string | number | boolean | null | object; previousValue: string | number | boolean | null | object; item: string | number | boolean | null | object; index: number })`
|
||||
|
||||
### Card
|
||||
|
||||
Group related content in a responsive themed surface with title, description, content, and supporting slots.
|
||||
|
||||
- Mount: `data-component="Card"`
|
||||
- Props: `title: string = "Card title"`, `subtitle: string = ""`, `description: string = ""`, `header: string = ""`, `footer: string = ""`, `imageSrc: string = ""`, `imageAlt: string = ""`, `imagePosition: string = "top"`, `actionLabel: string = ""`, `actionHref: string = ""`, `headerActions: unknown[] = []`, `navigation: unknown[] = []`, `activeNav: string = ""`, `mobileNavigation: boolean = false`, `alertTitle: string = ""`, `alertDescription: string = ""`, `empty: boolean = false`, `emptyTitle: string = "No data to show"`, `emptyIcon: string = "icon-[lucide--inbox]"`, `items: unknown[] = []`, `size: string = "md"`, `color: string = "primary"`, `variant: string = "default"`, `layout: string = "vertical"`, `align: string = "left"`, `hover: string = "none"`, `scrollable: boolean = false`, `maxHeight: string = "18rem"`, `dismissible: boolean = false`, `ariaLabel: string = ""`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `click({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `action({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `navigate({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `dismiss({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `load({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `error({ error: Error | string; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | Error | string)`
|
||||
|
||||
### Carousel
|
||||
|
||||
Theme-aware, responsive carousel component.
|
||||
|
||||
- Mount: `data-component="Carousel"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = ""`, `description: string = ""`, `items: unknown[] = []`, `activeIndex: number = 0`, `slidesPerView: number = 1`, `gap: string = "0.75rem"`, `showPagination: boolean = false`, `isAutoPlay: boolean = false`, `autoplayInterval: number = 4000`, `isInfiniteLoop: boolean = false`, `isRTL: boolean = false`, `isCentered: boolean = false`, `isDraggable: boolean = false`, `isAutoHeight: boolean = false`, `isSnap: boolean = false`, `showCounter: boolean = false`, `thumbnails: string = "none"`, `ariaLabel: string = "Content carousel"`, `variant: string = "default"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `initialize({ index: number; count: number })`, `change({ index: number; previousIndex: number; item: string | number | boolean | null | object; reason: string | boolean })`, `previous({ index: number; previousIndex: number; item: string | number | boolean | null | object })`, `next({ index: number; previousIndex: number; item: string | number | boolean | null | object })`, `play({ index: number; interval: number })`, `pause({ index: number })`, `reachStart({ index: number })`, `reachEnd({ index: number })`, `dragStart({ index: number; x: null })`, `dragEnd({ index: number; distance: number })`
|
||||
|
||||
### ChatBubble
|
||||
|
||||
Theme-aware, responsive chat bubble component.
|
||||
|
||||
- Mount: `data-component="ChatBubble"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = ""`, `description: string = ""`, `items: unknown[] = []`, `oneSided: boolean = false`, `showAvatars: boolean = false`, `showMetadata: boolean = false`, `ariaLabel: string = "Conversation"`, `variant: string = "default"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `action({ action: boolean; item: string | number | boolean | null | object; index: number })`, `messageClick({ item: string | number | boolean | null | object; index: number; direction: string })`, `avatarClick({ item: string | number | boolean | null | object; index: number; direction: string })`, `linkClick({ link: string | number | boolean | null | object; item: string | number | boolean | null | object; index: number })`
|
||||
|
||||
### Collapse
|
||||
|
||||
Theme-aware, responsive collapse component.
|
||||
|
||||
- Mount: `data-component="Collapse"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `items: unknown[] = []`, `multiple: boolean = false`, `mode: string = "panel"`, `initialOpenIndexes: unknown[] = []`, `ariaLabel: string = "Collapsible content"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `toggle({ index: number; item: string | number | boolean | null | object; open: boolean; openIndexes: number[] })`, `open({ index: number; item: string | number | boolean | null | object })`, `close({ index: number; item: string | number | boolean | null | object })`
|
||||
|
||||
### DatePicker
|
||||
|
||||
Theme-aware, responsive date picker component.
|
||||
|
||||
- Mount: `data-component="DatePicker"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Date Picker"`, `id: string = ""`, `name: string = ""`, `value: string = ""`, `placeholder: string = ""`, `type: string = "date"`, `locale: string = "en-US"`, `firstDayOfWeek: number = 0`, `months: string = [{ label: "Jan", value: "01" }, { label: "Feb", value: "02" }, { label: "Mar", value: "03" }, { label: "Apr", value: "04" }, { label: "May", value: "05" }, { label: "Jun", value: "06" }, { label: "Jul", value: "07" }, { label: "Aug", value: "08" }, { label: "Sep", value: "09" }, { label: "Oct", value: "10" }, { label: "Nov", value: "11" }, { label: "Dec", value: "12" }]`, `days: string = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]`, `years: string = [2024, 2025, 2026, 2027, 2028, 2029, 2030]`, `min: string = ""`, `max: string = ""`, `step: string = ""`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `variant: string = "normal"`, `inline: boolean = false`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `input({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `change({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `open({ value: string; name: string; sourceEvent: Event })`, `close({ value: string; name: string; sourceEvent: Event })`, `focus({ value: string; name: string; sourceEvent: Event })`, `blur({ value: string; name: string; sourceEvent: Event })`, `invalid({ name: string; message: string; sourceEvent: Event })`
|
||||
|
||||
### DeviceFrame
|
||||
|
||||
Theme-aware, responsive device frame component.
|
||||
|
||||
- Mount: `data-component="DeviceFrame"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Device Frame"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `device: string = "phone"`, `orientation: string = "portrait"`, `src: string = ""`, `srcdoc: string = ""`, `frameTitle: string = "Device preview"`, `showToolbar: boolean = true`, `allow: string = ""`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `change({ device: string; orientation: string; sourceEvent: Event })`, `rotate({ device: string; orientation: string; sourceEvent: Event })`
|
||||
|
||||
### FileUploadProgress
|
||||
|
||||
Theme-aware, responsive file upload progress component.
|
||||
|
||||
- Mount: `data-component="FileUploadProgress"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Progress"`, `value: number = 50`, `max: number = 100`, `showValue: boolean = true`, `fileName: string = ""`, `fileSize: string = ""`, `uploadedSize: string = ""`, `status: string = "uploading"`, `cancelLabel: string = "Cancel upload"`, `retryLabel: string = "Retry upload"`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `cancel({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `retry({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `complete({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`
|
||||
|
||||
### LegendIndicator
|
||||
|
||||
Theme-aware, responsive legend indicator component.
|
||||
|
||||
- Mount: `data-component="LegendIndicator"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Legend Indicator"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `toggle({ 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)`
|
||||
|
||||
### List
|
||||
|
||||
Present structured responsive linked or status items with icons, descriptions, actions, and selection events.
|
||||
|
||||
- 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: `select({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### ListGroup
|
||||
|
||||
Theme-aware, responsive list group component.
|
||||
|
||||
- Mount: `data-component="ListGroup"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "List Group"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
|
||||
- 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)`
|
||||
|
||||
### Marquee
|
||||
|
||||
Continuously present responsive labels, partners, notices, or capabilities with pause and resume behavior.
|
||||
|
||||
- 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: `pause({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `resume({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### Progress
|
||||
|
||||
Theme-aware, responsive progress component.
|
||||
|
||||
- Mount: `data-component="Progress"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Progress"`, `value: number = 50`, `max: number = 100`, `showValue: boolean = true`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: None
|
||||
|
||||
### Rating
|
||||
|
||||
Theme-aware, responsive rating component.
|
||||
|
||||
- Mount: `data-component="Rating"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Rating"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `input({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`
|
||||
|
||||
### Skeleton
|
||||
|
||||
Theme-aware, responsive skeleton component.
|
||||
|
||||
- Mount: `data-component="Skeleton"`
|
||||
- Props: `color: string = "primary"`, `label: string = "Loading"`, `size: string = "md"`, `lines: number = 3`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: None
|
||||
|
||||
### Spinner
|
||||
|
||||
Theme-aware, responsive spinner component.
|
||||
|
||||
- Mount: `data-component="Spinner"`
|
||||
- Props: `color: string = "primary"`, `label: string = "Loading"`, `size: string = "md"`, `lines: number = 3`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: None
|
||||
|
||||
### StyledIcon
|
||||
|
||||
Theme-aware, responsive styled icon component.
|
||||
|
||||
- Mount: `data-component="StyledIcon"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Styled Icon"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
|
||||
### Timeline
|
||||
|
||||
Present responsive chronological activity, milestones, or workflow status with rich item metadata.
|
||||
|
||||
- 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: `select({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### TreeView
|
||||
|
||||
Theme-aware, responsive tree view component.
|
||||
|
||||
- Mount: `data-component="TreeView"`
|
||||
- Props: `title: string = "Tree View"`, `description: string = ""`, `items: unknown[] = []`, `valueKey: string = "value"`, `labelKey: string = "label"`, `defaultExpanded: unknown[] = []`, `selected: string = ""`, `size: string = "default"`, `color: string = "primary"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `select({ item: object; value: string; sourceEvent?: Event })`, `toggle({ item: object; value: string; expanded: boolean; sourceEvent?: Event })`, `expand({ item: object; value: string; sourceEvent?: Event })`, `collapse({ item: object; value: string; sourceEvent?: Event })`
|
||||
|
||||
## Core
|
||||
|
||||
### AuthForm
|
||||
|
||||
Reusable auth form component.
|
||||
|
||||
- Mount: `data-component="AuthForm"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `mode: string = "sign-in"`, `action: string = "/api/auth/login"`, `method: string = "post"`, `title: string = "Sign in"`, `description: string = ""`, `returnTo: string = ""`, `schema: string = ""`, `showRemember: boolean = true`, `showName: boolean = true`, `submitLabel: string = "Continue"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `submit({ event: Event; mode: string; action: string })`, `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)`, `input({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `focus({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `blur({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### AuthSplitLayout
|
||||
|
||||
Reusable auth split layout component.
|
||||
|
||||
- Mount: `data-component="AuthSplitLayout"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `eyebrow: string = "Secure identity"`, `title: string = "Welcome back"`, `description: string = ""`, `brand: string = "Police Management System"`, `features: unknown[] = []`, `class: string = ""`
|
||||
- Slots: `aside-extra`, `form`
|
||||
- Outputs: None
|
||||
|
||||
### PortalDashboard
|
||||
|
||||
Reusable portal dashboard component.
|
||||
|
||||
- Mount: `data-component="PortalDashboard"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `eyebrow: string = "Overview"`, `eyebrowKey: string = ""`, `title: string = "Dashboard"`, `titleKey: string = ""`, `description: string = ""`, `descriptionKey: string = ""`, `userName: string = ""`, `metrics: unknown[] = []`, `actions: unknown[] = []`, `updates: unknown[] = []`, `tasks: unknown[] = []`, `class: string = ""`
|
||||
- Slots: `hero-action`
|
||||
- Outputs: `action({ item: string | number | boolean | null | object; value: string | number | boolean; index: number })`, `navigate({ item: string | number | boolean | null | object; value: string | number | boolean; index: number })`
|
||||
|
||||
### PreferenceSwitcher
|
||||
|
||||
Reusable preference switcher component.
|
||||
|
||||
- Mount: `data-component="PreferenceSwitcher"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `themeLabel: string = "Theme"`, `colorLabel: string = "Accent color"`, `languageLabel: string = "Language"`, `languages: string = [`, `colors: string = [`, `compact: boolean = true`, `class: string = ""`
|
||||
- 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
|
||||
|
||||
Display one operational metric with value, suffix, description, icon, trend, progress, and optional action.
|
||||
|
||||
- Mount: `data-component="MetricCard"`
|
||||
- Props: `label: string = "Metric"`, `value: string = "0"`, `description: string = ""`, `icon: string = ""`, `iconStyle: string = "soft"`, `prefix: string = ""`, `suffix: string = ""`, `badge: string = ""`, `trend: string = ""`, `trendLabel: string = ""`, `trendDirection: string = "neutral"`, `progress: number = -1`, `progressLabel: string = ""`, `href: string = ""`, `target: string = ""`, `rel: string = ""`, `external: boolean = false`, `actionLabel: string = ""`, `actionIcon: string = ""`, `showArrow: boolean = true`, `selectable: boolean = false`, `disabled: boolean = false`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "default"`, `hover: string = "lift"`, `align: string = "left"`, `class: string = ""`
|
||||
- Slots: `default`, `footer`
|
||||
- Outputs: `select({ label: string; value: string; href: string; sourceEvent: Event })`, `action({ label: string; value: string; href: string; sourceEvent: Event })`
|
||||
|
||||
### MetricGrid
|
||||
|
||||
Arrange operational metrics, KPIs, public statistics, or service indicators in a responsive equal-height grid.
|
||||
|
||||
- Mount: `data-component="MetricGrid"`
|
||||
- Props: `items: unknown[] = []`, `columns: number = 4`, `tabletColumns: number = 2`, `mobileColumns: number = 1`, `gap: string = "md"`, `equalHeight: boolean = true`, `dividers: boolean = false`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "default"`, `maxWidth: string = "full"`, `minItemWidth: string = ""`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `select({ item: string | number | boolean | null | object; itemIndex: number; sourceEvent: Event })`, `action({ item: string | number | boolean | null | object; itemIndex: number; sourceEvent: Event })`
|
||||
|
||||
### StatsBar
|
||||
|
||||
Present a compact responsive strip of key facts, counts, performance indicators, or trust signals.
|
||||
|
||||
- Mount: `data-component="StatsBar"`
|
||||
- Props: `items: unknown[] = []`, `columns: number = 4`, `compact: boolean = true`, `dividers: boolean = true`, `icons: boolean = true`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "raised"`, `maxWidth: string = "xl"`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: None
|
||||
|
||||
## Forms
|
||||
|
||||
### Checkbox
|
||||
|
||||
Theme-aware, responsive checkbox component.
|
||||
|
||||
- Mount: `data-component="Checkbox"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `id: string = ""`, `name: string = ""`, `label: string = "Checkbox"`, `hiddenLabel: boolean = false`, `placeholder: string = ""`, `variant: string = "normal"`, `icon: string = ""`, `iconPosition: string = "start"`, `value: string = "on"`, `values: unknown[] = []`, `options: unknown[] = []`, `checked: boolean = false`, `indeterminate: boolean = false`, `orientation: string = "vertical"`, `card: boolean = false`, `rightAligned: boolean = false`, `list: boolean = false`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `inline: boolean = false`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `input({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `focus({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `blur({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `invalid({ message?: string; value: string | number | boolean | null | object; sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### ColorPicker
|
||||
|
||||
Theme-aware, responsive color picker component.
|
||||
|
||||
- Mount: `data-component="ColorPicker"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `id: string = ""`, `name: string = ""`, `label: string = "Color"`, `hiddenLabel: boolean = false`, `placeholder: string = ""`, `value: string = "#2563eb"`, `icon: string = ""`, `iconPosition: string = "start"`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `inline: boolean = false`, `variant: string = "normal"`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `input({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `focus({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `blur({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### FileInput
|
||||
|
||||
Theme-aware, responsive file input component.
|
||||
|
||||
- Mount: `data-component="FileInput"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `id: string = ""`, `name: string = ""`, `label: string = "File"`, `hiddenLabel: boolean = false`, `placeholder: string = "Choose a file"`, `value: string = ""`, `icon: string = "icon-[lucide--upload]"`, `iconPosition: string = "start"`, `accept: string = ""`, `multiple: boolean = false`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `inline: boolean = false`, `variant: string = "normal"`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `input({ files: File[]; name: string; sourceEvent: Event })`, `change({ files: File[]; name: string; sourceEvent: Event })`, `focus({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `blur({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `select({ files: File[]; name: string; sourceEvent: Event })`, `clear({ name: string; sourceEvent: Event })`, `invalid({ message?: string; value: string | number | boolean | null | object; sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### Input
|
||||
|
||||
Theme-aware, responsive input component.
|
||||
|
||||
- Mount: `data-component="Input"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `id: string = ""`, `name: string = ""`, `label: string = "Input"`, `hiddenLabel: boolean = false`, `placeholder: string = ""`, `value: string = ""`, `type: string = "text"`, `variant: string = "normal"`, `icon: string = ""`, `iconPosition: string = "start"`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `inline: boolean = false`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `autocomplete: string = ""`, `inputmode: string = ""`, `minlength: string = ""`, `maxlength: string = ""`, `pattern: string = ""`, `min: string = ""`, `max: string = ""`, `step: string = ""`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `input({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `change({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `focus({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `blur({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `invalid({ value: string | number | boolean | null | object; name: string; message: string; sourceEvent: Event })`, `keydown({ key: string; value: string | number | boolean | null | object; name: string; sourceEvent: Event })`, `keyup({ key: string; value: string | number | boolean | null | object; name: string; sourceEvent: Event })`
|
||||
|
||||
### InputGroup
|
||||
|
||||
Theme-aware, responsive input group component.
|
||||
|
||||
- Mount: `data-component="InputGroup"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `id: string = ""`, `name: string = ""`, `label: string = "Input group"`, `hiddenLabel: boolean = false`, `value: string = ""`, `placeholder: string = ""`, `type: string = "text"`, `startText: string = ""`, `endText: string = ""`, `icon: string = ""`, `iconPosition: string = "start"`, `actionLabel: string = ""`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `inline: boolean = false`, `variant: string = "normal"`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `input({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `focus({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `blur({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `submit({ value: string | number | boolean | null | object; name: string; sourceEvent: Event })`, `action({ name: string; sourceEvent: Event })`
|
||||
|
||||
### Radio
|
||||
|
||||
Theme-aware, responsive radio component.
|
||||
|
||||
- Mount: `data-component="Radio"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `id: string = ""`, `name: string = ""`, `label: string = "Radio"`, `hiddenLabel: boolean = false`, `placeholder: string = ""`, `variant: string = "normal"`, `icon: string = ""`, `iconPosition: string = "start"`, `value: string = "on"`, `options: unknown[] = []`, `checked: boolean = false`, `orientation: string = "vertical"`, `card: boolean = false`, `rightAligned: boolean = false`, `list: boolean = false`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `inline: boolean = false`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `input({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `focus({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `blur({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `invalid({ message?: string; value: string | number | boolean | null | object; sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### RangeSlider
|
||||
|
||||
Theme-aware, responsive range slider component.
|
||||
|
||||
- Mount: `data-component="RangeSlider"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `id: string = ""`, `name: string = ""`, `label: string = "Range"`, `hiddenLabel: boolean = false`, `placeholder: string = ""`, `variant: string = "normal"`, `icon: string = ""`, `iconPosition: string = "start"`, `value: number = 50`, `min: number = 0`, `max: number = 100`, `step: number = 1`, `showValue: boolean = true`, `showBounds: boolean = true`, `showSteps: boolean = false`, `marks: unknown[] = []`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `inline: boolean = false`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `input(({ value: number; name: string; sourceEvent: Event }) | ({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[]))`, `change(({ value: number; name: string; sourceEvent: Event }) | ({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[]))`, `focus({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `blur({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### SearchBox
|
||||
|
||||
Provide an accessible responsive search field with labels, validation states, sizes, and input or change events.
|
||||
|
||||
- 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: `search({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `clear({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### Select
|
||||
|
||||
Theme-aware, responsive select component.
|
||||
|
||||
- Mount: `data-component="Select"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `id: string = ""`, `name: string = ""`, `label: string = "Select"`, `hiddenLabel: boolean = false`, `value: string = ""`, `values: unknown[] = []`, `options: unknown[] = []`, `placeholder: string = "Select an option"`, `variant: string = "normal"`, `icon: string = ""`, `iconPosition: string = "start"`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `inline: boolean = false`, `multiple: boolean = false`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `input({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `focus({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `blur({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `open({ name: string; sourceEvent: Event })`, `close({ name: string; sourceEvent: Event })`, `invalid({ name: string; message: string; sourceEvent: Event })`
|
||||
|
||||
### Switch
|
||||
|
||||
Theme-aware, responsive switch component.
|
||||
|
||||
- Mount: `data-component="Switch"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `id: string = ""`, `name: string = ""`, `label: string = "Switch"`, `hiddenLabel: boolean = false`, `placeholder: string = ""`, `variant: string = "normal"`, `icon: string = ""`, `iconPosition: string = "start"`, `value: string = "on"`, `checked: boolean = false`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `inline: boolean = false`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `input({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `focus({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `blur({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### Textarea
|
||||
|
||||
Theme-aware, responsive textarea component.
|
||||
|
||||
- Mount: `data-component="Textarea"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `id: string = ""`, `name: string = ""`, `label: string = "Textarea"`, `hiddenLabel: boolean = false`, `placeholder: string = ""`, `value: string = ""`, `variant: string = "normal"`, `icon: string = ""`, `iconPosition: string = "start"`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `inline: boolean = false`, `rows: number = 5`, `resize: string = "vertical"`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `minlength: string = ""`, `maxlength: string = ""`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `input({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `focus({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `blur({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `invalid({ value: string | number | boolean | null | object; name: string; message: string; sourceEvent: Event })`
|
||||
|
||||
### TimePicker
|
||||
|
||||
Theme-aware, responsive time picker component.
|
||||
|
||||
- Mount: `data-component="TimePicker"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `id: string = ""`, `name: string = ""`, `label: string = "Time"`, `hiddenLabel: boolean = false`, `value: string = ""`, `placeholder: string = ""`, `variant: string = "normal"`, `icon: string = "icon-[lucide--clock-3]"`, `iconPosition: string = "end"`, `helperText: string = ""`, `cornerHint: string = ""`, `error: string = ""`, `inline: boolean = false`, `min: string = ""`, `max: string = ""`, `step: string = ""`, `format: string = "24"`, `minuteStep: number = 5`, `hours: string = ["00", "01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23"]`, `minutes: string = ["00", "05", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55"]`, `readonly: boolean = false`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `input({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `change({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `focus({ value: string; name: string; sourceEvent: Event })`, `blur({ value: string; name: string; sourceEvent: Event })`, `open({ value: string; name: string; sourceEvent: Event })`, `close({ value: string; name: string; sourceEvent: Event })`, `invalid({ name: string; message: string; sourceEvent: Event })`
|
||||
|
||||
## Integrations
|
||||
|
||||
### Chart
|
||||
|
||||
Theme-aware, responsive chart component.
|
||||
|
||||
- Mount: `data-component="Chart"`
|
||||
- Props: `title: string = "Chart"`, `description: string = ""`, `items: unknown[] = []`, `valueKey: string = "value"`, `labelKey: string = "label"`, `height: number = 240`, `showLegend: boolean = true`, `showValues: boolean = true`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "bar"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `select({ item: object; index: number; sourceEvent?: Event })`, `dataPointClick({ item: object; index: number; sourceEvent?: Event })`, `legendToggle({ item: object; index: number; hidden: boolean; sourceEvent?: Event })`
|
||||
|
||||
### Clipboard
|
||||
|
||||
Theme-aware, responsive clipboard component.
|
||||
|
||||
- Mount: `data-component="Clipboard"`
|
||||
- Props: `value: string = ""`, `label: string = "Copy"`, `copiedLabel: string = "Copied"`, `errorLabel: string = "Copy failed"`, `title: string = "Clipboard"`, `description: string = ""`, `code: boolean = true`, `size: string = "default"`, `color: string = "primary"`, `disabled: boolean = false`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `copy({ value: string; sourceEvent?: Event })`, `success({ value: string; sourceEvent?: Event })`, `error({ error: Error | string; value: string; sourceEvent?: Event })`
|
||||
|
||||
### Confetti
|
||||
|
||||
Theme-aware, responsive confetti component.
|
||||
|
||||
- Mount: `data-component="Confetti"`
|
||||
- Props: `label: string = "Celebrate"`, `duration: number = 1200`, `count: number = 24`, `autoStart: boolean = false`, `disabled: boolean = false`, `size: string = "default"`, `color: string = "primary"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `start({ sourceEvent?: Event; duration: number })`, `complete({ sourceEvent?: Event; duration: number })`
|
||||
|
||||
### DataMap
|
||||
|
||||
Theme-aware, responsive data map component.
|
||||
|
||||
- Mount: `data-component="DataMap"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Data Map"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `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)`
|
||||
|
||||
### DragAndDrop
|
||||
|
||||
Theme-aware, responsive drag and drop component.
|
||||
|
||||
- Mount: `data-component="DragAndDrop"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Drag And Drop"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `dragStart({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `dragEnd({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `dragEnter({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `dragLeave({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `drop({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `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)`
|
||||
|
||||
### Map
|
||||
|
||||
Present responsive location information and markers with map-ready metadata and movement or marker events.
|
||||
|
||||
- Mount: `data-component="Map"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Map"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `markerClick({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `select({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `zoom({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### WysiwygEditor
|
||||
|
||||
Theme-aware, responsive wysiwyg editor component.
|
||||
|
||||
- Mount: `data-component="WysiwygEditor"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Wysiwyg Editor"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `input({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `focus({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `blur({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
## Layout
|
||||
|
||||
### Columns
|
||||
|
||||
Create responsive balanced content columns with configurable count, gap, density, and maximum width.
|
||||
|
||||
- Mount: `data-component="Columns"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `columns: number = 2`, `gap: string = "md"`, `maxWidth: string = "xl"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
|
||||
### Container
|
||||
|
||||
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"`, `centered: boolean = true`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
|
||||
### CustomScrollbar
|
||||
|
||||
Theme-aware, responsive custom scrollbar component.
|
||||
|
||||
- Mount: `data-component="CustomScrollbar"`
|
||||
- Props: `color: string = "primary"`, `size: string = "default"`, `axis: string = "vertical"`, `thickness: number = 8`, `maxHeight: string = "20rem"`, `radius: string = "999px"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- 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"`, `variant: string = "solid"`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: None
|
||||
|
||||
### FeatureGrid
|
||||
|
||||
Arrange feature cards, services, solutions, or benefits in a responsive equal-height grid.
|
||||
|
||||
- Mount: `data-component="FeatureGrid"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `items: unknown[] = []`, `columns: number = 3`, `tabletColumns: number = 2`, `mobileColumns: number = 1`, `gap: string = "md"`, `minItemWidth: string = ""`, `equalHeight: boolean = true`, `align: string = "stretch"`, `maxWidth: string = "full"`, `variant: string = "default"`, `label: string = "Features"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
|
||||
### Footer
|
||||
|
||||
Render structured responsive footer navigation, pre and post content, copyright content, links, and public events.
|
||||
|
||||
- Mount: `data-component="Footer"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Footer navigation"`, `items: unknown[] = []`, `columns: number = 3`, `maxWidth: string = "compact"`, `copyright: string = ""`, `class: string = ""`
|
||||
- Slots: `pre-footer`, `post-footer`, `copyright-left`, `copyright-right`
|
||||
- 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)`, `action({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### Grid
|
||||
|
||||
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"`, `minItemWidth: string = ""`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
|
||||
### Image
|
||||
|
||||
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"`, `fit: string = "cover"`, `rounded: boolean = false`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
|
||||
### Kbd
|
||||
|
||||
Theme-aware, responsive kbd component.
|
||||
|
||||
- Mount: `data-component="Kbd"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "K"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
|
||||
### LayoutSplitter
|
||||
|
||||
Theme-aware, responsive layout splitter component.
|
||||
|
||||
- Mount: `data-component="LayoutSplitter"`
|
||||
- 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 = ""`, `underline: string = "hover"`, `external: boolean = false`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
|
||||
### PublicPageShell
|
||||
|
||||
Provide the outer responsive structure, width, background, slots, overflow, and minimum-height behavior for public pages.
|
||||
|
||||
- Mount: `data-component="PublicPageShell"`
|
||||
- Props: `maxWidth: string = "full"`, `fullWidth: boolean = true`, `headerOffset: string = "none"`, `background: string = "default"`, `overflow: string = "clip"`, `minHeight: string = "screen"`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "default"`, `class: string = ""`
|
||||
- Slots: `before`, `default`, `after`
|
||||
- Outputs: None
|
||||
|
||||
### Section
|
||||
|
||||
Create a responsive themed page section with controlled spacing, width, borders, and surface treatment.
|
||||
|
||||
- Mount: `data-component="Section"`
|
||||
- Props: `id: string = ""`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "default"`, `spacing: string = "lg"`, `maxWidth: string = "xl"`, `fullWidth: boolean = false`, `borderTop: boolean = false`, `borderBottom: boolean = false`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
|
||||
### SectionHeader
|
||||
|
||||
Introduce a section with an eyebrow, title, description, alignment, and responsive heading hierarchy.
|
||||
|
||||
- Mount: `data-component="SectionHeader"`
|
||||
- Props: `id: string = ""`, `eyebrow: string = ""`, `title: string = ""`, `description: string = ""`, `align: string = "left"`, `size: string = "default"`, `color: string = "primary"`, `headingLevel: number = 2`, `maxWidth: string = "3xl"`, `class: string = ""`
|
||||
- Slots: `icon`, `default`, `actions`
|
||||
- Outputs: None
|
||||
|
||||
### Typography
|
||||
|
||||
Apply consistent readable responsive typography, widths, columns, spacing, and editorial hierarchy.
|
||||
|
||||
- Mount: `data-component="Typography"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `columns: number = 2`, `gap: string = "md"`, `maxWidth: string = "xl"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
|
||||
## Marketing
|
||||
|
||||
### AnnouncementBar
|
||||
|
||||
Publish a responsive notice with badge, icon, supporting copy, action, dismiss behavior, and width controls.
|
||||
|
||||
- Mount: `data-component="AnnouncementBar"`
|
||||
- Props: `badge: string = ""`, `badgeIcon: string = ""`, `message: string = "Announcement"`, `description: string = ""`, `icon: string = "icon-[lucide--megaphone]"`, `actionLabel: string = ""`, `actionHref: string = ""`, `actionIcon: string = ""`, `dismissible: boolean = false`, `dismissLabel: string = "Dismiss announcement"`, `sticky: boolean = false`, `compact: boolean = false`, `size: string = "default"`, `width: string = "default"`, `color: string = "primary"`, `variant: string = "soft"`, `role: string = "status"`, `live: string = "polite"`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `dismiss({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
### CTASection
|
||||
|
||||
Close a page or major section with conversion-focused copy, actions, and optional supporting visual content.
|
||||
|
||||
- Mount: `data-component="CTASection"`
|
||||
- Props: `eyebrow: string = ""`, `title: string = "Ready to get started?"`, `description: string = ""`, `icon: string = ""`, `align: string = "center"`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "solid"`, `primaryLabel: string = "Get started"`, `primaryHref: string = "#"`, `primaryIcon: string = ""`, `secondaryLabel: string = ""`, `secondaryHref: string = ""`, `secondaryIcon: string = ""`, `backgroundImage: string = ""`, `visualImage: string = ""`, `visualAlt: string = ""`, `visualIcon: string = ""`, `visualTitle: string = ""`, `visualDescription: string = ""`, `visualItems: unknown[] = []`, `visualPosition: string = "right"`, `maxWidth: string = "xl"`, `fullBleed: boolean = false`, `class: string = ""`
|
||||
- Slots: `default`, `actions`, `visual`, `footer`
|
||||
- Outputs: None
|
||||
|
||||
### FeatureCard
|
||||
|
||||
Present one linked feature or service with media, icon, badge, description, and action.
|
||||
|
||||
- Mount: `data-component="FeatureCard"`
|
||||
- Props: `icon: string = ""`, `iconStyle: string = "soft"`, `iconSize: string = "default"`, `eyebrow: string = ""`, `title: string = "Feature"`, `description: string = ""`, `image: string = ""`, `imageAlt: string = ""`, `imagePosition: string = "top"`, `imageAspect: string = "wide"`, `imageLoading: string = "lazy"`, `href: string = ""`, `target: string = ""`, `rel: string = ""`, `external: boolean = false`, `actionLabel: string = "Learn more"`, `actionIcon: string = ""`, `showArrow: boolean = true`, `stretchedLink: boolean = true`, `badge: string = ""`, `badgeColor: string = "primary"`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "default"`, `hover: string = "lift"`, `align: string = "left"`, `disabled: boolean = false`, `class: string = ""`
|
||||
- Slots: `media`, `icon`, `default`, `footer`
|
||||
- Outputs: None
|
||||
|
||||
### FeatureIconCard
|
||||
|
||||
Present a compact feature or benefit with a styled icon, title, description, badge, and optional link.
|
||||
|
||||
- Mount: `data-component="FeatureIconCard"`
|
||||
- Props: `icon: string = "icon-[lucide--sparkles]"`, `iconSize: string = "md"`, `iconVariant: string = "soft"`, `title: string = "Feature"`, `description: string = ""`, `href: string = ""`, `actionLabel: string = "Explore"`, `badge: string = ""`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "default"`, `align: string = "left"`, `hover: string = "lift"`, `class: string = ""`
|
||||
- Slots: `default`, `footer`
|
||||
- Outputs: None
|
||||
|
||||
### Hero
|
||||
|
||||
Build a full-width responsive hero with constrained content, actions, trust signals, and a structured or custom visual panel.
|
||||
|
||||
- Mount: `data-component="Hero"`
|
||||
- Props: `eyebrow: string = ""`, `eyebrowIcon: string = ""`, `icon: string = ""`, `title: string = "Build something remarkable"`, `highlight: string = ""`, `description: string = ""`, `align: string = "left"`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "default"`, `layout: string = "split"`, `visualPosition: string = "right"`, `visualStyle: string = "plain"`, `showDecorations: boolean = true`, `fullBleed: boolean = true`, `visualEyebrow: string = ""`, `visualTitle: string = ""`, `visualDescription: string = ""`, `visualIcon: string = ""`, `visualImage: string = ""`, `visualAlt: string = ""`, `visualItems: unknown[] = []`, `primaryLabel: string = ""`, `primaryHref: string = ""`, `primaryIcon: string = ""`, `primaryTarget: string = ""`, `secondaryLabel: string = ""`, `secondaryHref: string = ""`, `secondaryIcon: string = ""`, `secondaryTarget: string = ""`, `tertiaryLabel: string = ""`, `tertiaryHref: string = ""`, `tertiaryIcon: string = ""`, `tertiaryTarget: string = ""`, `badges: unknown[] = []`, `trustItems: unknown[] = []`, `maxWidth: string = "xl"`, `class: string = ""`
|
||||
- Slots: `eyebrow`, `actions`, `trust`, `default`, `visual`, `footer`
|
||||
- Outputs: None
|
||||
|
||||
### HeroActions
|
||||
|
||||
Group hero and campaign actions with consistent alignment, orientation, sizing, and responsive mobile stacking.
|
||||
|
||||
- Mount: `data-component="HeroActions"`
|
||||
- Props: `actions: unknown[] = []`, `align: string = "left"`, `orientation: string = "horizontal"`, `stackOnMobile: boolean = true`, `fullWidthMobile: boolean = true`, `size: string = "default"`, `color: string = "primary"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: None
|
||||
|
||||
### MarketingSectionHeader
|
||||
|
||||
Introduce marketing content with an eyebrow, title, description, and optional linked action.
|
||||
|
||||
- Mount: `data-component="MarketingSectionHeader"`
|
||||
- Props: `id: string = ""`, `eyebrow: string = ""`, `title: string = ""`, `description: string = ""`, `align: string = "split"`, `size: string = "default"`, `color: string = "primary"`, `actionLabel: string = ""`, `actionHref: string = ""`, `actionIcon: string = ""`, `actionExternal: boolean = false`, `class: string = ""`
|
||||
- Slots: `icon`, `actions`, `default`
|
||||
- Outputs: None
|
||||
|
||||
### PageHeader
|
||||
|
||||
Introduce an internal or public page with breadcrumbs, icon, title, description, and primary or secondary actions.
|
||||
|
||||
- Mount: `data-component="PageHeader"`
|
||||
- Props: `eyebrow: string = ""`, `title: string = ""`, `description: string = ""`, `icon: string = ""`, `id: string = "page-title"`, `align: string = "left"`, `centered: boolean = false`, `compact: boolean = false`, `size: string = "default"`, `maxWidth: string = "xl"`, `showBreadcrumbs: boolean = false`, `breadcrumbs: unknown[] = []`, `breadcrumbParent: string = ""`, `breadcrumbParentHref: string = ""`, `breadcrumbCurrent: string = ""`, `primaryLabel: string = ""`, `primaryHref: string = ""`, `primaryIcon: string = ""`, `secondaryLabel: string = ""`, `secondaryHref: string = ""`, `secondaryIcon: string = ""`, `color: string = "primary"`, `variant: string = "default"`, `borderBottom: boolean = true`, `class: string = ""`
|
||||
- Slots: `meta`, `actions`, `default`
|
||||
- Outputs: None
|
||||
|
||||
### SplitHero
|
||||
|
||||
Build a responsive two-column introduction balancing descriptive content with a visual, image, or structured data panel.
|
||||
|
||||
- Mount: `data-component="SplitHero"`
|
||||
- Props: `eyebrow: string = ""`, `eyebrowIcon: string = "icon-[lucide--sparkles]"`, `title: string = "A better digital experience"`, `highlight: string = ""`, `description: string = ""`, `primaryLabel: string = ""`, `primaryHref: string = ""`, `primaryIcon: string = ""`, `secondaryLabel: string = ""`, `secondaryHref: string = ""`, `secondaryIcon: string = ""`, `visualPosition: string = "right"`, `reverse: boolean = false`, `ratio: string = "balanced"`, `align: string = "left"`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "default"`, `maxWidth: string = "xl"`, `fullBleed: boolean = true`, `backgroundImage: string = ""`, `visualImage: string = ""`, `visualAlt: string = ""`, `visualIcon: string = ""`, `visualEyebrow: string = ""`, `visualTitle: string = ""`, `visualDescription: string = ""`, `visualItems: unknown[] = []`, `visualStyle: string = "panel"`, `trustItems: unknown[] = []`, `class: string = ""`
|
||||
- Slots: `default`, `actions`, `trust`, `visual`
|
||||
- Outputs: None
|
||||
|
||||
### TextLink
|
||||
|
||||
Render an accessible text action with optional icon, arrow, underline, external state, and semantic styling.
|
||||
|
||||
- Mount: `data-component="TextLink"`
|
||||
- Props: `label: string = "Learn more"`, `href: string = "#"`, `target: string = ""`, `rel: string = ""`, `external: boolean = false`, `icon: string = ""`, `iconPosition: string = "start"`, `showArrow: boolean = true`, `underline: boolean = false`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "default"`, `disabled: boolean = false`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: `click({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `focus({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `blur({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
||||
|
||||
## Navigation
|
||||
|
||||
### BackToTop
|
||||
|
||||
Provide a responsive floating control that returns long pages to the top and can show scroll progress.
|
||||
|
||||
- Mount: `data-component="BackToTop"`
|
||||
- Props: `threshold: number = 500`, `label: string = "Back to top"`, `ariaLabel: string = "Scroll back to top"`, `icon: string = "icon-[lucide--arrow-up]"`, `position: string = "right"`, `offset: string = "md"`, `behavior: string = "smooth"`, `showProgress: boolean = false`, `showLabel: boolean = false`, `alwaysVisible: boolean = false`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "solid"`, `shape: string = "round"`, `class: string = ""`
|
||||
- Slots: None
|
||||
- Outputs: None
|
||||
|
||||
### Breadcrumb
|
||||
|
||||
Show responsive hierarchical navigation with home support, separators, current-page state, sizes, and selection events.
|
||||
|
||||
- Mount: `data-component="Breadcrumb"`
|
||||
- Props: `label: string = "Breadcrumb"`, `items: unknown[] = []`, `active: string = ""`, `separator: string = "chevron"`, `showHome: boolean = false`, `homeLabel: string = "Home"`, `homeHref: string = "/"`, `homeIcon: string = "icon-[lucide--house]"`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "minimal"`, `class: string = ""`
|
||||
- Slots: None
|
||||
- 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)`
|
||||
|
||||
### MegaMenu
|
||||
|
||||
Theme-aware, responsive mega menu component.
|
||||
|
||||
- Mount: `data-component="MegaMenu"`
|
||||
- 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 })`, `close({ reason: string })`, `select({ item: object; value: string })`
|
||||
|
||||
### Nav
|
||||
|
||||
Theme-aware, responsive nav component.
|
||||
|
||||
- Mount: `data-component="Nav"`
|
||||
- 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({ item: object; value: string })`
|
||||
|
||||
### Navbar
|
||||
|
||||
Theme-aware, responsive navbar component.
|
||||
|
||||
- Mount: `data-component="Navbar"`
|
||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Primary navigation"`, `topbarLabel: string = "Utility navigation"`, `brand: Record<string, unknown> = {}`, `items: unknown[] = []`, `actions: unknown[] = []`, `active: string = ""`, `sticky: boolean = false`, `openOnHover: boolean = false`, `maxWidth: string = "full"`, `mobileLabel: string = "Toggle navigation"`, `class: string = ""`
|
||||
- Slots: `topbar`, `actions`
|
||||
- Outputs: `toggle({ open: boolean })`, `open({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `close({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `select({ item: string | number | boolean | null | object; value: string | number | boolean; level: string })`, `action({ item: string | number | boolean | null | object; value: string | number | boolean })`
|
||||
|
||||
### Pagination
|
||||
|
||||
Theme-aware, responsive pagination component.
|
||||
|
||||
- Mount: `data-component="Pagination"`
|
||||
- Props: `color: string = "primary"`, `size: string = "default"`, `page: number = 1`, `pageSize: number = 10`, `total: number = 0`, `variant: string = "compact"`, `siblingCount: number = 1`, `showSummary: boolean = true`, `label: string = "Pagination"`, `previousLabel: string = "Previous"`, `nextLabel: string = "Next"`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `change({ page: number; pageSize: number })`, `previous({ page: number })`, `next({ page: number })`
|
||||
|
||||
### Scrollspy
|
||||
|
||||
Theme-aware, responsive scrollspy component.
|
||||
|
||||
- Mount: `data-component="Scrollspy"`
|
||||
- Props: `color: string = "primary"`, `size: string = "default"`, `items: unknown[] = []`, `active: string = ""`, `label: string = "On this page"`, `heading: string = ""`, `class: string = ""`
|
||||
- Slots: `default`
|
||||
- Outputs: `change({ href: string; label: string })`
|
||||
|
||||
### Sidebar
|
||||
|
||||
Theme-aware, responsive sidebar component.
|
||||
|
||||
- Mount: `data-component="Sidebar"`
|
||||
- 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: `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: `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
|
||||
|
||||
### ContextMenu
|
||||
|
||||
Open an accessible keyboard-aware action menu from pointer or keyboard context interactions.
|
||||
|
||||
- Mount: `data-component="ContextMenu"`
|
||||
- Props: `items: unknown[] = []`, `open: boolean = false`, `defaultOpen: boolean = false`, `trigger: string = "contextmenu"`, `placement: string = "pointer"`, `align: string = "start"`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "raised"`, `title: string = ""`, `description: string = ""`, `label: string = "Context menu"`, `closeOnSelect: boolean = true`, `closeOnOutside: boolean = true`, `disabled: boolean = false`, `minWidth: string = "14rem"`, `maxWidth: string = "20rem"`, `class: string = ""`
|
||||
- Slots: `trigger`, `header`, `default`, `footer`
|
||||
- Outputs: `open({ x: number; y: number; trigger: string; sourceEvent: Event })`, `close({ reason: string; sourceEvent: Event })`, `select({ item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event })`, `action({ item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event })`
|
||||
|
||||
### Drawer
|
||||
|
||||
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`, `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 })`
|
||||
|
||||
### Dropdown
|
||||
|
||||
Open an accessible anchored menu with keyboard navigation, item selection, actions, and responsive placement.
|
||||
|
||||
- Mount: `data-component="Dropdown"`
|
||||
- Props: `items: unknown[] = []`, `open: boolean = false`, `defaultOpen: boolean = false`, `label: string = "Open menu"`, `icon: string = ""`, `showChevron: boolean = true`, `menuLabel: string = "Dropdown menu"`, `placement: string = "bottom-start"`, `width: string = "md"`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "raised"`, `closeOnSelect: boolean = true`, `closeOnOutside: boolean = true`, `disabled: boolean = false`, `emptyLabel: string = "No menu items"`, `class: string = ""`
|
||||
- Slots: `trigger`, `header`, `default`, `footer`
|
||||
- Outputs: `toggle({ open: boolean; sourceEvent: Event; reason?: object })`, `open({ sourceEvent: Event })`, `close({ reason: string; sourceEvent: Event })`, `select({ item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event })`, `action({ item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event })`
|
||||
|
||||
### Modal
|
||||
|
||||
Present an accessible modal dialog with focus management, confirmation, cancellation, slots, and responsive sizing.
|
||||
|
||||
- Mount: `data-component="Modal"`
|
||||
- Props: `open: boolean = false`, `defaultOpen: boolean = false`, `title: string = "Modal"`, `description: string = ""`, `icon: string = ""`, `label: string = "Modal dialog"`, `size: string = "md"`, `placement: string = "center"`, `color: string = "primary"`, `variant: string = "default"`, `showClose: boolean = true`, `closeLabel: string = "Close modal"`, `closeOnBackdrop: boolean = true`, `closeOnEscape: boolean = true`, `closeOnCancel: boolean = true`, `closeOnConfirm: boolean = false`, `showFooter: boolean = true`, `cancelLabel: string = "Cancel"`, `cancelIcon: string = ""`, `confirmLabel: string = "Confirm"`, `confirmIcon: string = ""`, `confirmDisabled: boolean = false`, `confirmLoading: boolean = false`, `destructive: boolean = false`, `triggerLabel: string = ""`, `triggerIcon: string = ""`, `scrollable: boolean = true`, `scrollBehavior: string = "inside"`, `class: string = ""`
|
||||
- Slots: `trigger`, `header`, `default`, `footer`
|
||||
- Outputs: `open({ sourceEvent: Event })`, `close({ reason: string; sourceEvent: Event })`, `cancel({ sourceEvent: Event })`, `confirm({ sourceEvent: Event })`
|
||||
|
||||
### Popover
|
||||
|
||||
Display anchored supporting content with configurable trigger, placement, responsive sizing, and open or close events.
|
||||
|
||||
- Mount: `data-component="Popover"`
|
||||
- Props: `open: boolean = false`, `defaultOpen: boolean = false`, `triggerLabel: string = "Open popover"`, `triggerIcon: string = ""`, `title: string = ""`, `description: string = ""`, `icon: string = ""`, `placement: string = "bottom-start"`, `width: string = "md"`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "raised"`, `showArrow: boolean = true`, `showClose: boolean = false`, `closeLabel: string = "Close popover"`, `closeOnOutside: boolean = true`, `closeOnEscape: boolean = true`, `actionLabel: string = ""`, `actionHref: string = ""`, `actionIcon: string = ""`, `closeOnAction: boolean = true`, `disabled: boolean = false`, `class: string = ""`
|
||||
- Slots: `trigger`, `header`, `default`, `footer`
|
||||
- Outputs: `toggle({ open: boolean; sourceEvent: Event; reason?: object })`, `open({ sourceEvent: Event })`, `close({ reason: string; sourceEvent: Event })`, `action({ href: string; sourceEvent: Event })`
|
||||
|
||||
### Tooltip
|
||||
|
||||
Show concise accessible contextual help on hover, focus, click, or controlled open state.
|
||||
|
||||
- Mount: `data-component="Tooltip"`
|
||||
- Props: `id: string = ""`, `open: boolean = false`, `defaultOpen: boolean = false`, `title: string = ""`, `description: string = ""`, `content: string = "Tooltip"`, `trigger: string = "hover"`, `placement: string = "top"`, `size: string = "default"`, `color: string = "primary"`, `variant: string = "dark"`, `maxWidth: string = "18rem"`, `offset: number = 10`, `showArrow: boolean = true`, `interactive: boolean = false`, `disabled: boolean = false`, `class: string = ""`
|
||||
- Slots: `trigger`, `default`
|
||||
- Outputs: `toggle({ open: boolean; reason: string; sourceEvent: Event })`, `open({ reason: string; sourceEvent: Event })`, `close({ reason: string; sourceEvent: Event })`
|
||||
|
||||
## Tables
|
||||
|
||||
### DataTable
|
||||
|
||||
Sortable, filterable, paginated data table with row selection.
|
||||
|
||||
- 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({ 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 })`
|
||||
+98
-34
@@ -1,6 +1,6 @@
|
||||
# @wrnexus/ui
|
||||
|
||||
> First-party Wire UI component library — a set of themeable `.wrn` components plus a single tokenized stylesheet.
|
||||
> First-party WrNexus UI component library — a set of themeable `.wrn` components plus a single tokenized stylesheet.
|
||||
|
||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
||||
|
||||
@@ -11,10 +11,15 @@ controls, and feedback UI) together with one themeable stylesheet, `ui.css`. The
|
||||
components are **auto-discovered** by the framework router — you don't import them
|
||||
in code. Once the package's component directory is on the router's scan path, you
|
||||
mount any component in a page with `data-component="<name>"`. Every visual is
|
||||
driven by `var(--wire-*)` theme tokens, so components restyle instantly when the
|
||||
driven by `var(--wrn-*)` theme tokens, so components restyle instantly when the
|
||||
theme changes. The tiny JS surface (`src/index.ts`) exists only so the toolchain
|
||||
(CLI build + dev server) can locate the component directory and stylesheet.
|
||||
|
||||
The complete PDF-aligned catalog currently contains **85 components**. The
|
||||
generated `COMPONENTS.md` and `component-reference.json` files document every
|
||||
mount name, prop, inferred type, default/required status, slot, event, category,
|
||||
and source file directly from the packaged `.wrn` source.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
@@ -25,15 +30,16 @@ bun add @wrnexus/ui
|
||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
||||
|
||||
In practice you rarely install this directly: `@wrnexus/cli` and
|
||||
`@wrnexus/dev-server` already depend on it and wire it into the router for you
|
||||
`@wrnexus/dev-server` already depend on it and wrn it into the router for you
|
||||
(see [Auto-discovery](#auto-discovery)).
|
||||
|
||||
## Components
|
||||
|
||||
Components live as `.wrn` files under `packages/ui/components/`. The mount name
|
||||
is the **lowercase file basename** (e.g. `button.wrn` → `data-component="button"`).
|
||||
Each accepts a `class` prop (appended to its root element) and most render their
|
||||
body from either a named prop or the default slot.
|
||||
Components live as `.wrn` files under `packages/ui/components/`. The canonical mount
|
||||
name comes from the component declaration (for example, `component Button` mounts as
|
||||
`data-component="Button"`). Component lookup is case-insensitive, so existing lowercase
|
||||
mounts continue to work. Each component accepts a `class` prop (appended to its root
|
||||
element), and most render their body from either a named prop or the default slot.
|
||||
|
||||
### Layout
|
||||
|
||||
@@ -48,40 +54,55 @@ body from either a named prop or the default slot.
|
||||
|
||||
### Core / feedback
|
||||
|
||||
| Name | Purpose | Key props |
|
||||
| -------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `button` | Button | `label`, `variant` (`default`\|`primary`\|`danger`\|`ghost`), `size` (`sm`\|`md`\|`lg`), `type` |
|
||||
| `input` | Text input | see source |
|
||||
| `textarea` | Multi-line input | see source |
|
||||
| `checkbox` | Checkbox | see source |
|
||||
| `badge` | Small status badge | `label`, `variant` |
|
||||
| `alert` | Callout box | `variant` (`info`\|`success`\|`danger`\|`warning`), `title`, `message` |
|
||||
| `card` | Padded, bordered surface | `class` |
|
||||
| `avatar` | User avatar | see source |
|
||||
| `spinner` | Loading indicator | see source |
|
||||
| `disclosure` | Expandable details/summary | see source |
|
||||
| `theme-toggle` | Theme switch button (binds `data-wire-theme-toggle`) | `label` |
|
||||
| Name | Purpose | Key props |
|
||||
| -------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `button` | Button | `label`, `variant` (`default`\|`primary`\|`danger`\|`ghost`), `size` (`sm`\|`md`\|`lg`), `type` |
|
||||
| `input` | Text input | see source |
|
||||
| `textarea` | Multi-line input | see source |
|
||||
| `checkbox` | Checkbox | see source |
|
||||
| `badge` | Small status badge | `label`, `variant` |
|
||||
| `alert` | Callout box | `variant` (`info`\|`success`\|`danger`\|`warning`), `title`, `message` |
|
||||
| `card` | Padded, bordered surface | `class` |
|
||||
| `avatar` | User avatar | see source |
|
||||
| `spinner` | Loading indicator | see source |
|
||||
| `disclosure` | Expandable details/summary | see source |
|
||||
| `theme-toggle` | Theme switch button (binds `data-wrn-theme-toggle`) | `label` |
|
||||
|
||||
### Additional controls & data display
|
||||
|
||||
Also shipped: `select`, `radio`, `switch`, `progress`, `tag`, `skeleton`,
|
||||
`tooltip`, and `table`.
|
||||
`tooltip`, `table`, `FAQAccordion`, `AnnouncementBar`, and `BackToTop`.
|
||||
|
||||
The PDF-defined minimum release and essential build-first set also includes
|
||||
typed typography, form primitives, loading actions, combobox and multi-select,
|
||||
time/date-time and recurring schedule controls, confirmation dialogs, data
|
||||
tables, filters, desktop/mobile navigation, mega menus, marketing/product/legal
|
||||
page shells, product and metric cards, FAQ composition, pricing comparison,
|
||||
SDK tabs, legal navigation, and cookie preferences.
|
||||
|
||||
`Seo` and `StructuredData` remain framework/page concerns rather than body
|
||||
components: use the native page `seo { ... }` block and document-head APIs so
|
||||
metadata is emitted in `<head>` instead of invalid component markup.
|
||||
|
||||
The authoritative, always-current list is `uiComponentNames()` (below), which reads
|
||||
the component directory at runtime.
|
||||
|
||||
For the full catalog, see [`COMPONENTS.md`](./COMPONENTS.md). The machine-readable
|
||||
equivalent is exported as `@wrnexus/ui/component-reference.json`.
|
||||
|
||||
## API
|
||||
|
||||
The JS module (`@wrnexus/ui`) exposes four helpers used by the build tooling to
|
||||
The JS module (`@wrnexus/ui`) exposes five helpers used by the build tooling to
|
||||
locate the component assets. There is no component code to import — the components
|
||||
are `.wrn` files rendered server-side.
|
||||
|
||||
| Export | Signature | Returns |
|
||||
| ------------------ | ---------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `uiComponentsDir` | `() => string` | Absolute path to the `.wrn` component directory (feed to `buildRouter`'s `componentDirs`). |
|
||||
| `uiCssPath` | `() => string` | Absolute path to `ui.css`. |
|
||||
| `uiCss` | `() => string` | The `ui.css` file contents (all `.wire-*` classes, themed via tokens). |
|
||||
| `uiComponentNames` | `() => string[]` | Sorted list of built-in component names (e.g. for `wrnexus eject` listing). |
|
||||
| Export | Signature | Returns |
|
||||
| ------------------ | -------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `uiComponentsDir` | `() => string` | Absolute path to the `.wrn` component directory (feed to `buildRouter`'s `componentDirs`). |
|
||||
| `uiCssPath` | `() => string` | Absolute path to `ui.css`. |
|
||||
| `uiCss` | `() => string` | The `ui.css` file contents (all `.wrn-*` classes, themed via tokens). |
|
||||
| `uiComponentNames` | `() => string[]` | Sorted list of declared built-in component names. |
|
||||
| `uiComponentPath` | `(name: string) => string` | Absolute source path for a declared component name or case-insensitive alias. |
|
||||
|
||||
### `./ui.css` asset export
|
||||
|
||||
@@ -97,6 +118,49 @@ are `.wrn` files rendered server-side.
|
||||
The framework serves this stylesheet once at `/__wrnexus/ui.css`, so pages get all
|
||||
component styles from a single request.
|
||||
|
||||
### Component styles and motion
|
||||
|
||||
Components own their BEM styles in local `style {}` blocks and do not require a
|
||||
Tailwind scan. `ui.css` supplies only global tokens, resets, and shared
|
||||
primitives. Applications can still use Tailwind independently in their own
|
||||
source files.
|
||||
|
||||
The shared stylesheet gives all component boundaries consistent, GPU-friendly
|
||||
entry and interaction motion. Override `--wrn-motion-fast`,
|
||||
`--wrn-motion-base`, `--wrn-motion-slow`, `--wrn-ease-standard`, or
|
||||
`--wrn-ease-emphasized` to tune it. Hover lift is limited to precise pointing
|
||||
devices and `prefers-reduced-motion` is honored automatically.
|
||||
|
||||
### Using the selected theme in application UI
|
||||
|
||||
The active theme and palette are not limited to `@wrnexus/ui` components. The
|
||||
framework exposes the resolved values as semantic CSS custom properties, so
|
||||
pages and custom `.wrn` components can use the same contract:
|
||||
|
||||
```css
|
||||
.account-card {
|
||||
background: var(--wrn-color-surface);
|
||||
color: var(--wrn-color-text);
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
}
|
||||
|
||||
.account-card__action {
|
||||
background: var(--wrn-color-primary);
|
||||
color: var(--wrn-color-primary-contrast);
|
||||
}
|
||||
```
|
||||
|
||||
Stable no-spacing helper classes are also available: `wrn-bg-page`,
|
||||
`wrn-bg-surface`, `wrn-bg-surface-2`, `wrn-bg-primary`, `wrn-bg-secondary`,
|
||||
`wrn-text`, `wrn-text-muted`, `wrn-text-primary`, `wrn-text-success`,
|
||||
`wrn-text-warning`, `wrn-text-danger`, and `wrn-border`.
|
||||
|
||||
Tailwind-authored custom markup can continue using the palette families already
|
||||
used by packaged components. `indigo-*` and `violet-*` resolve to primary,
|
||||
`blue-*` to info, `emerald-*`/`green-*` to success, `amber-*` to warning, and
|
||||
`red-*`/`rose-*` to danger. These aliases live at `:root`, so they work outside
|
||||
a `[data-component]` boundary too.
|
||||
|
||||
## Usage
|
||||
|
||||
### Auto-discovery
|
||||
@@ -105,7 +169,7 @@ The router scans extra `componentDirs` (in addition to the app's own
|
||||
`app/components`) and keys components by name. Library dirs are scanned **first**
|
||||
and `app/components` **last**, so an app component of the same name shadows the
|
||||
library's. The CLI build (`@wrnexus/cli`) and dev server (`@wrnexus/dev-server`)
|
||||
both wire the UI directory in for you:
|
||||
both wrn the UI directory in for you:
|
||||
|
||||
```ts
|
||||
import { buildRouter } from "@wrnexus/router";
|
||||
@@ -131,11 +195,11 @@ attributes (other than `data-component`) become string props:
|
||||
|
||||
Ways to customize the components, in increasing order of power:
|
||||
|
||||
1. **Theme tokens** — override CSS custom properties such as `--wire-color-primary`,
|
||||
`--wire-color-surface`, `--wire-radius-sm`, etc. Every component style resolves
|
||||
through `var(--wire-*)`, so changing a token restyles everything instantly
|
||||
1. **Theme tokens** — override CSS custom properties such as `--wrn-color-primary`,
|
||||
`--wrn-color-surface`, `--wrn-radius-sm`, etc. Every component style resolves
|
||||
through `var(--wrn-*)`, so changing a token restyles everything instantly
|
||||
(including across theme switches).
|
||||
2. **App CSS** — redefine a `.wire-*` class in your own stylesheet, which is loaded
|
||||
2. **App CSS** — redefine a `.wrn-*` class in your own stylesheet, which is loaded
|
||||
after `ui.css` and therefore wins.
|
||||
3. **`class` prop** — pass a `class` prop to a component; it is appended to the
|
||||
component's root element, letting you add per-instance classes without touching
|
||||
@@ -153,4 +217,4 @@ Ways to customize the components, in increasing order of power:
|
||||
[`@wrnexus/dev-server`](../dev-server) / built by [`@wrnexus/cli`](../cli).
|
||||
- Depends on [`@wrnexus/core`](../core) (`dependencies`).
|
||||
- `theme-toggle` relies on the framework's theme runtime, which binds the
|
||||
`data-wire-theme-toggle` attribute — no per-component JS is required.
|
||||
`data-wrn-theme-toggle` attribute — no per-component JS is required.
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
{
|
||||
"sourceDocuments": ["Screenshot-directed WRNexus UI catalog reset"],
|
||||
"components": [
|
||||
{
|
||||
"name": "Accordion",
|
||||
"category": "base",
|
||||
"purpose": "Theme-aware, responsive accordion component."
|
||||
},
|
||||
{
|
||||
"name": "AdvancedSelect",
|
||||
"category": "advanced-forms",
|
||||
"purpose": "Theme-aware, responsive advanced select component."
|
||||
},
|
||||
{
|
||||
"name": "Alert",
|
||||
"category": "base",
|
||||
"purpose": "Theme-aware, responsive alert component."
|
||||
},
|
||||
{
|
||||
"name": "AnnouncementBar",
|
||||
"category": "marketing",
|
||||
"purpose": "Publish a responsive notice with badge, icon, supporting copy, action, dismiss behavior, and width controls."
|
||||
},
|
||||
{
|
||||
"name": "AuthForm",
|
||||
"category": "core",
|
||||
"purpose": "Reusable auth form component."
|
||||
},
|
||||
{
|
||||
"name": "AuthSplitLayout",
|
||||
"category": "core",
|
||||
"purpose": "Reusable auth split layout component."
|
||||
},
|
||||
{
|
||||
"name": "Avatar",
|
||||
"category": "base",
|
||||
"purpose": "Theme-aware, responsive avatar component."
|
||||
},
|
||||
{
|
||||
"name": "AvatarGroup",
|
||||
"category": "base",
|
||||
"purpose": "Theme-aware, responsive avatar group component."
|
||||
},
|
||||
{
|
||||
"name": "BackToTop",
|
||||
"category": "navigation",
|
||||
"purpose": "Provide a responsive floating control that returns long pages to the top and can show scroll progress."
|
||||
},
|
||||
{
|
||||
"name": "Badge",
|
||||
"category": "base",
|
||||
"purpose": "Theme-aware, responsive badge component."
|
||||
},
|
||||
{
|
||||
"name": "Blockquote",
|
||||
"category": "base",
|
||||
"purpose": "Theme-aware, responsive blockquote component."
|
||||
},
|
||||
{
|
||||
"name": "Breadcrumb",
|
||||
"category": "navigation",
|
||||
"purpose": "Show responsive hierarchical navigation with home support, separators, current-page state, sizes, and selection events."
|
||||
},
|
||||
{
|
||||
"name": "Button",
|
||||
"category": "base",
|
||||
"purpose": "Theme-aware, responsive button component."
|
||||
},
|
||||
{
|
||||
"name": "ButtonGroup",
|
||||
"category": "base",
|
||||
"purpose": "Theme-aware, responsive button group component."
|
||||
},
|
||||
{
|
||||
"name": "CTASection",
|
||||
"category": "marketing",
|
||||
"purpose": "Close a page or major section with conversion-focused copy, actions, and optional supporting visual content."
|
||||
},
|
||||
{
|
||||
"name": "Card",
|
||||
"category": "base",
|
||||
"purpose": "Group related content in a responsive themed surface with title, description, content, and supporting slots."
|
||||
},
|
||||
{
|
||||
"name": "Carousel",
|
||||
"category": "base",
|
||||
"purpose": "Theme-aware, responsive carousel component."
|
||||
},
|
||||
{
|
||||
"name": "Chart",
|
||||
"category": "integrations",
|
||||
"purpose": "Theme-aware, responsive chart component."
|
||||
},
|
||||
{
|
||||
"name": "ChatBubble",
|
||||
"category": "base",
|
||||
"purpose": "Theme-aware, responsive chat bubble component."
|
||||
},
|
||||
{
|
||||
"name": "Checkbox",
|
||||
"category": "forms",
|
||||
"purpose": "Theme-aware, responsive checkbox component."
|
||||
},
|
||||
{
|
||||
"name": "Clipboard",
|
||||
"category": "integrations",
|
||||
"purpose": "Theme-aware, responsive clipboard component."
|
||||
},
|
||||
{
|
||||
"name": "Collapse",
|
||||
"category": "base",
|
||||
"purpose": "Theme-aware, responsive collapse component."
|
||||
},
|
||||
{
|
||||
"name": "ColorPicker",
|
||||
"category": "forms",
|
||||
"purpose": "Theme-aware, responsive color picker component."
|
||||
},
|
||||
{
|
||||
"name": "Columns",
|
||||
"category": "layout",
|
||||
"purpose": "Create responsive balanced content columns with configurable count, gap, density, and maximum width."
|
||||
},
|
||||
{
|
||||
"name": "ComboBox",
|
||||
"category": "advanced-forms",
|
||||
"purpose": "Editable autocomplete combobox with local and remote suggestions."
|
||||
},
|
||||
{
|
||||
"name": "Confetti",
|
||||
"category": "integrations",
|
||||
"purpose": "Theme-aware, responsive confetti component."
|
||||
},
|
||||
{
|
||||
"name": "Container",
|
||||
"category": "layout",
|
||||
"purpose": "Constrain and align page content with responsive gutters and compact, wide, or full width options."
|
||||
},
|
||||
{
|
||||
"name": "ContextMenu",
|
||||
"category": "overlays",
|
||||
"purpose": "Open an accessible keyboard-aware action menu from pointer or keyboard context interactions."
|
||||
},
|
||||
{
|
||||
"name": "CustomScrollbar",
|
||||
"category": "layout",
|
||||
"purpose": "Theme-aware, responsive custom scrollbar component."
|
||||
},
|
||||
{
|
||||
"name": "DataMap",
|
||||
"category": "integrations",
|
||||
"purpose": "Theme-aware, responsive data map component."
|
||||
},
|
||||
{
|
||||
"name": "DataTable",
|
||||
"category": "tables",
|
||||
"purpose": "Sortable, filterable, paginated data table with row selection."
|
||||
},
|
||||
{
|
||||
"name": "DatePicker",
|
||||
"category": "base",
|
||||
"purpose": "Theme-aware, responsive date picker component."
|
||||
},
|
||||
{
|
||||
"name": "DeviceFrame",
|
||||
"category": "base",
|
||||
"purpose": "Theme-aware, responsive device frame component."
|
||||
},
|
||||
{
|
||||
"name": "Divider",
|
||||
"category": "layout",
|
||||
"purpose": "Separate related horizontal or vertical content with optional labels, sizes, and semantic colors."
|
||||
},
|
||||
{
|
||||
"name": "DragAndDrop",
|
||||
"category": "integrations",
|
||||
"purpose": "Theme-aware, responsive drag and drop component."
|
||||
},
|
||||
{
|
||||
"name": "Drawer",
|
||||
"category": "overlays",
|
||||
"purpose": "Present responsive modal side or bottom content with focus management, backdrop behavior, slots, and close events."
|
||||
},
|
||||
{
|
||||
"name": "Dropdown",
|
||||
"category": "overlays",
|
||||
"purpose": "Open an accessible anchored menu with keyboard navigation, item selection, actions, and responsive placement."
|
||||
},
|
||||
{
|
||||
"name": "FeatureCard",
|
||||
"category": "marketing",
|
||||
"purpose": "Present one linked feature or service with media, icon, badge, description, and action."
|
||||
},
|
||||
{
|
||||
"name": "FeatureGrid",
|
||||
"category": "layout",
|
||||
"purpose": "Arrange feature cards, services, solutions, or benefits in a responsive equal-height grid."
|
||||
},
|
||||
{
|
||||
"name": "FeatureIconCard",
|
||||
"category": "marketing",
|
||||
"purpose": "Present a compact feature or benefit with a styled icon, title, description, badge, and optional link."
|
||||
},
|
||||
{
|
||||
"name": "FileInput",
|
||||
"category": "forms",
|
||||
"purpose": "Theme-aware, responsive file input component."
|
||||
},
|
||||
{
|
||||
"name": "FileUploadProgress",
|
||||
"category": "base",
|
||||
"purpose": "Theme-aware, responsive file upload progress component."
|
||||
},
|
||||
{
|
||||
"name": "Footer",
|
||||
"category": "layout",
|
||||
"purpose": "Render structured responsive footer navigation, pre and post content, copyright content, links, and public events."
|
||||
},
|
||||
{
|
||||
"name": "Grid",
|
||||
"category": "layout",
|
||||
"purpose": "Arrange arbitrary content in a responsive configurable CSS grid with stable columns, gaps, alignment, and width."
|
||||
},
|
||||
{
|
||||
"name": "Hero",
|
||||
"category": "marketing",
|
||||
"purpose": "Build a full-width responsive hero with constrained content, actions, trust signals, and a structured or custom visual panel."
|
||||
},
|
||||
{
|
||||
"name": "HeroActions",
|
||||
"category": "marketing",
|
||||
"purpose": "Group hero and campaign actions with consistent alignment, orientation, sizing, and responsive mobile stacking."
|
||||
},
|
||||
{
|
||||
"name": "Image",
|
||||
"category": "layout",
|
||||
"purpose": "Render a responsive image with explicit dimensions, loading behavior, alternative text, sizing, and rounded treatment."
|
||||
},
|
||||
{
|
||||
"name": "Input",
|
||||
"category": "forms",
|
||||
"purpose": "Theme-aware, responsive input component."
|
||||
},
|
||||
{
|
||||
"name": "InputGroup",
|
||||
"category": "forms",
|
||||
"purpose": "Theme-aware, responsive input group component."
|
||||
},
|
||||
{
|
||||
"name": "InputNumber",
|
||||
"category": "advanced-forms",
|
||||
"purpose": "Theme-aware, responsive input number component."
|
||||
},
|
||||
{
|
||||
"name": "Kbd",
|
||||
"category": "layout",
|
||||
"purpose": "Theme-aware, responsive kbd component."
|
||||
},
|
||||
{
|
||||
"name": "LayoutSplitter",
|
||||
"category": "layout",
|
||||
"purpose": "Theme-aware, responsive layout splitter component."
|
||||
},
|
||||
{
|
||||
"name": "LegendIndicator",
|
||||
"category": "base",
|
||||
"purpose": "Theme-aware, responsive legend indicator component."
|
||||
},
|
||||
{
|
||||
"name": "Link",
|
||||
"category": "layout",
|
||||
"purpose": "Render an accessible internal or external link with target, relation, size, color, and public focus or click events."
|
||||
},
|
||||
{
|
||||
"name": "List",
|
||||
"category": "base",
|
||||
"purpose": "Present structured responsive linked or status items with icons, descriptions, actions, and selection events."
|
||||
},
|
||||
{
|
||||
"name": "ListGroup",
|
||||
"category": "base",
|
||||
"purpose": "Theme-aware, responsive list group component."
|
||||
},
|
||||
{
|
||||
"name": "Map",
|
||||
"category": "integrations",
|
||||
"purpose": "Present responsive location information and markers with map-ready metadata and movement or marker events."
|
||||
},
|
||||
{
|
||||
"name": "MarketingSectionHeader",
|
||||
"category": "marketing",
|
||||
"purpose": "Introduce marketing content with an eyebrow, title, description, and optional linked action."
|
||||
},
|
||||
{
|
||||
"name": "Marquee",
|
||||
"category": "base",
|
||||
"purpose": "Continuously present responsive labels, partners, notices, or capabilities with pause and resume behavior."
|
||||
},
|
||||
{
|
||||
"name": "MegaMenu",
|
||||
"category": "navigation",
|
||||
"purpose": "Theme-aware, responsive mega menu component."
|
||||
},
|
||||
{
|
||||
"name": "MetricCard",
|
||||
"category": "data",
|
||||
"purpose": "Display one operational metric with value, suffix, description, icon, trend, progress, and optional action."
|
||||
},
|
||||
{
|
||||
"name": "MetricGrid",
|
||||
"category": "data",
|
||||
"purpose": "Arrange operational metrics, KPIs, public statistics, or service indicators in a responsive equal-height grid."
|
||||
},
|
||||
{
|
||||
"name": "Modal",
|
||||
"category": "overlays",
|
||||
"purpose": "Present an accessible modal dialog with focus management, confirmation, cancellation, slots, and responsive sizing."
|
||||
},
|
||||
{
|
||||
"name": "Nav",
|
||||
"category": "navigation",
|
||||
"purpose": "Theme-aware, responsive nav component."
|
||||
},
|
||||
{
|
||||
"name": "Navbar",
|
||||
"category": "navigation",
|
||||
"purpose": "Theme-aware, responsive navbar component."
|
||||
},
|
||||
{
|
||||
"name": "PageHeader",
|
||||
"category": "marketing",
|
||||
"purpose": "Introduce an internal or public page with breadcrumbs, icon, title, description, and primary or secondary actions."
|
||||
},
|
||||
{
|
||||
"name": "Pagination",
|
||||
"category": "navigation",
|
||||
"purpose": "Theme-aware, responsive pagination component."
|
||||
},
|
||||
{
|
||||
"name": "PinInput",
|
||||
"category": "advanced-forms",
|
||||
"purpose": "Secure multi-cell PIN and verification-code input with regex and paste support."
|
||||
},
|
||||
{
|
||||
"name": "Popover",
|
||||
"category": "overlays",
|
||||
"purpose": "Display anchored supporting content with configurable trigger, placement, responsive sizing, and open or close events."
|
||||
},
|
||||
{
|
||||
"name": "PortalDashboard",
|
||||
"category": "core",
|
||||
"purpose": "Reusable portal dashboard component."
|
||||
},
|
||||
{
|
||||
"name": "PreferenceSwitcher",
|
||||
"category": "core",
|
||||
"purpose": "Reusable preference switcher component."
|
||||
},
|
||||
{
|
||||
"name": "Progress",
|
||||
"category": "base",
|
||||
"purpose": "Theme-aware, responsive progress component."
|
||||
},
|
||||
{
|
||||
"name": "PublicPageShell",
|
||||
"category": "layout",
|
||||
"purpose": "Provide the outer responsive structure, width, background, slots, overflow, and minimum-height behavior for public pages."
|
||||
},
|
||||
{
|
||||
"name": "Radio",
|
||||
"category": "forms",
|
||||
"purpose": "Theme-aware, responsive radio component."
|
||||
},
|
||||
{
|
||||
"name": "RangeSlider",
|
||||
"category": "forms",
|
||||
"purpose": "Theme-aware, responsive range slider component."
|
||||
},
|
||||
{
|
||||
"name": "Rating",
|
||||
"category": "base",
|
||||
"purpose": "Theme-aware, responsive rating component."
|
||||
},
|
||||
{
|
||||
"name": "Scrollspy",
|
||||
"category": "navigation",
|
||||
"purpose": "Theme-aware, responsive scrollspy component."
|
||||
},
|
||||
{
|
||||
"name": "SearchBox",
|
||||
"category": "forms",
|
||||
"purpose": "Provide an accessible responsive search field with labels, validation states, sizes, and input or change events."
|
||||
},
|
||||
{
|
||||
"name": "Section",
|
||||
"category": "layout",
|
||||
"purpose": "Create a responsive themed page section with controlled spacing, width, borders, and surface treatment."
|
||||
},
|
||||
{
|
||||
"name": "SectionHeader",
|
||||
"category": "layout",
|
||||
"purpose": "Introduce a section with an eyebrow, title, description, alignment, and responsive heading hierarchy."
|
||||
},
|
||||
{
|
||||
"name": "Select",
|
||||
"category": "forms",
|
||||
"purpose": "Theme-aware, responsive select component."
|
||||
},
|
||||
{
|
||||
"name": "Sidebar",
|
||||
"category": "navigation",
|
||||
"purpose": "Theme-aware, responsive sidebar component."
|
||||
},
|
||||
{
|
||||
"name": "Skeleton",
|
||||
"category": "base",
|
||||
"purpose": "Theme-aware, responsive skeleton component."
|
||||
},
|
||||
{
|
||||
"name": "Spinner",
|
||||
"category": "base",
|
||||
"purpose": "Theme-aware, responsive spinner component."
|
||||
},
|
||||
{
|
||||
"name": "SplitHero",
|
||||
"category": "marketing",
|
||||
"purpose": "Build a responsive two-column introduction balancing descriptive content with a visual, image, or structured data panel."
|
||||
},
|
||||
{
|
||||
"name": "StatsBar",
|
||||
"category": "data",
|
||||
"purpose": "Present a compact responsive strip of key facts, counts, performance indicators, or trust signals."
|
||||
},
|
||||
{
|
||||
"name": "Stepper",
|
||||
"category": "navigation",
|
||||
"purpose": "Theme-aware, responsive stepper component."
|
||||
},
|
||||
{
|
||||
"name": "StrongPassword",
|
||||
"category": "advanced-forms",
|
||||
"purpose": "Theme-aware, responsive strong password component."
|
||||
},
|
||||
{
|
||||
"name": "StyledIcon",
|
||||
"category": "base",
|
||||
"purpose": "Theme-aware, responsive styled icon component."
|
||||
},
|
||||
{
|
||||
"name": "Switch",
|
||||
"category": "forms",
|
||||
"purpose": "Theme-aware, responsive switch component."
|
||||
},
|
||||
{
|
||||
"name": "Tabs",
|
||||
"category": "navigation",
|
||||
"purpose": "Switch between related responsive content panels with horizontal or vertical orientation and selection events."
|
||||
},
|
||||
{
|
||||
"name": "TextLink",
|
||||
"category": "marketing",
|
||||
"purpose": "Render an accessible text action with optional icon, arrow, underline, external state, and semantic styling."
|
||||
},
|
||||
{
|
||||
"name": "Textarea",
|
||||
"category": "forms",
|
||||
"purpose": "Theme-aware, responsive textarea component."
|
||||
},
|
||||
{
|
||||
"name": "TimePicker",
|
||||
"category": "forms",
|
||||
"purpose": "Theme-aware, responsive time picker component."
|
||||
},
|
||||
{
|
||||
"name": "Timeline",
|
||||
"category": "base",
|
||||
"purpose": "Present responsive chronological activity, milestones, or workflow status with rich item metadata."
|
||||
},
|
||||
{
|
||||
"name": "Toaster",
|
||||
"category": "core",
|
||||
"purpose": "Reusable toaster component."
|
||||
},
|
||||
{
|
||||
"name": "ToggleCount",
|
||||
"category": "advanced-forms",
|
||||
"purpose": "Theme-aware, responsive toggle count component."
|
||||
},
|
||||
{
|
||||
"name": "TogglePassword",
|
||||
"category": "advanced-forms",
|
||||
"purpose": "Accessible password field with optional show and hide controls."
|
||||
},
|
||||
{
|
||||
"name": "Tooltip",
|
||||
"category": "overlays",
|
||||
"purpose": "Show concise accessible contextual help on hover, focus, click, or controlled open state."
|
||||
},
|
||||
{
|
||||
"name": "TreeView",
|
||||
"category": "base",
|
||||
"purpose": "Theme-aware, responsive tree view component."
|
||||
},
|
||||
{
|
||||
"name": "Typography",
|
||||
"category": "layout",
|
||||
"purpose": "Apply consistent readable responsive typography, widths, columns, spacing, and editorial hierarchy."
|
||||
},
|
||||
{
|
||||
"name": "WysiwygEditor",
|
||||
"category": "integrations",
|
||||
"purpose": "Theme-aware, responsive wysiwyg editor component."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"generatedFrom": "framework remediation 0.8.7",
|
||||
"removedCount": 6,
|
||||
"replacements": {
|
||||
"AdvancedDatePicker": "DatePicker",
|
||||
"AdvancedRangeSlider": "RangeSlider",
|
||||
"FileUpload": "FileInput",
|
||||
"Toast": "Toaster",
|
||||
"ToastNotifications": "Toaster",
|
||||
"CopyMarkup": "Clipboard"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,478 @@
|
||||
component Accordion {
|
||||
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)
|
||||
open(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
close(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
variant: string = "default"
|
||||
class: string = ""
|
||||
|
||||
id: string = "accordion"
|
||||
items: unknown[] = []
|
||||
defaultOpen: unknown[] = []
|
||||
multiple: boolean = false
|
||||
alwaysOpen: boolean = false
|
||||
disabled: boolean = false
|
||||
|
||||
indicator: string = "plus"
|
||||
indicatorPosition: string = "start"
|
||||
showIndicator: boolean = true
|
||||
bordered: boolean = false
|
||||
separated: boolean = false
|
||||
flush: boolean = false
|
||||
contentItalic: boolean = false
|
||||
|
||||
}
|
||||
|
||||
state openValues = defaultOpen
|
||||
|
||||
functions {
|
||||
shared function itemValue(item, index) {
|
||||
return item.value !== undefined && item.value !== ""
|
||||
? String(item.value)
|
||||
: String(index)
|
||||
}
|
||||
|
||||
shared function nestedValue(parent, parentIndex, item, index) {
|
||||
return itemValue(parent, parentIndex) + "." + itemValue(item, index)
|
||||
}
|
||||
|
||||
shared function isOpen(value) {
|
||||
return openValues.includes(value)
|
||||
}
|
||||
|
||||
shared function allowsMultiple() {
|
||||
return multiple || alwaysOpen
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
if (eventName === "open") {
|
||||
output.open(payload)
|
||||
} else if (eventName === "close") {
|
||||
output.close(payload)
|
||||
} else {
|
||||
output.change(payload)
|
||||
}
|
||||
}
|
||||
|
||||
client function toggleItem(sourceEvent, value, item, wasOpen) {
|
||||
if (disabled || item.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
wasOpen = isOpen(value)
|
||||
|
||||
if (wasOpen) {
|
||||
openValues = openValues.filter((entry) => entry !== value)
|
||||
} else if (allowsMultiple()) {
|
||||
openValues = openValues.concat([value])
|
||||
} else {
|
||||
openValues = [value]
|
||||
}
|
||||
|
||||
dispatchAccordionEvent(
|
||||
sourceEvent,
|
||||
wasOpen ? "close" : "open",
|
||||
value,
|
||||
item
|
||||
)
|
||||
dispatchAccordionEvent(sourceEvent, "change", value, item)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
view {
|
||||
<div
|
||||
{...attrs}
|
||||
id="{id}"
|
||||
data-wrn-accordion
|
||||
data-variant="{variant}"
|
||||
data-indicator="{indicator}"
|
||||
data-multiple="{allowsMultiple() ? 'true' : 'false'}"
|
||||
class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--accordion wrn-next--accordion-{variant} {bordered ? 'wrn-next--accordion-bordered' : ''} {separated ? 'wrn-next--accordion-separated' : ''} {flush ? 'wrn-next--accordion-flush' : ''} {disabled ? 'wrn-next--disabled' : ''} {class}"
|
||||
>
|
||||
{#each items as item, index}
|
||||
<section
|
||||
class="wrn-next__accordion-item"
|
||||
data-open="{isOpen(itemValue(item, index)) ? 'true' : 'false'}"
|
||||
data-disabled="{disabled || item.disabled ? 'true' : 'false'}"
|
||||
>
|
||||
<h3 class="wrn-next__accordion-heading">
|
||||
<button
|
||||
type="button"
|
||||
id="{id}-trigger-{index}"
|
||||
aria-expanded="{isOpen(itemValue(item, index)) ? 'true' : 'false'}"
|
||||
aria-controls="{id}-panel-{index}"
|
||||
disabled="{disabled || item.disabled}"
|
||||
@click="toggleItem(event, itemValue(item, index), item)"
|
||||
>
|
||||
{#if showIndicator && indicatorPosition === "start"}
|
||||
<span class="wrn-next__accordion-indicator" aria-hidden="true">
|
||||
{#if indicator === "chevron"}
|
||||
<span class="icon-[lucide--chevron-down]"></span>
|
||||
{:else}
|
||||
<span>+</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
<span>{item.label || item.title}</span>
|
||||
{#if showIndicator && indicatorPosition === "end"}
|
||||
<span class="wrn-next__accordion-indicator" aria-hidden="true">
|
||||
{#if indicator === "chevron"}
|
||||
<span class="icon-[lucide--chevron-down]"></span>
|
||||
{:else}
|
||||
<span>+</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</h3>
|
||||
|
||||
<div
|
||||
id="{id}-panel-{index}"
|
||||
class="wrn-next__accordion-panel"
|
||||
role="region"
|
||||
aria-labelledby="{id}-trigger-{index}"
|
||||
aria-hidden="{isOpen(itemValue(item, index)) ? 'false' : 'true'}"
|
||||
>
|
||||
<div>
|
||||
<div class="wrn-next__accordion-content {contentItalic ? 'wrn-next__accordion-content-italic' : ''}">
|
||||
{#if item.content}<p>{item.content}</p>{/if}
|
||||
|
||||
{#if item.children && item.children.length > 0}
|
||||
<div class="wrn-next__accordion-nested">
|
||||
{#each item.children as child, childIndex}
|
||||
<section
|
||||
class="wrn-next__accordion-item"
|
||||
data-open="{isOpen(nestedValue(item, index, child, childIndex)) ? 'true' : 'false'}"
|
||||
>
|
||||
<h4 class="wrn-next__accordion-heading">
|
||||
<button
|
||||
type="button"
|
||||
id="{id}-trigger-{index}-{childIndex}"
|
||||
aria-expanded="{isOpen(nestedValue(item, index, child, childIndex)) ? 'true' : 'false'}"
|
||||
aria-controls="{id}-panel-{index}-{childIndex}"
|
||||
disabled="{disabled || child.disabled}"
|
||||
@click="toggleItem(event, nestedValue(item, index, child, childIndex), child)"
|
||||
>
|
||||
{#if showIndicator}
|
||||
<span class="wrn-next__accordion-indicator" aria-hidden="true">
|
||||
{#if indicator === "chevron"}
|
||||
<span class="icon-[lucide--chevron-down]"></span>
|
||||
{:else}
|
||||
<span>+</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
<span>{child.label || child.title}</span>
|
||||
</button>
|
||||
</h4>
|
||||
<div
|
||||
id="{id}-panel-{index}-{childIndex}"
|
||||
class="wrn-next__accordion-panel"
|
||||
role="region"
|
||||
aria-labelledby="{id}-trigger-{index}-{childIndex}"
|
||||
aria-hidden="{isOpen(nestedValue(item, index, child, childIndex)) ? 'false' : 'true'}"
|
||||
>
|
||||
<div>
|
||||
<div class="wrn-next__accordion-content {contentItalic ? 'wrn-next__accordion-content-italic' : ''}">
|
||||
<p>{child.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-next--accordion {
|
||||
--wrn-accordion-accent: var(--wrn-component-color);
|
||||
display: grid;
|
||||
width: 100%;
|
||||
color: var(--wrn-color-text);
|
||||
}
|
||||
|
||||
.wrn-next__accordion-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wrn-next__accordion-heading {
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.wrn-next__accordion-heading > button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--wrn-color-text);
|
||||
font: inherit;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wrn-next__accordion-heading > button > span:not(.wrn-next__accordion-indicator) {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.wrn-next__accordion-heading > button:focus-visible {
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
outline: 2px solid var(--wrn-accordion-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.wrn-next__accordion-heading > button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.wrn-next__accordion-indicator {
|
||||
display: inline-flex;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex: 0 0 1rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
transition:
|
||||
color var(--wrn-motion-fast) var(--wrn-ease-standard),
|
||||
transform var(--wrn-motion-fast) var(--wrn-ease-standard);
|
||||
}
|
||||
|
||||
.wrn-next__accordion-indicator > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wrn-next__accordion-indicator > [class*="icon-["] {
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
}
|
||||
|
||||
.wrn-next__accordion-item[data-open="true"]
|
||||
> .wrn-next__accordion-heading
|
||||
.wrn-next__accordion-indicator {
|
||||
color: var(--wrn-accordion-accent);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.wrn-next--accordion[data-indicator="chevron"]
|
||||
.wrn-next__accordion-item[data-open="true"]
|
||||
> .wrn-next__accordion-heading
|
||||
.wrn-next__accordion-indicator {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.wrn-next__accordion-item[data-open="true"] > .wrn-next__accordion-heading > button {
|
||||
color: var(--wrn-accordion-accent);
|
||||
}
|
||||
|
||||
.wrn-next__accordion-panel {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
transition:
|
||||
grid-template-rows var(--wrn-motion-base) var(--wrn-ease-standard),
|
||||
opacity var(--wrn-motion-fast) var(--wrn-ease-standard);
|
||||
}
|
||||
|
||||
.wrn-next__accordion-item[data-open="true"] > .wrn-next__accordion-panel {
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wrn-next__accordion-panel > div {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wrn-next__accordion-content {
|
||||
padding: 0 0 1rem 1.75rem;
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.wrn-next__accordion-content > p {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.wrn-next__accordion-content-italic > p::first-line {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.wrn-next__accordion-nested {
|
||||
display: grid;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.wrn-next__accordion-nested .wrn-next__accordion-content {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.wrn-next--accordion-bordered {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-md);
|
||||
background: var(--wrn-color-surface);
|
||||
}
|
||||
|
||||
.wrn-next--accordion-bordered > .wrn-next__accordion-item + .wrn-next__accordion-item {
|
||||
border-top: 1px solid var(--wrn-color-border);
|
||||
}
|
||||
|
||||
.wrn-next--accordion-bordered
|
||||
> .wrn-next__accordion-item
|
||||
> .wrn-next__accordion-heading
|
||||
> button {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.wrn-next--accordion-bordered
|
||||
> .wrn-next__accordion-item
|
||||
> .wrn-next__accordion-panel
|
||||
.wrn-next__accordion-content {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.wrn-next--accordion-separated {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.wrn-next--accordion-separated > .wrn-next__accordion-item {
|
||||
overflow: hidden;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--wrn-radius-md);
|
||||
background: var(--wrn-color-surface-2);
|
||||
}
|
||||
|
||||
.wrn-next--accordion-separated > .wrn-next__accordion-item[data-open="true"] {
|
||||
border-color: var(--wrn-color-border);
|
||||
}
|
||||
|
||||
.wrn-next--accordion-separated
|
||||
> .wrn-next__accordion-item
|
||||
> .wrn-next__accordion-heading
|
||||
> button,
|
||||
.wrn-next--accordion-separated
|
||||
> .wrn-next__accordion-item
|
||||
> .wrn-next__accordion-panel
|
||||
.wrn-next__accordion-content {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.wrn-next--accordion-flush {
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wrn-next__accordion-panel,
|
||||
.wrn-next__accordion-indicator {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shared component foundation. */
|
||||
|
||||
.wrn-component {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
--wrn-component-scale: 1;
|
||||
margin: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-family: var(--wrn-font-sans, inherit);
|
||||
}
|
||||
|
||||
.wrn-component--color-primary {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-component--color-secondary {
|
||||
--wrn-component-color: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-component--color-success {
|
||||
--wrn-component-color: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-component--color-warning {
|
||||
--wrn-component-color: var(--wrn-color-warning);
|
||||
}
|
||||
|
||||
.wrn-component--color-danger {
|
||||
--wrn-component-color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-component--color-info {
|
||||
--wrn-component-color: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-component--size-xs {
|
||||
--wrn-component-scale: 0.78;
|
||||
}
|
||||
|
||||
.wrn-component--size-sm {
|
||||
--wrn-component-scale: 0.88;
|
||||
}
|
||||
|
||||
.wrn-component--size-default,
|
||||
.wrn-component--size-md {
|
||||
--wrn-component-scale: 1;
|
||||
}
|
||||
|
||||
.wrn-component--size-lg {
|
||||
--wrn-component-scale: 1.14;
|
||||
}
|
||||
|
||||
.wrn-component--size-xl {
|
||||
--wrn-component-scale: 1.28;
|
||||
}
|
||||
|
||||
.wrn-component:not(.wrn-btn) {
|
||||
font-size: calc(1em * var(--wrn-component-scale));
|
||||
}
|
||||
|
||||
.wrn-component :is(a, button, input, select, textarea):focus-visible {
|
||||
outline-color: var(--wrn-component-color);
|
||||
}
|
||||
|
||||
.wrn-component p {
|
||||
margin: 0;
|
||||
color: var(--wrn-color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
import SelectStyles from "../styles/SelectStyles.wrn"
|
||||
|
||||
component AdvancedSelect {
|
||||
outputs {
|
||||
search(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)
|
||||
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)
|
||||
clear(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; [key: string]: string | number | boolean | null | object })
|
||||
close(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
load(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
error(payload: { error: Error | string; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | Error | string)
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
label: string = "Advanced Select"
|
||||
name: string = ""
|
||||
value: string = ""
|
||||
values: unknown[] = []
|
||||
options: unknown[] = []
|
||||
groups: unknown[] = []
|
||||
placeholder: string = "Select an option"
|
||||
placeholderIcon: string = ""
|
||||
searchPlaceholder: string = "Search options…"
|
||||
multiple: boolean = false
|
||||
searchable: boolean = true
|
||||
defaultOpen: boolean = false
|
||||
clearable: boolean = true
|
||||
allowEmpty: boolean = true
|
||||
tags: boolean = false
|
||||
disabled: boolean = false
|
||||
required: boolean = false
|
||||
invalid: boolean = false
|
||||
validationMessage: string = ""
|
||||
helpText: string = ""
|
||||
loading: boolean = false
|
||||
loadingLabel: string = "Loading options…"
|
||||
emptyLabel: string = "No options found"
|
||||
selectedOptionsLabel: string = "Selected options"
|
||||
clearLabel: string = "Clear selection"
|
||||
createLabel: string = "Create"
|
||||
loadMoreLabel: string = "Load more"
|
||||
searchMode: string = "contains"
|
||||
searchFields: string = "label,description"
|
||||
minSearchLength: number = 0
|
||||
searchResultLimit: number = 0
|
||||
maxSelections: number = 0
|
||||
showCounter: boolean = false
|
||||
counterTemplate: string = "{selected} selected"
|
||||
optionTemplate: string = "default"
|
||||
selectedTemplate: string = "default"
|
||||
closeOnSelect: boolean = true
|
||||
scrollToSelected: boolean = true
|
||||
fixed: boolean = false
|
||||
placement: string = "bottom"
|
||||
remote: boolean = false
|
||||
remoteUrl: string = ""
|
||||
remoteQueryParam: string = "q"
|
||||
remoteDebounce: number = 250
|
||||
remoteAutoLoad: boolean = true
|
||||
infinite: boolean = false
|
||||
hasMore: boolean = false
|
||||
page: number = 1
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
state open = defaultOpen
|
||||
state query: string = ""
|
||||
state activeIndex: number = -1
|
||||
state selectedValue = value
|
||||
state selectedValues = values
|
||||
|
||||
functions {
|
||||
shared function allOptions() {
|
||||
return [...groups.flatMap((group) => group.options || []), ...options]
|
||||
}
|
||||
|
||||
shared function searchableText(option) {
|
||||
return ((option.label || "") + " " + (option.description || "")).toLowerCase()
|
||||
}
|
||||
|
||||
shared function matches(option) {
|
||||
if (!query || query.length < Number(minSearchLength || 0)) {
|
||||
return true
|
||||
}
|
||||
if (searchMode === "startsWith") {
|
||||
return searchableText(option).startsWith(query.toLowerCase())
|
||||
}
|
||||
if (searchMode === "exact") {
|
||||
return searchableText(option) === query.toLowerCase()
|
||||
}
|
||||
return searchableText(option).includes(query.toLowerCase())
|
||||
}
|
||||
|
||||
shared function matchingOptions(list) {
|
||||
return list.filter((option) => matches(option))
|
||||
}
|
||||
|
||||
shared function visibleOptions(list) {
|
||||
if (searchResultLimit > 0) {
|
||||
return matchingOptions(list).slice(0, Number(searchResultLimit))
|
||||
}
|
||||
return matchingOptions(list)
|
||||
}
|
||||
|
||||
shared function flatVisibleOptions() {
|
||||
return visibleOptions(allOptions())
|
||||
}
|
||||
|
||||
shared function isSelected(option) {
|
||||
return (
|
||||
multiple
|
||||
? selectedValues.includes(option.value)
|
||||
: selectedValue === option.value
|
||||
)
|
||||
}
|
||||
|
||||
shared function selectedOptions() {
|
||||
return allOptions().filter((option) => isSelected(option))
|
||||
}
|
||||
|
||||
shared function selectedCount() {
|
||||
return multiple ? selectedValues.length : selectedValue ? 1 : 0
|
||||
}
|
||||
|
||||
shared function counterText() {
|
||||
return (
|
||||
maxSelections
|
||||
? selectedCount() + " / " + maxSelections + " selected"
|
||||
: selectedCount() + " selected"
|
||||
)
|
||||
}
|
||||
|
||||
shared function selectedText() {
|
||||
return (
|
||||
multiple
|
||||
? selectedValues.join(", ")
|
||||
: selectedOptions().length
|
||||
? selectedOptions()[0].label
|
||||
: ""
|
||||
)
|
||||
}
|
||||
|
||||
shared function triggerText() {
|
||||
return selectedCount() ? selectedText() : placeholder
|
||||
}
|
||||
|
||||
shared function canSelect(option) {
|
||||
if (option.disabled) {
|
||||
return false
|
||||
}
|
||||
if (!multiple || isSelected(option)) {
|
||||
return true
|
||||
}
|
||||
if (maxSelections <= 0) {
|
||||
return true
|
||||
}
|
||||
return selectedCount() < maxSelections
|
||||
}
|
||||
|
||||
shared function chooseSingle(option) {
|
||||
if (!canSelect(option)) {
|
||||
return
|
||||
}
|
||||
selectedValue = option.value
|
||||
query = ""
|
||||
if (closeOnSelect) {
|
||||
open = false
|
||||
}
|
||||
}
|
||||
|
||||
shared function chooseMultiple(option) {
|
||||
if (!canSelect(option)) {
|
||||
return
|
||||
}
|
||||
if (isSelected(option)) {
|
||||
selectedValues = selectedValues.filter((item) => item !== option.value)
|
||||
} else {
|
||||
selectedValues = selectedValues.concat([option.value])
|
||||
}
|
||||
query = ""
|
||||
}
|
||||
|
||||
shared function chooseOption(option) {
|
||||
if (multiple) {
|
||||
chooseMultiple(option)
|
||||
return
|
||||
}
|
||||
chooseSingle(option)
|
||||
}
|
||||
|
||||
client function clearSelection(event) {
|
||||
event.stopPropagation()
|
||||
selectedValue = ""
|
||||
selectedValues = []
|
||||
query = ""
|
||||
open = false
|
||||
}
|
||||
|
||||
shared function toggle() {
|
||||
if (disabled) {
|
||||
return;
|
||||
};
|
||||
open = !open;
|
||||
activeIndex = open && flatVisibleOptions().length ? 0 : -1;
|
||||
}
|
||||
|
||||
shared function moveActive(direction) {
|
||||
if (!flatVisibleOptions().length) {
|
||||
return
|
||||
}
|
||||
activeIndex = (activeIndex + direction + flatVisibleOptions().length) % flatVisibleOptions().length
|
||||
}
|
||||
|
||||
client function handleKeydown(event) {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
open = true
|
||||
}
|
||||
moveActive(1)
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
open = true
|
||||
}
|
||||
moveActive(-1)
|
||||
} else if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
open = true
|
||||
} else if (activeIndex >= 0) {
|
||||
chooseOption(flatVisibleOptions()[activeIndex])
|
||||
}
|
||||
} else if (event.key === "Escape") {
|
||||
open = false
|
||||
} else if (event.key === "Home" && open) {
|
||||
event.preventDefault()
|
||||
activeIndex = 0
|
||||
} else if (event.key === "End" && open) {
|
||||
event.preventDefault()
|
||||
activeIndex = flatVisibleOptions().length - 1
|
||||
}
|
||||
}
|
||||
|
||||
shared function optionIndex(option) {
|
||||
return flatVisibleOptions().findIndex((item) => item.value === option.value)
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<div
|
||||
class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--advanced-select {open ? 'wrn-next--open' : ''} {fixed ? 'wrn-next--advanced-select-fixed' : ''} {invalid ? 'wrn-next--invalid' : ''} {disabled ? 'wrn-next--disabled' : ''} {class}"
|
||||
data-placement="{placement}"
|
||||
data-search-mode="{searchMode}"
|
||||
data-remote="{remote ? 'true' : 'false'}"
|
||||
data-remote-url="{remoteUrl}"
|
||||
data-remote-query-param="{remoteQueryParam}"
|
||||
data-remote-debounce="{remoteDebounce}"
|
||||
data-remote-auto-load="{remoteAutoLoad ? 'true' : 'false'}"
|
||||
data-infinite="{infinite ? 'true' : 'false'}"
|
||||
data-page="{page}"
|
||||
data-wrn-select
|
||||
@focusout="if (!event.currentTarget.contains(event.relatedTarget)) { open = false }"
|
||||
>
|
||||
<div class="wrn-next__row">
|
||||
<label id="{name}-label" for="{name}-trigger">{label}</label>
|
||||
<small data-show="showCounter" data-text="counterText()">{counterText()}</small>
|
||||
</div>
|
||||
|
||||
<div class="wrn-next__select-control">
|
||||
<button
|
||||
{...attrs}
|
||||
id="{name}-trigger"
|
||||
type="button"
|
||||
class="wrn-next__select-trigger"
|
||||
role="combobox"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="{open}"
|
||||
aria-controls="{name}-listbox"
|
||||
aria-labelledby="{name}-label"
|
||||
aria-invalid="{invalid}"
|
||||
disabled="{disabled}"
|
||||
@click="toggle()"
|
||||
@keydown="handleKeydown(event)"
|
||||
>
|
||||
<span class="wrn-next__select-value">
|
||||
<span data-show="!multiple" data-text="triggerText()">{triggerText()}</span>
|
||||
<span
|
||||
data-show="multiple && selectedTemplate === 'count' && selectedCount() > 0"
|
||||
data-text="selectedCount() + ' selected'"
|
||||
>{selectedCount()} selected</span>
|
||||
<span
|
||||
data-show="multiple && selectedTemplate === 'text' && selectedCount() > 0"
|
||||
data-text="selectedText()"
|
||||
>{selectedText()}</span>
|
||||
<span
|
||||
class="wrn-next__select-tags"
|
||||
aria-label="{selectedOptionsLabel}"
|
||||
data-show="multiple && selectedTemplate !== 'count' && selectedTemplate !== 'text' && selectedCount() > 0"
|
||||
>
|
||||
{#each allOptions() as option}
|
||||
<span data-show="isSelected(option)">
|
||||
{#if optionTemplate === "icon" && option.icon}<i class="{option.icon}" aria-hidden="true"></i>{/if}
|
||||
{#if optionTemplate === "avatar" && option.avatar}<img src="{option.avatar}" alt="" />{/if}
|
||||
{#if optionTemplate === "color" && option.color}<i class="wrn-next__color-dot" style="--option-color: {option.color}"></i>{/if}
|
||||
{option.label}
|
||||
</span>
|
||||
{/each}
|
||||
</span>
|
||||
<span data-show="multiple && selectedCount() === 0">
|
||||
{#if placeholderIcon}<i class="{placeholderIcon}" aria-hidden="true"></i>{/if}
|
||||
<span class="wrn-next__placeholder">{placeholder}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="wrn-next__select-chevron icon-[lucide--chevrons-up-down]"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-next__clear-select"
|
||||
data-show="clearable && allowEmpty && selectedCount() > 0 && !disabled"
|
||||
aria-label="{clearLabel}"
|
||||
title="{clearLabel}"
|
||||
@click="clearSelection(event)"
|
||||
>
|
||||
<span class="icon-[lucide--x]" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="wrn-next__select-dropdown {fixed ? 'wrn-next__select-dropdown--fixed' : ''}"
|
||||
data-show="open"
|
||||
style="{open ? '' : 'display: none'}"
|
||||
>
|
||||
{#if searchable}
|
||||
<label class="wrn-next__select-search">
|
||||
<span class="wrn-visually-hidden">{searchPlaceholder}</span>
|
||||
<span class="wrn-next__search-icon" aria-hidden="true">⌕</span>
|
||||
<input
|
||||
type="search"
|
||||
value="{query}"
|
||||
placeholder="{searchPlaceholder}"
|
||||
autocomplete="off"
|
||||
@input="query = event.target.value; activeIndex = flatVisibleOptions().length ? 0 : -1"
|
||||
@keydown="handleKeydown(event)"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="wrn-next__select-message"
|
||||
data-show="remote && query.length !== 0 && query.length < minSearchLength"
|
||||
>Enter at least {minSearchLength} characters.</div>
|
||||
<div class="wrn-next__select-message" data-show="loading" role="status">
|
||||
<i class="wrn-spinner wrn-spinner--inline" aria-hidden="true"></i>
|
||||
{loadingLabel}
|
||||
</div>
|
||||
<div
|
||||
id="{name}-listbox"
|
||||
class="wrn-next__select-list"
|
||||
data-show="(!remote || (query.length === 0 && remoteAutoLoad) || query.length >= minSearchLength) && !loading"
|
||||
role="listbox"
|
||||
aria-multiselectable="{multiple}"
|
||||
>
|
||||
{#each groups as group}
|
||||
{#if visibleOptions(group.options || []).length}
|
||||
<div class="wrn-next__option-group" role="group" aria-label="{group.label}">
|
||||
<div class="wrn-next__group-label">{group.label}</div>
|
||||
{#each group.options || [] as option}
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-next__select-option {isSelected(option) ? 'wrn-next__select-option--selected' : ''} {optionIndex(option) === activeIndex ? 'wrn-next__select-option--active' : ''}"
|
||||
data-option-value="{option.value}"
|
||||
data-show="matches(option)"
|
||||
role="option"
|
||||
aria-selected="{isSelected(option)}"
|
||||
disabled="{!canSelect(option)}"
|
||||
@mouseenter="activeIndex = optionIndex(option)"
|
||||
@click="chooseOption(option)"
|
||||
>
|
||||
{#if optionTemplate === "icon" && option.icon}<i class="{option.icon}" aria-hidden="true"></i>{/if}
|
||||
{#if optionTemplate === "avatar" && option.avatar}<img src="{option.avatar}" alt="" />{/if}
|
||||
{#if optionTemplate === "color" && option.color}<i class="wrn-next__color-dot" style="--option-color: {option.color}"></i>{/if}
|
||||
<span><strong>{option.label}</strong>{#if option.description}<small>{option.description}</small>{/if}</span>
|
||||
<i
|
||||
class="wrn-next__check icon-[lucide--check]"
|
||||
data-show="isSelected(option)"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#each options as option}
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-next__select-option {isSelected(option) ? 'wrn-next__select-option--selected' : ''} {optionIndex(option) === activeIndex ? 'wrn-next__select-option--active' : ''}"
|
||||
data-option-value="{option.value}"
|
||||
data-show="matches(option)"
|
||||
role="option"
|
||||
aria-selected="{isSelected(option)}"
|
||||
disabled="{!canSelect(option)}"
|
||||
@mouseenter="activeIndex = optionIndex(option)"
|
||||
@click="chooseOption(option)"
|
||||
>
|
||||
{#if optionTemplate === "icon" && option.icon}<i class="{option.icon}" aria-hidden="true"></i>{/if}
|
||||
{#if optionTemplate === "avatar" && option.avatar}<img src="{option.avatar}" alt="" />{/if}
|
||||
{#if optionTemplate === "color" && option.color}<i class="wrn-next__color-dot" style="--option-color: {option.color}"></i>{/if}
|
||||
<span><strong>{option.label}</strong>{#if option.description}<small>{option.description}</small>{/if}</span>
|
||||
<i
|
||||
class="wrn-next__check icon-[lucide--check]"
|
||||
data-show="isSelected(option)"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if !loading && !flatVisibleOptions().length}
|
||||
<div class="wrn-next__select-message">{emptyLabel}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if tags && query && !allOptions().some((option) => option.label.toLowerCase() === query.toLowerCase())}
|
||||
<button type="button" class="wrn-next__create-option">{createLabel} “{query}”</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-next__load-more"
|
||||
data-wrn-select-load-more
|
||||
data-show="infinite && hasMore"
|
||||
>{loadMoreLabel}</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="{name}"
|
||||
value="{multiple ? selectedValues.join(',') : selectedValue}"
|
||||
required="{required}"
|
||||
aria-describedby="{validationMessage ? `${name}-validation` : helpText ? `${name}-help` : ''}"
|
||||
/>
|
||||
|
||||
{#if helpText && !validationMessage}<small id="{name}-help">{helpText}</small>{/if}
|
||||
<small
|
||||
id="{name}-validation"
|
||||
class="wrn-next__validation"
|
||||
data-error="{name}"
|
||||
>{validationMessage}</small>
|
||||
</div>
|
||||
<SelectStyles hidden />
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-next__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.wrn-next__select-search {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wrn-next__select-search input {
|
||||
padding-left: 2.25em;
|
||||
}
|
||||
|
||||
.wrn-next__select-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.wrn-next__select-tags > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
color: var(--wrn-component-color);
|
||||
background: color-mix(in srgb, var(--wrn-component-color) 12%, transparent);
|
||||
font-size: 0.75em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wrn-next__select-tags img {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.wrn-next__select-value {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.55em;
|
||||
}
|
||||
|
||||
.wrn-next__placeholder {
|
||||
overflow: hidden;
|
||||
color: var(--wrn-color-muted);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Shared component foundation. */
|
||||
|
||||
.wrn-component {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
--wrn-component-scale: 1;
|
||||
margin: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-family: var(--wrn-font-sans, inherit);
|
||||
}
|
||||
|
||||
.wrn-component--color-primary {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-component--color-secondary {
|
||||
--wrn-component-color: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-component--color-success {
|
||||
--wrn-component-color: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-component--color-warning {
|
||||
--wrn-component-color: var(--wrn-color-warning);
|
||||
}
|
||||
|
||||
.wrn-component--color-danger {
|
||||
--wrn-component-color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-component--color-info {
|
||||
--wrn-component-color: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-component--size-xs {
|
||||
--wrn-component-scale: 0.78;
|
||||
}
|
||||
|
||||
.wrn-component--size-sm {
|
||||
--wrn-component-scale: 0.88;
|
||||
}
|
||||
|
||||
.wrn-component--size-default,
|
||||
.wrn-component--size-md {
|
||||
--wrn-component-scale: 1;
|
||||
}
|
||||
|
||||
.wrn-component--size-lg {
|
||||
--wrn-component-scale: 1.14;
|
||||
}
|
||||
|
||||
.wrn-component--size-xl {
|
||||
--wrn-component-scale: 1.28;
|
||||
}
|
||||
|
||||
.wrn-component:not(.wrn-btn) {
|
||||
font-size: calc(1em * var(--wrn-component-scale));
|
||||
}
|
||||
|
||||
.wrn-component :is(a, button, input, select, textarea):focus-visible {
|
||||
outline-color: var(--wrn-component-color);
|
||||
}
|
||||
|
||||
.wrn-component p {
|
||||
margin: 0;
|
||||
color: var(--wrn-color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
component AvatarGroup {
|
||||
outputs {
|
||||
overflow(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
|
||||
}
|
||||
|
||||
props {
|
||||
items: unknown[] = []
|
||||
size: string = "md"
|
||||
color: string = "primary"
|
||||
variant: string = "solid"
|
||||
shape: string = "circle"
|
||||
layout: string = "stack"
|
||||
maxVisible: number = 4
|
||||
columns: number = 3
|
||||
borderColor: string = ""
|
||||
showTooltips: boolean = true
|
||||
overflowLabel: string = "Show remaining members"
|
||||
class: string = ""
|
||||
|
||||
}
|
||||
|
||||
state overflowOpen: boolean = false
|
||||
|
||||
functions {
|
||||
shared function visibleMembers() {
|
||||
return items.slice(0, Number(maxVisible))
|
||||
}
|
||||
|
||||
shared function hiddenMembers() {
|
||||
return items.slice(Number(maxVisible))
|
||||
}
|
||||
|
||||
client function toggleOverflow() {
|
||||
overflowOpen = !overflowOpen
|
||||
output.overflow({
|
||||
component: "AvatarGroup",
|
||||
open: overflowOpen,
|
||||
hiddenCount: hiddenMembers().length
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<div
|
||||
{...attrs}
|
||||
class="wrn-component wrn-next--avatar-group {class}"
|
||||
data-wrn-avatar-group
|
||||
data-layout="{layout}"
|
||||
data-shape="{shape}"
|
||||
data-size="{size}"
|
||||
style="--wrn-avatar-group-columns:{columns}; --wrn-avatar-group-ring:{borderColor || 'var(--wrn-color-bg)'}"
|
||||
role="group"
|
||||
aria-label="Avatar group"
|
||||
>
|
||||
<div
|
||||
class="wrn-next__avatar-group-members"
|
||||
>
|
||||
{#each visibleMembers() as item}
|
||||
<span
|
||||
class="wrn-next__avatar-group-member"
|
||||
data-size="{item.size || size}"
|
||||
data-shape="{item.shape || shape}"
|
||||
data-color="{item.color || color}"
|
||||
data-variant="{item.variant || variant}"
|
||||
tabindex="{showTooltips && (item.tooltip || item.name) ? '0' : '-1'}"
|
||||
aria-label="{item.name || item.alt || item.initials || 'Group member'}"
|
||||
>
|
||||
<span
|
||||
class="wrn-next__avatar-group-avatar"
|
||||
>
|
||||
{#if item.src}
|
||||
<img
|
||||
src="{item.src}"
|
||||
alt="{item.alt || item.name || ''}"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else if item.initials}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
>
|
||||
{item.initials}
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="icon-[lucide--user-round]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if showTooltips && (item.tooltip || item.name)}
|
||||
<span
|
||||
class="wrn-next__avatar-group-tooltip"
|
||||
role="tooltip"
|
||||
>
|
||||
{item.tooltip || item.name}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
{#if hiddenMembers().length > 0}
|
||||
<span
|
||||
class="wrn-next__avatar-group-overflow"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-next__avatar-group-overflow-button"
|
||||
aria-label="{overflowLabel}"
|
||||
aria-expanded="{overflowOpen ? 'true' : 'false'}"
|
||||
@click="toggleOverflow(event)"
|
||||
>
|
||||
+{hiddenMembers().length}
|
||||
</button>
|
||||
|
||||
<span
|
||||
class="wrn-next__avatar-group-menu"
|
||||
role="menu"
|
||||
data-show="overflowOpen"
|
||||
aria-hidden="{overflowOpen ? 'false' : 'true'}"
|
||||
>
|
||||
{#each hiddenMembers() as item}
|
||||
<span
|
||||
class="wrn-next__avatar-group-menu-item"
|
||||
role="menuitem"
|
||||
>
|
||||
<span
|
||||
class="wrn-next__avatar-group-menu-avatar"
|
||||
>
|
||||
{#if item.src}
|
||||
<img
|
||||
src="{item.src}"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<span>{item.initials || '?'}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span>{item.name || item.alt || item.initials || 'Team member'}</span>
|
||||
</span>
|
||||
{/each}
|
||||
</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
/* --- Avatar group -------------------------------------------------------- */
|
||||
.wrn-next--avatar-group {
|
||||
--wrn-avatar-group-size: 3rem;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.wrn-next--avatar-group[data-size="xs"] {
|
||||
--wrn-avatar-group-size: 1.75rem;
|
||||
}
|
||||
|
||||
.wrn-next--avatar-group[data-size="sm"] {
|
||||
--wrn-avatar-group-size: 2.25rem;
|
||||
}
|
||||
|
||||
.wrn-next--avatar-group[data-size="md"],
|
||||
.wrn-next--avatar-group[data-size="default"] {
|
||||
--wrn-avatar-group-size: 3rem;
|
||||
}
|
||||
|
||||
.wrn-next--avatar-group[data-size="lg"] {
|
||||
--wrn-avatar-group-size: 4rem;
|
||||
}
|
||||
|
||||
.wrn-next--avatar-group[data-size="xl"] {
|
||||
--wrn-avatar-group-size: 5rem;
|
||||
}
|
||||
|
||||
.wrn-next__avatar-group-members {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wrn-next--avatar-group[data-layout="stack"] .wrn-next__avatar-group-member,
|
||||
.wrn-next--avatar-group[data-layout="stack"] .wrn-next__avatar-group-overflow {
|
||||
margin-left: calc(var(--wrn-avatar-group-size) * -0.22);
|
||||
}
|
||||
|
||||
.wrn-next--avatar-group[data-layout="stack"] :first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.wrn-next--avatar-group[data-layout="grid"] .wrn-next__avatar-group-members {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--wrn-avatar-group-columns), var(--wrn-avatar-group-size));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.wrn-next__avatar-group-member,
|
||||
.wrn-next__avatar-group-overflow {
|
||||
--wrn-avatar-group-color: var(--wrn-color-primary);
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
width: var(--wrn-avatar-group-size);
|
||||
height: var(--wrn-avatar-group-size);
|
||||
flex: none;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
outline: none;
|
||||
transition:
|
||||
z-index var(--wrn-motion-fast),
|
||||
transform var(--wrn-motion-base) var(--wrn-ease-emphasized);
|
||||
}
|
||||
|
||||
.wrn-next__avatar-group-member[data-color="secondary"] {
|
||||
--wrn-avatar-group-color: #737373;
|
||||
}
|
||||
|
||||
.wrn-next__avatar-group-member[data-color="success"] {
|
||||
--wrn-avatar-group-color: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-next__avatar-group-member[data-color="info"] {
|
||||
--wrn-avatar-group-color: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-next__avatar-group-member[data-color="warning"] {
|
||||
--wrn-avatar-group-color: var(--wrn-color-warning);
|
||||
}
|
||||
|
||||
.wrn-next__avatar-group-member[data-color="danger"] {
|
||||
--wrn-avatar-group-color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-next__avatar-group-member[data-shape="rounded"] {
|
||||
border-radius: calc(var(--wrn-radius-sm, 0.5rem) * 0.72);
|
||||
}
|
||||
|
||||
.wrn-next__avatar-group-avatar,
|
||||
.wrn-next__avatar-group-overflow-button {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
place-items: center;
|
||||
color: white;
|
||||
border: 2px solid var(--wrn-avatar-group-ring);
|
||||
border-radius: inherit;
|
||||
background: var(--wrn-avatar-group-color, var(--wrn-color-primary));
|
||||
box-shadow: 0 5px 14px rgb(0 0 0 / 0.16);
|
||||
font: inherit;
|
||||
font-size: calc(var(--wrn-avatar-group-size) * 0.3);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.wrn-next__avatar-group-avatar img,
|
||||
.wrn-next__avatar-group-menu-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.wrn-next__avatar-group-overflow-button {
|
||||
cursor: pointer;
|
||||
color: var(--wrn-color-text);
|
||||
background: var(--wrn-color-surface-2);
|
||||
}
|
||||
|
||||
.wrn-next__avatar-group-member:hover,
|
||||
.wrn-next__avatar-group-member:focus-visible,
|
||||
.wrn-next__avatar-group-overflow:focus-within {
|
||||
z-index: 5;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.wrn-next__avatar-group-member:focus-visible,
|
||||
.wrn-next__avatar-group-overflow-button:focus-visible {
|
||||
outline: 2px solid var(--wrn-color-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.wrn-next__avatar-group-tooltip {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 0.65rem);
|
||||
z-index: 20;
|
||||
width: max-content;
|
||||
max-width: 14rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
color: var(--wrn-color-bg);
|
||||
border-radius: 0.4rem;
|
||||
background: var(--wrn-color-text);
|
||||
box-shadow: 0 8px 22px rgb(0 0 0 / 0.22);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 650;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 0.25rem);
|
||||
transition:
|
||||
opacity var(--wrn-motion-fast),
|
||||
transform var(--wrn-motion-fast);
|
||||
}
|
||||
|
||||
.wrn-next__avatar-group-member:hover .wrn-next__avatar-group-tooltip,
|
||||
.wrn-next__avatar-group-member:focus-visible .wrn-next__avatar-group-tooltip {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.wrn-next__avatar-group-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.65rem);
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
width: max-content;
|
||||
min-width: 12rem;
|
||||
gap: 0.25rem;
|
||||
padding: 0.45rem;
|
||||
color: var(--wrn-color-text);
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm, 0.5rem);
|
||||
background: var(--wrn-color-surface);
|
||||
box-shadow: 0 16px 38px rgb(0 0 0 / 0.22);
|
||||
}
|
||||
|
||||
.wrn-next__avatar-group-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 0.4rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.wrn-next__avatar-group-menu-item:hover {
|
||||
background: var(--wrn-color-surface-2);
|
||||
}
|
||||
|
||||
.wrn-next__avatar-group-menu-avatar {
|
||||
display: grid;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: var(--wrn-color-surface-2);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
/* Shared component foundation. */
|
||||
|
||||
.wrn-component {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
--wrn-component-scale: 1;
|
||||
margin: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-family: var(--wrn-font-sans, inherit);
|
||||
}
|
||||
|
||||
.wrn-component--color-primary {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-component--color-secondary {
|
||||
--wrn-component-color: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-component--color-success {
|
||||
--wrn-component-color: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-component--color-warning {
|
||||
--wrn-component-color: var(--wrn-color-warning);
|
||||
}
|
||||
|
||||
.wrn-component--color-danger {
|
||||
--wrn-component-color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-component--color-info {
|
||||
--wrn-component-color: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-component--size-xs {
|
||||
--wrn-component-scale: 0.78;
|
||||
}
|
||||
|
||||
.wrn-component--size-sm {
|
||||
--wrn-component-scale: 0.88;
|
||||
}
|
||||
|
||||
.wrn-component--size-default,
|
||||
.wrn-component--size-md {
|
||||
--wrn-component-scale: 1;
|
||||
}
|
||||
|
||||
.wrn-component--size-lg {
|
||||
--wrn-component-scale: 1.14;
|
||||
}
|
||||
|
||||
.wrn-component--size-xl {
|
||||
--wrn-component-scale: 1.28;
|
||||
}
|
||||
|
||||
.wrn-component:not(.wrn-btn) {
|
||||
font-size: calc(1em * var(--wrn-component-scale));
|
||||
}
|
||||
|
||||
.wrn-component :is(a, button, input, select, textarea):focus-visible {
|
||||
outline-color: var(--wrn-component-color);
|
||||
}
|
||||
|
||||
.wrn-component p {
|
||||
margin: 0;
|
||||
color: var(--wrn-color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
component Blockquote {
|
||||
props {
|
||||
quote: string = "I just wanted to say that I'm very happy with my purchase so far. The documentation is outstanding - clear and detailed."
|
||||
citation: string = ""
|
||||
citationTitle: string = ""
|
||||
citationUrl: string = ""
|
||||
avatarSrc: string = ""
|
||||
avatarAlt: string = ""
|
||||
size: string = "md"
|
||||
color: string = "primary"
|
||||
align: string = "left"
|
||||
variant: string = "default"
|
||||
quoteMark: boolean = true
|
||||
italic: boolean = true
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<figure
|
||||
{...attrs}
|
||||
class="wrn-component wrn-next--blockquote wrn-component--color-{color} wrn-component--size-{size} {class}"
|
||||
data-size="{size}"
|
||||
data-color="{color}"
|
||||
data-align="{align}"
|
||||
data-variant="{variant}"
|
||||
data-italic="{italic}"
|
||||
>
|
||||
<blockquote cite="{citationUrl}">
|
||||
{#if quoteMark}
|
||||
<span class="wrn-next__blockquote-mark" aria-hidden="true">“</span>
|
||||
{/if}
|
||||
|
||||
<div class="wrn-next__blockquote-copy">
|
||||
{#if quote}
|
||||
<p>{quote}</p>
|
||||
{:else}
|
||||
<slot />
|
||||
{/if}
|
||||
</div>
|
||||
</blockquote>
|
||||
|
||||
{#if citation || citationTitle || avatarSrc}
|
||||
<figcaption class="wrn-next__blockquote-citation">
|
||||
{#if avatarSrc}
|
||||
<img
|
||||
class="wrn-next__blockquote-avatar"
|
||||
src="{avatarSrc}"
|
||||
alt="{avatarAlt}"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<span class="wrn-next__blockquote-attribution">
|
||||
{#if citation}
|
||||
{#if citationUrl}
|
||||
<cite><a href="{citationUrl}">{citation}</a></cite>
|
||||
{:else}
|
||||
<cite>{citation}</cite>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if citationTitle}<span>{citationTitle}</span>{/if}
|
||||
</span>
|
||||
</figcaption>
|
||||
{/if}
|
||||
</figure>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-next--blockquote {
|
||||
--wrn-blockquote-color: var(--wrn-component-color, var(--wrn-color-primary));
|
||||
--wrn-blockquote-font-size: 1.125rem;
|
||||
--wrn-blockquote-mark-size: 4.25rem;
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
width: 100%;
|
||||
color: var(--wrn-color-text);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.wrn-next--blockquote[data-size="xs"] {
|
||||
--wrn-blockquote-font-size: 0.875rem;
|
||||
--wrn-blockquote-mark-size: 3rem;
|
||||
}
|
||||
|
||||
.wrn-next--blockquote[data-size="sm"] {
|
||||
--wrn-blockquote-font-size: 1rem;
|
||||
--wrn-blockquote-mark-size: 3.5rem;
|
||||
}
|
||||
|
||||
.wrn-next--blockquote[data-size="md"],
|
||||
.wrn-next--blockquote[data-size="default"] {
|
||||
--wrn-blockquote-font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.wrn-next--blockquote[data-size="lg"] {
|
||||
--wrn-blockquote-font-size: clamp(1.35rem, 2.4vw, 1.75rem);
|
||||
--wrn-blockquote-mark-size: 5rem;
|
||||
}
|
||||
|
||||
.wrn-next--blockquote[data-size="xl"] {
|
||||
--wrn-blockquote-font-size: clamp(1.65rem, 3vw, 2.25rem);
|
||||
--wrn-blockquote-mark-size: 5.75rem;
|
||||
}
|
||||
|
||||
.wrn-next--blockquote > blockquote {
|
||||
position: relative;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding-top: calc(var(--wrn-blockquote-mark-size) * 0.78);
|
||||
}
|
||||
|
||||
.wrn-next__blockquote-mark {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: color-mix(in srgb, var(--wrn-blockquote-color) 28%, var(--wrn-color-text));
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: var(--wrn-blockquote-mark-size);
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: 0.8;
|
||||
text-align: start;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.wrn-next__blockquote-copy {
|
||||
min-width: 0;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: var(--wrn-blockquote-font-size);
|
||||
line-height: 1.55;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.wrn-next--blockquote[data-italic="true"] .wrn-next__blockquote-copy {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.wrn-next__blockquote-copy > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.wrn-next__blockquote-copy > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.wrn-next__blockquote-citation {
|
||||
display: inline-flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
margin-inline-start: 0;
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.wrn-next__blockquote-avatar {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
flex: 0 0 auto;
|
||||
border: 2px solid color-mix(in srgb, var(--wrn-blockquote-color) 35%, transparent);
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.wrn-next__blockquote-attribution {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.wrn-next__blockquote-attribution cite {
|
||||
color: var(--wrn-color-text);
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wrn-next__blockquote-attribution a {
|
||||
color: inherit;
|
||||
text-decoration-color: color-mix(in srgb, var(--wrn-blockquote-color) 55%, transparent);
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
.wrn-next__blockquote-attribution a:hover {
|
||||
color: var(--wrn-blockquote-color);
|
||||
}
|
||||
|
||||
.wrn-next--blockquote[data-align="center"] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wrn-next--blockquote[data-align="right"],
|
||||
.wrn-next--blockquote[data-align="end"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.wrn-next--blockquote[data-align="center"] > blockquote,
|
||||
.wrn-next--blockquote[data-align="right"] > blockquote,
|
||||
.wrn-next--blockquote[data-align="end"] > blockquote {
|
||||
text-align: inherit;
|
||||
}
|
||||
|
||||
.wrn-next--blockquote[data-align="center"] .wrn-next__blockquote-mark {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wrn-next--blockquote[data-align="right"] .wrn-next__blockquote-mark,
|
||||
.wrn-next--blockquote[data-align="end"] .wrn-next__blockquote-mark {
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.wrn-next--blockquote[data-align="center"] .wrn-next__blockquote-citation {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.wrn-next--blockquote[data-align="right"] .wrn-next__blockquote-citation,
|
||||
.wrn-next--blockquote[data-align="end"] .wrn-next__blockquote-citation {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.wrn-next--blockquote[data-variant="bordered"],
|
||||
.wrn-next--blockquote[data-variant="border"],
|
||||
.wrn-next--blockquote[data-variant="left-border"] {
|
||||
padding-inline-start: 1.5rem;
|
||||
border-inline-start: 4px solid var(--wrn-blockquote-color);
|
||||
}
|
||||
|
||||
.wrn-next--blockquote[data-variant="bordered"] .wrn-next__blockquote-mark,
|
||||
.wrn-next--blockquote[data-variant="border"] .wrn-next__blockquote-mark,
|
||||
.wrn-next--blockquote[data-variant="left-border"] .wrn-next__blockquote-mark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wrn-next--blockquote[data-variant="bordered"] > blockquote,
|
||||
.wrn-next--blockquote[data-variant="border"] > blockquote,
|
||||
.wrn-next--blockquote[data-variant="left-border"] > blockquote {
|
||||
display: block;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.wrn-next--blockquote[data-variant="bordered"] .wrn-next__blockquote-copy,
|
||||
.wrn-next--blockquote[data-variant="border"] .wrn-next__blockquote-copy,
|
||||
.wrn-next--blockquote[data-variant="left-border"] .wrn-next__blockquote-copy {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.wrn-next--blockquote[data-variant="bordered"] .wrn-next__blockquote-citation,
|
||||
.wrn-next--blockquote[data-variant="border"] .wrn-next__blockquote-citation,
|
||||
.wrn-next--blockquote[data-variant="left-border"] .wrn-next__blockquote-citation {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.wrn-next--blockquote {
|
||||
--wrn-blockquote-mark-size: 3.25rem;
|
||||
--wrn-blockquote-font-size: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wrn-next--blockquote[data-size="lg"],
|
||||
.wrn-next--blockquote[data-size="xl"] {
|
||||
--wrn-blockquote-font-size: 1.25rem;
|
||||
}
|
||||
.wrn-next__blockquote-citation {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shared component foundation. */
|
||||
|
||||
.wrn-component {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
--wrn-component-scale: 1;
|
||||
margin: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-family: var(--wrn-font-sans, inherit);
|
||||
}
|
||||
|
||||
.wrn-component--color-primary {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-component--color-secondary {
|
||||
--wrn-component-color: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-component--color-success {
|
||||
--wrn-component-color: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-component--color-warning {
|
||||
--wrn-component-color: var(--wrn-color-warning);
|
||||
}
|
||||
|
||||
.wrn-component--color-danger {
|
||||
--wrn-component-color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-component--color-info {
|
||||
--wrn-component-color: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-component--size-xs {
|
||||
--wrn-component-scale: 0.78;
|
||||
}
|
||||
|
||||
.wrn-component--size-sm {
|
||||
--wrn-component-scale: 0.88;
|
||||
}
|
||||
|
||||
.wrn-component--size-default,
|
||||
.wrn-component--size-md {
|
||||
--wrn-component-scale: 1;
|
||||
}
|
||||
|
||||
.wrn-component--size-lg {
|
||||
--wrn-component-scale: 1.14;
|
||||
}
|
||||
|
||||
.wrn-component--size-xl {
|
||||
--wrn-component-scale: 1.28;
|
||||
}
|
||||
|
||||
.wrn-component:not(.wrn-btn) {
|
||||
font-size: calc(1em * var(--wrn-component-scale));
|
||||
}
|
||||
|
||||
.wrn-component :is(a, button, input, select, textarea):focus-visible {
|
||||
outline-color: var(--wrn-component-color);
|
||||
}
|
||||
|
||||
.wrn-component p {
|
||||
margin: 0;
|
||||
color: var(--wrn-color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
component Breadcrumb {
|
||||
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)
|
||||
}
|
||||
|
||||
props {
|
||||
label: string = "Breadcrumb"
|
||||
items: unknown[] = []
|
||||
active: string = ""
|
||||
separator: string = "chevron"
|
||||
showHome: boolean = false
|
||||
homeLabel: string = "Home"
|
||||
homeHref: string = "/"
|
||||
homeIcon: string = "icon-[lucide--house]"
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
variant: string = "minimal"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<nav
|
||||
{...attrs}
|
||||
data-ui-component="Breadcrumb"
|
||||
data-size='{size}'
|
||||
data-color='{color}'
|
||||
data-variant='{variant}'
|
||||
aria-label='{label}'
|
||||
class='wrn-breadcrumb {class}'
|
||||
>
|
||||
<ol class="wrn-breadcrumb__list">
|
||||
{#if showHome}
|
||||
<li class="wrn-breadcrumb__item wrn-breadcrumb__item--home">
|
||||
<a
|
||||
href='{homeHref || "/"}'
|
||||
class="wrn-breadcrumb__link wrn-breadcrumb__home"
|
||||
@click='output.select({ item: { label: homeLabel, href: homeHref || "/", value: "home" }, itemIndex: -1 })'
|
||||
>
|
||||
{#if homeIcon === "icon-[lucide--house]"}
|
||||
<span class="icon-[lucide--house] wrn-breadcrumb__icon" aria-hidden="true"></span>
|
||||
{:else if homeIcon}
|
||||
<span class='{homeIcon} wrn-breadcrumb__icon' aria-hidden="true"></span>
|
||||
{/if}
|
||||
|
||||
<span class="wrn-breadcrumb__home-label">{homeLabel}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
{#each items as item, itemIndex}
|
||||
<li
|
||||
class="wrn-breadcrumb__item"
|
||||
data-current='{item.active || item.current || (active && active === (item.value || item.label || item.title)) || (!active && itemIndex === items.length - 1) ? "true" : "false"}'
|
||||
>
|
||||
{#if showHome || itemIndex > 0}
|
||||
<span class="wrn-breadcrumb__separator" aria-hidden="true">
|
||||
{#if separator === "slash"}
|
||||
<span>/</span>
|
||||
{:else if separator === "dot"}
|
||||
<span>•</span>
|
||||
{:else if separator === "arrow"}
|
||||
<span>→</span>
|
||||
{:else}
|
||||
<span class="icon-[lucide--chevron-right] wrn-breadcrumb__separator-icon"></span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if item.href && !item.disabled && !(item.active || item.current || (active && active === (item.value || item.label || item.title)) || (!active && itemIndex === items.length - 1))}
|
||||
<a
|
||||
href='{item.href}'
|
||||
target='{item.target || ""}'
|
||||
rel='{item.external ? "noopener noreferrer" : (item.rel || "")}'
|
||||
class="wrn-breadcrumb__link"
|
||||
@click='output.select({ item: item, itemIndex: itemIndex })'
|
||||
>
|
||||
{#if item.icon}
|
||||
<span class='{item.icon} wrn-breadcrumb__icon' aria-hidden="true"></span>
|
||||
{/if}
|
||||
|
||||
<span class="wrn-breadcrumb__label">{item.label || item.title}</span>
|
||||
|
||||
{#if item.external}
|
||||
<span class="icon-[lucide--arrow-up-right] wrn-breadcrumb__external" aria-hidden="true"></span>
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<span
|
||||
class="wrn-breadcrumb__current"
|
||||
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}
|
||||
<span class='{item.icon} wrn-breadcrumb__icon' aria-hidden="true"></span>
|
||||
{/if}
|
||||
|
||||
<span class="wrn-breadcrumb__label">{item.label || item.title}</span>
|
||||
</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</nav>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-breadcrumb {
|
||||
--breadcrumb-accent: var(--wrn-color-primary);
|
||||
--breadcrumb-soft: var(--wrn-color-primary-soft);
|
||||
--breadcrumb-muted: var(--wrn-color-primary-muted);
|
||||
--breadcrumb-text: var(--wrn-color-primary-text);
|
||||
|
||||
min-width: 0;
|
||||
color: var(--wrn-color-text-muted);
|
||||
}
|
||||
|
||||
.wrn-breadcrumb[data-color="secondary"] {
|
||||
--breadcrumb-accent: var(--wrn-color-secondary);
|
||||
--breadcrumb-soft: var(--wrn-color-secondary-soft);
|
||||
--breadcrumb-muted: var(--wrn-color-secondary-muted);
|
||||
--breadcrumb-text: var(--wrn-color-secondary-text);
|
||||
}
|
||||
|
||||
.wrn-breadcrumb[data-color="info"] {
|
||||
--breadcrumb-accent: var(--wrn-color-info);
|
||||
--breadcrumb-soft: var(--wrn-color-info-soft);
|
||||
--breadcrumb-muted: var(--wrn-color-info-muted);
|
||||
--breadcrumb-text: var(--wrn-color-info-text);
|
||||
}
|
||||
|
||||
.wrn-breadcrumb[data-color="success"] {
|
||||
--breadcrumb-accent: var(--wrn-color-success);
|
||||
--breadcrumb-soft: var(--wrn-color-success-soft);
|
||||
--breadcrumb-muted: var(--wrn-color-success-muted);
|
||||
--breadcrumb-text: var(--wrn-color-success-text);
|
||||
}
|
||||
|
||||
.wrn-breadcrumb[data-color="warning"] {
|
||||
--breadcrumb-accent: var(--wrn-color-warning);
|
||||
--breadcrumb-soft: var(--wrn-color-warning-soft);
|
||||
--breadcrumb-muted: var(--wrn-color-warning-muted);
|
||||
--breadcrumb-text: var(--wrn-color-warning-text);
|
||||
}
|
||||
|
||||
.wrn-breadcrumb[data-color="danger"] {
|
||||
--breadcrumb-accent: var(--wrn-color-danger);
|
||||
--breadcrumb-soft: var(--wrn-color-danger-soft);
|
||||
--breadcrumb-muted: var(--wrn-color-danger-muted);
|
||||
--breadcrumb-text: var(--wrn-color-danger-text);
|
||||
}
|
||||
|
||||
.wrn-breadcrumb[data-variant="soft"] {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
padding: 0.45rem 0.65rem;
|
||||
background: var(--breadcrumb-soft);
|
||||
border: 1px solid var(--breadcrumb-muted);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.wrn-breadcrumb[data-variant="outline"] {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
padding: 0.45rem 0.65rem;
|
||||
background: var(--wrn-color-surface-raised);
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: var(--wrn-shadow-sm);
|
||||
}
|
||||
|
||||
.wrn-breadcrumb[data-variant="contrast"] {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.wrn-breadcrumb__list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: auto;
|
||||
list-style: none;
|
||||
scrollbar-width: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wrn-breadcrumb__list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wrn-breadcrumb__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.wrn-breadcrumb__separator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-inline: 0.45rem;
|
||||
color: var(--wrn-color-text-subtle);
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.wrn-breadcrumb__separator-icon {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
}
|
||||
|
||||
.wrn-breadcrumb__link,
|
||||
.wrn-breadcrumb__current {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
min-height: 2rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 0.55rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.25rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wrn-breadcrumb[data-size="sm"] .wrn-breadcrumb__link,
|
||||
.wrn-breadcrumb[data-size="sm"] .wrn-breadcrumb__current {
|
||||
min-height: 1.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.wrn-breadcrumb[data-size="lg"] .wrn-breadcrumb__link,
|
||||
.wrn-breadcrumb[data-size="lg"] .wrn-breadcrumb__current {
|
||||
min-height: 2.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.wrn-breadcrumb__link {
|
||||
color: var(--wrn-color-text-muted);
|
||||
font-weight: 600;
|
||||
transition:
|
||||
color 160ms ease,
|
||||
background 160ms ease;
|
||||
}
|
||||
|
||||
.wrn-breadcrumb__link:hover {
|
||||
color: var(--breadcrumb-accent);
|
||||
background: var(--breadcrumb-soft);
|
||||
}
|
||||
|
||||
.wrn-breadcrumb__link:focus-visible {
|
||||
color: var(--breadcrumb-accent);
|
||||
outline: 2px solid var(--breadcrumb-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.wrn-breadcrumb__current {
|
||||
max-width: 22rem;
|
||||
color: var(--wrn-color-text);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.wrn-breadcrumb__item[data-current="true"] .wrn-breadcrumb__current {
|
||||
color: var(--breadcrumb-text);
|
||||
background: var(--breadcrumb-soft);
|
||||
}
|
||||
|
||||
.wrn-breadcrumb__current[aria-disabled="true"] {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.wrn-breadcrumb__icon,
|
||||
.wrn-breadcrumb__external {
|
||||
flex: 0 0 auto;
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
}
|
||||
|
||||
.wrn-breadcrumb__home {
|
||||
color: var(--breadcrumb-accent);
|
||||
}
|
||||
|
||||
.wrn-breadcrumb__label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wrn-breadcrumb[data-variant="contrast"] .wrn-breadcrumb__link,
|
||||
.wrn-breadcrumb[data-variant="contrast"] .wrn-breadcrumb__current,
|
||||
.wrn-breadcrumb[data-variant="contrast"] .wrn-breadcrumb__home {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.wrn-breadcrumb[data-variant="contrast"] .wrn-breadcrumb__link:hover,
|
||||
.wrn-breadcrumb[data-variant="contrast"] .wrn-breadcrumb__item[data-current="true"] .wrn-breadcrumb__current {
|
||||
color: #ffffff;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.wrn-breadcrumb[data-variant="contrast"] .wrn-breadcrumb__separator {
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.wrn-breadcrumb__home-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wrn-breadcrumb__current {
|
||||
max-width: 13rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wrn-breadcrumb__link {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
component ButtonGroup {
|
||||
outputs {
|
||||
click(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
select(payload: { value: string | number | boolean | null | object; previousValue: string | number | boolean | null | object; item: string | number | boolean | null | object; index: number })
|
||||
change(payload: { value: string | number | boolean | null | object; previousValue: string | number | boolean | null | object; item: string | number | boolean | null | object; index: number })
|
||||
}
|
||||
|
||||
props {
|
||||
items: unknown[] = []
|
||||
value: string = ""
|
||||
size: string = "md"
|
||||
color: string = "primary"
|
||||
variant: string = "default"
|
||||
orientation: string = "horizontal"
|
||||
responsive: boolean = false
|
||||
attached: boolean = true
|
||||
selectable: boolean = false
|
||||
toolbar: boolean = false
|
||||
disabled: boolean = false
|
||||
ariaLabel: string = "Button group"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
state selectedValue = value
|
||||
|
||||
functions {
|
||||
shared function itemValue(item, index) {
|
||||
return item.value || item.label || String(index)
|
||||
}
|
||||
|
||||
client function selectItem(item, index) {
|
||||
previousValue = selectedValue
|
||||
nextValue = itemValue(item, index)
|
||||
|
||||
output.select({
|
||||
value: nextValue,
|
||||
previousValue: previousValue,
|
||||
item: item,
|
||||
index: index
|
||||
})
|
||||
|
||||
if (selectable && previousValue !== nextValue) {
|
||||
selectedValue = nextValue
|
||||
output.change({
|
||||
value: nextValue,
|
||||
previousValue: previousValue,
|
||||
item: item,
|
||||
index: index
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<div
|
||||
{...attrs}
|
||||
class="wrn-component wrn-next--button-group {class}"
|
||||
role="{toolbar ? 'toolbar' : 'group'}"
|
||||
aria-label="{ariaLabel}"
|
||||
aria-orientation="{orientation}"
|
||||
data-size="{size}"
|
||||
data-color="{color}"
|
||||
data-variant="{variant}"
|
||||
data-orientation="{orientation}"
|
||||
data-responsive="{responsive}"
|
||||
data-attached="{attached}"
|
||||
data-selectable="{selectable}"
|
||||
data-disabled="{disabled}"
|
||||
>
|
||||
{#if items}
|
||||
{#each items as item, index}
|
||||
<button
|
||||
type="{item.type || 'button'}"
|
||||
class="wrn-btn wrn-btn--variant-{item.variant || variant} wrn-btn--color-{item.color || color} wrn-btn--size-{item.size || size}"
|
||||
disabled="{disabled || item.disabled}"
|
||||
aria-label="{item.ariaLabel || item.label}"
|
||||
aria-pressed="{selectable ? (selectedValue === (item.value || item.label || String(index)) ? 'true' : 'false') : ''}"
|
||||
data-value="{item.value || item.label || String(index)}"
|
||||
data-selected="{selectable && selectedValue === (item.value || item.label || String(index)) ? 'true' : 'false'}"
|
||||
@click="selectItem(item, index)"
|
||||
>
|
||||
{#if item.icon}
|
||||
<span class="wrn-btn__icon {item.icon}" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<span class="wrn-btn__label">{item.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
<slot />
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-next--button-group {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.wrn-next--button-group[data-orientation="vertical"] {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wrn-next--button-group[data-attached="false"] {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.wrn-next--button-group[data-disabled="true"] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.wrn-next--button-group[data-attached="true"] > :is(.wrn-btn, .wrn-action) {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
}
|
||||
.wrn-next--button-group[data-attached="true"] > .wrn-action {
|
||||
display: flex;
|
||||
}
|
||||
.wrn-next--button-group[data-attached="true"] > :is(.wrn-btn, .wrn-action) :is(.wrn-btn) {
|
||||
height: 100%;
|
||||
}
|
||||
.wrn-next--button-group[data-attached="true"][data-orientation="horizontal"]
|
||||
> :is(.wrn-btn, .wrn-action):not(:first-child) {
|
||||
margin-inline-start: -1px;
|
||||
}
|
||||
.wrn-next--button-group[data-attached="true"][data-orientation="horizontal"]
|
||||
> .wrn-btn:not(:first-child):not(:last-child),
|
||||
.wrn-next--button-group[data-attached="true"][data-orientation="horizontal"]
|
||||
> .wrn-action:not(:first-child):not(:last-child)
|
||||
> .wrn-btn {
|
||||
border-radius: 0;
|
||||
}
|
||||
.wrn-next--button-group[data-attached="true"][data-orientation="horizontal"]
|
||||
> .wrn-btn:first-child:not(:last-child),
|
||||
.wrn-next--button-group[data-attached="true"][data-orientation="horizontal"]
|
||||
> .wrn-action:first-child:not(:last-child)
|
||||
> .wrn-btn {
|
||||
border-start-end-radius: 0;
|
||||
border-end-end-radius: 0;
|
||||
}
|
||||
.wrn-next--button-group[data-attached="true"][data-orientation="horizontal"]
|
||||
> .wrn-btn:last-child:not(:first-child),
|
||||
.wrn-next--button-group[data-attached="true"][data-orientation="horizontal"]
|
||||
> .wrn-action:last-child:not(:first-child)
|
||||
> .wrn-btn {
|
||||
border-start-start-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
}
|
||||
.wrn-next--button-group[data-attached="true"][data-orientation="vertical"]
|
||||
> :is(.wrn-btn, .wrn-action):not(:first-child) {
|
||||
margin-top: -1px;
|
||||
}
|
||||
.wrn-next--button-group[data-attached="true"][data-orientation="vertical"]
|
||||
> .wrn-btn:not(:first-child):not(:last-child),
|
||||
.wrn-next--button-group[data-attached="true"][data-orientation="vertical"]
|
||||
> .wrn-action:not(:first-child):not(:last-child)
|
||||
> .wrn-btn {
|
||||
border-radius: 0;
|
||||
}
|
||||
.wrn-next--button-group[data-attached="true"][data-orientation="vertical"]
|
||||
> .wrn-btn:first-child:not(:last-child),
|
||||
.wrn-next--button-group[data-attached="true"][data-orientation="vertical"]
|
||||
> .wrn-action:first-child:not(:last-child)
|
||||
> .wrn-btn {
|
||||
border-end-start-radius: 0;
|
||||
border-end-end-radius: 0;
|
||||
}
|
||||
.wrn-next--button-group[data-attached="true"][data-orientation="vertical"]
|
||||
> .wrn-btn:last-child:not(:first-child),
|
||||
.wrn-next--button-group[data-attached="true"][data-orientation="vertical"]
|
||||
> .wrn-action:last-child:not(:first-child)
|
||||
> .wrn-btn {
|
||||
border-start-start-radius: 0;
|
||||
border-start-end-radius: 0;
|
||||
}
|
||||
.wrn-next--button-group > :is(.wrn-btn, .wrn-action):focus-within,
|
||||
.wrn-next--button-group > .wrn-btn:focus-visible {
|
||||
z-index: 2;
|
||||
}
|
||||
.wrn-next--button-group[data-selectable="true"] > .wrn-btn[data-selected="true"] {
|
||||
z-index: 1;
|
||||
border-color: var(--wrn-component-color);
|
||||
background: color-mix(in srgb, var(--wrn-component-color) 16%, var(--wrn-color-surface));
|
||||
color: var(--wrn-component-color);
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.wrn-next--button-group[data-responsive="true"] > :is(.wrn-btn, .wrn-action) {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wrn-next--button-group[data-responsive="true"] > .wrn-action > .wrn-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wrn-next--button-group[data-responsive="true"][data-attached="true"]
|
||||
> :is(.wrn-btn, .wrn-action) {
|
||||
margin-top: -1px;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
.wrn-next--button-group[data-responsive="true"][data-attached="true"]
|
||||
> :is(.wrn-btn, .wrn-action):first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.wrn-next--button-group[data-responsive="true"][data-attached="true"] > .wrn-btn,
|
||||
.wrn-next--button-group[data-responsive="true"][data-attached="true"]
|
||||
> .wrn-action
|
||||
> .wrn-btn {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.wrn-next--button-group[data-responsive="true"][data-attached="true"] > .wrn-btn:first-child,
|
||||
.wrn-next--button-group[data-responsive="true"][data-attached="true"]
|
||||
> .wrn-action:first-child
|
||||
> .wrn-btn {
|
||||
border-start-start-radius: var(--wrn-radius-sm);
|
||||
border-start-end-radius: var(--wrn-radius-sm);
|
||||
}
|
||||
|
||||
.wrn-next--button-group[data-responsive="true"][data-attached="true"] > .wrn-btn:last-child,
|
||||
.wrn-next--button-group[data-responsive="true"][data-attached="true"]
|
||||
> .wrn-action:last-child
|
||||
> .wrn-btn {
|
||||
border-end-start-radius: var(--wrn-radius-sm);
|
||||
border-end-end-radius: var(--wrn-radius-sm);
|
||||
}
|
||||
}
|
||||
|
||||
/* Shared component foundation. */
|
||||
|
||||
.wrn-component {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
--wrn-component-scale: 1;
|
||||
margin: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-family: var(--wrn-font-sans, inherit);
|
||||
}
|
||||
|
||||
.wrn-component--color-primary {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-component--color-secondary {
|
||||
--wrn-component-color: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-component--color-success {
|
||||
--wrn-component-color: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-component--color-warning {
|
||||
--wrn-component-color: var(--wrn-color-warning);
|
||||
}
|
||||
|
||||
.wrn-component--color-danger {
|
||||
--wrn-component-color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-component--color-info {
|
||||
--wrn-component-color: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-component--size-xs {
|
||||
--wrn-component-scale: 0.78;
|
||||
}
|
||||
|
||||
.wrn-component--size-sm {
|
||||
--wrn-component-scale: 0.88;
|
||||
}
|
||||
|
||||
.wrn-component--size-default,
|
||||
.wrn-component--size-md {
|
||||
--wrn-component-scale: 1;
|
||||
}
|
||||
|
||||
.wrn-component--size-lg {
|
||||
--wrn-component-scale: 1.14;
|
||||
}
|
||||
|
||||
.wrn-component--size-xl {
|
||||
--wrn-component-scale: 1.28;
|
||||
}
|
||||
|
||||
.wrn-component:not(.wrn-btn) {
|
||||
font-size: calc(1em * var(--wrn-component-scale));
|
||||
}
|
||||
|
||||
.wrn-component :is(a, button, input, select, textarea):focus-visible {
|
||||
outline-color: var(--wrn-component-color);
|
||||
}
|
||||
|
||||
.wrn-component p {
|
||||
margin: 0;
|
||||
color: var(--wrn-color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,898 @@
|
||||
// Interactive, hydration-safe content carousel.
|
||||
component Carousel {
|
||||
outputs {
|
||||
initialize(payload: { index: number; count: number })
|
||||
change(payload: { index: number; previousIndex: number; item: string | number | boolean | null | object; reason: string | boolean })
|
||||
previous(payload: { index: number; previousIndex: number; item: string | number | boolean | null | object })
|
||||
next(payload: { index: number; previousIndex: number; item: string | number | boolean | null | object })
|
||||
play(payload: { index: number; interval: number })
|
||||
pause(payload: { index: number })
|
||||
reachStart(payload: { index: number })
|
||||
reachEnd(payload: { index: number })
|
||||
dragStart(payload: { index: number; x: null })
|
||||
dragEnd(payload: { index: number; distance: number })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
title: string = ""
|
||||
description: string = ""
|
||||
items: unknown[] = []
|
||||
activeIndex: number = 0
|
||||
slidesPerView: number = 1
|
||||
gap: string = "0.75rem"
|
||||
showPagination: boolean = false
|
||||
isAutoPlay: boolean = false
|
||||
autoplayInterval: number = 4000
|
||||
isInfiniteLoop: boolean = false
|
||||
isRTL: boolean = false
|
||||
isCentered: boolean = false
|
||||
isDraggable: boolean = false
|
||||
isAutoHeight: boolean = false
|
||||
isSnap: boolean = false
|
||||
showCounter: boolean = false
|
||||
thumbnails: string = "none"
|
||||
ariaLabel: string = "Content carousel"
|
||||
variant: string = "default"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
state currentIndex = activeIndex
|
||||
state playing: boolean = false
|
||||
state dragOrigin = null
|
||||
state dragOffset: number = 0
|
||||
state autoplayTimer = null
|
||||
state carouselRoot = null
|
||||
|
||||
functions {
|
||||
shared function slideCount() {
|
||||
return items.length
|
||||
}
|
||||
|
||||
shared function maximumIndex(count, visible) {
|
||||
count = slideCount()
|
||||
visible = Math.max(1, Number(slidesPerView) || 1)
|
||||
if (isCentered || isSnap) {
|
||||
return Math.max(0, count - 1)
|
||||
}
|
||||
return Math.max(0, count - visible)
|
||||
}
|
||||
|
||||
shared function normalizedIndex(index, maximum) {
|
||||
maximum = maximumIndex()
|
||||
if (slideCount() === 0) {
|
||||
return 0
|
||||
}
|
||||
if (isInfiniteLoop) {
|
||||
if (index < 0) {
|
||||
return maximum
|
||||
}
|
||||
if (index > maximum) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return Math.max(0, Math.min(index, maximum))
|
||||
}
|
||||
|
||||
client function rememberRoot(sourceEvent, root) {
|
||||
if (!sourceEvent || !sourceEvent.currentTarget) {
|
||||
return
|
||||
}
|
||||
root = sourceEvent.currentTarget.closest(".wrn-next--carousel")
|
||||
if (root) {
|
||||
carouselRoot = root
|
||||
}
|
||||
}
|
||||
|
||||
client function syncNavigation(sourceEvent, index, root, viewport, slides, slide, thumbnails, thumbnail, rail) {
|
||||
rememberRoot(sourceEvent)
|
||||
root = carouselRoot
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
if (isSnap) {
|
||||
viewport = root.querySelector(".wrn-next__carousel-viewport")
|
||||
slides = root.querySelectorAll(".wrn-next__carousel-slide")
|
||||
slide = slides[index]
|
||||
if (viewport && slide) {
|
||||
viewport.scrollTo({
|
||||
left: slide.offsetLeft - (viewport.clientWidth - slide.clientWidth) / 2,
|
||||
behavior: "smooth"
|
||||
})
|
||||
}
|
||||
}
|
||||
thumbnails = root.querySelectorAll(".wrn-next__carousel-thumbnails button")
|
||||
if (thumbnails[index]) {
|
||||
thumbnail = thumbnails[index]
|
||||
rail = thumbnail.parentElement
|
||||
rail.scrollTo({
|
||||
left: thumbnail.offsetLeft - (rail.clientWidth - thumbnail.clientWidth) / 2,
|
||||
top: thumbnail.offsetTop - (rail.clientHeight - thumbnail.clientHeight) / 2,
|
||||
behavior: "smooth"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
client function handleSnapScroll(sourceEvent, slides, viewport, viewportCenter, closestIndex, closestDistance, slide, slideCenter, distance, previousIndex) {
|
||||
if (!isSnap) {
|
||||
return
|
||||
}
|
||||
rememberRoot(sourceEvent)
|
||||
viewport = sourceEvent.currentTarget
|
||||
slides = viewport.querySelectorAll(".wrn-next__carousel-slide")
|
||||
viewportCenter = viewport.scrollLeft + viewport.clientWidth / 2
|
||||
closestIndex = currentIndex
|
||||
closestDistance = 1000000000
|
||||
slides.forEach(function (candidate, index) {
|
||||
slideCenter = candidate.offsetLeft + candidate.clientWidth / 2
|
||||
distance = Math.abs(slideCenter - viewportCenter)
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance
|
||||
closestIndex = index
|
||||
}
|
||||
})
|
||||
closestIndex = normalizedIndex(closestIndex)
|
||||
if (closestIndex !== currentIndex) {
|
||||
previousIndex = currentIndex
|
||||
currentIndex = closestIndex
|
||||
output.change({
|
||||
index: currentIndex,
|
||||
previousIndex: previousIndex,
|
||||
item: items[currentIndex],
|
||||
reason: "snap"
|
||||
})
|
||||
slides = carouselRoot.querySelectorAll(".wrn-next__carousel-thumbnails button")
|
||||
slide = slides[currentIndex]
|
||||
if (slide) {
|
||||
viewport = slide.parentElement
|
||||
viewport.scrollTo({
|
||||
left: slide.offsetLeft - (viewport.clientWidth - slide.clientWidth) / 2,
|
||||
top: slide.offsetTop - (viewport.clientHeight - slide.clientHeight) / 2,
|
||||
behavior: "smooth"
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client function selectSlide(index, reason, sourceEvent, previousIndex) {
|
||||
if (slideCount() === 0) {
|
||||
return
|
||||
}
|
||||
previousIndex = currentIndex
|
||||
currentIndex = normalizedIndex(Number(index))
|
||||
syncNavigation(sourceEvent, currentIndex)
|
||||
if (previousIndex === currentIndex && reason !== "initialize") {
|
||||
return
|
||||
}
|
||||
output.change({
|
||||
index: currentIndex,
|
||||
previousIndex: previousIndex,
|
||||
item: items[currentIndex],
|
||||
reason: reason || "select"
|
||||
})
|
||||
if (currentIndex === 0) {
|
||||
output.reachStart({ index: currentIndex })
|
||||
}
|
||||
if (currentIndex === maximumIndex()) {
|
||||
output.reachEnd({ index: currentIndex })
|
||||
}
|
||||
}
|
||||
|
||||
client function previousSlide(sourceEvent, previousIndex) {
|
||||
previousIndex = currentIndex
|
||||
selectSlide(currentIndex - 1, "previous", sourceEvent)
|
||||
output.previous({
|
||||
index: currentIndex,
|
||||
previousIndex: previousIndex,
|
||||
item: items[currentIndex]
|
||||
})
|
||||
}
|
||||
|
||||
client function nextSlide(sourceEvent, previousIndex) {
|
||||
previousIndex = currentIndex
|
||||
selectSlide(currentIndex + 1, "next", sourceEvent)
|
||||
output.next({
|
||||
index: currentIndex,
|
||||
previousIndex: previousIndex,
|
||||
item: items[currentIndex]
|
||||
})
|
||||
}
|
||||
|
||||
client function handleKeydown(sourceEvent) {
|
||||
if (sourceEvent.key === "ArrowLeft") {
|
||||
sourceEvent.preventDefault()
|
||||
if (isRTL) {
|
||||
nextSlide(sourceEvent)
|
||||
} else {
|
||||
previousSlide(sourceEvent)
|
||||
}
|
||||
}
|
||||
if (sourceEvent.key === "ArrowRight") {
|
||||
sourceEvent.preventDefault()
|
||||
if (isRTL) {
|
||||
previousSlide(sourceEvent)
|
||||
} else {
|
||||
nextSlide(sourceEvent)
|
||||
}
|
||||
}
|
||||
if (sourceEvent.key === "Home") {
|
||||
sourceEvent.preventDefault()
|
||||
selectSlide(0, "keyboard", sourceEvent)
|
||||
}
|
||||
if (sourceEvent.key === "End") {
|
||||
sourceEvent.preventDefault()
|
||||
selectSlide(maximumIndex(), "keyboard", sourceEvent)
|
||||
}
|
||||
}
|
||||
|
||||
client function beginDrag(sourceEvent) {
|
||||
if (!isDraggable || isSnap) {
|
||||
return
|
||||
}
|
||||
sourceEvent.preventDefault()
|
||||
dragOrigin = sourceEvent.clientX
|
||||
dragOffset = 0
|
||||
sourceEvent.currentTarget.setPointerCapture(sourceEvent.pointerId)
|
||||
output.dragStart({ index: currentIndex, x: dragOrigin })
|
||||
}
|
||||
|
||||
client function moveDrag(sourceEvent) {
|
||||
if (!isDraggable || isSnap || dragOrigin === null) {
|
||||
return
|
||||
}
|
||||
sourceEvent.preventDefault()
|
||||
dragOffset = sourceEvent.clientX - dragOrigin
|
||||
}
|
||||
|
||||
shared function cancelDrag() {
|
||||
dragOrigin = null
|
||||
dragOffset = 0
|
||||
}
|
||||
|
||||
client function endDrag(sourceEvent, distance) {
|
||||
if (!isDraggable || isSnap || dragOrigin === null) {
|
||||
return
|
||||
}
|
||||
sourceEvent.preventDefault()
|
||||
distance = dragOffset || sourceEvent.clientX - dragOrigin
|
||||
dragOrigin = null
|
||||
dragOffset = 0
|
||||
if (Math.abs(distance) > 20) {
|
||||
if ((distance < 0 && !isRTL) || (distance > 0 && isRTL)) {
|
||||
nextSlide(sourceEvent)
|
||||
} else {
|
||||
previousSlide(sourceEvent)
|
||||
}
|
||||
}
|
||||
output.dragEnd({
|
||||
index: currentIndex,
|
||||
distance: distance
|
||||
})
|
||||
}
|
||||
|
||||
shared function advanceAutoplay() {
|
||||
if (currentIndex >= maximumIndex()) {
|
||||
selectSlide(0, "autoplay")
|
||||
} else {
|
||||
selectSlide(currentIndex + 1, "autoplay")
|
||||
}
|
||||
}
|
||||
|
||||
client function startAutoplay() {
|
||||
if (!isAutoPlay || playing || slideCount() < 2) {
|
||||
return
|
||||
}
|
||||
playing = true
|
||||
autoplayTimer = setInterval(advanceAutoplay, Math.max(1000, Number(autoplayInterval)))
|
||||
output.play({ index: currentIndex, interval: autoplayInterval })
|
||||
}
|
||||
|
||||
client function pauseAutoplay() {
|
||||
if (autoplayTimer) {
|
||||
clearInterval(autoplayTimer)
|
||||
}
|
||||
autoplayTimer = null
|
||||
if (playing) {
|
||||
output.pause({ index: currentIndex })
|
||||
}
|
||||
playing = false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
mount {
|
||||
output.initialize({ index: currentIndex, count: slideCount() })
|
||||
startAutoplay()
|
||||
}
|
||||
unmount {
|
||||
if (autoplayTimer) {
|
||||
clearInterval(autoplayTimer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<section
|
||||
{...attrs}
|
||||
class="wrn-component wrn-next--carousel wrn-component--color-{color} wrn-component--size-{size} wrn-next--variant-{variant} {class}"
|
||||
data-rtl="{isRTL}"
|
||||
data-centered="{isCentered}"
|
||||
data-draggable="{isDraggable && !isSnap}"
|
||||
data-dragging="{dragOrigin !== null}"
|
||||
data-auto-height="{isAutoHeight}"
|
||||
data-snap="{isSnap}"
|
||||
data-thumbnails="{thumbnails}"
|
||||
dir="{isRTL ? 'rtl' : 'ltr'}"
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
aria-label="{ariaLabel}"
|
||||
@keydown="handleKeydown(event)"
|
||||
@mouseenter="rememberRoot(event); pauseAutoplay()"
|
||||
@mouseleave="startAutoplay()"
|
||||
@focusin="rememberRoot(event); pauseAutoplay()"
|
||||
@focusout="startAutoplay()"
|
||||
>
|
||||
{#if title || description}
|
||||
<header class="wrn-next__carousel-header">
|
||||
{#if title}<h3>{title}</h3>{/if}
|
||||
{#if description}<p>{description}</p>{/if}
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<div class="wrn-next__carousel-layout">
|
||||
{#if thumbnails === "vertical"}
|
||||
<div class="wrn-next__carousel-thumbnails" aria-label="Choose a slide">
|
||||
{#each items as item, index}
|
||||
<button
|
||||
type="button"
|
||||
data-active="{index === currentIndex}"
|
||||
aria-label="Show slide {index + 1}: {item.label || item.title}"
|
||||
aria-current="{index === currentIndex ? 'true' : 'false'}"
|
||||
@click="selectSlide(index, 'thumbnail', event)"
|
||||
>
|
||||
{#if item.thumbnail}<img src="{item.thumbnail}" alt="" />{/if}
|
||||
<span>{item.label || item.title || "Slide " + (index + 1)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="wrn-next__carousel-main">
|
||||
<div class="wrn-next__carousel-stage">
|
||||
<div
|
||||
class="wrn-next__carousel-viewport"
|
||||
tabindex="0"
|
||||
@scroll="handleSnapScroll(event)"
|
||||
@pointerdown="beginDrag(event)"
|
||||
@pointermove="moveDrag(event)"
|
||||
@pointerup="endDrag(event)"
|
||||
@pointercancel="cancelDrag()"
|
||||
@lostpointercapture="cancelDrag()"
|
||||
>
|
||||
<div
|
||||
class="wrn-next__carousel-track"
|
||||
style="--wrn-carousel-index: {currentIndex}; --wrn-carousel-per-view: {slidesPerView}; --wrn-carousel-gap: {gap}; --wrn-carousel-drag-offset: {dragOffset}px"
|
||||
>
|
||||
{#each items as item, index}
|
||||
<article
|
||||
class="wrn-next__carousel-slide"
|
||||
data-active="{index === currentIndex}"
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
aria-label="{index + 1} of {items.length}"
|
||||
aria-hidden="{index === currentIndex ? 'false' : 'true'}"
|
||||
>
|
||||
{#if item.imageSrc}
|
||||
<img src="{item.imageSrc}" alt="{item.imageAlt || item.title || ''}" />
|
||||
{/if}
|
||||
<div class="wrn-next__carousel-slide-content">
|
||||
{#if item.eyebrow}<span>{item.eyebrow}</span>{/if}
|
||||
{#if item.title || item.label}<h4>{item.title || item.label}</h4>{/if}
|
||||
{#if item.description}<p>{item.description}</p>{/if}
|
||||
{#if item.actionLabel}
|
||||
<a href="{item.actionHref || '#'}">{item.actionLabel}</a>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="wrn-next__carousel-control wrn-next__carousel-control--previous"
|
||||
type="button"
|
||||
aria-label="Previous slide"
|
||||
disabled="{!isInfiniteLoop && currentIndex === 0}"
|
||||
@click="previousSlide(event)"
|
||||
>
|
||||
<span class="icon-[lucide--chevron-left]" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button
|
||||
class="wrn-next__carousel-control wrn-next__carousel-control--next"
|
||||
type="button"
|
||||
aria-label="Next slide"
|
||||
disabled="{!isInfiniteLoop && currentIndex === maximumIndex()}"
|
||||
@click="nextSlide(event)"
|
||||
>
|
||||
<span class="icon-[lucide--chevron-right]" aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
{#if showCounter}
|
||||
<output class="wrn-next__carousel-counter" aria-live="polite">
|
||||
{currentIndex + 1} / {items.length}
|
||||
</output>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showPagination}
|
||||
<div class="wrn-next__carousel-pagination" aria-label="Choose a slide">
|
||||
{#each items as item, index}
|
||||
<button
|
||||
type="button"
|
||||
data-active="{index === currentIndex}"
|
||||
aria-label="Show slide {index + 1}"
|
||||
aria-current="{index === currentIndex ? 'true' : 'false'}"
|
||||
@click="selectSlide(index, 'pagination', event)"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if thumbnails === "horizontal"}
|
||||
<div class="wrn-next__carousel-thumbnails" aria-label="Choose a slide">
|
||||
{#each items as item, index}
|
||||
<button
|
||||
type="button"
|
||||
data-active="{index === currentIndex}"
|
||||
aria-label="Show slide {index + 1}: {item.label || item.title}"
|
||||
aria-current="{index === currentIndex ? 'true' : 'false'}"
|
||||
@click="selectSlide(index, 'thumbnail', event)"
|
||||
>
|
||||
{#if item.thumbnail}<img src="{item.thumbnail}" alt="" />{/if}
|
||||
<span>{item.label || item.title || "Slide " + (index + 1)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-next--carousel {
|
||||
--wrn-carousel-color: var(--wrn-component-color, var(--wrn-color-primary));
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-md);
|
||||
color: var(--wrn-color-text);
|
||||
background: var(--wrn-color-surface);
|
||||
box-shadow: var(--wrn-shadow-1);
|
||||
}
|
||||
|
||||
.wrn-next__carousel-header h3,
|
||||
.wrn-next__carousel-header p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wrn-next__carousel-header p {
|
||||
margin-top: 0.35rem;
|
||||
color: var(--wrn-color-text-muted);
|
||||
}
|
||||
|
||||
.wrn-next__carousel-layout {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.wrn-next__carousel-main {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.wrn-next__carousel-stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wrn-next__carousel-viewport {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-md);
|
||||
background: var(--wrn-color-surface-subtle, var(--wrn-color-surface));
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.wrn-next__carousel-viewport:focus-visible {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--wrn-carousel-color) 30%, transparent);
|
||||
}
|
||||
|
||||
.wrn-next__carousel-track {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: var(--wrn-carousel-gap);
|
||||
transform: translateX(
|
||||
calc(
|
||||
var(--wrn-carousel-index) * -1 *
|
||||
(
|
||||
(100% - (var(--wrn-carousel-per-view) - 1) * var(--wrn-carousel-gap)) /
|
||||
var(--wrn-carousel-per-view) + var(--wrn-carousel-gap)
|
||||
)
|
||||
)
|
||||
);
|
||||
transition: transform var(--wrn-motion-base) var(--wrn-ease-emphasized);
|
||||
translate: var(--wrn-carousel-drag-offset, 0) 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.wrn-next--carousel[data-dragging="true"] .wrn-next__carousel-track {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.wrn-next--carousel[data-rtl="true"] .wrn-next__carousel-track {
|
||||
transform: translateX(
|
||||
calc(
|
||||
var(--wrn-carousel-index) *
|
||||
(
|
||||
(100% - (var(--wrn-carousel-per-view) - 1) * var(--wrn-carousel-gap)) /
|
||||
var(--wrn-carousel-per-view) + var(--wrn-carousel-gap)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
.wrn-next__carousel-slide {
|
||||
position: relative;
|
||||
display: grid;
|
||||
flex: 0 0
|
||||
calc(
|
||||
(100% - (var(--wrn-carousel-per-view) - 1) * var(--wrn-carousel-gap)) /
|
||||
var(--wrn-carousel-per-view)
|
||||
);
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
min-height: 20rem;
|
||||
place-items: center;
|
||||
background: var(--wrn-color-surface-raised, var(--wrn-color-surface));
|
||||
}
|
||||
|
||||
.wrn-next--carousel[data-auto-height="true"] .wrn-next__carousel-slide {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.wrn-next--carousel[data-centered="true"] .wrn-next__carousel-slide {
|
||||
flex-basis: min(78%, 32rem);
|
||||
}
|
||||
|
||||
.wrn-next--carousel[data-centered="true"] .wrn-next__carousel-track {
|
||||
box-sizing: border-box;
|
||||
transform: translateX(
|
||||
calc(var(--wrn-carousel-index) * -1 * (min(78%, 32rem) + var(--wrn-carousel-gap)))
|
||||
);
|
||||
}
|
||||
|
||||
.wrn-next--carousel[data-centered="true"] .wrn-next__carousel-slide:first-child {
|
||||
margin-inline-start: calc((100% - min(78%, 32rem)) / 2);
|
||||
}
|
||||
|
||||
.wrn-next--carousel[data-centered="true"] .wrn-next__carousel-slide:last-child {
|
||||
margin-inline-end: calc((100% - min(78%, 32rem)) / 2);
|
||||
}
|
||||
|
||||
.wrn-next--carousel[data-centered="true"][data-rtl="true"] .wrn-next__carousel-track {
|
||||
transform: translateX(
|
||||
calc(var(--wrn-carousel-index) * (min(78%, 32rem) + var(--wrn-carousel-gap)))
|
||||
);
|
||||
}
|
||||
|
||||
.wrn-next__carousel-slide > img {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.wrn-next__carousel-slide-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(100%, 38rem);
|
||||
padding: clamp(2rem, 8vw, 5rem);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wrn-next__carousel-slide-content h4,
|
||||
.wrn-next__carousel-slide-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wrn-next__carousel-slide-content h4 {
|
||||
font-size: clamp(1.5rem, 4vw, 2.4rem);
|
||||
}
|
||||
|
||||
.wrn-next__carousel-slide-content p {
|
||||
margin-top: 0.65rem;
|
||||
color: var(--wrn-color-text-muted);
|
||||
}
|
||||
|
||||
.wrn-next__carousel-control {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 50%;
|
||||
display: grid;
|
||||
width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
padding: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--wrn-color-border) 70%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--wrn-color-text) 76%, transparent);
|
||||
color: var(--wrn-color-surface);
|
||||
cursor: pointer;
|
||||
place-items: center;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.wrn-next__carousel-control:focus-visible,
|
||||
.wrn-next__carousel-pagination button:focus-visible,
|
||||
.wrn-next__carousel-thumbnails button:focus-visible {
|
||||
outline: 2px solid var(--wrn-carousel-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.wrn-next__carousel-control:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.wrn-next__carousel-control--previous {
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.wrn-next__carousel-control--next {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.wrn-next--carousel[data-rtl="true"] .wrn-next__carousel-control--previous {
|
||||
right: 1rem;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.wrn-next--carousel[data-rtl="true"] .wrn-next__carousel-control--next {
|
||||
right: auto;
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.wrn-next__carousel-pagination {
|
||||
display: flex;
|
||||
min-height: 1.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.wrn-next__carousel-pagination button {
|
||||
width: 0.65rem;
|
||||
min-height: 0.65rem;
|
||||
padding: 0;
|
||||
border: 1px solid var(--wrn-color-text-muted);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wrn-next__carousel-pagination button[data-active="true"] {
|
||||
border-color: var(--wrn-carousel-color);
|
||||
background: var(--wrn-carousel-color);
|
||||
}
|
||||
|
||||
.wrn-next__carousel-counter {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
bottom: 0.8rem;
|
||||
left: 50%;
|
||||
padding: 0.25rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--wrn-color-text) 82%, transparent);
|
||||
color: var(--wrn-color-surface);
|
||||
font-weight: 700;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.wrn-next__carousel-thumbnails {
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
gap: 0.55rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.wrn-next__carousel-thumbnails button {
|
||||
display: grid;
|
||||
flex: 0 0 8.5rem;
|
||||
min-height: 3.75rem;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface);
|
||||
color: var(--wrn-color-text);
|
||||
cursor: pointer;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.wrn-next__carousel-thumbnails button[data-active="true"] {
|
||||
border-color: var(--wrn-carousel-color);
|
||||
box-shadow: inset 0 0 0 1px var(--wrn-carousel-color);
|
||||
}
|
||||
|
||||
.wrn-next__carousel-thumbnails img {
|
||||
width: 100%;
|
||||
height: 4rem;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.wrn-next__carousel-thumbnails span {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.wrn-next--carousel[data-thumbnails="vertical"] .wrn-next__carousel-thumbnails {
|
||||
flex: 0 0 9rem;
|
||||
flex-direction: column;
|
||||
max-height: 28rem;
|
||||
}
|
||||
|
||||
.wrn-next--carousel[data-thumbnails="vertical"] .wrn-next__carousel-thumbnails button {
|
||||
flex-basis: 7.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wrn-next--carousel[data-draggable="true"] .wrn-next__carousel-viewport {
|
||||
cursor: grab;
|
||||
touch-action: pan-y;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.wrn-next--carousel[data-draggable="true"] .wrn-next__carousel-viewport:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.wrn-next--carousel[data-draggable="true"] img,
|
||||
.wrn-next--carousel[data-draggable="true"] a {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.wrn-next--carousel[data-snap="true"] .wrn-next__carousel-viewport {
|
||||
overflow-x: auto;
|
||||
scroll-behavior: smooth;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.wrn-next--carousel[data-snap="true"] .wrn-next__carousel-track {
|
||||
--wrn-carousel-snap-slide: calc(
|
||||
(100% - (var(--wrn-carousel-per-view) - 1) * var(--wrn-carousel-gap)) /
|
||||
var(--wrn-carousel-per-view)
|
||||
);
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
box-sizing: content-box;
|
||||
grid-auto-columns: var(--wrn-carousel-snap-slide);
|
||||
grid-auto-flow: column;
|
||||
padding-inline: calc((100% - var(--wrn-carousel-snap-slide)) / 2);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.wrn-next--carousel[data-snap="true"] .wrn-next__carousel-slide {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
scroll-snap-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.wrn-next__carousel-slide {
|
||||
flex-basis: 100%;
|
||||
min-height: 16rem;
|
||||
}
|
||||
.wrn-next--carousel[data-thumbnails="vertical"] .wrn-next__carousel-layout {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
.wrn-next--carousel[data-thumbnails="vertical"] .wrn-next__carousel-thumbnails {
|
||||
flex: auto;
|
||||
flex-direction: row;
|
||||
max-height: none;
|
||||
}
|
||||
.wrn-next--carousel[data-thumbnails="vertical"] .wrn-next__carousel-thumbnails button {
|
||||
flex-basis: 8.5rem;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wrn-next__carousel-track {
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shared component foundation. */
|
||||
|
||||
.wrn-component {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
--wrn-component-scale: 1;
|
||||
margin: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-family: var(--wrn-font-sans, inherit);
|
||||
}
|
||||
|
||||
.wrn-component--color-primary {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-component--color-secondary {
|
||||
--wrn-component-color: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-component--color-success {
|
||||
--wrn-component-color: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-component--color-warning {
|
||||
--wrn-component-color: var(--wrn-color-warning);
|
||||
}
|
||||
|
||||
.wrn-component--color-danger {
|
||||
--wrn-component-color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-component--color-info {
|
||||
--wrn-component-color: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-component--size-xs {
|
||||
--wrn-component-scale: 0.78;
|
||||
}
|
||||
|
||||
.wrn-component--size-sm {
|
||||
--wrn-component-scale: 0.88;
|
||||
}
|
||||
|
||||
.wrn-component--size-default,
|
||||
.wrn-component--size-md {
|
||||
--wrn-component-scale: 1;
|
||||
}
|
||||
|
||||
.wrn-component--size-lg {
|
||||
--wrn-component-scale: 1.14;
|
||||
}
|
||||
|
||||
.wrn-component--size-xl {
|
||||
--wrn-component-scale: 1.28;
|
||||
}
|
||||
|
||||
.wrn-component:not(.wrn-btn) {
|
||||
font-size: calc(1em * var(--wrn-component-scale));
|
||||
}
|
||||
|
||||
.wrn-component :is(a, button, input, select, textarea):focus-visible {
|
||||
outline-color: var(--wrn-component-color);
|
||||
}
|
||||
|
||||
.wrn-component p {
|
||||
margin: 0;
|
||||
color: var(--wrn-color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
component Chart {
|
||||
outputs {
|
||||
select(payload: { item: object; index: number; sourceEvent?: Event })
|
||||
dataPointClick(payload: { item: object; index: number; sourceEvent?: Event })
|
||||
legendToggle(payload: { item: object; index: number; hidden: boolean; sourceEvent?: Event })
|
||||
}
|
||||
|
||||
props {
|
||||
title: string = "Chart"
|
||||
description: string = ""
|
||||
items: unknown[] = []
|
||||
valueKey: string = "value"
|
||||
labelKey: string = "label"
|
||||
height: number = 240
|
||||
showLegend: boolean = true
|
||||
showValues: boolean = true
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
variant: string = "bar"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
state hiddenIndexes = []
|
||||
|
||||
functions {
|
||||
shared function numericValue(item) { return Math.max(0, Number(item[valueKey]) || 0) }
|
||||
shared function maximumValue() {
|
||||
return Math.max(1, ...items.map(function (item) { return numericValue(item) }))
|
||||
}
|
||||
shared function isHidden(index) { return hiddenIndexes.includes(index) }
|
||||
shared function barWidth() { return items.length ? Math.max(4, 80 / items.length) : 80 }
|
||||
shared function barX(index) { return 10 + index * (80 / Math.max(1, items.length)) }
|
||||
shared function barHeight(item) { return numericValue(item) / maximumValue() * 72 }
|
||||
shared function barY(item) { return 84 - barHeight(item) }
|
||||
|
||||
client function selectPoint(item, index, sourceEvent) {
|
||||
output.dataPointClick({ item: item, index: index, sourceEvent: sourceEvent })
|
||||
output.select({ item: item, index: index, sourceEvent: sourceEvent })
|
||||
}
|
||||
|
||||
client function toggleLegend(item, index, sourceEvent, next) {
|
||||
next = hiddenIndexes.slice()
|
||||
if (next.includes(index)) {
|
||||
next = next.filter(function (value) { return value !== index })
|
||||
} else {
|
||||
next.push(index)
|
||||
}
|
||||
hiddenIndexes = next
|
||||
output.legendToggle({ item: item, index: index, hidden: next.includes(index), sourceEvent: sourceEvent })
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<figure {...attrs} data-ui-component="Chart" data-variant='{variant}' class='wrn-chart {class}'>
|
||||
{#if title || description}
|
||||
<figcaption class="wrn-chart__header">
|
||||
{#if title}<strong>{title}</strong>{/if}
|
||||
{#if description}<span>{description}</span>{/if}
|
||||
</figcaption>
|
||||
{/if}
|
||||
<svg class="wrn-chart__plot" viewBox="0 0 100 100" role="img" aria-label='{title}' style='height:{height}px'>
|
||||
<line x1="8" y1="84" x2="96" y2="84" class="wrn-chart__axis" />
|
||||
{#each items as item, index}
|
||||
<g data-hidden='{isHidden(index) ? "true" : "false"}'>
|
||||
<rect
|
||||
x='{barX(index)}' y='{barY(item)}' width='{barWidth()}' height='{barHeight(item)}'
|
||||
rx="1.5" class="wrn-chart__bar" role="button" tabindex="0"
|
||||
aria-label='{item[labelKey] + ": " + numericValue(item)}'
|
||||
@click='selectPoint(item, index, event)'
|
||||
@keydown='if (event.key === "Enter" || event.key === " ") { event.preventDefault(); selectPoint(item, index, event) }'
|
||||
/>
|
||||
{#if showValues}<text x='{barX(index) + barWidth() / 2}' y='{barY(item) - 3}' class="wrn-chart__value">{numericValue(item)}</text>{/if}
|
||||
<text x='{barX(index) + barWidth() / 2}' y="94" class="wrn-chart__label">{item[labelKey]}</text>
|
||||
</g>
|
||||
{/each}
|
||||
</svg>
|
||||
{#if showLegend}
|
||||
<div class="wrn-chart__legend" aria-label="Chart legend">
|
||||
{#each items as item, index}
|
||||
<button type="button" aria-pressed='{isHidden(index) ? "false" : "true"}' @click='toggleLegend(item, index, event)'>
|
||||
<span aria-hidden="true"></span>{item[labelKey]}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<slot />
|
||||
</figure>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-chart { display: grid; gap: 1rem; min-width: 0; margin: 0; padding: 1rem; border: 1px solid var(--wrn-color-border); border-radius: var(--wrn-radius); color: var(--wrn-color-text); background: var(--wrn-color-surface); box-shadow: var(--wrn-shadow-1); }
|
||||
.wrn-chart__header { display: grid; gap: 0.2rem; }
|
||||
.wrn-chart__header > span { color: var(--wrn-color-muted); font-size: 0.8rem; }
|
||||
.wrn-chart__plot { display: block; width: 100%; min-height: 12rem; overflow: visible; }
|
||||
.wrn-chart__axis { stroke: var(--wrn-color-border); stroke-width: 0.5; }
|
||||
.wrn-chart__bar { fill: var(--wrn-color-primary); cursor: pointer; transition: opacity var(--wrn-motion-fast), transform var(--wrn-motion-fast); transform-box: fill-box; transform-origin: bottom; }
|
||||
.wrn-chart__bar:hover { opacity: 0.82; transform: scaleY(1.02); }
|
||||
.wrn-chart__bar:focus-visible { outline: 1.5px solid var(--wrn-color-focus); outline-offset: 1px; }
|
||||
.wrn-chart__plot g[data-hidden="true"] { opacity: 0.18; pointer-events: none; }
|
||||
.wrn-chart__value, .wrn-chart__label { fill: var(--wrn-color-text); font-size: 3.5px; text-anchor: middle; }
|
||||
.wrn-chart__label { fill: var(--wrn-color-muted); }
|
||||
.wrn-chart__legend { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.wrn-chart__legend button { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.3rem 0.5rem; border: 1px solid var(--wrn-color-border); border-radius: 999px; color: var(--wrn-color-text); background: transparent; font: inherit; font-size: 0.75rem; cursor: pointer; }
|
||||
.wrn-chart__legend button > span { width: 0.65rem; height: 0.65rem; border-radius: 0.2rem; background: var(--wrn-color-primary); }
|
||||
.wrn-chart__legend button[aria-pressed="false"] { opacity: 0.5; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
component ChatBubble {
|
||||
outputs {
|
||||
action(payload: { action: boolean; item: string | number | boolean | null | object; index: number })
|
||||
messageClick(payload: { item: string | number | boolean | null | object; index: number; direction: string })
|
||||
avatarClick(payload: { item: string | number | boolean | null | object; index: number; direction: string })
|
||||
linkClick(payload: { link: string | number | boolean | null | object; item: string | number | boolean | null | object; index: number })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
title: string = ""
|
||||
description: string = ""
|
||||
items: unknown[] = []
|
||||
oneSided: boolean = false
|
||||
showAvatars: boolean = false
|
||||
showMetadata: boolean = false
|
||||
ariaLabel: string = "Conversation"
|
||||
variant: string = "default"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
functions {
|
||||
shared function messageDirection(item) {
|
||||
return item.direction === "outgoing" ? "outgoing" : "incoming"
|
||||
}
|
||||
|
||||
client function selectMessage(item, index) {
|
||||
output.messageClick({
|
||||
item: item,
|
||||
index: index,
|
||||
direction: messageDirection(item)
|
||||
})
|
||||
}
|
||||
|
||||
client function selectAvatar(sourceEvent, item, index) {
|
||||
sourceEvent.stopPropagation()
|
||||
output.avatarClick({
|
||||
item: item,
|
||||
index: index,
|
||||
direction: messageDirection(item)
|
||||
})
|
||||
}
|
||||
|
||||
client function selectLink(sourceEvent, link, item, index) {
|
||||
sourceEvent.stopPropagation()
|
||||
output.linkClick({
|
||||
link: link,
|
||||
item: item,
|
||||
index: index
|
||||
})
|
||||
}
|
||||
|
||||
client function selectAction(sourceEvent, item, index) {
|
||||
sourceEvent.stopPropagation()
|
||||
output.action({
|
||||
action: item.action || "retry",
|
||||
item: item,
|
||||
index: index
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<section
|
||||
{...attrs}
|
||||
class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--chat-bubble wrn-next--variant-{variant} {class}"
|
||||
data-one-sided="{oneSided}"
|
||||
role="log"
|
||||
aria-label="{ariaLabel}"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if title || description}
|
||||
<header class="wrn-next__chat-header">
|
||||
{#if title}<h3>{title}</h3>{/if}
|
||||
{#if description}<p>{description}</p>{/if}
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
{#if items.length}
|
||||
<div class="wrn-next__chat-thread">
|
||||
{#each items as item, index}
|
||||
<article
|
||||
class="wrn-next__chat-message"
|
||||
data-direction="{messageDirection(item)}"
|
||||
aria-label="{messageDirection(item) === 'outgoing' ? 'Sent message' : 'Received message'}"
|
||||
>
|
||||
<div class="wrn-next__chat-row">
|
||||
{#if showAvatars && (item.avatarSrc || item.avatarFallback)}
|
||||
<button
|
||||
class="wrn-next__chat-avatar"
|
||||
type="button"
|
||||
aria-label="{item.avatarLabel || item.author || 'Message author'}"
|
||||
@click="selectAvatar(event, item, index)"
|
||||
>
|
||||
{#if item.avatarSrc}
|
||||
<img src="{item.avatarSrc}" alt="{item.avatarAlt || ''}" />
|
||||
{:else}
|
||||
<span>{item.avatarFallback}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="wrn-next__chat-content"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="selectMessage(item, index)"
|
||||
@keydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); selectMessage(item, index) }"
|
||||
>
|
||||
{#if item.title}<h4>{item.title}</h4>{/if}
|
||||
{#if item.text}<p>{item.text}</p>{/if}
|
||||
{#if item.bullets && item.bullets.length}
|
||||
<ul>
|
||||
{#each item.bullets as bullet}<li>{bullet}</li>{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if item.links && item.links.length}
|
||||
<nav aria-label="Message links">
|
||||
{#each item.links as link}
|
||||
<a
|
||||
href="{link.href || '#'}"
|
||||
@click="selectLink(event, link, item, index)"
|
||||
>{link.label}</a>
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showMetadata && (item.timestamp || item.status || item.actionLabel)}
|
||||
<footer class="wrn-next__chat-meta" data-tone="{item.statusTone || 'muted'}">
|
||||
{#if item.statusIcon}<span class="{item.statusIcon}" aria-hidden="true"></span>{/if}
|
||||
{#if item.status}<span>{item.status}</span>{/if}
|
||||
{#if item.timestamp}<time datetime="{item.datetime || ''}">{item.timestamp}</time>{/if}
|
||||
{#if item.actionLabel}
|
||||
<button type="button" @click="selectAction(event, item, index)">
|
||||
{item.actionLabel}
|
||||
</button>
|
||||
{/if}
|
||||
</footer>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<slot />
|
||||
</section>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-next--chat-bubble.wrn-component--size-sm {
|
||||
--wrn-chat-padding: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.wrn-next--chat-bubble.wrn-component--size-lg {
|
||||
--wrn-chat-padding: 1.25rem;
|
||||
font-size: 1.0625rem;
|
||||
}
|
||||
|
||||
.wrn-next--chat-bubble {
|
||||
--wrn-chat-color: var(--wrn-component-color, var(--wrn-color-primary));
|
||||
--wrn-chat-padding: 1rem;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 1rem;
|
||||
color: var(--wrn-color-text);
|
||||
}
|
||||
|
||||
.wrn-next__chat-header h3,
|
||||
.wrn-next__chat-header p,
|
||||
.wrn-next__chat-content h4,
|
||||
.wrn-next__chat-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wrn-next__chat-header p {
|
||||
margin-top: 0.35rem;
|
||||
color: var(--wrn-color-text-muted);
|
||||
}
|
||||
|
||||
.wrn-next__chat-thread {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.wrn-next__chat-message {
|
||||
display: grid;
|
||||
width: min(78%, 34rem);
|
||||
min-width: 0;
|
||||
justify-self: start;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.wrn-next__chat-message[data-direction="outgoing"] {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.wrn-next--chat-bubble[data-one-sided="true"] .wrn-next__chat-message {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.wrn-next__chat-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.wrn-next__chat-message[data-direction="outgoing"] .wrn-next__chat-row {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.wrn-next--chat-bubble[data-one-sided="true"]
|
||||
.wrn-next__chat-message[data-direction="outgoing"]
|
||||
.wrn-next__chat-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.wrn-next__chat-content {
|
||||
min-width: 0;
|
||||
padding: var(--wrn-chat-padding);
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: 1rem;
|
||||
background: var(--wrn-color-surface-raised, var(--wrn-color-surface));
|
||||
color: var(--wrn-color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wrn-next__chat-content:focus-visible {
|
||||
outline: 2px solid var(--wrn-chat-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.wrn-next__chat-message[data-direction="outgoing"] .wrn-next__chat-content {
|
||||
border-color: var(--wrn-chat-color);
|
||||
background: var(--wrn-chat-color);
|
||||
color: var(--wrn-color-primary-contrast, #fff);
|
||||
}
|
||||
|
||||
.wrn-next__chat-message[data-direction="outgoing"] .wrn-next__chat-content :is(h4, p, li, span) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.wrn-next__chat-content h4 {
|
||||
margin-bottom: 0.65rem;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.wrn-next__chat-content p + p,
|
||||
.wrn-next__chat-content p + nav {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.wrn-next__chat-content ul {
|
||||
display: grid;
|
||||
margin: 0.55rem 0 0;
|
||||
padding-inline-start: 1.25rem;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.wrn-next__chat-content nav {
|
||||
display: grid;
|
||||
margin-top: 0.75rem;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.wrn-next__chat-content a {
|
||||
color: var(--wrn-chat-color);
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wrn-next__chat-message[data-direction="outgoing"] .wrn-next__chat-content a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.wrn-next__chat-avatar {
|
||||
display: grid;
|
||||
flex: 0 0 2.5rem;
|
||||
width: 2.5rem;
|
||||
min-height: 2.5rem;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--wrn-color-surface-raised, var(--wrn-color-surface));
|
||||
color: var(--wrn-color-text);
|
||||
cursor: pointer;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.wrn-next__chat-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.wrn-next__chat-avatar:focus-visible {
|
||||
outline: 2px solid var(--wrn-chat-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.wrn-next__chat-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding-inline: calc(2.5rem + 0.75rem);
|
||||
color: var(--wrn-color-text-muted);
|
||||
font-size: 0.78em;
|
||||
}
|
||||
|
||||
.wrn-next__chat-message[data-direction="outgoing"] .wrn-next__chat-meta {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.wrn-next__chat-meta[data-tone="danger"] {
|
||||
color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-next__chat-meta button {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.wrn-next__chat-message {
|
||||
width: min(92%, 34rem);
|
||||
}
|
||||
.wrn-next__chat-avatar {
|
||||
flex-basis: 2rem;
|
||||
width: 2rem;
|
||||
min-height: 2rem;
|
||||
}
|
||||
.wrn-next__chat-meta {
|
||||
padding-inline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shared component foundation. */
|
||||
|
||||
.wrn-component {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
--wrn-component-scale: 1;
|
||||
margin: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-family: var(--wrn-font-sans, inherit);
|
||||
}
|
||||
|
||||
.wrn-component--color-primary {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-component--color-secondary {
|
||||
--wrn-component-color: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-component--color-success {
|
||||
--wrn-component-color: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-component--color-warning {
|
||||
--wrn-component-color: var(--wrn-color-warning);
|
||||
}
|
||||
|
||||
.wrn-component--color-danger {
|
||||
--wrn-component-color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-component--color-info {
|
||||
--wrn-component-color: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-component--size-xs {
|
||||
--wrn-component-scale: 0.78;
|
||||
}
|
||||
|
||||
.wrn-component--size-sm {
|
||||
--wrn-component-scale: 0.88;
|
||||
}
|
||||
|
||||
.wrn-component--size-default,
|
||||
.wrn-component--size-md {
|
||||
--wrn-component-scale: 1;
|
||||
}
|
||||
|
||||
.wrn-component--size-lg {
|
||||
--wrn-component-scale: 1.14;
|
||||
}
|
||||
|
||||
.wrn-component--size-xl {
|
||||
--wrn-component-scale: 1.28;
|
||||
}
|
||||
|
||||
.wrn-component:not(.wrn-btn) {
|
||||
font-size: calc(1em * var(--wrn-component-scale));
|
||||
}
|
||||
|
||||
.wrn-component :is(a, button, input, select, textarea):focus-visible {
|
||||
outline-color: var(--wrn-component-color);
|
||||
}
|
||||
|
||||
.wrn-component p {
|
||||
margin: 0;
|
||||
color: var(--wrn-color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
component Clipboard {
|
||||
outputs {
|
||||
copy(payload: { value: string; sourceEvent?: Event })
|
||||
success(payload: { value: string; sourceEvent?: Event })
|
||||
error(payload: { error: Error | string; value: string; sourceEvent?: Event })
|
||||
}
|
||||
|
||||
props {
|
||||
value: string = ""
|
||||
label: string = "Copy"
|
||||
copiedLabel: string = "Copied"
|
||||
errorLabel: string = "Copy failed"
|
||||
title: string = "Clipboard"
|
||||
description: string = ""
|
||||
code: boolean = true
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
disabled: boolean = false
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
state status = "idle"
|
||||
|
||||
functions {
|
||||
client function resetStatus() { status = "idle" }
|
||||
client function copySucceeded() {
|
||||
status = "success"
|
||||
output.success({ value: value })
|
||||
setTimeout(resetStatus, 1600)
|
||||
}
|
||||
client function copyFailed(error) {
|
||||
status = "error"
|
||||
output.error({ error: error, value: value })
|
||||
setTimeout(resetStatus, 1600)
|
||||
}
|
||||
client function copyValue(sourceEvent) {
|
||||
if (disabled || status === "copying") {
|
||||
return
|
||||
}
|
||||
status = "copying"
|
||||
output.copy({ value: value, sourceEvent: sourceEvent })
|
||||
if (!window.navigator.clipboard || !window.navigator.clipboard.writeText) {
|
||||
copyFailed(new Error("Clipboard API is unavailable"))
|
||||
return
|
||||
}
|
||||
return window.navigator.clipboard.writeText(value).then(copySucceeded).catch(copyFailed)
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<section
|
||||
{...attrs}
|
||||
data-ui-component="Clipboard"
|
||||
data-status='{status}'
|
||||
data-size='{size}'
|
||||
data-color='{color}'
|
||||
class='wrn-clipboard {class}'
|
||||
>
|
||||
{#if title}<strong class="wrn-clipboard__title">{title}</strong>{/if}
|
||||
{#if description}<p class="wrn-clipboard__description">{description}</p>{/if}
|
||||
<div class="wrn-clipboard__control">
|
||||
{#if code}
|
||||
<code class="wrn-clipboard__value">{value}</code>
|
||||
{:else}
|
||||
<span class="wrn-clipboard__value">{value}</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-clipboard__button"
|
||||
disabled='{disabled}'
|
||||
aria-label='{status === "success" ? copiedLabel : status === "error" ? errorLabel : label}'
|
||||
@click='copyValue(event)'
|
||||
>
|
||||
<span class="icon-[lucide--copy] wrn-clipboard__icon" data-show='status !== "success"' aria-hidden="true"></span>
|
||||
<span class="icon-[lucide--check] wrn-clipboard__icon" data-show='status === "success"' aria-hidden="true"></span>
|
||||
<span>{status === "success" ? copiedLabel : status === "error" ? errorLabel : label}</span>
|
||||
</button>
|
||||
</div>
|
||||
<slot />
|
||||
</section>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-clipboard { display: grid; gap: 0.5rem; color: var(--wrn-color-text); }
|
||||
.wrn-clipboard__title { font-size: 0.875rem; }
|
||||
.wrn-clipboard__description { margin: 0; color: var(--wrn-color-muted); font-size: 0.8rem; }
|
||||
.wrn-clipboard__control { display: flex; min-width: 0; align-items: stretch; overflow: hidden; border: 1px solid var(--wrn-color-border); border-radius: var(--wrn-radius); background: var(--wrn-color-surface); box-shadow: var(--wrn-shadow-1); }
|
||||
.wrn-clipboard__value { min-width: 0; flex: 1; overflow: auto; padding: 0.7rem 0.85rem; color: var(--wrn-color-text); background: var(--wrn-color-surface-2); font: inherit; font-family: var(--wrn-font-mono, ui-monospace, monospace); white-space: nowrap; }
|
||||
.wrn-clipboard__button { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.6rem 0.85rem; border: 0; border-left: 1px solid var(--wrn-color-border); color: var(--wrn-color-primary); background: transparent; font: inherit; font-size: 0.8rem; font-weight: 700; cursor: pointer; }
|
||||
.wrn-clipboard__button:hover { background: var(--wrn-color-primary-soft); }
|
||||
.wrn-clipboard__button:focus-visible { outline: 2px solid var(--wrn-color-focus); outline-offset: -3px; }
|
||||
.wrn-clipboard__button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
.wrn-clipboard[data-status="success"] .wrn-clipboard__button { color: var(--wrn-color-success); }
|
||||
.wrn-clipboard[data-status="error"] .wrn-clipboard__button { color: var(--wrn-color-danger); }
|
||||
.wrn-clipboard__icon { width: 1rem; height: 1rem; flex: none; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
component Collapse {
|
||||
outputs {
|
||||
toggle(payload: { index: number; item: string | number | boolean | null | object; open: boolean; openIndexes: number[] })
|
||||
open(payload: { index: number; item: string | number | boolean | null | object })
|
||||
close(payload: { index: number; item: string | number | boolean | null | object })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
items: unknown[] = []
|
||||
multiple: boolean = false
|
||||
mode: string = "panel"
|
||||
initialOpenIndexes: unknown[] = []
|
||||
ariaLabel: string = "Collapsible content"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
state openIndexes = initialOpenIndexes
|
||||
|
||||
functions {
|
||||
shared function isOpen(index) {
|
||||
return openIndexes.includes(index)
|
||||
}
|
||||
|
||||
client function toggleItem(index, item, opening) {
|
||||
if (item.disabled) {
|
||||
return
|
||||
}
|
||||
opening = !isOpen(index)
|
||||
if (opening) {
|
||||
if (multiple) {
|
||||
openIndexes = openIndexes.concat(index)
|
||||
} else {
|
||||
openIndexes = [index]
|
||||
}
|
||||
output.open({ index: index, item: item })
|
||||
} else {
|
||||
openIndexes = openIndexes.filter((openIndex) => openIndex !== index)
|
||||
output.close({ index: index, item: item })
|
||||
}
|
||||
output.toggle({
|
||||
index: index,
|
||||
item: item,
|
||||
open: opening,
|
||||
openIndexes: openIndexes
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<section
|
||||
{...attrs}
|
||||
class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--collapse {class}"
|
||||
data-mode="{mode}"
|
||||
aria-label="{ariaLabel}"
|
||||
>
|
||||
{#each items as item, index}
|
||||
<article class="wrn-next__collapse-item" data-open="{isOpen(index)}">
|
||||
{#if mode === "inline" && item.preview}
|
||||
<p class="wrn-next__collapse-preview">{item.preview}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="wrn-next__collapse-trigger"
|
||||
type="button"
|
||||
aria-expanded="{isOpen(index) ? 'true' : 'false'}"
|
||||
aria-controls="{item.id || 'collapse-panel-' + index}"
|
||||
disabled="{item.disabled}"
|
||||
@click="toggleItem(index, item)"
|
||||
>
|
||||
<span>{isOpen(index) ? (item.closeLabel || "Read less") : item.label}</span>
|
||||
<span class="icon-[lucide--chevron-down] wrn-next__collapse-chevron" aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="{item.id || 'collapse-panel-' + index}"
|
||||
class="wrn-next__collapse-panel"
|
||||
data-open="{isOpen(index)}"
|
||||
aria-hidden="{isOpen(index) ? 'false' : 'true'}"
|
||||
inert="{!isOpen(index)}"
|
||||
>
|
||||
<div class="wrn-next__collapse-content">
|
||||
{#if item.title}<h4>{item.title}</h4>{/if}
|
||||
{#if item.content}<p>{item.content}</p>{/if}
|
||||
{#if item.links && item.links.length}
|
||||
<nav aria-label="Related links">
|
||||
{#each item.links as link}<a href="{link.href || '#'}">{link.label}</a>{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
<slot />
|
||||
</section>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-next--collapse.wrn-component--size-sm {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.wrn-next--collapse.wrn-component--size-lg {
|
||||
font-size: 1.0625rem;
|
||||
}
|
||||
|
||||
.wrn-next--collapse {
|
||||
--wrn-collapse-color: var(--wrn-component-color, var(--wrn-color-primary));
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 0.75rem;
|
||||
color: var(--wrn-color-text);
|
||||
}
|
||||
|
||||
.wrn-next__collapse-item {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
justify-items: start;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.wrn-next__collapse-trigger {
|
||||
display: inline-flex;
|
||||
min-height: 2.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.7rem 1rem;
|
||||
border: 1px solid var(--wrn-collapse-color);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-collapse-color);
|
||||
color: var(--wrn-color-primary-contrast, #fff);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wrn-next__collapse-trigger:focus-visible {
|
||||
outline: 2px solid var(--wrn-collapse-color);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.wrn-next__collapse-trigger:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.wrn-next__collapse-chevron {
|
||||
transition: transform var(--wrn-motion-fast) var(--wrn-ease-standard);
|
||||
}
|
||||
|
||||
.wrn-next__collapse-item[data-open="true"] .wrn-next__collapse-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.wrn-next__collapse-panel {
|
||||
width: min(100%, 42rem);
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
margin-top: 0;
|
||||
opacity: 0;
|
||||
transition:
|
||||
max-height 350ms var(--wrn-ease-emphasized),
|
||||
margin-top 300ms var(--wrn-ease-emphasized),
|
||||
opacity 200ms var(--wrn-ease-standard);
|
||||
}
|
||||
|
||||
.wrn-next__collapse-panel[data-open="true"] {
|
||||
max-height: 40rem;
|
||||
margin-top: 0.75rem;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wrn-next__collapse-content {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
gap: 0.65rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-md);
|
||||
background: var(--wrn-color-surface-raised, var(--wrn-color-surface));
|
||||
}
|
||||
|
||||
.wrn-next__collapse-content h4,
|
||||
.wrn-next__collapse-content p,
|
||||
.wrn-next__collapse-preview {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wrn-next__collapse-content nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.wrn-next__collapse-content a {
|
||||
color: var(--wrn-collapse-color);
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wrn-next--collapse[data-mode="inline"] .wrn-next__collapse-item {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.wrn-next--collapse[data-mode="inline"] .wrn-next__collapse-preview {
|
||||
order: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.wrn-next--collapse[data-mode="inline"] .wrn-next__collapse-panel {
|
||||
order: 2;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wrn-next--collapse[data-mode="inline"] .wrn-next__collapse-panel[data-open="true"] {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.wrn-next--collapse[data-mode="inline"] .wrn-next__collapse-trigger {
|
||||
order: 3;
|
||||
margin-top: 0.5rem;
|
||||
min-height: auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--wrn-collapse-color);
|
||||
}
|
||||
|
||||
.wrn-next--collapse[data-mode="inline"] .wrn-next__collapse-content {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wrn-next__collapse-chevron,
|
||||
.wrn-next__collapse-panel {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shared component foundation. */
|
||||
|
||||
.wrn-component {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
--wrn-component-scale: 1;
|
||||
margin: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-family: var(--wrn-font-sans, inherit);
|
||||
}
|
||||
|
||||
.wrn-component--color-primary {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-component--color-secondary {
|
||||
--wrn-component-color: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-component--color-success {
|
||||
--wrn-component-color: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-component--color-warning {
|
||||
--wrn-component-color: var(--wrn-color-warning);
|
||||
}
|
||||
|
||||
.wrn-component--color-danger {
|
||||
--wrn-component-color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-component--color-info {
|
||||
--wrn-component-color: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-component--size-xs {
|
||||
--wrn-component-scale: 0.78;
|
||||
}
|
||||
|
||||
.wrn-component--size-sm {
|
||||
--wrn-component-scale: 0.88;
|
||||
}
|
||||
|
||||
.wrn-component--size-default,
|
||||
.wrn-component--size-md {
|
||||
--wrn-component-scale: 1;
|
||||
}
|
||||
|
||||
.wrn-component--size-lg {
|
||||
--wrn-component-scale: 1.14;
|
||||
}
|
||||
|
||||
.wrn-component--size-xl {
|
||||
--wrn-component-scale: 1.28;
|
||||
}
|
||||
|
||||
.wrn-component:not(.wrn-btn) {
|
||||
font-size: calc(1em * var(--wrn-component-scale));
|
||||
}
|
||||
|
||||
.wrn-component :is(a, button, input, select, textarea):focus-visible {
|
||||
outline-color: var(--wrn-component-color);
|
||||
}
|
||||
|
||||
.wrn-component p {
|
||||
margin: 0;
|
||||
color: var(--wrn-color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
component ColorPicker {
|
||||
outputs {
|
||||
input(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)
|
||||
focus(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
blur(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
id: string = ""
|
||||
name: string = ""
|
||||
label: string = "Color"
|
||||
hiddenLabel: boolean = false
|
||||
placeholder: string = ""
|
||||
value: string = "#2563eb"
|
||||
icon: string = ""
|
||||
iconPosition: string = "start"
|
||||
helperText: string = ""
|
||||
cornerHint: string = ""
|
||||
error: string = ""
|
||||
inline: boolean = false
|
||||
variant: string = "normal"
|
||||
readonly: boolean = false
|
||||
disabled: boolean = false
|
||||
required: boolean = false
|
||||
class: string = ""
|
||||
}
|
||||
functions {
|
||||
client function emitField(nameEvent, sourceEvent) { sourceEvent.stopPropagation(); output[nameEvent]({ value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }) }
|
||||
client function preventReadonly(sourceEvent) { if (readonly) sourceEvent.preventDefault() }
|
||||
}
|
||||
view {
|
||||
<div {...attrs} class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--field wrn-component--color-picker {class}" data-variant="{variant}" data-inline="{inline}" data-invalid="{error ? 'true' : 'false'}">
|
||||
<div class="wrn-next__field-heading"><label class="{hiddenLabel ? 'wrn-next__sr-only' : ''}" for="{id || name}">{label}</label>{#if cornerHint}<span class="wrn-next__field-hint">{cornerHint}</span>{/if}</div>
|
||||
<div class="wrn-next__color-control" data-icon-position="{iconPosition}">{#if icon}<span class="{icon}" aria-hidden="true"></span>{/if}<input id="{id || name}" type="color" name="{name}" value="{value}" disabled="{disabled}" required="{required}" readonly="{readonly}" aria-invalid="{error ? 'true' : 'false'}" @click="preventReadonly(event)" @input="emitField('input', event)" @change="emitField('change', event)" @focus="emitField('focus', event)" @blur="emitField('blur', event)" /><output>{value}</output></div>
|
||||
{#if helperText}<small class="wrn-next__field-help">{helperText}</small>{/if}<small class="wrn-next__field-error" data-error="{name}">{error}</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-next__color-control input[type="color"] {
|
||||
width: 3rem;
|
||||
padding: 0.2rem;
|
||||
}
|
||||
|
||||
/* Shared component foundation. */
|
||||
|
||||
.wrn-component {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
--wrn-component-scale: 1;
|
||||
margin: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-family: var(--wrn-font-sans, inherit);
|
||||
}
|
||||
|
||||
.wrn-component--color-primary {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-component--color-secondary {
|
||||
--wrn-component-color: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-component--color-success {
|
||||
--wrn-component-color: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-component--color-warning {
|
||||
--wrn-component-color: var(--wrn-color-warning);
|
||||
}
|
||||
|
||||
.wrn-component--color-danger {
|
||||
--wrn-component-color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-component--color-info {
|
||||
--wrn-component-color: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-component--size-xs {
|
||||
--wrn-component-scale: 0.78;
|
||||
}
|
||||
|
||||
.wrn-component--size-sm {
|
||||
--wrn-component-scale: 0.88;
|
||||
}
|
||||
|
||||
.wrn-component--size-default,
|
||||
.wrn-component--size-md {
|
||||
--wrn-component-scale: 1;
|
||||
}
|
||||
|
||||
.wrn-component--size-lg {
|
||||
--wrn-component-scale: 1.14;
|
||||
}
|
||||
|
||||
.wrn-component--size-xl {
|
||||
--wrn-component-scale: 1.28;
|
||||
}
|
||||
|
||||
.wrn-component:not(.wrn-btn) {
|
||||
font-size: calc(1em * var(--wrn-component-scale));
|
||||
}
|
||||
|
||||
.wrn-component :is(a, button, input, select, textarea):focus-visible {
|
||||
outline-color: var(--wrn-component-color);
|
||||
}
|
||||
|
||||
.wrn-component p {
|
||||
margin: 0;
|
||||
color: var(--wrn-color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Shared field foundation. */
|
||||
|
||||
/* Validation states set by the browser validation runtime. */
|
||||
.wrn-invalid {
|
||||
border-color: var(--wrn-color-danger) !important;
|
||||
}
|
||||
|
||||
.wrn-field-error {
|
||||
display: block;
|
||||
min-height: 1em;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--wrn-color-danger);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.wrn-next--choice {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wrn-next--choice input {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
accent-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-next--field {
|
||||
display: grid;
|
||||
width: min(100%, 28rem);
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.wrn-next--field :where(input, textarea, select) {
|
||||
width: 100%;
|
||||
min-height: 2.65rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: 0.6rem;
|
||||
outline: 0;
|
||||
color: var(--wrn-color-text);
|
||||
background: var(--wrn-color-bg);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-auth-form form > .wrn-next--field,
|
||||
.wrn-auth-form form > .wrn-field {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
.wrn-auth-form__submit > .wrn-btn {
|
||||
width: 100%;
|
||||
min-height: 3rem;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 0.8rem 1.8rem color-mix(in srgb, var(--wrn-btn-color) 20%, transparent);
|
||||
}
|
||||
.wrn-next--field :where(input, textarea, select):focus-visible {
|
||||
border-color: var(--wrn-color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--wrn-color-primary) 16%, transparent);
|
||||
}
|
||||
.wrn-next--field textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
.wrn-next--field,
|
||||
.wrn-next--choice-field {
|
||||
--wrn-field-color: var(--wrn-component-color, var(--wrn-color-primary));
|
||||
width: min(100%, 34rem);
|
||||
min-width: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-next--field.wrn-next--size-sm,
|
||||
.wrn-next--choice-field.wrn-next--size-sm {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.wrn-next--field.wrn-next--size-lg,
|
||||
.wrn-next--choice-field.wrn-next--size-lg {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.wrn-next__field-heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wrn-next__field-heading label {
|
||||
color: var(--wrn-color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wrn-next__field-hint,
|
||||
.wrn-next__field-help {
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 0.82em;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-next__field-control {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.wrn-next__field-control > :is(input, textarea, select) {
|
||||
padding-inline: 0.85rem;
|
||||
border-color: var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.wrn-next__field-control[data-icon-position="start"] > :is(input, textarea, select) {
|
||||
padding-inline-start: 2.5rem;
|
||||
}
|
||||
.wrn-next__field-control[data-icon-position="end"] > :is(input, textarea, select) {
|
||||
padding-inline-end: 2.5rem;
|
||||
}
|
||||
.wrn-next__field-icon {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 50%;
|
||||
left: 0.85rem;
|
||||
color: var(--wrn-color-muted);
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.wrn-next__field-control[data-icon-position="end"] > .wrn-next__field-icon {
|
||||
right: 0.85rem;
|
||||
left: auto;
|
||||
}
|
||||
.wrn-next--field[data-variant="outlined"]
|
||||
.wrn-next__field-control
|
||||
> :is(input, textarea, select) {
|
||||
border-width: 2px;
|
||||
}
|
||||
.wrn-next--field[data-variant="underline"]
|
||||
.wrn-next__field-control
|
||||
> :is(input, textarea, select) {
|
||||
padding-inline: 0;
|
||||
border-width: 0 0 1px;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.wrn-next--field[data-variant="pill"] .wrn-next__field-control > :is(input, select) {
|
||||
border-radius: 999px;
|
||||
padding-inline: 1.15rem;
|
||||
}
|
||||
.wrn-next--field[data-variant="ghost"] .wrn-next__field-control > :is(input, textarea, select) {
|
||||
border-color: transparent;
|
||||
background: color-mix(in srgb, var(--wrn-field-color) 8%, var(--wrn-color-surface));
|
||||
}
|
||||
.wrn-next__field-floating-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0.85rem;
|
||||
color: var(--wrn-color-muted);
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%);
|
||||
transition: 150ms ease;
|
||||
}
|
||||
.wrn-next--textarea .wrn-next__field-floating-label {
|
||||
top: 1.35rem;
|
||||
}
|
||||
.wrn-next--field[data-floating="true"] .wrn-next__field-control > :is(input, textarea, select) {
|
||||
padding-top: 1.2rem;
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
.wrn-next--field[data-floating="true"] .wrn-next__field-heading > label {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
overflow: hidden !important;
|
||||
margin: -1px !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
.wrn-next--field[data-floating="true"]
|
||||
.wrn-next__field-heading:not(:has(.wrn-next__field-hint)) {
|
||||
display: none;
|
||||
}
|
||||
.wrn-next--field[data-floating="true"]
|
||||
.wrn-next__field-control
|
||||
> :is(input, textarea):is(:focus, :not(:placeholder-shown))
|
||||
+ .wrn-next__field-floating-label {
|
||||
top: 0.42rem;
|
||||
font-size: 0.7em;
|
||||
transform: none;
|
||||
}
|
||||
.wrn-next__field-error {
|
||||
min-height: 1em;
|
||||
color: var(--wrn-color-danger);
|
||||
font-size: 0.82em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.wrn-next__field-error:empty {
|
||||
display: none;
|
||||
}
|
||||
.wrn-next--field[data-invalid="true"] :is(input, textarea, select),
|
||||
.wrn-next--choice-field[data-invalid="true"] input {
|
||||
border-color: var(--wrn-color-danger);
|
||||
}
|
||||
.wrn-next--field :is(input, textarea, select):disabled,
|
||||
.wrn-next--choice-field input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
.wrn-next--field :is(input, textarea, select):read-only {
|
||||
background: var(--wrn-color-surface-2);
|
||||
}
|
||||
.wrn-next--field[data-inline="true"],
|
||||
.wrn-next--choice-field[data-inline="true"] {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
grid-template-columns: minmax(7rem, max-content) minmax(12rem, 20rem) minmax(10rem, 1fr);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-heading,
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-heading {
|
||||
min-height: 2.65rem;
|
||||
align-items: center;
|
||||
}
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-heading {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-help,
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-help {
|
||||
display: flex;
|
||||
min-height: 2.65rem;
|
||||
align-items: center;
|
||||
}
|
||||
.wrn-next--choice-field[data-inline="true"] {
|
||||
grid-template-columns: minmax(12rem, 20rem) minmax(10rem, 1fr);
|
||||
}
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-heading:empty {
|
||||
display: none;
|
||||
}
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-error,
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-error {
|
||||
grid-column: 2 / -1;
|
||||
}
|
||||
.wrn-next__sr-only {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
overflow: hidden !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
margin: -1px !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
.wrn-next__choice-field {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.wrn-next__choice {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
color: var(--wrn-color-text);
|
||||
width: 100%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-next__checkbox-content > .wrn-next__choice {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.wrn-next__choice input {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
accent-color: var(--wrn-field-color);
|
||||
}
|
||||
.wrn-next__choice-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.15rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.wrn-next__choice-copy small {
|
||||
color: var(--wrn-color-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-next__radio-group,
|
||||
.wrn-next__checkbox-group {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.wrn-next--radio-field[data-orientation="horizontal"] .wrn-next__radio-group,
|
||||
.wrn-next--checkbox-field[data-orientation="horizontal"] .wrn-next__checkbox-group {
|
||||
grid-template-columns: repeat(auto-fit, minmax(8rem, max-content));
|
||||
align-items: stretch;
|
||||
}
|
||||
.wrn-next--radio-field[data-card="true"] .wrn-next__radio-group,
|
||||
.wrn-next--checkbox-field[data-card="true"] .wrn-next__checkbox-group {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
|
||||
}
|
||||
.wrn-next--radio-field[data-card="true"] .wrn-next--radio,
|
||||
.wrn-next--checkbox-field[data-card="true"] .wrn-next--checkbox {
|
||||
min-height: 4.5rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface);
|
||||
}
|
||||
.wrn-next--radio-field[data-card="true"] .wrn-next--radio:has(input:checked),
|
||||
.wrn-next--checkbox-field[data-card="true"] .wrn-next--checkbox:has(input:checked) {
|
||||
border-color: var(--wrn-field-color);
|
||||
box-shadow: 0 0 0 1px var(--wrn-field-color);
|
||||
}
|
||||
.wrn-next--radio-field[data-list="true"] .wrn-next--radio,
|
||||
.wrn-next--checkbox-field[data-list="true"] .wrn-next--checkbox {
|
||||
padding-block: 0.75rem;
|
||||
border-bottom: 1px solid var(--wrn-color-border);
|
||||
}
|
||||
.wrn-next--radio-field[data-right-aligned="true"] .wrn-next--radio,
|
||||
.wrn-next--checkbox-field[data-right-aligned="true"] .wrn-next--checkbox {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.wrn-next--radio-field[data-right-aligned="true"] .wrn-next--radio input,
|
||||
.wrn-next--checkbox-field[data-right-aligned="true"] .wrn-next--checkbox input {
|
||||
order: 2;
|
||||
}
|
||||
.wrn-next--radio-field[data-right-aligned="true"] .wrn-next__choice-copy,
|
||||
.wrn-next--checkbox-field[data-right-aligned="true"] .wrn-next__choice-copy {
|
||||
order: 1;
|
||||
}
|
||||
.wrn-next__choice[data-variant="outlined"],
|
||||
.wrn-next__choice[data-variant="floating"] {
|
||||
padding: 0.65rem 0.8rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
}
|
||||
.wrn-next__choice[data-variant="pill"] {
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--wrn-field-color) 9%, var(--wrn-color-surface));
|
||||
}
|
||||
.wrn-next__choice[data-variant="ghost"] {
|
||||
padding: 0.65rem 0.8rem;
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: color-mix(in srgb, var(--wrn-field-color) 7%, transparent);
|
||||
}
|
||||
.wrn-next__choice[data-variant="underline"] {
|
||||
padding-block: 0.5rem;
|
||||
border-bottom: 1px solid var(--wrn-color-border);
|
||||
}
|
||||
.wrn-next--select .wrn-next__field-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.wrn-next__color-control,
|
||||
.wrn-next__range-control,
|
||||
.wrn-next__input-group-control,
|
||||
.wrn-next__file-control {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.wrn-next__picker-control,
|
||||
.wrn-next--time-picker .wrn-next__field-control {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
}
|
||||
.wrn-next__picker-control > button:first-of-type,
|
||||
.wrn-next__time-trigger {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 2.65rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface);
|
||||
color: var(--wrn-color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.wrn-next__calendar,
|
||||
.wrn-next__time-panel {
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
width: min(100%, 22rem);
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-md);
|
||||
background: var(--wrn-color-surface);
|
||||
box-shadow: var(--wrn-shadow-3);
|
||||
}
|
||||
.wrn-next--date-picker[data-expanded="false"] .wrn-next__calendar,
|
||||
.wrn-next--time-picker[data-expanded="false"] .wrn-next__time-panel {
|
||||
display: none;
|
||||
}
|
||||
.wrn-next__calendar button,
|
||||
.wrn-next__time-panel button {
|
||||
min-height: 2.25rem;
|
||||
border: 0;
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
.wrn-next__calendar-weekdays,
|
||||
.wrn-next__calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 0.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.wrn-next__calendar-weekdays {
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.wrn-next__calendar-grid button:hover,
|
||||
.wrn-next__time-panel button:hover {
|
||||
background: var(--wrn-color-surface-2);
|
||||
}
|
||||
.wrn-next__calendar-grid button[aria-pressed="true"],
|
||||
.wrn-next__time-panel button[aria-pressed="true"] {
|
||||
background: var(--wrn-field-color);
|
||||
color: var(--wrn-color-primary-contrast, #fff);
|
||||
}
|
||||
.wrn-next__time-period {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.wrn-next--file-upload-progress .wrn-next__row,
|
||||
.wrn-next__upload-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.wrn-next--file-upload-progress .wrn-next__row > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.wrn-next--field[data-variant="underline"]
|
||||
:is(.wrn-next__input-group-control, .wrn-next__file-control, .wrn-next__color-control) {
|
||||
border-width: 0 0 1px;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.wrn-next--field[data-variant="outlined"]
|
||||
:is(.wrn-next__input-group-control, .wrn-next__file-control) {
|
||||
border-width: 2px;
|
||||
}
|
||||
.wrn-next--field[data-variant="pill"]
|
||||
:is(.wrn-next__input-group-control, .wrn-next__file-control) {
|
||||
border-radius: 999px;
|
||||
}
|
||||
.wrn-next--field[data-variant="ghost"]
|
||||
:is(.wrn-next__input-group-control, .wrn-next__file-control, .wrn-next__color-control) {
|
||||
border-color: transparent;
|
||||
background: color-mix(in srgb, var(--wrn-field-color) 8%, var(--wrn-color-surface));
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.wrn-next--field[data-inline="true"],
|
||||
.wrn-next--choice-field[data-inline="true"] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-error,
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-error {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
.wrn-next--toggle-password:has(input.wrn-invalid) .wrn-next__password-control > input,
|
||||
.wrn-next--toggle-password.wrn-next--invalid .wrn-next__password-control > input {
|
||||
border-color: var(--wrn-color-danger);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--wrn-color-danger) 14%, transparent);
|
||||
}
|
||||
.wrn-next--strong-password.wrn-next--invalid input,
|
||||
.wrn-next--strong-password input.wrn-invalid {
|
||||
border-color: var(--wrn-color-danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// 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"
|
||||
color: string = "primary"
|
||||
columns: number = 2
|
||||
gap: string = "md"
|
||||
maxWidth: string = "xl"
|
||||
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='wrn-columns {class}'
|
||||
data-size='{size}'
|
||||
data-color='{color}'
|
||||
data-gap='{gap}'
|
||||
data-max-width='{maxWidth}'
|
||||
data-columns='{columnCount()}'
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-columns {
|
||||
--columns-gap: 1.5rem;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: var(--columns-gap);
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.wrn-columns[data-max-width="md"] {
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
.wrn-columns[data-max-width="lg"] {
|
||||
max-width: 64rem;
|
||||
}
|
||||
|
||||
.wrn-columns[data-max-width="xl"] {
|
||||
max-width: 80rem;
|
||||
}
|
||||
|
||||
.wrn-columns[data-max-width="2xl"] {
|
||||
max-width: 96rem;
|
||||
}
|
||||
|
||||
.wrn-columns[data-max-width="full"] {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.wrn-columns[data-gap="sm"] {
|
||||
--columns-gap: 0.75rem;
|
||||
}
|
||||
|
||||
.wrn-columns[data-gap="lg"] {
|
||||
--columns-gap: 2.5rem;
|
||||
}
|
||||
|
||||
.wrn-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) {
|
||||
.wrn-columns:not([data-columns="1"]) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.wrn-columns[data-columns="3"] {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wrn-columns[data-columns="4"] {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
import SelectStyles from "../styles/SelectStyles.wrn"
|
||||
|
||||
component ComboBox {
|
||||
outputs {
|
||||
search(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)
|
||||
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)
|
||||
clear(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; [key: string]: string | number | boolean | null | object })
|
||||
close(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
load(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
error(payload: { error: Error | string; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | Error | string)
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
label: string = "ComboBox"
|
||||
name: string = ""
|
||||
value: string = ""
|
||||
options: unknown[] = []
|
||||
groups: unknown[] = []
|
||||
placeholder: string = "Search or select an option"
|
||||
searchPlaceholder: string = "Start typing…"
|
||||
clearable: boolean = true
|
||||
allowCustomValue: boolean = false
|
||||
disabled: boolean = false
|
||||
required: boolean = false
|
||||
invalid: boolean = false
|
||||
validationMessage: string = ""
|
||||
helpText: string = ""
|
||||
loading: boolean = false
|
||||
loadingLabel: string = "Loading suggestions…"
|
||||
emptyLabel: string = "No matching options"
|
||||
clearLabel: string = "Clear value"
|
||||
toggleLabel: string = "Toggle suggestions"
|
||||
searchMode: string = "contains"
|
||||
searchFields: string = "label,description"
|
||||
minSearchLength: number = 0
|
||||
searchResultLimit: number = 0
|
||||
optionTemplate: string = "default"
|
||||
defaultOpen: boolean = false
|
||||
closeOnSelect: boolean = true
|
||||
fixed: boolean = false
|
||||
placement: string = "bottom"
|
||||
autocomplete: string = "off"
|
||||
remote: boolean = false
|
||||
remoteUrl: string = ""
|
||||
remoteQueryParam: string = "q"
|
||||
remoteDebounce: number = 250
|
||||
remoteAutoLoad: boolean = true
|
||||
infinite: boolean = false
|
||||
hasMore: boolean = false
|
||||
page: number = 1
|
||||
loadMoreLabel: string = "Load more"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
state open = defaultOpen
|
||||
state query: string = ""
|
||||
state selectedValue = value
|
||||
state activeIndex: number = -1
|
||||
|
||||
functions {
|
||||
shared function allOptions() {
|
||||
return [...groups.flatMap((group) => group.options || []), ...options]
|
||||
}
|
||||
|
||||
shared function searchableText(option) {
|
||||
return ((option.label || "") + " " + (option.description || "")).toLowerCase()
|
||||
}
|
||||
|
||||
shared function matches(option) {
|
||||
if (!query || query.length < minSearchLength) {
|
||||
return true
|
||||
}
|
||||
if (searchMode === "startsWith") {
|
||||
return searchableText(option).startsWith(query.toLowerCase())
|
||||
}
|
||||
if (searchMode === "exact") {
|
||||
return searchableText(option) === query.toLowerCase()
|
||||
}
|
||||
return searchableText(option).includes(query.toLowerCase())
|
||||
}
|
||||
|
||||
shared function matchingOptions(list) {
|
||||
return list.filter((option) => matches(option))
|
||||
}
|
||||
|
||||
shared function visibleOptions(list) {
|
||||
if (searchResultLimit > 0) {
|
||||
return matchingOptions(list).slice(0, searchResultLimit)
|
||||
}
|
||||
return matchingOptions(list)
|
||||
}
|
||||
|
||||
shared function flatVisibleOptions() {
|
||||
return visibleOptions(allOptions())
|
||||
}
|
||||
|
||||
shared function isSelected(option) {
|
||||
return selectedValue === option.value
|
||||
}
|
||||
|
||||
shared function selectedOptions() {
|
||||
return allOptions().filter((option) => isSelected(option))
|
||||
}
|
||||
|
||||
shared function selectedText() {
|
||||
return selectedOptions().length ? selectedOptions()[0].label : selectedValue
|
||||
}
|
||||
|
||||
shared function inputText() {
|
||||
return query || selectedText()
|
||||
}
|
||||
|
||||
shared function shouldShowDropdown() {
|
||||
if (!open) {
|
||||
return false
|
||||
}
|
||||
if (loading) {
|
||||
return true
|
||||
}
|
||||
if (remote && query.length !== 0 && query.length < minSearchLength) {
|
||||
return true
|
||||
}
|
||||
if (flatVisibleOptions().length) {
|
||||
return true
|
||||
}
|
||||
return infinite && hasMore
|
||||
}
|
||||
|
||||
shared function canSelect(option) {
|
||||
return !option.disabled
|
||||
}
|
||||
|
||||
shared function chooseOption(option) {
|
||||
if (!canSelect(option)) {
|
||||
return
|
||||
}
|
||||
selectedValue = option.value
|
||||
query = option.label
|
||||
activeIndex = optionIndex(option)
|
||||
if (closeOnSelect) {
|
||||
open = false
|
||||
}
|
||||
}
|
||||
|
||||
client function updateQuery(event) {
|
||||
query = event.target.value
|
||||
selectedValue = allowCustomValue ? event.target.value : ""
|
||||
open = true
|
||||
activeIndex = flatVisibleOptions().length ? 0 : -1
|
||||
}
|
||||
|
||||
client function clearValue(event) {
|
||||
if (event) {
|
||||
event.stopPropagation()
|
||||
}
|
||||
query = ""
|
||||
selectedValue = ""
|
||||
activeIndex = -1
|
||||
open = false
|
||||
}
|
||||
|
||||
shared function openDropdown() {
|
||||
if (!disabled) {
|
||||
open = true
|
||||
activeIndex = flatVisibleOptions().length ? 0 : -1
|
||||
}
|
||||
}
|
||||
|
||||
shared function closeDropdown() {
|
||||
open = false
|
||||
}
|
||||
|
||||
shared function toggle() {
|
||||
if (open) {
|
||||
closeDropdown()
|
||||
} else {
|
||||
openDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
shared function setValue(nextValue) {
|
||||
selectedValue = nextValue
|
||||
query = ""
|
||||
}
|
||||
|
||||
shared function moveActive(direction) {
|
||||
if (!flatVisibleOptions().length) {
|
||||
return
|
||||
}
|
||||
activeIndex = (activeIndex + direction + flatVisibleOptions().length) % flatVisibleOptions().length
|
||||
}
|
||||
|
||||
client function handleKeydown(event) {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
openDropdown()
|
||||
moveActive(1)
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
openDropdown()
|
||||
moveActive(-1)
|
||||
} else if (event.key === "Enter" && open && activeIndex >= 0) {
|
||||
event.preventDefault()
|
||||
chooseOption(flatVisibleOptions()[activeIndex])
|
||||
} else if (event.key === "Escape") {
|
||||
closeDropdown()
|
||||
} else if (event.key === "Home" && open) {
|
||||
event.preventDefault()
|
||||
activeIndex = 0
|
||||
} else if (event.key === "End" && open) {
|
||||
event.preventDefault()
|
||||
activeIndex = flatVisibleOptions().length - 1
|
||||
}
|
||||
}
|
||||
|
||||
shared function optionIndex(option) {
|
||||
return flatVisibleOptions().findIndex((item) => item.value === option.value)
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<div
|
||||
class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--advanced-select wrn-next--combobox {open ? 'wrn-next--open' : ''} {fixed ? 'wrn-next--advanced-select-fixed' : ''} {invalid ? 'wrn-next--invalid' : ''} {disabled ? 'wrn-next--disabled' : ''} {class}"
|
||||
data-placement="{placement}"
|
||||
data-search-mode="{searchMode}"
|
||||
data-remote="{remote ? 'true' : 'false'}"
|
||||
data-remote-url="{remoteUrl}"
|
||||
data-remote-query-param="{remoteQueryParam}"
|
||||
data-remote-debounce="{remoteDebounce}"
|
||||
data-remote-auto-load="{remoteAutoLoad ? 'true' : 'false'}"
|
||||
data-infinite="{infinite ? 'true' : 'false'}"
|
||||
data-page="{page}"
|
||||
data-wrn-select
|
||||
data-wrn-combobox
|
||||
@focusout="if (!event.currentTarget.contains(event.relatedTarget)) { closeDropdown() }"
|
||||
>
|
||||
<label id="{name}-label" for="{name}-input">{label}</label>
|
||||
|
||||
<div class="wrn-next__select-control wrn-next__combobox-control">
|
||||
<span class="wrn-next__search-icon icon-[lucide--search]" aria-hidden="true"></span>
|
||||
<input
|
||||
{...attrs}
|
||||
id="{name}-input"
|
||||
class="wrn-next__select-trigger wrn-next__combobox-input"
|
||||
type="text"
|
||||
role="combobox"
|
||||
name="{allowCustomValue ? name : ''}"
|
||||
value="{inputText()}"
|
||||
placeholder="{placeholder}"
|
||||
autocomplete="{autocomplete}"
|
||||
aria-autocomplete="list"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="{open}"
|
||||
aria-controls="{name}-listbox"
|
||||
aria-labelledby="{name}-label"
|
||||
aria-invalid="{invalid}"
|
||||
disabled="{disabled}"
|
||||
required="{required && allowCustomValue}"
|
||||
@focus="openDropdown()"
|
||||
@click="openDropdown()"
|
||||
@input="updateQuery(event)"
|
||||
@keydown="handleKeydown(event)"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-next__clear-select"
|
||||
data-show="clearable && inputText() && !disabled"
|
||||
aria-label="{clearLabel}"
|
||||
title="{clearLabel}"
|
||||
@click="clearValue(event)"
|
||||
>
|
||||
<span class="icon-[lucide--x]" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-next__combobox-toggle"
|
||||
aria-label="{toggleLabel}"
|
||||
tabindex="-1"
|
||||
disabled="{disabled}"
|
||||
@click="toggle()"
|
||||
>
|
||||
<span class="wrn-next__select-chevron icon-[lucide--chevrons-up-down]" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="wrn-next__select-dropdown {fixed ? 'wrn-next__select-dropdown--fixed' : ''}"
|
||||
data-show="shouldShowDropdown()"
|
||||
style="{shouldShowDropdown() ? '' : 'display: none'}"
|
||||
>
|
||||
<div
|
||||
class="wrn-next__select-message"
|
||||
data-show="remote && query.length !== 0 && query.length < minSearchLength"
|
||||
>Enter at least {minSearchLength} characters.</div>
|
||||
<div class="wrn-next__select-message" data-show="loading" role="status">
|
||||
<i class="wrn-spinner wrn-spinner--inline" aria-hidden="true"></i>
|
||||
{loadingLabel}
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="{name}-listbox"
|
||||
class="wrn-next__select-list"
|
||||
data-show="(!remote || (query.length === 0 && remoteAutoLoad) || query.length >= minSearchLength) && !loading"
|
||||
role="listbox"
|
||||
>
|
||||
{#each groups as group}
|
||||
{#if visibleOptions(group.options || []).length}
|
||||
<div class="wrn-next__option-group" role="group" aria-label="{group.label}">
|
||||
<div class="wrn-next__group-label">{group.label}</div>
|
||||
{#each group.options || [] as option}
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-next__select-option {isSelected(option) ? 'wrn-next__select-option--selected' : ''} {optionIndex(option) === activeIndex ? 'wrn-next__select-option--active' : ''}"
|
||||
data-option-value="{option.value}"
|
||||
data-show="matches(option)"
|
||||
role="option"
|
||||
aria-selected="{isSelected(option)}"
|
||||
disabled="{!canSelect(option)}"
|
||||
@mouseenter="activeIndex = optionIndex(option)"
|
||||
@mousedown="event.preventDefault()"
|
||||
@click="chooseOption(option)"
|
||||
>
|
||||
{#if optionTemplate === "icon" && option.icon}<i class="{option.icon}" aria-hidden="true"></i>{/if}
|
||||
{#if optionTemplate === "avatar" && option.avatar}<img src="{option.avatar}" alt="" />{/if}
|
||||
{#if optionTemplate === "color" && option.color}<i class="wrn-next__color-dot" style="--option-color: {option.color}"></i>{/if}
|
||||
<span><strong>{option.label}</strong>{#if option.description}<small>{option.description}</small>{/if}</span>
|
||||
<i class="wrn-next__check icon-[lucide--check]" data-show="isSelected(option)" aria-hidden="true"></i>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#each options as option}
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-next__select-option {isSelected(option) ? 'wrn-next__select-option--selected' : ''} {optionIndex(option) === activeIndex ? 'wrn-next__select-option--active' : ''}"
|
||||
data-option-value="{option.value}"
|
||||
data-show="matches(option)"
|
||||
role="option"
|
||||
aria-selected="{isSelected(option)}"
|
||||
disabled="{!canSelect(option)}"
|
||||
@mouseenter="activeIndex = optionIndex(option)"
|
||||
@mousedown="event.preventDefault()"
|
||||
@click="chooseOption(option)"
|
||||
>
|
||||
{#if optionTemplate === "icon" && option.icon}<i class="{option.icon}" aria-hidden="true"></i>{/if}
|
||||
{#if optionTemplate === "avatar" && option.avatar}<img src="{option.avatar}" alt="" />{/if}
|
||||
{#if optionTemplate === "color" && option.color}<i class="wrn-next__color-dot" style="--option-color: {option.color}"></i>{/if}
|
||||
<span><strong>{option.label}</strong>{#if option.description}<small>{option.description}</small>{/if}</span>
|
||||
<i class="wrn-next__check icon-[lucide--check]" data-show="isSelected(option)" aria-hidden="true"></i>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-next__load-more"
|
||||
data-wrn-select-load-more
|
||||
data-show="infinite && hasMore"
|
||||
>{loadMoreLabel}</button>
|
||||
</div>
|
||||
|
||||
{#if !allowCustomValue}
|
||||
<input
|
||||
type="hidden"
|
||||
name="{name}"
|
||||
value="{selectedValue}"
|
||||
required="{required}"
|
||||
data-error="{name}"
|
||||
aria-describedby="{validationMessage ? `${name}-validation` : helpText ? `${name}-help` : ''}"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if helpText && !validationMessage}<small id="{name}-help">{helpText}</small>{/if}
|
||||
<small
|
||||
id="{name}-validation"
|
||||
class="wrn-next__validation"
|
||||
data-error="{name}"
|
||||
>{validationMessage}</small>
|
||||
</div>
|
||||
<SelectStyles hidden />
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-next__combobox-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
width: 2.25em;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
color: var(--wrn-color-muted);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.wrn-next--combobox .wrn-next__combobox-input {
|
||||
padding-right: 5.25em;
|
||||
padding-left: 2.5em;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
/* Shared component foundation. */
|
||||
|
||||
.wrn-component {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
--wrn-component-scale: 1;
|
||||
margin: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-family: var(--wrn-font-sans, inherit);
|
||||
}
|
||||
|
||||
.wrn-component--color-primary {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-component--color-secondary {
|
||||
--wrn-component-color: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-component--color-success {
|
||||
--wrn-component-color: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-component--color-warning {
|
||||
--wrn-component-color: var(--wrn-color-warning);
|
||||
}
|
||||
|
||||
.wrn-component--color-danger {
|
||||
--wrn-component-color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-component--color-info {
|
||||
--wrn-component-color: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-component--size-xs {
|
||||
--wrn-component-scale: 0.78;
|
||||
}
|
||||
|
||||
.wrn-component--size-sm {
|
||||
--wrn-component-scale: 0.88;
|
||||
}
|
||||
|
||||
.wrn-component--size-default,
|
||||
.wrn-component--size-md {
|
||||
--wrn-component-scale: 1;
|
||||
}
|
||||
|
||||
.wrn-component--size-lg {
|
||||
--wrn-component-scale: 1.14;
|
||||
}
|
||||
|
||||
.wrn-component--size-xl {
|
||||
--wrn-component-scale: 1.28;
|
||||
}
|
||||
|
||||
.wrn-component:not(.wrn-btn) {
|
||||
font-size: calc(1em * var(--wrn-component-scale));
|
||||
}
|
||||
|
||||
.wrn-component :is(a, button, input, select, textarea):focus-visible {
|
||||
outline-color: var(--wrn-component-color);
|
||||
}
|
||||
|
||||
.wrn-component p {
|
||||
margin: 0;
|
||||
color: var(--wrn-color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
component Confetti {
|
||||
outputs {
|
||||
start(payload: { sourceEvent?: Event; duration: number })
|
||||
complete(payload: { sourceEvent?: Event; duration: number })
|
||||
}
|
||||
|
||||
props {
|
||||
label: string = "Celebrate"
|
||||
duration: number = 1200
|
||||
count: number = 24
|
||||
autoStart: boolean = false
|
||||
disabled: boolean = false
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
state running = false
|
||||
|
||||
functions {
|
||||
shared function pieces() { return Array.from({ length: Math.max(1, Math.min(60, count)) }) }
|
||||
shared function pieceStyle(index) {
|
||||
return "--wrn-confetti-index:" + index + ";--wrn-confetti-x:" + ((index * 37) % 100) + ";--wrn-confetti-delay:" + ((index * 29) % 240) + "ms;"
|
||||
}
|
||||
client function finish(sourceEvent) {
|
||||
running = false
|
||||
output.complete({ sourceEvent: sourceEvent, duration: duration })
|
||||
}
|
||||
client function start(sourceEvent) {
|
||||
if (disabled || running) {
|
||||
return
|
||||
}
|
||||
running = true
|
||||
output.start({ sourceEvent: sourceEvent, duration: duration })
|
||||
setTimeout(finish, duration, sourceEvent)
|
||||
}
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
mount { if (autoStart) { start(null) } }
|
||||
}
|
||||
|
||||
view {
|
||||
<div {...attrs} data-ui-component="Confetti" data-running='{running ? "true" : "false"}' class='wrn-confetti {class}' style='--wrn-confetti-duration:{duration}ms'>
|
||||
<button type="button" class="wrn-confetti__trigger" disabled='{disabled}' @click='start(event)'>
|
||||
<span class="icon-[lucide--party-popper]" aria-hidden="true"></span><span>{label}</span>
|
||||
</button>
|
||||
<div class="wrn-confetti__burst" aria-hidden="true">
|
||||
{#each pieces() as piece, index}<i style='{pieceStyle(index)}'></i>{/each}
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-confetti { position: relative; display: inline-flex; }
|
||||
.wrn-confetti__trigger { display: inline-flex; min-height: 2.5rem; align-items: center; gap: 0.5rem; padding: 0.55rem 0.85rem; border: 1px solid var(--wrn-color-primary); border-radius: var(--wrn-radius); color: var(--wrn-color-on-primary); background: var(--wrn-color-primary); font: inherit; font-weight: 700; cursor: pointer; }
|
||||
.wrn-confetti__trigger > span:first-child { width: 1rem; height: 1rem; }
|
||||
.wrn-confetti__trigger:focus-visible { outline: 2px solid var(--wrn-color-focus); outline-offset: 2px; }
|
||||
.wrn-confetti__trigger:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
.wrn-confetti__burst { position: absolute; inset: 50% 0 auto; height: 0; pointer-events: none; }
|
||||
.wrn-confetti__burst > i { position: absolute; left: calc(var(--wrn-confetti-x) * 1%); width: 0.45rem; height: 0.7rem; border-radius: 0.1rem; opacity: 0; background: hsl(calc(var(--wrn-confetti-index) * 47deg) 80% 55%); transform: translate(-50%, 0) rotate(calc(var(--wrn-confetti-index) * 23deg)); }
|
||||
.wrn-confetti[data-running="true"] .wrn-confetti__burst > i { animation: wrn-confetti-burst var(--wrn-confetti-duration) cubic-bezier(0.2, 0.7, 0.2, 1) var(--wrn-confetti-delay) both; }
|
||||
@keyframes wrn-confetti-burst {
|
||||
0% { opacity: 1; transform: translate(-50%, 0) rotate(0); }
|
||||
100% { opacity: 0; transform: translate(calc(-50% + (var(--wrn-confetti-x) - 50) * 0.9px), 9rem) rotate(540deg); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wrn-confetti[data-running="true"] .wrn-confetti__burst > i { animation-duration: 1ms; animation-delay: 0ms; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
component ContextMenu {
|
||||
outputs {
|
||||
open(payload: { x: number; y: number; trigger: string; sourceEvent: Event })
|
||||
close(payload: { reason: string; sourceEvent: Event })
|
||||
select(payload: { item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event })
|
||||
action(payload: { item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event })
|
||||
}
|
||||
|
||||
props {
|
||||
|
||||
items: unknown[] = []
|
||||
open: boolean = false
|
||||
defaultOpen: boolean = false
|
||||
trigger: string = "contextmenu"
|
||||
placement: string = "pointer"
|
||||
align: string = "start"
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
variant: string = "raised"
|
||||
title: string = ""
|
||||
description: string = ""
|
||||
label: string = "Context menu"
|
||||
closeOnSelect: boolean = true
|
||||
closeOnOutside: boolean = true
|
||||
disabled: boolean = false
|
||||
minWidth: string = "14rem"
|
||||
maxWidth: string = "20rem"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
state visible = defaultOpen
|
||||
state positionX: number = 16
|
||||
state positionY: number = 16
|
||||
|
||||
functions {
|
||||
shared function isOpen() {
|
||||
return open || visible
|
||||
}
|
||||
|
||||
client function showMenu(sourceEvent) {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
if (sourceEvent && sourceEvent.type === "contextmenu") {
|
||||
sourceEvent.preventDefault()
|
||||
}
|
||||
if (placement === "pointer" && sourceEvent) {
|
||||
// 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({
|
||||
x: positionX,
|
||||
y: positionY,
|
||||
trigger: trigger,
|
||||
sourceEvent: sourceEvent
|
||||
})
|
||||
}
|
||||
|
||||
client function hideMenu(reason, sourceEvent) {
|
||||
visible = false
|
||||
output.close({
|
||||
reason: reason,
|
||||
sourceEvent: sourceEvent
|
||||
})
|
||||
}
|
||||
|
||||
client function toggleMenu(sourceEvent) {
|
||||
if (isOpen()) {
|
||||
hideMenu("toggle", sourceEvent)
|
||||
} else {
|
||||
showMenu(sourceEvent)
|
||||
}
|
||||
}
|
||||
|
||||
client function chooseItem(item, itemIndex, sourceEvent) {
|
||||
if (disabled || item.disabled || item.type === "header" || item.type === "divider") {
|
||||
sourceEvent.preventDefault()
|
||||
return
|
||||
}
|
||||
output.select({
|
||||
item: item,
|
||||
itemIndex: itemIndex,
|
||||
value: item.value || "",
|
||||
sourceEvent: sourceEvent
|
||||
})
|
||||
if (item.action) {
|
||||
output.action({
|
||||
item: item,
|
||||
itemIndex: itemIndex,
|
||||
value: item.value || "",
|
||||
sourceEvent: sourceEvent
|
||||
})
|
||||
}
|
||||
if (closeOnSelect) {
|
||||
hideMenu("select", sourceEvent)
|
||||
}
|
||||
}
|
||||
|
||||
client function moveFocus(sourceEvent, direction) {
|
||||
const root = sourceEvent.currentTarget.closest(".wrn-context-menu") || sourceEvent.currentTarget
|
||||
const options = [...root.querySelectorAll("[data-context-menu-item]:not([disabled])")]
|
||||
if (!options.length) {
|
||||
return
|
||||
}
|
||||
const activeIndex = options.indexOf(document.activeElement)
|
||||
const nextIndex = (activeIndex + direction + options.length) % options.length
|
||||
options[nextIndex].focus()
|
||||
}
|
||||
|
||||
client function handleKeydown(sourceEvent) {
|
||||
if (sourceEvent.key === "Escape") {
|
||||
sourceEvent.preventDefault()
|
||||
hideMenu("escape", sourceEvent)
|
||||
} else if (sourceEvent.key === "ArrowDown") {
|
||||
sourceEvent.preventDefault()
|
||||
moveFocus(sourceEvent, 1)
|
||||
} else if (sourceEvent.key === "ArrowUp") {
|
||||
sourceEvent.preventDefault()
|
||||
moveFocus(sourceEvent, -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<div
|
||||
{...attrs}
|
||||
data-ui-component="ContextMenu"
|
||||
data-open='{open || visible ? "true" : "false"}'
|
||||
data-trigger='{trigger}'
|
||||
data-placement='{placement}'
|
||||
data-align='{align}'
|
||||
data-size='{size}'
|
||||
data-color='{color}'
|
||||
data-variant='{variant}'
|
||||
class='wrn-context-menu {class}'
|
||||
style='--wrn-context-x:{positionX}px;--wrn-context-y:{positionY}px;--wrn-context-min-width:{minWidth};--wrn-context-max-width:{maxWidth};'
|
||||
@keydown='handleKeydown(event)'
|
||||
>
|
||||
<div
|
||||
class="wrn-context-menu__trigger"
|
||||
tabindex='{disabled ? "-1" : "0"}'
|
||||
aria-haspopup="menu"
|
||||
aria-expanded='{open || visible ? "true" : "false"}'
|
||||
@contextmenu='if (trigger === "contextmenu" || trigger === "both") { showMenu(event) }'
|
||||
@click='if (trigger === "click" || trigger === "both") { toggleMenu(event) }'
|
||||
@keydown='if (event.key === "Enter" || event.key === " ") { event.preventDefault(); toggleMenu(event) }'
|
||||
>
|
||||
<slot name="trigger"></slot>
|
||||
</div>
|
||||
|
||||
{#if closeOnOutside}
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-context-menu__dismiss-layer"
|
||||
data-show='{open || visible}'
|
||||
aria-label="Close context menu"
|
||||
@click='hideMenu("outside", event)'
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="wrn-context-menu__panel"
|
||||
data-wrn-anchored="true"
|
||||
data-show='{open || visible}'
|
||||
role="menu"
|
||||
aria-label='{label}'
|
||||
>
|
||||
{#if title || description}
|
||||
<div class="wrn-context-menu__header">
|
||||
{#if title}
|
||||
<strong>{title}</strong>
|
||||
{/if}
|
||||
{#if description}
|
||||
<span>{description}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<slot name="header"></slot>
|
||||
|
||||
<div class="wrn-context-menu__items">
|
||||
{#each items as item, itemIndex}
|
||||
{#if item.type === "divider"}
|
||||
<div class="wrn-context-menu__divider" role="separator"></div>
|
||||
{:else if item.type === "header"}
|
||||
<div class="wrn-context-menu__section-label">{item.label || item.title}</div>
|
||||
{:else if item.href}
|
||||
<a
|
||||
href='{item.href}'
|
||||
target='{item.target || ""}'
|
||||
rel='{item.external || item.target === "_blank" ? "noopener noreferrer" : (item.rel || "")}'
|
||||
role="menuitem"
|
||||
data-context-menu-item
|
||||
data-danger='{item.danger ? "true" : "false"}'
|
||||
data-selected='{item.selected || item.checked ? "true" : "false"}'
|
||||
aria-disabled='{item.disabled ? "true" : "false"}'
|
||||
class="wrn-context-menu__item"
|
||||
@click='chooseItem(item, itemIndex, event)'
|
||||
>
|
||||
{#if item.icon}
|
||||
<span class='wrn-context-menu__icon {item.icon}' aria-hidden="true"></span>
|
||||
{/if}
|
||||
<span class="wrn-context-menu__copy">
|
||||
<strong>{item.label || item.title}</strong>
|
||||
{#if item.description}
|
||||
<small>{item.description}</small>
|
||||
{/if}
|
||||
</span>
|
||||
{#if item.shortcut}
|
||||
<kbd>{item.shortcut}</kbd>
|
||||
{:else if item.checked || item.selected}
|
||||
<span class="icon-[lucide--check] wrn-context-menu__status" aria-hidden="true"></span>
|
||||
{:else if item.external}
|
||||
<span class="icon-[lucide--arrow-up-right] wrn-context-menu__status" aria-hidden="true"></span>
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-context-menu-item
|
||||
data-danger='{item.danger ? "true" : "false"}'
|
||||
data-selected='{item.selected || item.checked ? "true" : "false"}'
|
||||
disabled='{item.disabled}'
|
||||
class="wrn-context-menu__item"
|
||||
@click='chooseItem(item, itemIndex, event)'
|
||||
>
|
||||
{#if item.icon}
|
||||
<span class='wrn-context-menu__icon {item.icon}' aria-hidden="true"></span>
|
||||
{/if}
|
||||
<span class="wrn-context-menu__copy">
|
||||
<strong>{item.label || item.title}</strong>
|
||||
{#if item.description}
|
||||
<small>{item.description}</small>
|
||||
{/if}
|
||||
</span>
|
||||
{#if item.shortcut}
|
||||
<kbd>{item.shortcut}</kbd>
|
||||
{:else if item.checked || item.selected}
|
||||
<span class="icon-[lucide--check] wrn-context-menu__status" aria-hidden="true"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<slot></slot>
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-context-menu {
|
||||
--context-accent: var(--wrn-color-primary);
|
||||
--context-soft: var(--wrn-color-primary-soft);
|
||||
position: relative;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wrn-context-menu[data-color="secondary"] {
|
||||
--context-accent: var(--wrn-color-secondary);
|
||||
--context-soft: var(--wrn-color-secondary-soft);
|
||||
}
|
||||
|
||||
.wrn-context-menu[data-color="info"] {
|
||||
--context-accent: var(--wrn-color-info);
|
||||
--context-soft: var(--wrn-color-info-soft);
|
||||
}
|
||||
|
||||
.wrn-context-menu[data-color="success"] {
|
||||
--context-accent: var(--wrn-color-success);
|
||||
--context-soft: var(--wrn-color-success-soft);
|
||||
}
|
||||
|
||||
.wrn-context-menu[data-color="warning"] {
|
||||
--context-accent: var(--wrn-color-warning);
|
||||
--context-soft: var(--wrn-color-warning-soft);
|
||||
}
|
||||
|
||||
.wrn-context-menu[data-color="danger"] {
|
||||
--context-accent: var(--wrn-color-danger);
|
||||
--context-soft: var(--wrn-color-danger-soft);
|
||||
}
|
||||
|
||||
.wrn-context-menu__trigger {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.wrn-context-menu__trigger:focus-visible {
|
||||
border-radius: 0.6rem;
|
||||
outline: 2px solid var(--wrn-color-focus);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.wrn-context-menu__dismiss-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1090;
|
||||
appearance: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.wrn-context-menu__panel {
|
||||
position: absolute;
|
||||
z-index: 1091;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
width: max-content;
|
||||
min-width: var(--wrn-context-min-width);
|
||||
max-width: min(var(--wrn-context-max-width), calc(100vw - 1.5rem));
|
||||
padding: 0.45rem;
|
||||
color: var(--wrn-color-text);
|
||||
background:
|
||||
linear-gradient(145deg, color-mix(in srgb, var(--context-accent) 5%, transparent), transparent 58%),
|
||||
color-mix(in srgb, var(--wrn-color-surface-raised) 96%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--context-accent) 18%, var(--wrn-color-border));
|
||||
border-radius: 1rem;
|
||||
box-shadow:
|
||||
0 1px 0 color-mix(in srgb, white 5%, transparent) inset,
|
||||
0 24px 64px color-mix(in srgb, black 24%, transparent);
|
||||
backdrop-filter: blur(18px);
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.wrn-context-menu[data-placement="pointer"] .wrn-context-menu__panel {
|
||||
position: fixed;
|
||||
top: var(--wrn-context-y);
|
||||
left: var(--wrn-context-x);
|
||||
}
|
||||
|
||||
.wrn-context-menu[data-placement="bottom-end"] .wrn-context-menu__panel {
|
||||
right: 0;
|
||||
left: auto;
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
.wrn-context-menu[data-placement="top-start"] .wrn-context-menu__panel {
|
||||
top: auto;
|
||||
bottom: calc(100% + 0.5rem);
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
|
||||
.wrn-context-menu[data-placement="top-end"] .wrn-context-menu__panel {
|
||||
top: auto;
|
||||
right: 0;
|
||||
bottom: calc(100% + 0.5rem);
|
||||
left: auto;
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
.wrn-context-menu[data-variant="soft"] .wrn-context-menu__panel {
|
||||
background:
|
||||
linear-gradient(145deg, var(--context-soft), transparent 72%),
|
||||
var(--wrn-color-surface-raised);
|
||||
box-shadow: 0 18px 48px color-mix(in srgb, black 16%, transparent);
|
||||
}
|
||||
|
||||
.wrn-context-menu[data-variant="outline"] .wrn-context-menu__panel {
|
||||
background: var(--wrn-color-surface-raised);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.wrn-context-menu__header {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 0.65rem 0.75rem 0.75rem;
|
||||
border-bottom: 1px solid var(--wrn-color-border);
|
||||
}
|
||||
|
||||
.wrn-context-menu__header strong {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.wrn-context-menu__header span {
|
||||
color: var(--wrn-color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.wrn-context-menu__items {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
padding-block: 0.25rem;
|
||||
}
|
||||
|
||||
.wrn-context-menu__section-label {
|
||||
padding: 0.55rem 0.75rem 0.3rem;
|
||||
color: var(--wrn-color-text-muted);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wrn-context-menu__divider {
|
||||
height: 1px;
|
||||
margin: 0.3rem 0.45rem;
|
||||
background: var(--wrn-color-border);
|
||||
}
|
||||
|
||||
.wrn-context-menu__item {
|
||||
appearance: none;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0.65rem 0.7rem;
|
||||
color: var(--wrn-color-text);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0.72rem;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease, color 150ms ease, transform 150ms ease;
|
||||
}
|
||||
|
||||
.wrn-context-menu__item:hover,
|
||||
.wrn-context-menu__item:focus-visible,
|
||||
.wrn-context-menu__item[data-selected="true"] {
|
||||
color: var(--context-accent);
|
||||
background: var(--context-soft);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.wrn-context-menu__item:active {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
|
||||
.wrn-context-menu__item[data-danger="true"] {
|
||||
color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-context-menu__item[data-danger="true"]:hover,
|
||||
.wrn-context-menu__item[data-danger="true"]:focus-visible {
|
||||
background: var(--wrn-color-danger-soft);
|
||||
}
|
||||
|
||||
.wrn-context-menu__item[disabled],
|
||||
.wrn-context-menu__item[aria-disabled="true"] {
|
||||
opacity: 0.48;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wrn-context-menu__icon,
|
||||
.wrn-context-menu__status {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.wrn-context-menu__copy {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wrn-context-menu__copy strong {
|
||||
overflow: hidden;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wrn-context-menu__copy small {
|
||||
overflow: hidden;
|
||||
color: var(--wrn-color-text-muted);
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wrn-context-menu kbd {
|
||||
padding: 0.16rem 0.38rem;
|
||||
color: var(--wrn-color-text-muted);
|
||||
background: var(--wrn-color-surface-soft);
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: 0.36rem;
|
||||
font-size: 0.64rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.wrn-context-menu[data-size="sm"] .wrn-context-menu__item {
|
||||
padding: 0.52rem 0.6rem;
|
||||
}
|
||||
|
||||
.wrn-context-menu[data-size="lg"] .wrn-context-menu__item {
|
||||
padding: 0.78rem 0.82rem;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.wrn-context-menu[data-placement="pointer"] .wrn-context-menu__panel {
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
left: 0.75rem;
|
||||
top: auto;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wrn-context-menu__item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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 {
|
||||
props {
|
||||
color: string = "primary"
|
||||
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
|
||||
{...attrs}
|
||||
data-ui-component="CustomScrollbar"
|
||||
class='wrn-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 {
|
||||
.wrn-scrollbar {
|
||||
--scrollbar-accent: var(--wrn-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;
|
||||
}
|
||||
|
||||
.wrn-scrollbar[data-color="secondary"] {
|
||||
--scrollbar-accent: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-scrollbar[data-color="success"] {
|
||||
--scrollbar-accent: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-scrollbar[data-color="danger"] {
|
||||
--scrollbar-accent: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-scrollbar[data-color="info"] {
|
||||
--scrollbar-accent: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-scrollbar[data-axis="vertical"] {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wrn-scrollbar[data-axis="horizontal"] {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.wrn-scrollbar::-webkit-scrollbar {
|
||||
width: var(--scrollbar-thickness, 8px);
|
||||
height: var(--scrollbar-thickness, 8px);
|
||||
}
|
||||
|
||||
.wrn-scrollbar::-webkit-scrollbar-track {
|
||||
background: var(--wrn-color-surface-soft);
|
||||
border-radius: var(--scrollbar-radius, 999px);
|
||||
}
|
||||
|
||||
.wrn-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--scrollbar-accent) 45%, transparent);
|
||||
border-radius: var(--scrollbar-radius, 999px);
|
||||
}
|
||||
|
||||
.wrn-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) {
|
||||
.wrn-scrollbar {
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
component DataMap {
|
||||
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)
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
title: string = "Data Map"
|
||||
description: string = ""
|
||||
items: unknown[] = []
|
||||
variant: string = "default"
|
||||
class: string = ""
|
||||
}
|
||||
view {
|
||||
<section class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--data-map wrn-next--variant-{variant} {class}">
|
||||
{#if title}<strong>{title}</strong>{/if}
|
||||
{#if description}<p>{description}</p>{/if}
|
||||
{#if items}<div class="wrn-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if}
|
||||
<slot />
|
||||
</section>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-next--data-map {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius);
|
||||
color: var(--wrn-color-text);
|
||||
background: var(--wrn-color-surface);
|
||||
}
|
||||
.wrn-next--data-map > p { margin: 0; color: var(--wrn-color-muted); }
|
||||
.wrn-next--data-map > .wrn-next__items { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.wrn-next--data-map > .wrn-next__items > span { padding: 0.35rem 0.6rem; border-radius: var(--wrn-radius-sm); background: var(--wrn-color-surface-2); }
|
||||
|
||||
/* Shared component foundation. */
|
||||
|
||||
.wrn-component {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
--wrn-component-scale: 1;
|
||||
margin: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-family: var(--wrn-font-sans, inherit);
|
||||
}
|
||||
|
||||
.wrn-component--color-primary {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-component--color-secondary {
|
||||
--wrn-component-color: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-component--color-success {
|
||||
--wrn-component-color: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-component--color-warning {
|
||||
--wrn-component-color: var(--wrn-color-warning);
|
||||
}
|
||||
|
||||
.wrn-component--color-danger {
|
||||
--wrn-component-color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-component--color-info {
|
||||
--wrn-component-color: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-component--size-xs {
|
||||
--wrn-component-scale: 0.78;
|
||||
}
|
||||
|
||||
.wrn-component--size-sm {
|
||||
--wrn-component-scale: 0.88;
|
||||
}
|
||||
|
||||
.wrn-component--size-default,
|
||||
.wrn-component--size-md {
|
||||
--wrn-component-scale: 1;
|
||||
}
|
||||
|
||||
.wrn-component--size-lg {
|
||||
--wrn-component-scale: 1.14;
|
||||
}
|
||||
|
||||
.wrn-component--size-xl {
|
||||
--wrn-component-scale: 1.28;
|
||||
}
|
||||
|
||||
.wrn-component:not(.wrn-btn) {
|
||||
font-size: calc(1em * var(--wrn-component-scale));
|
||||
}
|
||||
|
||||
.wrn-component :is(a, button, input, select, textarea):focus-visible {
|
||||
outline-color: var(--wrn-component-color);
|
||||
}
|
||||
|
||||
.wrn-component p {
|
||||
margin: 0;
|
||||
color: var(--wrn-color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,678 @@
|
||||
component DatePicker {
|
||||
outputs {
|
||||
input(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
|
||||
change(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
|
||||
open(payload: { value: string; name: string; sourceEvent: Event })
|
||||
close(payload: { value: string; name: string; sourceEvent: Event })
|
||||
focus(payload: { value: string; name: string; sourceEvent: Event })
|
||||
blur(payload: { value: string; name: string; sourceEvent: Event })
|
||||
invalid(payload: { name: string; message: string; sourceEvent: Event })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
label: string = "Date Picker"
|
||||
id: string = ""
|
||||
name: string = ""
|
||||
value: string = ""
|
||||
placeholder: string = ""
|
||||
type: string = "date"
|
||||
locale: string = "en-US"
|
||||
firstDayOfWeek: number = 0
|
||||
months = [{ label: "Jan", value: "01" }, { label: "Feb", value: "02" }, { label: "Mar", value: "03" }, { label: "Apr", value: "04" }, { label: "May", value: "05" }, { label: "Jun", value: "06" }, { label: "Jul", value: "07" }, { label: "Aug", value: "08" }, { label: "Sep", value: "09" }, { label: "Oct", value: "10" }, { label: "Nov", value: "11" }, { label: "Dec", value: "12" }]
|
||||
days = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]
|
||||
years = [2024, 2025, 2026, 2027, 2028, 2029, 2030]
|
||||
min: string = ""
|
||||
max: string = ""
|
||||
step: string = ""
|
||||
helperText: string = ""
|
||||
cornerHint: string = ""
|
||||
error: string = ""
|
||||
variant: string = "normal"
|
||||
inline: boolean = false
|
||||
readonly: boolean = false
|
||||
disabled: boolean = false
|
||||
required: boolean = false
|
||||
class: string = ""
|
||||
}
|
||||
state currentValue = value
|
||||
state selectedYear = value ? value.split("-")[0] : ""
|
||||
state selectedMonth = value ? value.split("-")[1] : ""
|
||||
state selectedDay = value ? value.split("-")[2] : ""
|
||||
state expanded: boolean = false
|
||||
functions {
|
||||
client function openCalendar(sourceEvent) { expanded = true; output.open({ value: currentValue, name: name, sourceEvent: sourceEvent }) }
|
||||
client function updateDate(part, nextValue, sourceEvent, nextDate, detail) { if (part === "year") { selectedYear = String(nextValue) } if (part === "month") { selectedMonth = String(nextValue).padStart(2, "0") } if (part === "day") { selectedDay = String(nextValue).padStart(2, "0") } if (!selectedYear || !selectedMonth || !selectedDay) { return } nextDate = selectedYear + "-" + selectedMonth + "-" + selectedDay; if ((min && nextDate < min) || (max && nextDate > max)) { return } currentValue = nextDate; detail = { value: currentValue, year: selectedYear, month: selectedMonth, day: selectedDay, name: name, sourceEvent: sourceEvent }; output.input(detail); output.change(detail) }
|
||||
client function finishDate(sourceEvent) { if (!currentValue) { return } expanded = false; output.close({ value: currentValue, name: name, sourceEvent: sourceEvent }) }
|
||||
client function selectDay(sourceEvent, root, input, nextDate, trigger, detail) { root = sourceEvent.currentTarget.closest(".wrn-next--date-picker"); input = root.querySelector(".wrn-next__picker-value"); nextDate = input.getAttribute("value").slice(0, 5) + root.querySelector("select").getAttribute("value") + "-" + sourceEvent.currentTarget.dataset.value; currentValue = nextDate; input.setAttribute("value", nextDate); trigger = root.querySelector(".wrn-next__date-trigger span"); if (trigger) { trigger.replaceChildren(nextDate) } sourceEvent.currentTarget.setAttribute("aria-pressed", "true"); detail = { value: nextDate, name: name, sourceEvent: sourceEvent }; output.input(detail); output.change(detail) }
|
||||
client function clearDate(sourceEvent, detail) { if (disabled || readonly) { return } currentValue = ""; detail = { value: currentValue, name: name, sourceEvent: sourceEvent }; output.input(detail); output.change(detail) }
|
||||
client function handleFocus(sourceEvent) { output.focus({ value: currentValue, name: name, sourceEvent: sourceEvent }) }
|
||||
client function handleBlur(sourceEvent) { output.blur({ value: currentValue, name: name, sourceEvent: sourceEvent }) }
|
||||
client function handleInvalid(sourceEvent) { output.invalid({ name: name, message: sourceEvent.currentTarget.validationMessage, sourceEvent: sourceEvent }) }
|
||||
}
|
||||
view {
|
||||
<div {...attrs} class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--field wrn-next--date-picker {class}" data-variant="{variant}" data-inline="{inline}" data-invalid="{error ? 'true' : 'false'}" data-expanded="{expanded}">
|
||||
<div class="wrn-next__field-heading"><label for="{id || name}">{label}</label>{#if cornerHint}<span class="wrn-next__field-hint">{cornerHint}</span>{/if}</div>
|
||||
<div class="wrn-next__picker-control"><input id="{id || name}" class="wrn-next__sr-only wrn-next__picker-value" type="text" name="{name}" value="{currentValue}" required="{required}" readonly aria-hidden="true" tabindex="-1" @invalid="handleInvalid(event)" /><button type="button" class="wrn-next__date-trigger" disabled="{disabled || readonly}" aria-haspopup="dialog" aria-expanded="{expanded}" @click="openCalendar(event)" @focus="handleFocus(event)" @blur="handleBlur(event)"><span>{currentValue || placeholder || "Select date"}</span><i class="icon-[lucide--calendar-days]" aria-hidden="true"></i></button>{#if currentValue && !readonly && !disabled}<button type="button" class="wrn-next__picker-clear" aria-label="Clear date" @click="clearDate(event)">×</button>{/if}</div>
|
||||
<div class="wrn-next__calendar" role="dialog" aria-label="{label}"><div class="wrn-next__date-selectors"><label>Month<select value="{selectedMonth}" @change="updateDate('month', event.currentTarget.value, event)">{#each months as month}<option value="{month.value}" selected="{month.value === selectedMonth}">{month.label}</option>{/each}</select></label><label>Year<select value="{selectedYear}" @change="updateDate('year', event.currentTarget.value, event)">{#each years as year}<option value="{year}" selected="{String(year) === selectedYear}">{year}</option>{/each}</select></label></div><div class="wrn-next__calendar-grid">{#each days as day}<button type="button" data-value="{String(day).padStart(2, '0')}" aria-pressed="{String(day).padStart(2, '0') === selectedDay}" @click="selectDay(event)">{day}</button>{/each}</div><button type="button" class="wrn-next__date-done" disabled="{!currentValue}" @click="finishDate(event)">Done</button></div>
|
||||
{#if helperText}<small class="wrn-next__field-help">{helperText}</small>{/if}<small class="wrn-next__field-error" data-error="{name}">{error}</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-next__picker-clear {
|
||||
position: absolute;
|
||||
right: 2.5rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--wrn-color-muted);
|
||||
}
|
||||
|
||||
.wrn-next__date-selectors {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.wrn-next__date-selectors label {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wrn-next__date-selectors select {
|
||||
width: 100%;
|
||||
min-height: 2.35rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface);
|
||||
color: var(--wrn-color-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.wrn-next__date-done {
|
||||
background: var(--wrn-field-color) !important;
|
||||
color: var(--wrn-color-primary-contrast, #fff) !important;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.wrn-next__calendar > header {
|
||||
display: grid;
|
||||
grid-template-columns: 2.25rem 1fr 2.25rem;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wrn-next__calendar-grid button[data-outside="true"] {
|
||||
color: var(--wrn-color-muted);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.wrn-next__calendar-grid button[data-today="true"] {
|
||||
box-shadow: inset 0 0 0 1px var(--wrn-field-color);
|
||||
}
|
||||
|
||||
/* Shared component foundation. */
|
||||
|
||||
.wrn-component {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
--wrn-component-scale: 1;
|
||||
margin: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-family: var(--wrn-font-sans, inherit);
|
||||
}
|
||||
|
||||
.wrn-component--color-primary {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-component--color-secondary {
|
||||
--wrn-component-color: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-component--color-success {
|
||||
--wrn-component-color: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-component--color-warning {
|
||||
--wrn-component-color: var(--wrn-color-warning);
|
||||
}
|
||||
|
||||
.wrn-component--color-danger {
|
||||
--wrn-component-color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-component--color-info {
|
||||
--wrn-component-color: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-component--size-xs {
|
||||
--wrn-component-scale: 0.78;
|
||||
}
|
||||
|
||||
.wrn-component--size-sm {
|
||||
--wrn-component-scale: 0.88;
|
||||
}
|
||||
|
||||
.wrn-component--size-default,
|
||||
.wrn-component--size-md {
|
||||
--wrn-component-scale: 1;
|
||||
}
|
||||
|
||||
.wrn-component--size-lg {
|
||||
--wrn-component-scale: 1.14;
|
||||
}
|
||||
|
||||
.wrn-component--size-xl {
|
||||
--wrn-component-scale: 1.28;
|
||||
}
|
||||
|
||||
.wrn-component:not(.wrn-btn) {
|
||||
font-size: calc(1em * var(--wrn-component-scale));
|
||||
}
|
||||
|
||||
.wrn-component :is(a, button, input, select, textarea):focus-visible {
|
||||
outline-color: var(--wrn-component-color);
|
||||
}
|
||||
|
||||
.wrn-component p {
|
||||
margin: 0;
|
||||
color: var(--wrn-color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Shared field foundation. */
|
||||
|
||||
/* Validation states set by the browser validation runtime. */
|
||||
.wrn-invalid {
|
||||
border-color: var(--wrn-color-danger) !important;
|
||||
}
|
||||
|
||||
.wrn-field-error {
|
||||
display: block;
|
||||
min-height: 1em;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--wrn-color-danger);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.wrn-next--choice {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wrn-next--choice input {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
accent-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-next--field {
|
||||
display: grid;
|
||||
width: min(100%, 28rem);
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.wrn-next--field :where(input, textarea, select) {
|
||||
width: 100%;
|
||||
min-height: 2.65rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: 0.6rem;
|
||||
outline: 0;
|
||||
color: var(--wrn-color-text);
|
||||
background: var(--wrn-color-bg);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-auth-form form > .wrn-next--field,
|
||||
.wrn-auth-form form > .wrn-field {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
.wrn-auth-form__submit > .wrn-btn {
|
||||
width: 100%;
|
||||
min-height: 3rem;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 0.8rem 1.8rem color-mix(in srgb, var(--wrn-btn-color) 20%, transparent);
|
||||
}
|
||||
.wrn-next--field :where(input, textarea, select):focus-visible {
|
||||
border-color: var(--wrn-color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--wrn-color-primary) 16%, transparent);
|
||||
}
|
||||
.wrn-next--field textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
.wrn-next--field,
|
||||
.wrn-next--choice-field {
|
||||
--wrn-field-color: var(--wrn-component-color, var(--wrn-color-primary));
|
||||
width: min(100%, 34rem);
|
||||
min-width: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-next--field.wrn-next--size-sm,
|
||||
.wrn-next--choice-field.wrn-next--size-sm {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.wrn-next--field.wrn-next--size-lg,
|
||||
.wrn-next--choice-field.wrn-next--size-lg {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.wrn-next__field-heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wrn-next__field-heading label {
|
||||
color: var(--wrn-color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wrn-next__field-hint,
|
||||
.wrn-next__field-help {
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 0.82em;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-next__field-control {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.wrn-next__field-control > :is(input, textarea, select) {
|
||||
padding-inline: 0.85rem;
|
||||
border-color: var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.wrn-next__field-control[data-icon-position="start"] > :is(input, textarea, select) {
|
||||
padding-inline-start: 2.5rem;
|
||||
}
|
||||
.wrn-next__field-control[data-icon-position="end"] > :is(input, textarea, select) {
|
||||
padding-inline-end: 2.5rem;
|
||||
}
|
||||
.wrn-next__field-icon {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 50%;
|
||||
left: 0.85rem;
|
||||
color: var(--wrn-color-muted);
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.wrn-next__field-control[data-icon-position="end"] > .wrn-next__field-icon {
|
||||
right: 0.85rem;
|
||||
left: auto;
|
||||
}
|
||||
.wrn-next--field[data-variant="outlined"]
|
||||
.wrn-next__field-control
|
||||
> :is(input, textarea, select) {
|
||||
border-width: 2px;
|
||||
}
|
||||
.wrn-next--field[data-variant="underline"]
|
||||
.wrn-next__field-control
|
||||
> :is(input, textarea, select) {
|
||||
padding-inline: 0;
|
||||
border-width: 0 0 1px;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.wrn-next--field[data-variant="pill"] .wrn-next__field-control > :is(input, select) {
|
||||
border-radius: 999px;
|
||||
padding-inline: 1.15rem;
|
||||
}
|
||||
.wrn-next--field[data-variant="ghost"] .wrn-next__field-control > :is(input, textarea, select) {
|
||||
border-color: transparent;
|
||||
background: color-mix(in srgb, var(--wrn-field-color) 8%, var(--wrn-color-surface));
|
||||
}
|
||||
.wrn-next__field-floating-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0.85rem;
|
||||
color: var(--wrn-color-muted);
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%);
|
||||
transition: 150ms ease;
|
||||
}
|
||||
.wrn-next--textarea .wrn-next__field-floating-label {
|
||||
top: 1.35rem;
|
||||
}
|
||||
.wrn-next--field[data-floating="true"] .wrn-next__field-control > :is(input, textarea, select) {
|
||||
padding-top: 1.2rem;
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
.wrn-next--field[data-floating="true"] .wrn-next__field-heading > label {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
overflow: hidden !important;
|
||||
margin: -1px !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
.wrn-next--field[data-floating="true"]
|
||||
.wrn-next__field-heading:not(:has(.wrn-next__field-hint)) {
|
||||
display: none;
|
||||
}
|
||||
.wrn-next--field[data-floating="true"]
|
||||
.wrn-next__field-control
|
||||
> :is(input, textarea):is(:focus, :not(:placeholder-shown))
|
||||
+ .wrn-next__field-floating-label {
|
||||
top: 0.42rem;
|
||||
font-size: 0.7em;
|
||||
transform: none;
|
||||
}
|
||||
.wrn-next__field-error {
|
||||
min-height: 1em;
|
||||
color: var(--wrn-color-danger);
|
||||
font-size: 0.82em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.wrn-next__field-error:empty {
|
||||
display: none;
|
||||
}
|
||||
.wrn-next--field[data-invalid="true"] :is(input, textarea, select),
|
||||
.wrn-next--choice-field[data-invalid="true"] input {
|
||||
border-color: var(--wrn-color-danger);
|
||||
}
|
||||
.wrn-next--field :is(input, textarea, select):disabled,
|
||||
.wrn-next--choice-field input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
.wrn-next--field :is(input, textarea, select):read-only {
|
||||
background: var(--wrn-color-surface-2);
|
||||
}
|
||||
.wrn-next--field[data-inline="true"],
|
||||
.wrn-next--choice-field[data-inline="true"] {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
grid-template-columns: minmax(7rem, max-content) minmax(12rem, 20rem) minmax(10rem, 1fr);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-heading,
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-heading {
|
||||
min-height: 2.65rem;
|
||||
align-items: center;
|
||||
}
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-heading {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-help,
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-help {
|
||||
display: flex;
|
||||
min-height: 2.65rem;
|
||||
align-items: center;
|
||||
}
|
||||
.wrn-next--choice-field[data-inline="true"] {
|
||||
grid-template-columns: minmax(12rem, 20rem) minmax(10rem, 1fr);
|
||||
}
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-heading:empty {
|
||||
display: none;
|
||||
}
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-error,
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-error {
|
||||
grid-column: 2 / -1;
|
||||
}
|
||||
.wrn-next__sr-only {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
overflow: hidden !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
margin: -1px !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
.wrn-next__choice-field {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.wrn-next__choice {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
color: var(--wrn-color-text);
|
||||
width: 100%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-next__checkbox-content > .wrn-next__choice {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.wrn-next__choice input {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
accent-color: var(--wrn-field-color);
|
||||
}
|
||||
.wrn-next__choice-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.15rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.wrn-next__choice-copy small {
|
||||
color: var(--wrn-color-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-next__radio-group,
|
||||
.wrn-next__checkbox-group {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.wrn-next--radio-field[data-orientation="horizontal"] .wrn-next__radio-group,
|
||||
.wrn-next--checkbox-field[data-orientation="horizontal"] .wrn-next__checkbox-group {
|
||||
grid-template-columns: repeat(auto-fit, minmax(8rem, max-content));
|
||||
align-items: stretch;
|
||||
}
|
||||
.wrn-next--radio-field[data-card="true"] .wrn-next__radio-group,
|
||||
.wrn-next--checkbox-field[data-card="true"] .wrn-next__checkbox-group {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
|
||||
}
|
||||
.wrn-next--radio-field[data-card="true"] .wrn-next--radio,
|
||||
.wrn-next--checkbox-field[data-card="true"] .wrn-next--checkbox {
|
||||
min-height: 4.5rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface);
|
||||
}
|
||||
.wrn-next--radio-field[data-card="true"] .wrn-next--radio:has(input:checked),
|
||||
.wrn-next--checkbox-field[data-card="true"] .wrn-next--checkbox:has(input:checked) {
|
||||
border-color: var(--wrn-field-color);
|
||||
box-shadow: 0 0 0 1px var(--wrn-field-color);
|
||||
}
|
||||
.wrn-next--radio-field[data-list="true"] .wrn-next--radio,
|
||||
.wrn-next--checkbox-field[data-list="true"] .wrn-next--checkbox {
|
||||
padding-block: 0.75rem;
|
||||
border-bottom: 1px solid var(--wrn-color-border);
|
||||
}
|
||||
.wrn-next--radio-field[data-right-aligned="true"] .wrn-next--radio,
|
||||
.wrn-next--checkbox-field[data-right-aligned="true"] .wrn-next--checkbox {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.wrn-next--radio-field[data-right-aligned="true"] .wrn-next--radio input,
|
||||
.wrn-next--checkbox-field[data-right-aligned="true"] .wrn-next--checkbox input {
|
||||
order: 2;
|
||||
}
|
||||
.wrn-next--radio-field[data-right-aligned="true"] .wrn-next__choice-copy,
|
||||
.wrn-next--checkbox-field[data-right-aligned="true"] .wrn-next__choice-copy {
|
||||
order: 1;
|
||||
}
|
||||
.wrn-next__choice[data-variant="outlined"],
|
||||
.wrn-next__choice[data-variant="floating"] {
|
||||
padding: 0.65rem 0.8rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
}
|
||||
.wrn-next__choice[data-variant="pill"] {
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--wrn-field-color) 9%, var(--wrn-color-surface));
|
||||
}
|
||||
.wrn-next__choice[data-variant="ghost"] {
|
||||
padding: 0.65rem 0.8rem;
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: color-mix(in srgb, var(--wrn-field-color) 7%, transparent);
|
||||
}
|
||||
.wrn-next__choice[data-variant="underline"] {
|
||||
padding-block: 0.5rem;
|
||||
border-bottom: 1px solid var(--wrn-color-border);
|
||||
}
|
||||
.wrn-next--select .wrn-next__field-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.wrn-next__color-control,
|
||||
.wrn-next__range-control,
|
||||
.wrn-next__input-group-control,
|
||||
.wrn-next__file-control {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.wrn-next__picker-control,
|
||||
.wrn-next--time-picker .wrn-next__field-control {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
}
|
||||
.wrn-next__picker-control > button:first-of-type,
|
||||
.wrn-next__time-trigger {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 2.65rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface);
|
||||
color: var(--wrn-color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.wrn-next__calendar,
|
||||
.wrn-next__time-panel {
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
width: min(100%, 22rem);
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-md);
|
||||
background: var(--wrn-color-surface);
|
||||
box-shadow: var(--wrn-shadow-3);
|
||||
}
|
||||
.wrn-next--date-picker[data-expanded="false"] .wrn-next__calendar,
|
||||
.wrn-next--time-picker[data-expanded="false"] .wrn-next__time-panel {
|
||||
display: none;
|
||||
}
|
||||
.wrn-next__calendar button,
|
||||
.wrn-next__time-panel button {
|
||||
min-height: 2.25rem;
|
||||
border: 0;
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
.wrn-next__calendar-weekdays,
|
||||
.wrn-next__calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 0.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.wrn-next__calendar-weekdays {
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.wrn-next__calendar-grid button:hover,
|
||||
.wrn-next__time-panel button:hover {
|
||||
background: var(--wrn-color-surface-2);
|
||||
}
|
||||
.wrn-next__calendar-grid button[aria-pressed="true"],
|
||||
.wrn-next__time-panel button[aria-pressed="true"] {
|
||||
background: var(--wrn-field-color);
|
||||
color: var(--wrn-color-primary-contrast, #fff);
|
||||
}
|
||||
.wrn-next__time-period {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.wrn-next--file-upload-progress .wrn-next__row,
|
||||
.wrn-next__upload-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.wrn-next--file-upload-progress .wrn-next__row > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.wrn-next--field[data-variant="underline"]
|
||||
:is(.wrn-next__input-group-control, .wrn-next__file-control, .wrn-next__color-control) {
|
||||
border-width: 0 0 1px;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.wrn-next--field[data-variant="outlined"]
|
||||
:is(.wrn-next__input-group-control, .wrn-next__file-control) {
|
||||
border-width: 2px;
|
||||
}
|
||||
.wrn-next--field[data-variant="pill"]
|
||||
:is(.wrn-next__input-group-control, .wrn-next__file-control) {
|
||||
border-radius: 999px;
|
||||
}
|
||||
.wrn-next--field[data-variant="ghost"]
|
||||
:is(.wrn-next__input-group-control, .wrn-next__file-control, .wrn-next__color-control) {
|
||||
border-color: transparent;
|
||||
background: color-mix(in srgb, var(--wrn-field-color) 8%, var(--wrn-color-surface));
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.wrn-next--field[data-inline="true"],
|
||||
.wrn-next--choice-field[data-inline="true"] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-error,
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-error {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
.wrn-next--toggle-password:has(input.wrn-invalid) .wrn-next__password-control > input,
|
||||
.wrn-next--toggle-password.wrn-next--invalid .wrn-next__password-control > input {
|
||||
border-color: var(--wrn-color-danger);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--wrn-color-danger) 14%, transparent);
|
||||
}
|
||||
.wrn-next--strong-password.wrn-next--invalid input,
|
||||
.wrn-next--strong-password input.wrn-invalid {
|
||||
border-color: var(--wrn-color-danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
component DeviceFrame {
|
||||
outputs {
|
||||
change(payload: { device: string; orientation: string; sourceEvent: Event })
|
||||
rotate(payload: { device: string; orientation: string; sourceEvent: Event })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
title: string = "Device Frame"
|
||||
description: string = ""
|
||||
items: unknown[] = []
|
||||
variant: string = "default"
|
||||
device: string = "phone"
|
||||
orientation: string = "portrait"
|
||||
src: string = ""
|
||||
srcdoc: string = ""
|
||||
frameTitle: string = "Device preview"
|
||||
showToolbar: boolean = true
|
||||
allow: string = ""
|
||||
class: string = ""
|
||||
}
|
||||
state currentOrientation = orientation
|
||||
functions {
|
||||
client function setDevice(nextDevice, sourceEvent) { output.change({ device: nextDevice, orientation: currentOrientation, sourceEvent: sourceEvent }) }
|
||||
client function rotateFrame(sourceEvent) { currentOrientation = currentOrientation === "portrait" ? "landscape" : "portrait"; output.rotate({ device: device, orientation: currentOrientation, sourceEvent: sourceEvent }); output.change({ device: device, orientation: currentOrientation, sourceEvent: sourceEvent }) }
|
||||
}
|
||||
view {
|
||||
<section {...attrs} class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--device-frame wrn-next--variant-{variant} {class}" data-device="{device}" data-orientation="{currentOrientation}">
|
||||
<header><div>{#if title}<strong>{title}</strong>{/if}{#if description}<p>{description}</p>{/if}</div>{#if showToolbar}<div class="wrn-next__device-actions" role="toolbar" aria-label="Preview device">{#each items as item}<button type="button" aria-pressed="{item.value === device}" @click="setDevice(item.value, event)">{item.label}</button>{/each}<button type="button" aria-label="Rotate preview" @click="rotateFrame(event)">↻</button></div>{/if}</header>
|
||||
<div class="wrn-next__device-shell">{#if src || srcdoc}<iframe title="{frameTitle}" src="{src}" srcdoc="{srcdoc}" allow="{allow}"></iframe>{:else}<div class="wrn-next__device-content"><slot /></div>{/if}</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-next--device-frame {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-md);
|
||||
background: var(--wrn-color-surface);
|
||||
box-shadow: var(--wrn-shadow-1);
|
||||
}
|
||||
|
||||
.wrn-next--device-frame > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.wrn-next__device-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.wrn-next__device-actions button {
|
||||
min-height: 2.25rem;
|
||||
padding-inline: 0.7rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface-2);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.wrn-next__device-shell {
|
||||
overflow: hidden;
|
||||
width: min(100%, 24rem);
|
||||
min-height: 28rem;
|
||||
margin-inline: auto;
|
||||
border: 0.65rem solid var(--wrn-color-text);
|
||||
border-radius: 2rem;
|
||||
background: var(--wrn-color-background);
|
||||
transition: width var(--wrn-motion-normal) var(--wrn-ease-standard);
|
||||
}
|
||||
|
||||
.wrn-next--device-frame[data-device="tablet"] .wrn-next__device-shell {
|
||||
width: min(100%, 42rem);
|
||||
}
|
||||
|
||||
.wrn-next--device-frame[data-device="desktop"] .wrn-next__device-shell {
|
||||
width: 100%;
|
||||
border-width: 0.4rem;
|
||||
border-radius: var(--wrn-radius-md);
|
||||
}
|
||||
|
||||
.wrn-next--device-frame[data-orientation="landscape"] .wrn-next__device-shell {
|
||||
width: min(100%, 48rem);
|
||||
min-height: 20rem;
|
||||
}
|
||||
|
||||
.wrn-next__device-shell :is(iframe, .wrn-next__device-content) {
|
||||
width: 100%;
|
||||
min-height: inherit;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.wrn-next--device-frame > header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shared component foundation. */
|
||||
|
||||
.wrn-component {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
--wrn-component-scale: 1;
|
||||
margin: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-family: var(--wrn-font-sans, inherit);
|
||||
}
|
||||
|
||||
.wrn-component--color-primary {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-component--color-secondary {
|
||||
--wrn-component-color: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-component--color-success {
|
||||
--wrn-component-color: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-component--color-warning {
|
||||
--wrn-component-color: var(--wrn-color-warning);
|
||||
}
|
||||
|
||||
.wrn-component--color-danger {
|
||||
--wrn-component-color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-component--color-info {
|
||||
--wrn-component-color: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-component--size-xs {
|
||||
--wrn-component-scale: 0.78;
|
||||
}
|
||||
|
||||
.wrn-component--size-sm {
|
||||
--wrn-component-scale: 0.88;
|
||||
}
|
||||
|
||||
.wrn-component--size-default,
|
||||
.wrn-component--size-md {
|
||||
--wrn-component-scale: 1;
|
||||
}
|
||||
|
||||
.wrn-component--size-lg {
|
||||
--wrn-component-scale: 1.14;
|
||||
}
|
||||
|
||||
.wrn-component--size-xl {
|
||||
--wrn-component-scale: 1.28;
|
||||
}
|
||||
|
||||
.wrn-component:not(.wrn-btn) {
|
||||
font-size: calc(1em * var(--wrn-component-scale));
|
||||
}
|
||||
|
||||
.wrn-component :is(a, button, input, select, textarea):focus-visible {
|
||||
outline-color: var(--wrn-component-color);
|
||||
}
|
||||
|
||||
.wrn-component p {
|
||||
margin: 0;
|
||||
color: var(--wrn-color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
component DragAndDrop {
|
||||
outputs {
|
||||
dragStart(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
dragEnd(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
dragEnter(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
dragLeave(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
drop(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
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)
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
title: string = "Drag And Drop"
|
||||
description: string = ""
|
||||
items: unknown[] = []
|
||||
variant: string = "default"
|
||||
class: string = ""
|
||||
}
|
||||
view {
|
||||
<section class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--drag-and-drop wrn-next--variant-{variant} {class}">
|
||||
{#if title}<strong>{title}</strong>{/if}
|
||||
{#if description}<p>{description}</p>{/if}
|
||||
{#if items}<div class="wrn-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if}
|
||||
<slot />
|
||||
</section>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-next--drag-and-drop {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius);
|
||||
color: var(--wrn-color-text);
|
||||
background: var(--wrn-color-surface);
|
||||
}
|
||||
.wrn-next--drag-and-drop > p { margin: 0; color: var(--wrn-color-muted); }
|
||||
.wrn-next--drag-and-drop > .wrn-next__items { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.wrn-next--drag-and-drop > .wrn-next__items > span { padding: 0.35rem 0.6rem; border-radius: var(--wrn-radius-sm); background: var(--wrn-color-surface-2); }
|
||||
|
||||
/* Shared component foundation. */
|
||||
|
||||
.wrn-component {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
--wrn-component-scale: 1;
|
||||
margin: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-family: var(--wrn-font-sans, inherit);
|
||||
}
|
||||
|
||||
.wrn-component--color-primary {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-component--color-secondary {
|
||||
--wrn-component-color: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-component--color-success {
|
||||
--wrn-component-color: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-component--color-warning {
|
||||
--wrn-component-color: var(--wrn-color-warning);
|
||||
}
|
||||
|
||||
.wrn-component--color-danger {
|
||||
--wrn-component-color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-component--color-info {
|
||||
--wrn-component-color: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-component--size-xs {
|
||||
--wrn-component-scale: 0.78;
|
||||
}
|
||||
|
||||
.wrn-component--size-sm {
|
||||
--wrn-component-scale: 0.88;
|
||||
}
|
||||
|
||||
.wrn-component--size-default,
|
||||
.wrn-component--size-md {
|
||||
--wrn-component-scale: 1;
|
||||
}
|
||||
|
||||
.wrn-component--size-lg {
|
||||
--wrn-component-scale: 1.14;
|
||||
}
|
||||
|
||||
.wrn-component--size-xl {
|
||||
--wrn-component-scale: 1.28;
|
||||
}
|
||||
|
||||
.wrn-component:not(.wrn-btn) {
|
||||
font-size: calc(1em * var(--wrn-component-scale));
|
||||
}
|
||||
|
||||
.wrn-component :is(a, button, input, select, textarea):focus-visible {
|
||||
outline-color: var(--wrn-component-color);
|
||||
}
|
||||
|
||||
.wrn-component p {
|
||||
margin: 0;
|
||||
color: var(--wrn-color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
component Drawer {
|
||||
outputs {
|
||||
open(payload: { placement: string; sourceEvent: Event })
|
||||
close(payload: { reason: string; placement: string; sourceEvent: Event })
|
||||
cancel(payload: { placement: string; sourceEvent: Event })
|
||||
}
|
||||
|
||||
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
|
||||
// Open/close animation length in ms. 0 disables the animation entirely.
|
||||
duration: number = 260
|
||||
overlay: boolean = true
|
||||
scrollable: boolean = true
|
||||
triggerLabel: string = ""
|
||||
triggerIcon: string = ""
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
state visible = defaultOpen
|
||||
|
||||
functions {
|
||||
shared function isOpen() {
|
||||
return open || visible
|
||||
}
|
||||
|
||||
client function showDrawer(sourceEvent) {
|
||||
visible = true
|
||||
output.open({
|
||||
placement: placement,
|
||||
sourceEvent: sourceEvent
|
||||
})
|
||||
}
|
||||
|
||||
client function hideDrawer(reason, sourceEvent) {
|
||||
visible = false
|
||||
output.close({
|
||||
reason: reason,
|
||||
placement: placement,
|
||||
sourceEvent: sourceEvent
|
||||
})
|
||||
}
|
||||
|
||||
client function cancelDrawer(sourceEvent) {
|
||||
output.cancel({
|
||||
placement: placement,
|
||||
sourceEvent: sourceEvent
|
||||
})
|
||||
hideDrawer("cancel", sourceEvent)
|
||||
}
|
||||
|
||||
client function handleKeydown(sourceEvent) {
|
||||
if (closeOnEscape && sourceEvent.key === "Escape") {
|
||||
sourceEvent.preventDefault()
|
||||
cancelDrawer(sourceEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<div
|
||||
{...attrs}
|
||||
data-ui-component="Drawer"
|
||||
data-open='{open || visible ? "true" : "false"}'
|
||||
data-placement='{placement}'
|
||||
data-size='{size}'
|
||||
data-color='{color}'
|
||||
data-variant='{variant}'
|
||||
data-overlay='{overlay ? "true" : "false"}'
|
||||
data-scrollable='{scrollable ? "true" : "false"}'
|
||||
class='wrn-drawer {class}'
|
||||
style='--drawer-duration: {duration}ms'
|
||||
>
|
||||
|
||||
{#if triggerLabel}
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-drawer__trigger"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded='{open || visible ? "true" : "false"}'
|
||||
@click='showDrawer(event)'
|
||||
>
|
||||
{#if triggerIcon}
|
||||
<span class='{triggerIcon}' aria-hidden="true"></span>
|
||||
{/if}
|
||||
<span>{triggerLabel}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<span class="wrn-drawer__trigger-slot" @click='showDrawer(event)'>
|
||||
<slot name="trigger"></slot>
|
||||
</span>
|
||||
|
||||
<div
|
||||
class="wrn-drawer__layer"
|
||||
role="presentation"
|
||||
@keydown='handleKeydown(event)'
|
||||
>
|
||||
{#if overlay}
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-drawer__backdrop"
|
||||
aria-label='{closeLabel}'
|
||||
@click='if (closeOnBackdrop) { cancelDrawer(event) }'
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<section
|
||||
class="wrn-drawer__panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label='{label || title}'
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="wrn-drawer__handle" aria-hidden="true"></div>
|
||||
|
||||
<header class="wrn-drawer__header">
|
||||
<div class="wrn-drawer__heading">
|
||||
{#if icon}
|
||||
<span class='wrn-drawer__icon {icon}' aria-hidden="true"></span>
|
||||
{/if}
|
||||
|
||||
<div class="wrn-drawer__heading-copy">
|
||||
<slot name="header"></slot>
|
||||
{#if title}
|
||||
<h2>{title}</h2>
|
||||
{/if}
|
||||
{#if description}
|
||||
<p>{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showClose}
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-drawer__close"
|
||||
aria-label='{closeLabel}'
|
||||
@click='hideDrawer("close-button", event)'
|
||||
>
|
||||
<span class="icon-[lucide--x]" aria-hidden="true"></span>
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="wrn-drawer__body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<footer class="wrn-drawer__footer">
|
||||
<slot name="footer"></slot>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-drawer {
|
||||
--drawer-accent: var(--wrn-color-primary);
|
||||
--drawer-soft: var(--wrn-color-primary-soft);
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.wrn-drawer[data-color="secondary"] {
|
||||
--drawer-accent: var(--wrn-color-secondary);
|
||||
--drawer-soft: var(--wrn-color-secondary-soft);
|
||||
}
|
||||
|
||||
.wrn-drawer[data-color="info"] {
|
||||
--drawer-accent: var(--wrn-color-info);
|
||||
--drawer-soft: var(--wrn-color-info-soft);
|
||||
}
|
||||
|
||||
.wrn-drawer[data-color="success"] {
|
||||
--drawer-accent: var(--wrn-color-success);
|
||||
--drawer-soft: var(--wrn-color-success-soft);
|
||||
}
|
||||
|
||||
.wrn-drawer[data-color="warning"] {
|
||||
--drawer-accent: var(--wrn-color-warning);
|
||||
--drawer-soft: var(--wrn-color-warning-soft);
|
||||
}
|
||||
|
||||
.wrn-drawer[data-color="danger"] {
|
||||
--drawer-accent: var(--wrn-color-danger);
|
||||
--drawer-soft: var(--wrn-color-danger-soft);
|
||||
}
|
||||
|
||||
.wrn-drawer__trigger,
|
||||
.wrn-drawer__trigger-slot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.wrn-drawer__trigger {
|
||||
appearance: none;
|
||||
min-height: 2.55rem;
|
||||
padding: 0.65rem 1rem;
|
||||
color: var(--wrn-color-primary-contrast);
|
||||
background: var(--drawer-accent);
|
||||
border: 0;
|
||||
border-radius: 0.8rem;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 650;
|
||||
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.
|
||||
*/
|
||||
.wrn-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);
|
||||
}
|
||||
|
||||
.wrn-drawer[data-open="true"] .wrn-drawer__layer {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transition:
|
||||
opacity var(--drawer-duration, 260ms) ease,
|
||||
visibility 0s linear 0s;
|
||||
}
|
||||
|
||||
.wrn-drawer__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
appearance: none;
|
||||
padding: 0;
|
||||
background: color-mix(in srgb, black 56%, transparent);
|
||||
border: 0;
|
||||
backdrop-filter: blur(7px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.wrn-drawer[data-overlay="false"] .wrn-drawer__backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wrn-drawer__panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(28rem, calc(100vw - 1rem));
|
||||
max-height: 100%;
|
||||
margin-left: auto;
|
||||
color: var(--wrn-color-text);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 100% 0%,
|
||||
color-mix(in srgb, var(--drawer-accent) 8%, transparent),
|
||||
transparent 34%
|
||||
),
|
||||
var(--wrn-color-surface-raised);
|
||||
border-left: 1px solid color-mix(in srgb, var(--drawer-accent) 18%, var(--wrn-color-border));
|
||||
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);
|
||||
}
|
||||
|
||||
.wrn-drawer[data-open="true"] .wrn-drawer__panel {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.wrn-drawer[data-placement="left"] .wrn-drawer__panel {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.wrn-drawer[data-placement="top"] .wrn-drawer__panel {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.wrn-drawer[data-placement="bottom"] .wrn-drawer__panel {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.wrn-drawer[data-open="true"][data-placement="left"] .wrn-drawer__panel,
|
||||
.wrn-drawer[data-open="true"][data-placement="top"] .wrn-drawer__panel,
|
||||
.wrn-drawer[data-open="true"][data-placement="bottom"] .wrn-drawer__panel {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wrn-drawer__layer,
|
||||
.wrn-drawer__panel {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wrn-drawer[data-size="sm"] .wrn-drawer__panel {
|
||||
width: min(22rem, calc(100vw - 1rem));
|
||||
}
|
||||
|
||||
.wrn-drawer[data-size="lg"] .wrn-drawer__panel {
|
||||
width: min(38rem, calc(100vw - 1rem));
|
||||
}
|
||||
|
||||
.wrn-drawer[data-size="xl"] .wrn-drawer__panel {
|
||||
width: min(52rem, calc(100vw - 1rem));
|
||||
}
|
||||
|
||||
.wrn-drawer[data-size="full"] .wrn-drawer__panel {
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.wrn-drawer[data-placement="left"] .wrn-drawer__panel {
|
||||
margin-right: auto;
|
||||
margin-left: 0;
|
||||
border-right: 1px solid color-mix(in srgb, var(--drawer-accent) 18%, var(--wrn-color-border));
|
||||
border-left: 0;
|
||||
box-shadow: 28px 0 80px color-mix(in srgb, black 28%, transparent);
|
||||
}
|
||||
|
||||
.wrn-drawer[data-placement="top"] .wrn-drawer__layer,
|
||||
.wrn-drawer[data-placement="bottom"] .wrn-drawer__layer {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.wrn-drawer[data-placement="top"] .wrn-drawer__panel,
|
||||
.wrn-drawer[data-placement="bottom"] .wrn-drawer__panel {
|
||||
width: 100%;
|
||||
max-height: min(80vh, 44rem);
|
||||
margin: 0;
|
||||
border: 0;
|
||||
box-shadow: 0 24px 80px color-mix(in srgb, black 28%, transparent);
|
||||
}
|
||||
|
||||
.wrn-drawer[data-placement="top"] .wrn-drawer__panel {
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--drawer-accent) 18%, var(--wrn-color-border));
|
||||
}
|
||||
|
||||
.wrn-drawer[data-placement="bottom"] .wrn-drawer__layer {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.wrn-drawer[data-placement="bottom"] .wrn-drawer__panel {
|
||||
border-top: 1px solid color-mix(in srgb, var(--drawer-accent) 18%, var(--wrn-color-border));
|
||||
border-radius: 1.3rem 1.3rem 0 0;
|
||||
}
|
||||
|
||||
.wrn-drawer[data-variant="soft"] .wrn-drawer__panel {
|
||||
background:
|
||||
linear-gradient(145deg, var(--drawer-soft), transparent 65%),
|
||||
var(--wrn-color-surface-raised);
|
||||
}
|
||||
|
||||
.wrn-drawer[data-variant="solid"] .wrn-drawer__panel {
|
||||
color: var(--wrn-color-primary-contrast);
|
||||
background: var(--drawer-accent);
|
||||
border-color: color-mix(in srgb, white 18%, transparent);
|
||||
}
|
||||
|
||||
.wrn-drawer__handle {
|
||||
display: none;
|
||||
width: 2.75rem;
|
||||
height: 0.28rem;
|
||||
margin: 0.55rem auto 0;
|
||||
background: color-mix(in srgb, var(--wrn-color-text-muted) 48%, transparent);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.wrn-drawer[data-placement="bottom"] .wrn-drawer__handle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wrn-drawer__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.35rem 1.35rem 1.1rem;
|
||||
border-bottom: 1px solid var(--wrn-color-border);
|
||||
}
|
||||
|
||||
.wrn-drawer__heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.85rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wrn-drawer__icon {
|
||||
flex: 0 0 auto;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-top: 0.15rem;
|
||||
color: var(--drawer-accent);
|
||||
}
|
||||
|
||||
.wrn-drawer[data-variant="solid"] .wrn-drawer__icon {
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.wrn-drawer__heading-copy {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wrn-drawer__heading-copy h2,
|
||||
.wrn-drawer__heading-copy p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wrn-drawer__heading-copy h2 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.wrn-drawer__heading-copy p {
|
||||
color: var(--wrn-color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.wrn-drawer[data-variant="solid"] .wrn-drawer__heading-copy p {
|
||||
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.
|
||||
*/
|
||||
.wrn-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(--wrn-color-text-muted);
|
||||
background: var(--wrn-color-surface-soft);
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wrn-drawer__close:hover,
|
||||
.wrn-drawer__close:focus-visible {
|
||||
color: var(--drawer-accent);
|
||||
border-color: color-mix(in srgb, var(--drawer-accent) 34%, var(--wrn-color-border));
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Never let the glyph be shrunk by the flex container. */
|
||||
.wrn-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.
|
||||
*/
|
||||
.wrn-drawer__body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 1.35rem;
|
||||
color: var(--wrn-color-text);
|
||||
}
|
||||
|
||||
.wrn-drawer__body :where(p, li, dd, dt, h1, h2, h3, h4, h5, h6, span, label, code) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.wrn-drawer[data-scrollable="true"] .wrn-drawer__body {
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.wrn-drawer__footer {
|
||||
padding: 1rem 1.35rem 1.35rem;
|
||||
border-top: 1px solid var(--wrn-color-border);
|
||||
}
|
||||
|
||||
.wrn-drawer__footer:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.wrn-drawer[data-placement="right"] .wrn-drawer__panel,
|
||||
.wrn-drawer[data-placement="left"] .wrn-drawer__panel {
|
||||
width: min(24rem, 100vw);
|
||||
}
|
||||
|
||||
.wrn-drawer__header,
|
||||
.wrn-drawer__body,
|
||||
.wrn-drawer__footer {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
component Dropdown {
|
||||
outputs {
|
||||
toggle(payload: { open: boolean; sourceEvent: Event; reason?: object })
|
||||
open(payload: { sourceEvent: Event })
|
||||
close(payload: { reason: string; sourceEvent: Event })
|
||||
select(payload: { item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event })
|
||||
action(payload: { item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event })
|
||||
}
|
||||
|
||||
props {
|
||||
items: unknown[] = []
|
||||
open: boolean = false
|
||||
defaultOpen: boolean = false
|
||||
label: string = "Open menu"
|
||||
icon: string = ""
|
||||
showChevron: boolean = true
|
||||
menuLabel: string = "Dropdown menu"
|
||||
placement: string = "bottom-start"
|
||||
width: string = "md"
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
variant: string = "raised"
|
||||
closeOnSelect: boolean = true
|
||||
closeOnOutside: boolean = true
|
||||
disabled: boolean = false
|
||||
emptyLabel: string = "No menu items"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
state visible = defaultOpen
|
||||
|
||||
functions {
|
||||
shared function isOpen() {
|
||||
return open || visible
|
||||
}
|
||||
|
||||
client function showMenu(sourceEvent) {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
visible = true
|
||||
output.open({ sourceEvent: sourceEvent })
|
||||
output.toggle({ open: true, sourceEvent: sourceEvent })
|
||||
}
|
||||
|
||||
client function hideMenu(reason, sourceEvent) {
|
||||
visible = false
|
||||
output.close({ reason: reason, sourceEvent: sourceEvent })
|
||||
output.toggle({ open: false, reason: reason, sourceEvent: sourceEvent })
|
||||
}
|
||||
|
||||
client function toggleMenu(sourceEvent) {
|
||||
if (isOpen()) {
|
||||
hideMenu("toggle", sourceEvent)
|
||||
} else {
|
||||
showMenu(sourceEvent)
|
||||
}
|
||||
}
|
||||
|
||||
client function chooseItem(item, itemIndex, sourceEvent) {
|
||||
if (disabled || item.disabled || item.type === "divider" || item.type === "header") {
|
||||
sourceEvent.preventDefault()
|
||||
return
|
||||
}
|
||||
output.select({
|
||||
item: item,
|
||||
itemIndex: itemIndex,
|
||||
value: item.value || "",
|
||||
sourceEvent: sourceEvent
|
||||
})
|
||||
if (item.action) {
|
||||
output.action({
|
||||
item: item,
|
||||
itemIndex: itemIndex,
|
||||
value: item.value || "",
|
||||
sourceEvent: sourceEvent
|
||||
})
|
||||
}
|
||||
if (closeOnSelect) {
|
||||
hideMenu("select", sourceEvent)
|
||||
}
|
||||
}
|
||||
|
||||
client function moveFocus(sourceEvent, direction) {
|
||||
const root = sourceEvent.currentTarget.closest(".wrn-dropdown") || sourceEvent.currentTarget
|
||||
const options = [...root.querySelectorAll("[data-dropdown-item]:not([disabled])")]
|
||||
if (!options.length) {
|
||||
return
|
||||
}
|
||||
const activeIndex = options.indexOf(document.activeElement)
|
||||
const nextIndex = (activeIndex + direction + options.length) % options.length
|
||||
options[nextIndex].focus()
|
||||
}
|
||||
|
||||
client function handleKeydown(sourceEvent) {
|
||||
if (sourceEvent.key === "Escape") {
|
||||
sourceEvent.preventDefault()
|
||||
hideMenu("escape", sourceEvent)
|
||||
} else if (sourceEvent.key === "ArrowDown") {
|
||||
sourceEvent.preventDefault()
|
||||
if (!isOpen()) {
|
||||
showMenu(sourceEvent)
|
||||
}
|
||||
moveFocus(sourceEvent, 1)
|
||||
} else if (sourceEvent.key === "ArrowUp") {
|
||||
sourceEvent.preventDefault()
|
||||
if (!isOpen()) {
|
||||
showMenu(sourceEvent)
|
||||
}
|
||||
moveFocus(sourceEvent, -1)
|
||||
} else if (sourceEvent.key === "Home" && isOpen()) {
|
||||
sourceEvent.preventDefault()
|
||||
const root = sourceEvent.currentTarget.closest(".wrn-dropdown") || sourceEvent.currentTarget
|
||||
const first = root.querySelector("[data-dropdown-item]:not([disabled])")
|
||||
if (first) {
|
||||
first.focus()
|
||||
}
|
||||
} else if (sourceEvent.key === "End" && isOpen()) {
|
||||
sourceEvent.preventDefault()
|
||||
const root = sourceEvent.currentTarget.closest(".wrn-dropdown") || sourceEvent.currentTarget
|
||||
const options = [...root.querySelectorAll("[data-dropdown-item]:not([disabled])")]
|
||||
if (options.length) {
|
||||
options[options.length - 1].focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<div
|
||||
{...attrs}
|
||||
data-ui-component="Dropdown"
|
||||
data-open='{open || visible ? "true" : "false"}'
|
||||
data-placement='{placement}'
|
||||
data-width='{width}'
|
||||
data-size='{size}'
|
||||
data-color='{color}'
|
||||
data-variant='{variant}'
|
||||
class='wrn-dropdown {class}'
|
||||
@keydown='handleKeydown(event)'
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-dropdown__trigger"
|
||||
disabled='{disabled}'
|
||||
aria-haspopup="menu"
|
||||
aria-expanded='{open || visible ? "true" : "false"}'
|
||||
@click='toggleMenu(event)'
|
||||
>
|
||||
<slot name="trigger"></slot>
|
||||
{#if icon}
|
||||
<span class='wrn-dropdown__trigger-icon {icon}' aria-hidden="true"></span>
|
||||
{/if}
|
||||
{#if label}
|
||||
<span class="wrn-dropdown__trigger-label">{label}</span>
|
||||
{/if}
|
||||
{#if showChevron}
|
||||
<span class="icon-[lucide--chevron-down] wrn-dropdown__chevron" aria-hidden="true"></span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if closeOnOutside}
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-dropdown__dismiss-layer"
|
||||
data-show='{open || visible}'
|
||||
aria-label="Close menu"
|
||||
@click='hideMenu("outside", event)'
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="wrn-dropdown__panel"
|
||||
data-wrn-anchored="true"
|
||||
data-show='{open || visible}'
|
||||
role="menu"
|
||||
aria-label='{menuLabel}'
|
||||
>
|
||||
<slot name="header"></slot>
|
||||
|
||||
<div class="wrn-dropdown__items">
|
||||
{#each items as item, itemIndex}
|
||||
{#if item.type === "divider"}
|
||||
<div class="wrn-dropdown__divider" role="separator"></div>
|
||||
{:else if item.type === "header"}
|
||||
<div class="wrn-dropdown__section-label">{item.label || item.title}</div>
|
||||
{:else if item.href}
|
||||
<a
|
||||
href='{item.href}'
|
||||
target='{item.target || ""}'
|
||||
rel='{item.external || item.target === "_blank" ? "noopener noreferrer" : (item.rel || "")}'
|
||||
role="menuitem"
|
||||
data-dropdown-item
|
||||
data-danger='{item.danger ? "true" : "false"}'
|
||||
data-selected='{item.selected || item.checked ? "true" : "false"}'
|
||||
aria-disabled='{item.disabled ? "true" : "false"}'
|
||||
class="wrn-dropdown__item"
|
||||
@click='chooseItem(item, itemIndex, event)'
|
||||
>
|
||||
{#if item.icon}
|
||||
<span class='wrn-dropdown__item-icon {item.icon}' aria-hidden="true"></span>
|
||||
{/if}
|
||||
<span class="wrn-dropdown__copy">
|
||||
<strong>{item.label || item.title}</strong>
|
||||
{#if item.description}
|
||||
<small>{item.description}</small>
|
||||
{/if}
|
||||
</span>
|
||||
{#if item.badge}
|
||||
<span class="wrn-dropdown__badge">{item.badge}</span>
|
||||
{:else if item.shortcut}
|
||||
<kbd>{item.shortcut}</kbd>
|
||||
{:else if item.checked || item.selected}
|
||||
<span class="icon-[lucide--check] wrn-dropdown__status" aria-hidden="true"></span>
|
||||
{:else if item.external}
|
||||
<span class="icon-[lucide--arrow-up-right] wrn-dropdown__status" aria-hidden="true"></span>
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-dropdown-item
|
||||
data-danger='{item.danger ? "true" : "false"}'
|
||||
data-selected='{item.selected || item.checked ? "true" : "false"}'
|
||||
disabled='{item.disabled}'
|
||||
class="wrn-dropdown__item"
|
||||
@click='chooseItem(item, itemIndex, event)'
|
||||
>
|
||||
{#if item.icon}
|
||||
<span class='wrn-dropdown__item-icon {item.icon}' aria-hidden="true"></span>
|
||||
{/if}
|
||||
<span class="wrn-dropdown__copy">
|
||||
<strong>{item.label || item.title}</strong>
|
||||
{#if item.description}
|
||||
<small>{item.description}</small>
|
||||
{/if}
|
||||
</span>
|
||||
{#if item.badge}
|
||||
<span class="wrn-dropdown__badge">{item.badge}</span>
|
||||
{:else if item.shortcut}
|
||||
<kbd>{item.shortcut}</kbd>
|
||||
{:else if item.checked || item.selected}
|
||||
<span class="icon-[lucide--check] wrn-dropdown__status" aria-hidden="true"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{:empty}
|
||||
<div class="wrn-dropdown__empty">{emptyLabel}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<slot></slot>
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-dropdown {
|
||||
--dropdown-accent: var(--wrn-color-primary);
|
||||
--dropdown-soft: var(--wrn-color-primary-soft);
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wrn-dropdown[data-color="secondary"] {
|
||||
--dropdown-accent: var(--wrn-color-secondary);
|
||||
--dropdown-soft: var(--wrn-color-secondary-soft);
|
||||
}
|
||||
|
||||
.wrn-dropdown[data-color="info"] {
|
||||
--dropdown-accent: var(--wrn-color-info);
|
||||
--dropdown-soft: var(--wrn-color-info-soft);
|
||||
}
|
||||
|
||||
.wrn-dropdown[data-color="success"] {
|
||||
--dropdown-accent: var(--wrn-color-success);
|
||||
--dropdown-soft: var(--wrn-color-success-soft);
|
||||
}
|
||||
|
||||
.wrn-dropdown[data-color="warning"] {
|
||||
--dropdown-accent: var(--wrn-color-warning);
|
||||
--dropdown-soft: var(--wrn-color-warning-soft);
|
||||
}
|
||||
|
||||
.wrn-dropdown[data-color="danger"] {
|
||||
--dropdown-accent: var(--wrn-color-danger);
|
||||
--dropdown-soft: var(--wrn-color-danger-soft);
|
||||
}
|
||||
|
||||
.wrn-dropdown__trigger {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.55rem;
|
||||
min-height: 2.55rem;
|
||||
padding: 0.65rem 0.9rem;
|
||||
color: var(--wrn-color-text);
|
||||
background: var(--wrn-color-surface-raised);
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: 0.78rem;
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms ease, background-color 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.wrn-dropdown__trigger:hover,
|
||||
.wrn-dropdown__trigger:focus-visible,
|
||||
.wrn-dropdown[data-open="true"] .wrn-dropdown__trigger {
|
||||
color: var(--dropdown-accent);
|
||||
background: var(--dropdown-soft);
|
||||
border-color: color-mix(in srgb, var(--dropdown-accent) 38%, var(--wrn-color-border));
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.wrn-dropdown__trigger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.wrn-dropdown__trigger-icon,
|
||||
.wrn-dropdown__chevron {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.wrn-dropdown__chevron {
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.wrn-dropdown[data-open="true"] .wrn-dropdown__chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.wrn-dropdown[data-size="sm"] .wrn-dropdown__trigger {
|
||||
min-height: 2.2rem;
|
||||
padding: 0.5rem 0.72rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.wrn-dropdown[data-size="lg"] .wrn-dropdown__trigger {
|
||||
min-height: 2.9rem;
|
||||
padding: 0.78rem 1.05rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.wrn-dropdown__dismiss-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
appearance: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.wrn-dropdown__panel {
|
||||
position: absolute;
|
||||
z-index: 1101;
|
||||
top: calc(100% + 0.55rem);
|
||||
left: 0;
|
||||
width: max-content;
|
||||
min-width: 12rem;
|
||||
max-width: min(22rem, calc(100vw - 1.5rem));
|
||||
padding: 0.45rem;
|
||||
color: var(--wrn-color-text);
|
||||
background:
|
||||
linear-gradient(145deg, color-mix(in srgb, var(--dropdown-accent) 5%, transparent), transparent 60%),
|
||||
color-mix(in srgb, var(--wrn-color-surface-raised) 97%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--dropdown-accent) 18%, var(--wrn-color-border));
|
||||
border-radius: 1rem;
|
||||
box-shadow:
|
||||
0 1px 0 color-mix(in srgb, white 5%, transparent) inset,
|
||||
0 24px 64px color-mix(in srgb, black 22%, transparent);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.wrn-dropdown[data-width="trigger"] .wrn-dropdown__panel {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.wrn-dropdown[data-width="sm"] .wrn-dropdown__panel {
|
||||
width: 12rem;
|
||||
}
|
||||
|
||||
.wrn-dropdown[data-width="md"] .wrn-dropdown__panel {
|
||||
width: 17rem;
|
||||
}
|
||||
|
||||
.wrn-dropdown[data-width="lg"] .wrn-dropdown__panel {
|
||||
width: 22rem;
|
||||
}
|
||||
|
||||
.wrn-dropdown[data-placement="bottom-end"] .wrn-dropdown__panel {
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.wrn-dropdown[data-placement="top-start"] .wrn-dropdown__panel {
|
||||
top: auto;
|
||||
bottom: calc(100% + 0.55rem);
|
||||
}
|
||||
|
||||
.wrn-dropdown[data-placement="top-end"] .wrn-dropdown__panel {
|
||||
top: auto;
|
||||
right: 0;
|
||||
bottom: calc(100% + 0.55rem);
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.wrn-dropdown[data-variant="soft"] .wrn-dropdown__panel {
|
||||
background:
|
||||
linear-gradient(145deg, var(--dropdown-soft), transparent 70%),
|
||||
var(--wrn-color-surface-raised);
|
||||
box-shadow: 0 18px 48px color-mix(in srgb, black 16%, transparent);
|
||||
}
|
||||
|
||||
.wrn-dropdown[data-variant="outline"] .wrn-dropdown__panel {
|
||||
background: var(--wrn-color-surface-raised);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.wrn-dropdown__items {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.wrn-dropdown__section-label {
|
||||
padding: 0.55rem 0.72rem 0.3rem;
|
||||
color: var(--wrn-color-text-muted);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wrn-dropdown__divider {
|
||||
height: 1px;
|
||||
margin: 0.3rem 0.4rem;
|
||||
background: var(--wrn-color-border);
|
||||
}
|
||||
|
||||
.wrn-dropdown__item {
|
||||
appearance: none;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0.68rem 0.72rem;
|
||||
color: var(--wrn-color-text);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0.72rem;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease, color 150ms ease, transform 150ms ease;
|
||||
}
|
||||
|
||||
.wrn-dropdown__item:hover,
|
||||
.wrn-dropdown__item:focus-visible,
|
||||
.wrn-dropdown__item[data-selected="true"] {
|
||||
color: var(--dropdown-accent);
|
||||
background: var(--dropdown-soft);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.wrn-dropdown__item:active {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
|
||||
.wrn-dropdown__item[data-danger="true"] {
|
||||
color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-dropdown__item[data-danger="true"]:hover,
|
||||
.wrn-dropdown__item[data-danger="true"]:focus-visible {
|
||||
background: var(--wrn-color-danger-soft);
|
||||
}
|
||||
|
||||
.wrn-dropdown__item[disabled],
|
||||
.wrn-dropdown__item[aria-disabled="true"] {
|
||||
opacity: 0.48;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wrn-dropdown__item-icon,
|
||||
.wrn-dropdown__status {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.wrn-dropdown__copy {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wrn-dropdown__copy strong {
|
||||
overflow: hidden;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wrn-dropdown__copy small {
|
||||
overflow: hidden;
|
||||
color: var(--wrn-color-text-muted);
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wrn-dropdown__badge {
|
||||
padding: 0.18rem 0.45rem;
|
||||
color: var(--dropdown-accent);
|
||||
background: var(--dropdown-soft);
|
||||
border-radius: 999px;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wrn-dropdown kbd {
|
||||
padding: 0.16rem 0.38rem;
|
||||
color: var(--wrn-color-text-muted);
|
||||
background: var(--wrn-color-surface-soft);
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: 0.36rem;
|
||||
font-size: 0.64rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.wrn-dropdown__empty {
|
||||
padding: 1rem;
|
||||
color: var(--wrn-color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.wrn-dropdown__panel {
|
||||
position: fixed;
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
left: 0.75rem;
|
||||
top: auto;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wrn-dropdown__trigger,
|
||||
.wrn-dropdown__chevron,
|
||||
.wrn-dropdown__item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
component FileInput {
|
||||
outputs {
|
||||
input(payload: { files: File[]; name: string; sourceEvent: Event })
|
||||
change(payload: { files: File[]; name: string; sourceEvent: Event })
|
||||
focus(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
blur(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
select(payload: { files: File[]; name: string; sourceEvent: Event })
|
||||
clear(payload: { name: string; sourceEvent: Event })
|
||||
invalid(payload: { message?: string; value: string | number | boolean | null | object; sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
id: string = ""
|
||||
name: string = ""
|
||||
label: string = "File"
|
||||
hiddenLabel: boolean = false
|
||||
placeholder: string = "Choose a file"
|
||||
value: string = ""
|
||||
icon: string = "icon-[lucide--upload]"
|
||||
iconPosition: string = "start"
|
||||
accept: string = ""
|
||||
multiple: boolean = false
|
||||
helperText: string = ""
|
||||
cornerHint: string = ""
|
||||
error: string = ""
|
||||
inline: boolean = false
|
||||
variant: string = "normal"
|
||||
readonly: boolean = false
|
||||
disabled: boolean = false
|
||||
required: boolean = false
|
||||
class: string = ""
|
||||
}
|
||||
state selectedName: string = ""
|
||||
functions {
|
||||
client function handleChange(sourceEvent) {
|
||||
sourceEvent.stopPropagation()
|
||||
selectedName = sourceEvent.currentTarget.files && sourceEvent.currentTarget.files.length ? sourceEvent.currentTarget.files[0].name : ""
|
||||
output.input({ files: sourceEvent.currentTarget.files, name: name, sourceEvent: sourceEvent })
|
||||
output.change({ files: sourceEvent.currentTarget.files, name: name, sourceEvent: sourceEvent })
|
||||
if (selectedName) output.select({ files: sourceEvent.currentTarget.files, name: name, sourceEvent: sourceEvent })
|
||||
else output.clear({ name: name, sourceEvent: sourceEvent })
|
||||
}
|
||||
client function emitField(nameEvent, sourceEvent) { sourceEvent.stopPropagation(); output[nameEvent]({ name: name, sourceEvent: sourceEvent }) }
|
||||
client function preventReadonly(sourceEvent) { if (readonly) sourceEvent.preventDefault() }
|
||||
}
|
||||
view {
|
||||
<div {...attrs} class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--field wrn-next--file-input {class}" data-variant="{variant}" data-inline="{inline}" data-invalid="{error ? 'true' : 'false'}">
|
||||
<div class="wrn-next__field-heading"><label class="{hiddenLabel ? 'wrn-next__sr-only' : ''}" for="{id || name}">{label}</label>{#if cornerHint}<span class="wrn-next__field-hint">{cornerHint}</span>{/if}</div>
|
||||
<div class="wrn-next__file-control" data-icon-position="{iconPosition}">{#if icon}<span class="{icon}" aria-hidden="true"></span>{/if}<span>{selectedName || value || placeholder}</span><input id="{id || name}" type="file" name="{name}" accept="{accept}" multiple="{multiple}" disabled="{disabled}" required="{required}" aria-readonly="{readonly}" aria-invalid="{error ? 'true' : 'false'}" @click="preventReadonly(event)" @input="handleChange(event)" @change="handleChange(event)" @focus="emitField('focus', event)" @blur="emitField('blur', event)" @invalid="emitField('invalid', event)" /></div>
|
||||
{#if helperText}<small class="wrn-next__field-help">{helperText}</small>{/if}<small class="wrn-next__field-error" data-error="{name}">{error}</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-next__file-control {
|
||||
position: relative;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border: 1px dashed var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface);
|
||||
}
|
||||
|
||||
.wrn-next__file-control input {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* Shared component foundation. */
|
||||
|
||||
.wrn-component {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
--wrn-component-scale: 1;
|
||||
margin: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-family: var(--wrn-font-sans, inherit);
|
||||
}
|
||||
|
||||
.wrn-component--color-primary {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-component--color-secondary {
|
||||
--wrn-component-color: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-component--color-success {
|
||||
--wrn-component-color: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-component--color-warning {
|
||||
--wrn-component-color: var(--wrn-color-warning);
|
||||
}
|
||||
|
||||
.wrn-component--color-danger {
|
||||
--wrn-component-color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-component--color-info {
|
||||
--wrn-component-color: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-component--size-xs {
|
||||
--wrn-component-scale: 0.78;
|
||||
}
|
||||
|
||||
.wrn-component--size-sm {
|
||||
--wrn-component-scale: 0.88;
|
||||
}
|
||||
|
||||
.wrn-component--size-default,
|
||||
.wrn-component--size-md {
|
||||
--wrn-component-scale: 1;
|
||||
}
|
||||
|
||||
.wrn-component--size-lg {
|
||||
--wrn-component-scale: 1.14;
|
||||
}
|
||||
|
||||
.wrn-component--size-xl {
|
||||
--wrn-component-scale: 1.28;
|
||||
}
|
||||
|
||||
.wrn-component:not(.wrn-btn) {
|
||||
font-size: calc(1em * var(--wrn-component-scale));
|
||||
}
|
||||
|
||||
.wrn-component :is(a, button, input, select, textarea):focus-visible {
|
||||
outline-color: var(--wrn-component-color);
|
||||
}
|
||||
|
||||
.wrn-component p {
|
||||
margin: 0;
|
||||
color: var(--wrn-color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Shared field foundation. */
|
||||
|
||||
/* Validation states set by the browser validation runtime. */
|
||||
.wrn-invalid {
|
||||
border-color: var(--wrn-color-danger) !important;
|
||||
}
|
||||
|
||||
.wrn-field-error {
|
||||
display: block;
|
||||
min-height: 1em;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--wrn-color-danger);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.wrn-next--choice {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wrn-next--choice input {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
accent-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-next--field {
|
||||
display: grid;
|
||||
width: min(100%, 28rem);
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.wrn-next--field :where(input, textarea, select) {
|
||||
width: 100%;
|
||||
min-height: 2.65rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: 0.6rem;
|
||||
outline: 0;
|
||||
color: var(--wrn-color-text);
|
||||
background: var(--wrn-color-bg);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-auth-form form > .wrn-next--field,
|
||||
.wrn-auth-form form > .wrn-field {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
.wrn-auth-form__submit > .wrn-btn {
|
||||
width: 100%;
|
||||
min-height: 3rem;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 0.8rem 1.8rem color-mix(in srgb, var(--wrn-btn-color) 20%, transparent);
|
||||
}
|
||||
.wrn-next--field :where(input, textarea, select):focus-visible {
|
||||
border-color: var(--wrn-color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--wrn-color-primary) 16%, transparent);
|
||||
}
|
||||
.wrn-next--field textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
.wrn-next--field,
|
||||
.wrn-next--choice-field {
|
||||
--wrn-field-color: var(--wrn-component-color, var(--wrn-color-primary));
|
||||
width: min(100%, 34rem);
|
||||
min-width: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-next--field.wrn-next--size-sm,
|
||||
.wrn-next--choice-field.wrn-next--size-sm {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.wrn-next--field.wrn-next--size-lg,
|
||||
.wrn-next--choice-field.wrn-next--size-lg {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.wrn-next__field-heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wrn-next__field-heading label {
|
||||
color: var(--wrn-color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wrn-next__field-hint,
|
||||
.wrn-next__field-help {
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 0.82em;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-next__field-control {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.wrn-next__field-control > :is(input, textarea, select) {
|
||||
padding-inline: 0.85rem;
|
||||
border-color: var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.wrn-next__field-control[data-icon-position="start"] > :is(input, textarea, select) {
|
||||
padding-inline-start: 2.5rem;
|
||||
}
|
||||
.wrn-next__field-control[data-icon-position="end"] > :is(input, textarea, select) {
|
||||
padding-inline-end: 2.5rem;
|
||||
}
|
||||
.wrn-next__field-icon {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 50%;
|
||||
left: 0.85rem;
|
||||
color: var(--wrn-color-muted);
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.wrn-next__field-control[data-icon-position="end"] > .wrn-next__field-icon {
|
||||
right: 0.85rem;
|
||||
left: auto;
|
||||
}
|
||||
.wrn-next--field[data-variant="outlined"]
|
||||
.wrn-next__field-control
|
||||
> :is(input, textarea, select) {
|
||||
border-width: 2px;
|
||||
}
|
||||
.wrn-next--field[data-variant="underline"]
|
||||
.wrn-next__field-control
|
||||
> :is(input, textarea, select) {
|
||||
padding-inline: 0;
|
||||
border-width: 0 0 1px;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.wrn-next--field[data-variant="pill"] .wrn-next__field-control > :is(input, select) {
|
||||
border-radius: 999px;
|
||||
padding-inline: 1.15rem;
|
||||
}
|
||||
.wrn-next--field[data-variant="ghost"] .wrn-next__field-control > :is(input, textarea, select) {
|
||||
border-color: transparent;
|
||||
background: color-mix(in srgb, var(--wrn-field-color) 8%, var(--wrn-color-surface));
|
||||
}
|
||||
.wrn-next__field-floating-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0.85rem;
|
||||
color: var(--wrn-color-muted);
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%);
|
||||
transition: 150ms ease;
|
||||
}
|
||||
.wrn-next--textarea .wrn-next__field-floating-label {
|
||||
top: 1.35rem;
|
||||
}
|
||||
.wrn-next--field[data-floating="true"] .wrn-next__field-control > :is(input, textarea, select) {
|
||||
padding-top: 1.2rem;
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
.wrn-next--field[data-floating="true"] .wrn-next__field-heading > label {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
overflow: hidden !important;
|
||||
margin: -1px !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
.wrn-next--field[data-floating="true"]
|
||||
.wrn-next__field-heading:not(:has(.wrn-next__field-hint)) {
|
||||
display: none;
|
||||
}
|
||||
.wrn-next--field[data-floating="true"]
|
||||
.wrn-next__field-control
|
||||
> :is(input, textarea):is(:focus, :not(:placeholder-shown))
|
||||
+ .wrn-next__field-floating-label {
|
||||
top: 0.42rem;
|
||||
font-size: 0.7em;
|
||||
transform: none;
|
||||
}
|
||||
.wrn-next__field-error {
|
||||
min-height: 1em;
|
||||
color: var(--wrn-color-danger);
|
||||
font-size: 0.82em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.wrn-next__field-error:empty {
|
||||
display: none;
|
||||
}
|
||||
.wrn-next--field[data-invalid="true"] :is(input, textarea, select),
|
||||
.wrn-next--choice-field[data-invalid="true"] input {
|
||||
border-color: var(--wrn-color-danger);
|
||||
}
|
||||
.wrn-next--field :is(input, textarea, select):disabled,
|
||||
.wrn-next--choice-field input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
.wrn-next--field :is(input, textarea, select):read-only {
|
||||
background: var(--wrn-color-surface-2);
|
||||
}
|
||||
.wrn-next--field[data-inline="true"],
|
||||
.wrn-next--choice-field[data-inline="true"] {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
grid-template-columns: minmax(7rem, max-content) minmax(12rem, 20rem) minmax(10rem, 1fr);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-heading,
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-heading {
|
||||
min-height: 2.65rem;
|
||||
align-items: center;
|
||||
}
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-heading {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-help,
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-help {
|
||||
display: flex;
|
||||
min-height: 2.65rem;
|
||||
align-items: center;
|
||||
}
|
||||
.wrn-next--choice-field[data-inline="true"] {
|
||||
grid-template-columns: minmax(12rem, 20rem) minmax(10rem, 1fr);
|
||||
}
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-heading:empty {
|
||||
display: none;
|
||||
}
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-error,
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-error {
|
||||
grid-column: 2 / -1;
|
||||
}
|
||||
.wrn-next__sr-only {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
overflow: hidden !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
margin: -1px !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
.wrn-next__choice-field {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.wrn-next__choice {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
color: var(--wrn-color-text);
|
||||
width: 100%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-next__checkbox-content > .wrn-next__choice {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.wrn-next__choice input {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
accent-color: var(--wrn-field-color);
|
||||
}
|
||||
.wrn-next__choice-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.15rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.wrn-next__choice-copy small {
|
||||
color: var(--wrn-color-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-next__radio-group,
|
||||
.wrn-next__checkbox-group {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.wrn-next--radio-field[data-orientation="horizontal"] .wrn-next__radio-group,
|
||||
.wrn-next--checkbox-field[data-orientation="horizontal"] .wrn-next__checkbox-group {
|
||||
grid-template-columns: repeat(auto-fit, minmax(8rem, max-content));
|
||||
align-items: stretch;
|
||||
}
|
||||
.wrn-next--radio-field[data-card="true"] .wrn-next__radio-group,
|
||||
.wrn-next--checkbox-field[data-card="true"] .wrn-next__checkbox-group {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
|
||||
}
|
||||
.wrn-next--radio-field[data-card="true"] .wrn-next--radio,
|
||||
.wrn-next--checkbox-field[data-card="true"] .wrn-next--checkbox {
|
||||
min-height: 4.5rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface);
|
||||
}
|
||||
.wrn-next--radio-field[data-card="true"] .wrn-next--radio:has(input:checked),
|
||||
.wrn-next--checkbox-field[data-card="true"] .wrn-next--checkbox:has(input:checked) {
|
||||
border-color: var(--wrn-field-color);
|
||||
box-shadow: 0 0 0 1px var(--wrn-field-color);
|
||||
}
|
||||
.wrn-next--radio-field[data-list="true"] .wrn-next--radio,
|
||||
.wrn-next--checkbox-field[data-list="true"] .wrn-next--checkbox {
|
||||
padding-block: 0.75rem;
|
||||
border-bottom: 1px solid var(--wrn-color-border);
|
||||
}
|
||||
.wrn-next--radio-field[data-right-aligned="true"] .wrn-next--radio,
|
||||
.wrn-next--checkbox-field[data-right-aligned="true"] .wrn-next--checkbox {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.wrn-next--radio-field[data-right-aligned="true"] .wrn-next--radio input,
|
||||
.wrn-next--checkbox-field[data-right-aligned="true"] .wrn-next--checkbox input {
|
||||
order: 2;
|
||||
}
|
||||
.wrn-next--radio-field[data-right-aligned="true"] .wrn-next__choice-copy,
|
||||
.wrn-next--checkbox-field[data-right-aligned="true"] .wrn-next__choice-copy {
|
||||
order: 1;
|
||||
}
|
||||
.wrn-next__choice[data-variant="outlined"],
|
||||
.wrn-next__choice[data-variant="floating"] {
|
||||
padding: 0.65rem 0.8rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
}
|
||||
.wrn-next__choice[data-variant="pill"] {
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--wrn-field-color) 9%, var(--wrn-color-surface));
|
||||
}
|
||||
.wrn-next__choice[data-variant="ghost"] {
|
||||
padding: 0.65rem 0.8rem;
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: color-mix(in srgb, var(--wrn-field-color) 7%, transparent);
|
||||
}
|
||||
.wrn-next__choice[data-variant="underline"] {
|
||||
padding-block: 0.5rem;
|
||||
border-bottom: 1px solid var(--wrn-color-border);
|
||||
}
|
||||
.wrn-next--select .wrn-next__field-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.wrn-next__color-control,
|
||||
.wrn-next__range-control,
|
||||
.wrn-next__input-group-control,
|
||||
.wrn-next__file-control {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.wrn-next__picker-control,
|
||||
.wrn-next--time-picker .wrn-next__field-control {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
}
|
||||
.wrn-next__picker-control > button:first-of-type,
|
||||
.wrn-next__time-trigger {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 2.65rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface);
|
||||
color: var(--wrn-color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.wrn-next__calendar,
|
||||
.wrn-next__time-panel {
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
width: min(100%, 22rem);
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-md);
|
||||
background: var(--wrn-color-surface);
|
||||
box-shadow: var(--wrn-shadow-3);
|
||||
}
|
||||
.wrn-next--date-picker[data-expanded="false"] .wrn-next__calendar,
|
||||
.wrn-next--time-picker[data-expanded="false"] .wrn-next__time-panel {
|
||||
display: none;
|
||||
}
|
||||
.wrn-next__calendar button,
|
||||
.wrn-next__time-panel button {
|
||||
min-height: 2.25rem;
|
||||
border: 0;
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
.wrn-next__calendar-weekdays,
|
||||
.wrn-next__calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 0.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.wrn-next__calendar-weekdays {
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.wrn-next__calendar-grid button:hover,
|
||||
.wrn-next__time-panel button:hover {
|
||||
background: var(--wrn-color-surface-2);
|
||||
}
|
||||
.wrn-next__calendar-grid button[aria-pressed="true"],
|
||||
.wrn-next__time-panel button[aria-pressed="true"] {
|
||||
background: var(--wrn-field-color);
|
||||
color: var(--wrn-color-primary-contrast, #fff);
|
||||
}
|
||||
.wrn-next__time-period {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.wrn-next--file-upload-progress .wrn-next__row,
|
||||
.wrn-next__upload-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.wrn-next--file-upload-progress .wrn-next__row > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.wrn-next--field[data-variant="underline"]
|
||||
:is(.wrn-next__input-group-control, .wrn-next__file-control, .wrn-next__color-control) {
|
||||
border-width: 0 0 1px;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.wrn-next--field[data-variant="outlined"]
|
||||
:is(.wrn-next__input-group-control, .wrn-next__file-control) {
|
||||
border-width: 2px;
|
||||
}
|
||||
.wrn-next--field[data-variant="pill"]
|
||||
:is(.wrn-next__input-group-control, .wrn-next__file-control) {
|
||||
border-radius: 999px;
|
||||
}
|
||||
.wrn-next--field[data-variant="ghost"]
|
||||
:is(.wrn-next__input-group-control, .wrn-next__file-control, .wrn-next__color-control) {
|
||||
border-color: transparent;
|
||||
background: color-mix(in srgb, var(--wrn-field-color) 8%, var(--wrn-color-surface));
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.wrn-next--field[data-inline="true"],
|
||||
.wrn-next--choice-field[data-inline="true"] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-error,
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-error {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
.wrn-next--toggle-password:has(input.wrn-invalid) .wrn-next__password-control > input,
|
||||
.wrn-next--toggle-password.wrn-next--invalid .wrn-next__password-control > input {
|
||||
border-color: var(--wrn-color-danger);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--wrn-color-danger) 14%, transparent);
|
||||
}
|
||||
.wrn-next--strong-password.wrn-next--invalid input,
|
||||
.wrn-next--strong-password input.wrn-invalid {
|
||||
border-color: var(--wrn-color-danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
component FileUploadProgress {
|
||||
outputs {
|
||||
cancel(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
|
||||
retry(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
|
||||
complete(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"
|
||||
label: string = "Progress"
|
||||
value: number = 50
|
||||
max: number = 100
|
||||
showValue: boolean = true
|
||||
fileName: string = ""
|
||||
fileSize: string = ""
|
||||
uploadedSize: string = ""
|
||||
status: string = "uploading"
|
||||
cancelLabel: string = "Cancel upload"
|
||||
retryLabel: string = "Retry upload"
|
||||
class: string = ""
|
||||
}
|
||||
state currentValue = value
|
||||
state currentStatus = status
|
||||
functions {
|
||||
shared function percent() { if (!Number(max)) { return 0 } return Math.max(0, Math.min(100, Math.round(Number(currentValue) / Number(max) * 100))) }
|
||||
client function detail(sourceEvent) { return { value: currentValue, max: max, percent: percent(), status: currentStatus, fileName: fileName, sourceEvent: sourceEvent } }
|
||||
client function cancelUpload(sourceEvent) { currentStatus = "cancelled"; output.cancel(detail(sourceEvent)) }
|
||||
client function retryUpload(sourceEvent) { currentValue = 0; currentStatus = "uploading"; output.retry(detail(sourceEvent)) }
|
||||
client function completeUpload(sourceEvent) { currentValue = max; currentStatus = "complete"; output.complete(detail(sourceEvent)) }
|
||||
}
|
||||
view {
|
||||
<div {...attrs} class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--file-upload-progress {class}" data-status="{currentStatus}" aria-live="polite">
|
||||
<div class="wrn-next__row"><span>{#if fileName}<strong>{fileName}</strong><small>{#if uploadedSize || fileSize}{uploadedSize}{#if uploadedSize && fileSize} of {/if}{fileSize}{:else}{label}{/if}</small>{:else}<strong>{label}</strong>{/if}</span>{#if showValue}<strong>{percent()}%</strong>{/if}</div>
|
||||
<progress value="{currentValue}" max="{max}" aria-label="{label}"></progress>
|
||||
<div class="wrn-next__upload-status"><span class="wrn-next__upload-status-icon" aria-hidden="true"></span><span>{#if currentStatus === "uploading"}Uploading securely…{:else if currentStatus === "complete"}Upload complete{:else if currentStatus === "error"}Upload failed{:else}Upload cancelled{/if}</span></div>
|
||||
<div class="wrn-next__upload-actions">{#if currentStatus === "uploading"}<button type="button" @click="cancelUpload(event)">{cancelLabel}</button><button type="button" @click="completeUpload(event)">Complete</button>{/if}{#if currentStatus === "error" || currentStatus === "cancelled"}<button type="button" @click="retryUpload(event)">{retryLabel}</button>{/if}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-next__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.wrn-next--file-upload-progress progress {
|
||||
width: 100%;
|
||||
accent-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-next--file-upload-progress {
|
||||
display: grid;
|
||||
width: min(100%, 36rem);
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-md);
|
||||
background: var(--wrn-color-surface);
|
||||
box-shadow: var(--wrn-shadow-1);
|
||||
}
|
||||
|
||||
.wrn-next--file-upload-progress progress {
|
||||
width: 100%;
|
||||
height: 0.55rem;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
accent-color: var(--wrn-component-color);
|
||||
}
|
||||
|
||||
.wrn-next__upload-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.wrn-next__upload-actions button {
|
||||
min-height: 2.25rem;
|
||||
padding-inline: 0.8rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface-2);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Shared component foundation. */
|
||||
|
||||
.wrn-component {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
--wrn-component-scale: 1;
|
||||
margin: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-family: var(--wrn-font-sans, inherit);
|
||||
}
|
||||
|
||||
.wrn-component--color-primary {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-component--color-secondary {
|
||||
--wrn-component-color: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-component--color-success {
|
||||
--wrn-component-color: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-component--color-warning {
|
||||
--wrn-component-color: var(--wrn-color-warning);
|
||||
}
|
||||
|
||||
.wrn-component--color-danger {
|
||||
--wrn-component-color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-component--color-info {
|
||||
--wrn-component-color: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-component--size-xs {
|
||||
--wrn-component-scale: 0.78;
|
||||
}
|
||||
|
||||
.wrn-component--size-sm {
|
||||
--wrn-component-scale: 0.88;
|
||||
}
|
||||
|
||||
.wrn-component--size-default,
|
||||
.wrn-component--size-md {
|
||||
--wrn-component-scale: 1;
|
||||
}
|
||||
|
||||
.wrn-component--size-lg {
|
||||
--wrn-component-scale: 1.14;
|
||||
}
|
||||
|
||||
.wrn-component--size-xl {
|
||||
--wrn-component-scale: 1.28;
|
||||
}
|
||||
|
||||
.wrn-component:not(.wrn-btn) {
|
||||
font-size: calc(1em * var(--wrn-component-scale));
|
||||
}
|
||||
|
||||
.wrn-component :is(a, button, input, select, textarea):focus-visible {
|
||||
outline-color: var(--wrn-component-color);
|
||||
}
|
||||
|
||||
.wrn-component p {
|
||||
margin: 0;
|
||||
color: var(--wrn-color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Shared field foundation. */
|
||||
|
||||
/* Validation states set by the browser validation runtime. */
|
||||
.wrn-invalid {
|
||||
border-color: var(--wrn-color-danger) !important;
|
||||
}
|
||||
|
||||
.wrn-field-error {
|
||||
display: block;
|
||||
min-height: 1em;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--wrn-color-danger);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.wrn-next--choice {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wrn-next--choice input {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
accent-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-next--field {
|
||||
display: grid;
|
||||
width: min(100%, 28rem);
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.wrn-next--field :where(input, textarea, select) {
|
||||
width: 100%;
|
||||
min-height: 2.65rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: 0.6rem;
|
||||
outline: 0;
|
||||
color: var(--wrn-color-text);
|
||||
background: var(--wrn-color-bg);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-auth-form form > .wrn-next--field,
|
||||
.wrn-auth-form form > .wrn-field {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
.wrn-auth-form__submit > .wrn-btn {
|
||||
width: 100%;
|
||||
min-height: 3rem;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 0.8rem 1.8rem color-mix(in srgb, var(--wrn-btn-color) 20%, transparent);
|
||||
}
|
||||
.wrn-next--field :where(input, textarea, select):focus-visible {
|
||||
border-color: var(--wrn-color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--wrn-color-primary) 16%, transparent);
|
||||
}
|
||||
.wrn-next--field textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
.wrn-next--field,
|
||||
.wrn-next--choice-field {
|
||||
--wrn-field-color: var(--wrn-component-color, var(--wrn-color-primary));
|
||||
width: min(100%, 34rem);
|
||||
min-width: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-next--field.wrn-next--size-sm,
|
||||
.wrn-next--choice-field.wrn-next--size-sm {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.wrn-next--field.wrn-next--size-lg,
|
||||
.wrn-next--choice-field.wrn-next--size-lg {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.wrn-next__field-heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wrn-next__field-heading label {
|
||||
color: var(--wrn-color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wrn-next__field-hint,
|
||||
.wrn-next__field-help {
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 0.82em;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-next__field-control {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.wrn-next__field-control > :is(input, textarea, select) {
|
||||
padding-inline: 0.85rem;
|
||||
border-color: var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.wrn-next__field-control[data-icon-position="start"] > :is(input, textarea, select) {
|
||||
padding-inline-start: 2.5rem;
|
||||
}
|
||||
.wrn-next__field-control[data-icon-position="end"] > :is(input, textarea, select) {
|
||||
padding-inline-end: 2.5rem;
|
||||
}
|
||||
.wrn-next__field-icon {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 50%;
|
||||
left: 0.85rem;
|
||||
color: var(--wrn-color-muted);
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.wrn-next__field-control[data-icon-position="end"] > .wrn-next__field-icon {
|
||||
right: 0.85rem;
|
||||
left: auto;
|
||||
}
|
||||
.wrn-next--field[data-variant="outlined"]
|
||||
.wrn-next__field-control
|
||||
> :is(input, textarea, select) {
|
||||
border-width: 2px;
|
||||
}
|
||||
.wrn-next--field[data-variant="underline"]
|
||||
.wrn-next__field-control
|
||||
> :is(input, textarea, select) {
|
||||
padding-inline: 0;
|
||||
border-width: 0 0 1px;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.wrn-next--field[data-variant="pill"] .wrn-next__field-control > :is(input, select) {
|
||||
border-radius: 999px;
|
||||
padding-inline: 1.15rem;
|
||||
}
|
||||
.wrn-next--field[data-variant="ghost"] .wrn-next__field-control > :is(input, textarea, select) {
|
||||
border-color: transparent;
|
||||
background: color-mix(in srgb, var(--wrn-field-color) 8%, var(--wrn-color-surface));
|
||||
}
|
||||
.wrn-next__field-floating-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0.85rem;
|
||||
color: var(--wrn-color-muted);
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%);
|
||||
transition: 150ms ease;
|
||||
}
|
||||
.wrn-next--textarea .wrn-next__field-floating-label {
|
||||
top: 1.35rem;
|
||||
}
|
||||
.wrn-next--field[data-floating="true"] .wrn-next__field-control > :is(input, textarea, select) {
|
||||
padding-top: 1.2rem;
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
.wrn-next--field[data-floating="true"] .wrn-next__field-heading > label {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
overflow: hidden !important;
|
||||
margin: -1px !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
.wrn-next--field[data-floating="true"]
|
||||
.wrn-next__field-heading:not(:has(.wrn-next__field-hint)) {
|
||||
display: none;
|
||||
}
|
||||
.wrn-next--field[data-floating="true"]
|
||||
.wrn-next__field-control
|
||||
> :is(input, textarea):is(:focus, :not(:placeholder-shown))
|
||||
+ .wrn-next__field-floating-label {
|
||||
top: 0.42rem;
|
||||
font-size: 0.7em;
|
||||
transform: none;
|
||||
}
|
||||
.wrn-next__field-error {
|
||||
min-height: 1em;
|
||||
color: var(--wrn-color-danger);
|
||||
font-size: 0.82em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.wrn-next__field-error:empty {
|
||||
display: none;
|
||||
}
|
||||
.wrn-next--field[data-invalid="true"] :is(input, textarea, select),
|
||||
.wrn-next--choice-field[data-invalid="true"] input {
|
||||
border-color: var(--wrn-color-danger);
|
||||
}
|
||||
.wrn-next--field :is(input, textarea, select):disabled,
|
||||
.wrn-next--choice-field input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
.wrn-next--field :is(input, textarea, select):read-only {
|
||||
background: var(--wrn-color-surface-2);
|
||||
}
|
||||
.wrn-next--field[data-inline="true"],
|
||||
.wrn-next--choice-field[data-inline="true"] {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
grid-template-columns: minmax(7rem, max-content) minmax(12rem, 20rem) minmax(10rem, 1fr);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-heading,
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-heading {
|
||||
min-height: 2.65rem;
|
||||
align-items: center;
|
||||
}
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-heading {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-help,
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-help {
|
||||
display: flex;
|
||||
min-height: 2.65rem;
|
||||
align-items: center;
|
||||
}
|
||||
.wrn-next--choice-field[data-inline="true"] {
|
||||
grid-template-columns: minmax(12rem, 20rem) minmax(10rem, 1fr);
|
||||
}
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-heading:empty {
|
||||
display: none;
|
||||
}
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-error,
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-error {
|
||||
grid-column: 2 / -1;
|
||||
}
|
||||
.wrn-next__sr-only {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
overflow: hidden !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
margin: -1px !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
.wrn-next__choice-field {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.wrn-next__choice {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
color: var(--wrn-color-text);
|
||||
width: 100%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-next__checkbox-content > .wrn-next__choice {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.wrn-next__choice input {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
accent-color: var(--wrn-field-color);
|
||||
}
|
||||
.wrn-next__choice-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.15rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.wrn-next__choice-copy small {
|
||||
color: var(--wrn-color-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-next__radio-group,
|
||||
.wrn-next__checkbox-group {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.wrn-next--radio-field[data-orientation="horizontal"] .wrn-next__radio-group,
|
||||
.wrn-next--checkbox-field[data-orientation="horizontal"] .wrn-next__checkbox-group {
|
||||
grid-template-columns: repeat(auto-fit, minmax(8rem, max-content));
|
||||
align-items: stretch;
|
||||
}
|
||||
.wrn-next--radio-field[data-card="true"] .wrn-next__radio-group,
|
||||
.wrn-next--checkbox-field[data-card="true"] .wrn-next__checkbox-group {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
|
||||
}
|
||||
.wrn-next--radio-field[data-card="true"] .wrn-next--radio,
|
||||
.wrn-next--checkbox-field[data-card="true"] .wrn-next--checkbox {
|
||||
min-height: 4.5rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface);
|
||||
}
|
||||
.wrn-next--radio-field[data-card="true"] .wrn-next--radio:has(input:checked),
|
||||
.wrn-next--checkbox-field[data-card="true"] .wrn-next--checkbox:has(input:checked) {
|
||||
border-color: var(--wrn-field-color);
|
||||
box-shadow: 0 0 0 1px var(--wrn-field-color);
|
||||
}
|
||||
.wrn-next--radio-field[data-list="true"] .wrn-next--radio,
|
||||
.wrn-next--checkbox-field[data-list="true"] .wrn-next--checkbox {
|
||||
padding-block: 0.75rem;
|
||||
border-bottom: 1px solid var(--wrn-color-border);
|
||||
}
|
||||
.wrn-next--radio-field[data-right-aligned="true"] .wrn-next--radio,
|
||||
.wrn-next--checkbox-field[data-right-aligned="true"] .wrn-next--checkbox {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.wrn-next--radio-field[data-right-aligned="true"] .wrn-next--radio input,
|
||||
.wrn-next--checkbox-field[data-right-aligned="true"] .wrn-next--checkbox input {
|
||||
order: 2;
|
||||
}
|
||||
.wrn-next--radio-field[data-right-aligned="true"] .wrn-next__choice-copy,
|
||||
.wrn-next--checkbox-field[data-right-aligned="true"] .wrn-next__choice-copy {
|
||||
order: 1;
|
||||
}
|
||||
.wrn-next__choice[data-variant="outlined"],
|
||||
.wrn-next__choice[data-variant="floating"] {
|
||||
padding: 0.65rem 0.8rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
}
|
||||
.wrn-next__choice[data-variant="pill"] {
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--wrn-field-color) 9%, var(--wrn-color-surface));
|
||||
}
|
||||
.wrn-next__choice[data-variant="ghost"] {
|
||||
padding: 0.65rem 0.8rem;
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: color-mix(in srgb, var(--wrn-field-color) 7%, transparent);
|
||||
}
|
||||
.wrn-next__choice[data-variant="underline"] {
|
||||
padding-block: 0.5rem;
|
||||
border-bottom: 1px solid var(--wrn-color-border);
|
||||
}
|
||||
.wrn-next--select .wrn-next__field-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.wrn-next__color-control,
|
||||
.wrn-next__range-control,
|
||||
.wrn-next__input-group-control,
|
||||
.wrn-next__file-control {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.wrn-next__picker-control,
|
||||
.wrn-next--time-picker .wrn-next__field-control {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
}
|
||||
.wrn-next__picker-control > button:first-of-type,
|
||||
.wrn-next__time-trigger {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 2.65rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface);
|
||||
color: var(--wrn-color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.wrn-next__calendar,
|
||||
.wrn-next__time-panel {
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
width: min(100%, 22rem);
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-md);
|
||||
background: var(--wrn-color-surface);
|
||||
box-shadow: var(--wrn-shadow-3);
|
||||
}
|
||||
.wrn-next--date-picker[data-expanded="false"] .wrn-next__calendar,
|
||||
.wrn-next--time-picker[data-expanded="false"] .wrn-next__time-panel {
|
||||
display: none;
|
||||
}
|
||||
.wrn-next__calendar button,
|
||||
.wrn-next__time-panel button {
|
||||
min-height: 2.25rem;
|
||||
border: 0;
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
.wrn-next__calendar-weekdays,
|
||||
.wrn-next__calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 0.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.wrn-next__calendar-weekdays {
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.wrn-next__calendar-grid button:hover,
|
||||
.wrn-next__time-panel button:hover {
|
||||
background: var(--wrn-color-surface-2);
|
||||
}
|
||||
.wrn-next__calendar-grid button[aria-pressed="true"],
|
||||
.wrn-next__time-panel button[aria-pressed="true"] {
|
||||
background: var(--wrn-field-color);
|
||||
color: var(--wrn-color-primary-contrast, #fff);
|
||||
}
|
||||
.wrn-next__time-period {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.wrn-next--file-upload-progress .wrn-next__row,
|
||||
.wrn-next__upload-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.wrn-next--file-upload-progress .wrn-next__row > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.wrn-next--field[data-variant="underline"]
|
||||
:is(.wrn-next__input-group-control, .wrn-next__file-control, .wrn-next__color-control) {
|
||||
border-width: 0 0 1px;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.wrn-next--field[data-variant="outlined"]
|
||||
:is(.wrn-next__input-group-control, .wrn-next__file-control) {
|
||||
border-width: 2px;
|
||||
}
|
||||
.wrn-next--field[data-variant="pill"]
|
||||
:is(.wrn-next__input-group-control, .wrn-next__file-control) {
|
||||
border-radius: 999px;
|
||||
}
|
||||
.wrn-next--field[data-variant="ghost"]
|
||||
:is(.wrn-next__input-group-control, .wrn-next__file-control, .wrn-next__color-control) {
|
||||
border-color: transparent;
|
||||
background: color-mix(in srgb, var(--wrn-field-color) 8%, var(--wrn-color-surface));
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.wrn-next--field[data-inline="true"],
|
||||
.wrn-next--choice-field[data-inline="true"] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-error,
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-error {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
.wrn-next--toggle-password:has(input.wrn-invalid) .wrn-next__password-control > input,
|
||||
.wrn-next--toggle-password.wrn-next--invalid .wrn-next__password-control > input {
|
||||
border-color: var(--wrn-color-danger);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--wrn-color-danger) 14%, transparent);
|
||||
}
|
||||
.wrn-next--strong-password.wrn-next--invalid input,
|
||||
.wrn-next--strong-password input.wrn-invalid {
|
||||
border-color: var(--wrn-color-danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// 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"
|
||||
color: string = "primary"
|
||||
src: string = ""
|
||||
alt: string = ""
|
||||
width: string = ""
|
||||
height: string = ""
|
||||
loading: string = "lazy"
|
||||
fit: string = "cover"
|
||||
rounded: boolean = false
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<figure
|
||||
{...attrs}
|
||||
data-ui-component="Image"
|
||||
class='wrn-image {class}'
|
||||
data-size='{size}'
|
||||
data-color='{color}'
|
||||
data-fit='{fit}'
|
||||
data-rounded='{rounded}'
|
||||
>
|
||||
<img
|
||||
class="wrn-image__img"
|
||||
data-show="src"
|
||||
src='{src}'
|
||||
alt='{alt}'
|
||||
width='{width}'
|
||||
height='{height}'
|
||||
loading='{loading}'
|
||||
decoding="async"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="wrn-image__placeholder"
|
||||
data-show="!src"
|
||||
role="img"
|
||||
aria-label='{alt || "Image placeholder"}'
|
||||
>
|
||||
<span class="icon-[lucide--image] wrn-image__placeholder-icon" aria-hidden="true"></span>
|
||||
</div>
|
||||
|
||||
<slot></slot>
|
||||
</figure>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-image {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
background: var(--wrn-color-surface-soft);
|
||||
}
|
||||
|
||||
.wrn-image[data-rounded="true"] {
|
||||
border-radius: var(--wrn-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.
|
||||
*/
|
||||
.wrn-image[data-size="square"] {
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.wrn-image[data-size="video"] {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.wrn-image[data-size="landscape"] {
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.wrn-image[data-size="portrait"] {
|
||||
aspect-ratio: 3 / 4;
|
||||
}
|
||||
|
||||
.wrn-image__img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.wrn-image[data-fit="contain"] .wrn-image__img {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.wrn-image[data-fit="fill"] .wrn-image__img {
|
||||
object-fit: fill;
|
||||
}
|
||||
|
||||
.wrn-image[data-size="default"] .wrn-image__img {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.wrn-image__placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 12rem;
|
||||
height: 100%;
|
||||
color: var(--wrn-color-text-muted);
|
||||
}
|
||||
|
||||
.wrn-image__placeholder-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
component InputGroup {
|
||||
outputs {
|
||||
input(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)
|
||||
focus(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
blur(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
submit(payload: { value: string | number | boolean | null | object; name: string; sourceEvent: Event })
|
||||
action(payload: { name: string; sourceEvent: Event })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
id: string = ""
|
||||
name: string = ""
|
||||
label: string = "Input group"
|
||||
hiddenLabel: boolean = false
|
||||
value: string = ""
|
||||
placeholder: string = ""
|
||||
type: string = "text"
|
||||
startText: string = ""
|
||||
endText: string = ""
|
||||
icon: string = ""
|
||||
iconPosition: string = "start"
|
||||
actionLabel: string = ""
|
||||
helperText: string = ""
|
||||
cornerHint: string = ""
|
||||
error: string = ""
|
||||
inline: boolean = false
|
||||
variant: string = "normal"
|
||||
readonly: boolean = false
|
||||
disabled: boolean = false
|
||||
required: boolean = false
|
||||
class: string = ""
|
||||
}
|
||||
functions {
|
||||
client function emitField(nameEvent, sourceEvent) { sourceEvent.stopPropagation(); output[nameEvent]({ value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }) }
|
||||
client function handleKeydown(sourceEvent) { if (sourceEvent.key === "Enter") { sourceEvent.stopPropagation(); output.submit({ value: sourceEvent.currentTarget.value, name: name, sourceEvent: sourceEvent }) } }
|
||||
client function handleAction(sourceEvent) { output.action({ name: name, sourceEvent: sourceEvent }) }
|
||||
}
|
||||
view {
|
||||
<div {...attrs} class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--field wrn-next--input-group {class}" data-variant="{variant}" data-inline="{inline}" data-invalid="{error ? 'true' : 'false'}">
|
||||
<div class="wrn-next__field-heading"><label class="{hiddenLabel ? 'wrn-next__sr-only' : ''}" for="{id || name}">{label}</label>{#if cornerHint}<span class="wrn-next__field-hint">{cornerHint}</span>{/if}</div>
|
||||
<div class="wrn-next__input-group-control">
|
||||
{#if startText}<span class="wrn-next__input-addon">{startText}</span>{/if}
|
||||
{#if icon}<span class="{icon} wrn-next__field-icon" data-position="{iconPosition}" aria-hidden="true"></span>{/if}
|
||||
<input id="{id || name}" type="{type}" name="{name}" value="{value}" placeholder="{placeholder}" readonly="{readonly}" disabled="{disabled}" required="{required}" aria-invalid="{error ? 'true' : 'false'}" @input="emitField('input', event)" @change="emitField('change', event)" @focus="emitField('focus', event)" @blur="emitField('blur', event)" @keydown="handleKeydown(event)" />
|
||||
{#if endText}<span class="wrn-next__input-addon">{endText}</span>{/if}
|
||||
{#if actionLabel}<button type="button" disabled="{disabled}" @click="handleAction(event)">{actionLabel}</button>{/if}
|
||||
</div>
|
||||
{#if helperText}<small class="wrn-next__field-help">{helperText}</small>{/if}<small class="wrn-next__field-error" data-error="{name}">{error}</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-next__input-group-control {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface);
|
||||
}
|
||||
|
||||
.wrn-next__input-group-control input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.wrn-next__input-addon,
|
||||
.wrn-next__input-group-control button {
|
||||
padding: 0.7rem 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wrn-next__input-group-control button {
|
||||
align-self: stretch;
|
||||
border: 0;
|
||||
background: var(--wrn-field-color);
|
||||
color: var(--wrn-color-primary-contrast, #fff);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Shared component foundation. */
|
||||
|
||||
.wrn-component {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
--wrn-component-scale: 1;
|
||||
margin: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-family: var(--wrn-font-sans, inherit);
|
||||
}
|
||||
|
||||
.wrn-component--color-primary {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-component--color-secondary {
|
||||
--wrn-component-color: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-component--color-success {
|
||||
--wrn-component-color: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-component--color-warning {
|
||||
--wrn-component-color: var(--wrn-color-warning);
|
||||
}
|
||||
|
||||
.wrn-component--color-danger {
|
||||
--wrn-component-color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-component--color-info {
|
||||
--wrn-component-color: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-component--size-xs {
|
||||
--wrn-component-scale: 0.78;
|
||||
}
|
||||
|
||||
.wrn-component--size-sm {
|
||||
--wrn-component-scale: 0.88;
|
||||
}
|
||||
|
||||
.wrn-component--size-default,
|
||||
.wrn-component--size-md {
|
||||
--wrn-component-scale: 1;
|
||||
}
|
||||
|
||||
.wrn-component--size-lg {
|
||||
--wrn-component-scale: 1.14;
|
||||
}
|
||||
|
||||
.wrn-component--size-xl {
|
||||
--wrn-component-scale: 1.28;
|
||||
}
|
||||
|
||||
.wrn-component:not(.wrn-btn) {
|
||||
font-size: calc(1em * var(--wrn-component-scale));
|
||||
}
|
||||
|
||||
.wrn-component :is(a, button, input, select, textarea):focus-visible {
|
||||
outline-color: var(--wrn-component-color);
|
||||
}
|
||||
|
||||
.wrn-component p {
|
||||
margin: 0;
|
||||
color: var(--wrn-color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Shared field foundation. */
|
||||
|
||||
/* Validation states set by the browser validation runtime. */
|
||||
.wrn-invalid {
|
||||
border-color: var(--wrn-color-danger) !important;
|
||||
}
|
||||
|
||||
.wrn-field-error {
|
||||
display: block;
|
||||
min-height: 1em;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--wrn-color-danger);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.wrn-next--choice {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wrn-next--choice input {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
accent-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-next--field {
|
||||
display: grid;
|
||||
width: min(100%, 28rem);
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.wrn-next--field :where(input, textarea, select) {
|
||||
width: 100%;
|
||||
min-height: 2.65rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: 0.6rem;
|
||||
outline: 0;
|
||||
color: var(--wrn-color-text);
|
||||
background: var(--wrn-color-bg);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-auth-form form > .wrn-next--field,
|
||||
.wrn-auth-form form > .wrn-field {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
.wrn-auth-form__submit > .wrn-btn {
|
||||
width: 100%;
|
||||
min-height: 3rem;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 0.8rem 1.8rem color-mix(in srgb, var(--wrn-btn-color) 20%, transparent);
|
||||
}
|
||||
.wrn-next--field :where(input, textarea, select):focus-visible {
|
||||
border-color: var(--wrn-color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--wrn-color-primary) 16%, transparent);
|
||||
}
|
||||
.wrn-next--field textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
.wrn-next--field,
|
||||
.wrn-next--choice-field {
|
||||
--wrn-field-color: var(--wrn-component-color, var(--wrn-color-primary));
|
||||
width: min(100%, 34rem);
|
||||
min-width: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-next--field.wrn-next--size-sm,
|
||||
.wrn-next--choice-field.wrn-next--size-sm {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.wrn-next--field.wrn-next--size-lg,
|
||||
.wrn-next--choice-field.wrn-next--size-lg {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.wrn-next__field-heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.wrn-next__field-heading label {
|
||||
color: var(--wrn-color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wrn-next__field-hint,
|
||||
.wrn-next__field-help {
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 0.82em;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-next__field-control {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.wrn-next__field-control > :is(input, textarea, select) {
|
||||
padding-inline: 0.85rem;
|
||||
border-color: var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.wrn-next__field-control[data-icon-position="start"] > :is(input, textarea, select) {
|
||||
padding-inline-start: 2.5rem;
|
||||
}
|
||||
.wrn-next__field-control[data-icon-position="end"] > :is(input, textarea, select) {
|
||||
padding-inline-end: 2.5rem;
|
||||
}
|
||||
.wrn-next__field-icon {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 50%;
|
||||
left: 0.85rem;
|
||||
color: var(--wrn-color-muted);
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.wrn-next__field-control[data-icon-position="end"] > .wrn-next__field-icon {
|
||||
right: 0.85rem;
|
||||
left: auto;
|
||||
}
|
||||
.wrn-next--field[data-variant="outlined"]
|
||||
.wrn-next__field-control
|
||||
> :is(input, textarea, select) {
|
||||
border-width: 2px;
|
||||
}
|
||||
.wrn-next--field[data-variant="underline"]
|
||||
.wrn-next__field-control
|
||||
> :is(input, textarea, select) {
|
||||
padding-inline: 0;
|
||||
border-width: 0 0 1px;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.wrn-next--field[data-variant="pill"] .wrn-next__field-control > :is(input, select) {
|
||||
border-radius: 999px;
|
||||
padding-inline: 1.15rem;
|
||||
}
|
||||
.wrn-next--field[data-variant="ghost"] .wrn-next__field-control > :is(input, textarea, select) {
|
||||
border-color: transparent;
|
||||
background: color-mix(in srgb, var(--wrn-field-color) 8%, var(--wrn-color-surface));
|
||||
}
|
||||
.wrn-next__field-floating-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0.85rem;
|
||||
color: var(--wrn-color-muted);
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%);
|
||||
transition: 150ms ease;
|
||||
}
|
||||
.wrn-next--textarea .wrn-next__field-floating-label {
|
||||
top: 1.35rem;
|
||||
}
|
||||
.wrn-next--field[data-floating="true"] .wrn-next__field-control > :is(input, textarea, select) {
|
||||
padding-top: 1.2rem;
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
.wrn-next--field[data-floating="true"] .wrn-next__field-heading > label {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
overflow: hidden !important;
|
||||
margin: -1px !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
.wrn-next--field[data-floating="true"]
|
||||
.wrn-next__field-heading:not(:has(.wrn-next__field-hint)) {
|
||||
display: none;
|
||||
}
|
||||
.wrn-next--field[data-floating="true"]
|
||||
.wrn-next__field-control
|
||||
> :is(input, textarea):is(:focus, :not(:placeholder-shown))
|
||||
+ .wrn-next__field-floating-label {
|
||||
top: 0.42rem;
|
||||
font-size: 0.7em;
|
||||
transform: none;
|
||||
}
|
||||
.wrn-next__field-error {
|
||||
min-height: 1em;
|
||||
color: var(--wrn-color-danger);
|
||||
font-size: 0.82em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.wrn-next__field-error:empty {
|
||||
display: none;
|
||||
}
|
||||
.wrn-next--field[data-invalid="true"] :is(input, textarea, select),
|
||||
.wrn-next--choice-field[data-invalid="true"] input {
|
||||
border-color: var(--wrn-color-danger);
|
||||
}
|
||||
.wrn-next--field :is(input, textarea, select):disabled,
|
||||
.wrn-next--choice-field input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
.wrn-next--field :is(input, textarea, select):read-only {
|
||||
background: var(--wrn-color-surface-2);
|
||||
}
|
||||
.wrn-next--field[data-inline="true"],
|
||||
.wrn-next--choice-field[data-inline="true"] {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
grid-template-columns: minmax(7rem, max-content) minmax(12rem, 20rem) minmax(10rem, 1fr);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-heading,
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-heading {
|
||||
min-height: 2.65rem;
|
||||
align-items: center;
|
||||
}
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-heading {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-help,
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-help {
|
||||
display: flex;
|
||||
min-height: 2.65rem;
|
||||
align-items: center;
|
||||
}
|
||||
.wrn-next--choice-field[data-inline="true"] {
|
||||
grid-template-columns: minmax(12rem, 20rem) minmax(10rem, 1fr);
|
||||
}
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-heading:empty {
|
||||
display: none;
|
||||
}
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-error,
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-error {
|
||||
grid-column: 2 / -1;
|
||||
}
|
||||
.wrn-next__sr-only {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
overflow: hidden !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
margin: -1px !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
.wrn-next__choice-field {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.wrn-next__choice {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
color: var(--wrn-color-text);
|
||||
width: 100%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-next__checkbox-content > .wrn-next__choice {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.wrn-next__choice input {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
accent-color: var(--wrn-field-color);
|
||||
}
|
||||
.wrn-next__choice-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.15rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.wrn-next__choice-copy small {
|
||||
color: var(--wrn-color-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.wrn-next__radio-group,
|
||||
.wrn-next__checkbox-group {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.wrn-next--radio-field[data-orientation="horizontal"] .wrn-next__radio-group,
|
||||
.wrn-next--checkbox-field[data-orientation="horizontal"] .wrn-next__checkbox-group {
|
||||
grid-template-columns: repeat(auto-fit, minmax(8rem, max-content));
|
||||
align-items: stretch;
|
||||
}
|
||||
.wrn-next--radio-field[data-card="true"] .wrn-next__radio-group,
|
||||
.wrn-next--checkbox-field[data-card="true"] .wrn-next__checkbox-group {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
|
||||
}
|
||||
.wrn-next--radio-field[data-card="true"] .wrn-next--radio,
|
||||
.wrn-next--checkbox-field[data-card="true"] .wrn-next--checkbox {
|
||||
min-height: 4.5rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface);
|
||||
}
|
||||
.wrn-next--radio-field[data-card="true"] .wrn-next--radio:has(input:checked),
|
||||
.wrn-next--checkbox-field[data-card="true"] .wrn-next--checkbox:has(input:checked) {
|
||||
border-color: var(--wrn-field-color);
|
||||
box-shadow: 0 0 0 1px var(--wrn-field-color);
|
||||
}
|
||||
.wrn-next--radio-field[data-list="true"] .wrn-next--radio,
|
||||
.wrn-next--checkbox-field[data-list="true"] .wrn-next--checkbox {
|
||||
padding-block: 0.75rem;
|
||||
border-bottom: 1px solid var(--wrn-color-border);
|
||||
}
|
||||
.wrn-next--radio-field[data-right-aligned="true"] .wrn-next--radio,
|
||||
.wrn-next--checkbox-field[data-right-aligned="true"] .wrn-next--checkbox {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.wrn-next--radio-field[data-right-aligned="true"] .wrn-next--radio input,
|
||||
.wrn-next--checkbox-field[data-right-aligned="true"] .wrn-next--checkbox input {
|
||||
order: 2;
|
||||
}
|
||||
.wrn-next--radio-field[data-right-aligned="true"] .wrn-next__choice-copy,
|
||||
.wrn-next--checkbox-field[data-right-aligned="true"] .wrn-next__choice-copy {
|
||||
order: 1;
|
||||
}
|
||||
.wrn-next__choice[data-variant="outlined"],
|
||||
.wrn-next__choice[data-variant="floating"] {
|
||||
padding: 0.65rem 0.8rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
}
|
||||
.wrn-next__choice[data-variant="pill"] {
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--wrn-field-color) 9%, var(--wrn-color-surface));
|
||||
}
|
||||
.wrn-next__choice[data-variant="ghost"] {
|
||||
padding: 0.65rem 0.8rem;
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: color-mix(in srgb, var(--wrn-field-color) 7%, transparent);
|
||||
}
|
||||
.wrn-next__choice[data-variant="underline"] {
|
||||
padding-block: 0.5rem;
|
||||
border-bottom: 1px solid var(--wrn-color-border);
|
||||
}
|
||||
.wrn-next--select .wrn-next__field-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.wrn-next__color-control,
|
||||
.wrn-next__range-control,
|
||||
.wrn-next__input-group-control,
|
||||
.wrn-next__file-control {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.wrn-next__picker-control,
|
||||
.wrn-next--time-picker .wrn-next__field-control {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
}
|
||||
.wrn-next__picker-control > button:first-of-type,
|
||||
.wrn-next__time-trigger {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 2.65rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface);
|
||||
color: var(--wrn-color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.wrn-next__calendar,
|
||||
.wrn-next__time-panel {
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
width: min(100%, 22rem);
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-md);
|
||||
background: var(--wrn-color-surface);
|
||||
box-shadow: var(--wrn-shadow-3);
|
||||
}
|
||||
.wrn-next--date-picker[data-expanded="false"] .wrn-next__calendar,
|
||||
.wrn-next--time-picker[data-expanded="false"] .wrn-next__time-panel {
|
||||
display: none;
|
||||
}
|
||||
.wrn-next__calendar button,
|
||||
.wrn-next__time-panel button {
|
||||
min-height: 2.25rem;
|
||||
border: 0;
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
.wrn-next__calendar-weekdays,
|
||||
.wrn-next__calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 0.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.wrn-next__calendar-weekdays {
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.wrn-next__calendar-grid button:hover,
|
||||
.wrn-next__time-panel button:hover {
|
||||
background: var(--wrn-color-surface-2);
|
||||
}
|
||||
.wrn-next__calendar-grid button[aria-pressed="true"],
|
||||
.wrn-next__time-panel button[aria-pressed="true"] {
|
||||
background: var(--wrn-field-color);
|
||||
color: var(--wrn-color-primary-contrast, #fff);
|
||||
}
|
||||
.wrn-next__time-period {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.wrn-next--file-upload-progress .wrn-next__row,
|
||||
.wrn-next__upload-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.wrn-next--file-upload-progress .wrn-next__row > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.wrn-next--field[data-variant="underline"]
|
||||
:is(.wrn-next__input-group-control, .wrn-next__file-control, .wrn-next__color-control) {
|
||||
border-width: 0 0 1px;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.wrn-next--field[data-variant="outlined"]
|
||||
:is(.wrn-next__input-group-control, .wrn-next__file-control) {
|
||||
border-width: 2px;
|
||||
}
|
||||
.wrn-next--field[data-variant="pill"]
|
||||
:is(.wrn-next__input-group-control, .wrn-next__file-control) {
|
||||
border-radius: 999px;
|
||||
}
|
||||
.wrn-next--field[data-variant="ghost"]
|
||||
:is(.wrn-next__input-group-control, .wrn-next__file-control, .wrn-next__color-control) {
|
||||
border-color: transparent;
|
||||
background: color-mix(in srgb, var(--wrn-field-color) 8%, var(--wrn-color-surface));
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.wrn-next--field[data-inline="true"],
|
||||
.wrn-next--choice-field[data-inline="true"] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.wrn-next--field[data-inline="true"] .wrn-next__field-error,
|
||||
.wrn-next--choice-field[data-inline="true"] .wrn-next__field-error {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
.wrn-next--toggle-password:has(input.wrn-invalid) .wrn-next__password-control > input,
|
||||
.wrn-next--toggle-password.wrn-next--invalid .wrn-next__password-control > input {
|
||||
border-color: var(--wrn-color-danger);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--wrn-color-danger) 14%, transparent);
|
||||
}
|
||||
.wrn-next--strong-password.wrn-next--invalid input,
|
||||
.wrn-next--strong-password input.wrn-invalid {
|
||||
border-color: var(--wrn-color-danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
component InputNumber {
|
||||
outputs {
|
||||
input(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)
|
||||
increment(payload: { value: number; previousValue?: number; sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
decrement(payload: { value: number; previousValue?: number; sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
variant: string = "default"
|
||||
class: string = ""
|
||||
|
||||
id: string = ""
|
||||
name: string = "quantity"
|
||||
value: number = 0
|
||||
min: string = ""
|
||||
max: string = ""
|
||||
step: number = 1
|
||||
precision: string = "auto"
|
||||
|
||||
label: string = ""
|
||||
description: string = ""
|
||||
helpText: string = ""
|
||||
error: string = ""
|
||||
invalid: boolean = false
|
||||
|
||||
prefix: string = ""
|
||||
suffix: string = ""
|
||||
placeholder: string = ""
|
||||
autocomplete: string = "off"
|
||||
inputMode: string = "decimal"
|
||||
ariaLabel: string = ""
|
||||
|
||||
required: boolean = false
|
||||
disabled: boolean = false
|
||||
inputDisabled: boolean = false
|
||||
buttonsDisabled: boolean = false
|
||||
readonly: boolean = false
|
||||
allowInput: boolean = true
|
||||
keyboard: boolean = true
|
||||
wheel: boolean = false
|
||||
clamp: boolean = true
|
||||
fullWidth: boolean = false
|
||||
showButtons: boolean = true
|
||||
showValidationMessage: boolean = true
|
||||
|
||||
decrementLabel: string = "Decrease value"
|
||||
incrementLabel: string = "Increase value"
|
||||
controlsLabel: string = "Quantity controls"
|
||||
requiredMessage: string = "A value is required."
|
||||
minMessage: string = "Value is below the minimum."
|
||||
maxMessage: string = "Value is above the maximum."
|
||||
|
||||
}
|
||||
|
||||
state currentValue = value
|
||||
state committedValue = value
|
||||
|
||||
functions {
|
||||
shared function inputId() {
|
||||
if (id !== "") {
|
||||
return id
|
||||
}
|
||||
|
||||
if (name !== "") {
|
||||
return name
|
||||
}
|
||||
|
||||
return "input-number"
|
||||
}
|
||||
|
||||
shared function descriptionId() {
|
||||
return inputId() + "-description"
|
||||
}
|
||||
|
||||
shared function messageId() {
|
||||
return inputId() + "-message"
|
||||
}
|
||||
|
||||
shared function componentColor() {
|
||||
if (color === "secondary") {
|
||||
return "var(--wrn-color-secondary)"
|
||||
}
|
||||
|
||||
if (color === "success") {
|
||||
return "var(--wrn-color-success)"
|
||||
}
|
||||
|
||||
if (color === "warning") {
|
||||
return "var(--wrn-color-warning)"
|
||||
}
|
||||
|
||||
if (color === "danger") {
|
||||
return "var(--wrn-color-danger)"
|
||||
}
|
||||
|
||||
if (color === "info") {
|
||||
return "var(--wrn-color-info)"
|
||||
}
|
||||
|
||||
return "var(--wrn-color-primary)"
|
||||
}
|
||||
|
||||
shared function hasMin() {
|
||||
return min !== "" && min !== null && min !== undefined
|
||||
}
|
||||
|
||||
shared function hasMax() {
|
||||
return max !== "" && max !== null && max !== undefined
|
||||
}
|
||||
|
||||
shared function isBlank() {
|
||||
return currentValue === "" || currentValue === null || currentValue === undefined
|
||||
}
|
||||
|
||||
shared function normalizedStep() {
|
||||
return Number(step) > 0 ? Number(step) : 1
|
||||
}
|
||||
|
||||
shared function inferredPrecision() {
|
||||
if (precision !== "auto" && precision !== "") {
|
||||
return Math.max(0, Number(precision) || 0)
|
||||
}
|
||||
|
||||
if (String(normalizedStep()).includes(".")) {
|
||||
return String(normalizedStep()).split(".")[1].length
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
shared function precisionFactor() {
|
||||
return Math.pow(10, inferredPrecision())
|
||||
}
|
||||
|
||||
shared function roundValue(nextValue) {
|
||||
return Math.round(Number(nextValue) * precisionFactor()) / precisionFactor()
|
||||
}
|
||||
|
||||
shared function clampValue(nextValue) {
|
||||
nextValue = Number(nextValue)
|
||||
|
||||
if (hasMin() && nextValue < Number(min)) {
|
||||
nextValue = Number(min)
|
||||
}
|
||||
|
||||
if (hasMax() && nextValue > Number(max)) {
|
||||
nextValue = Number(max)
|
||||
}
|
||||
|
||||
return roundValue(nextValue)
|
||||
}
|
||||
|
||||
shared function isBelowMin() {
|
||||
return !isBlank() && hasMin() && Number(currentValue) < Number(min)
|
||||
}
|
||||
|
||||
shared function isAboveMax() {
|
||||
return !isBlank() && hasMax() && Number(currentValue) > Number(max)
|
||||
}
|
||||
|
||||
shared function isInvalid() {
|
||||
return (
|
||||
invalid ||
|
||||
error !== "" ||
|
||||
(required && isBlank()) ||
|
||||
isBelowMin() ||
|
||||
isAboveMax()
|
||||
)
|
||||
}
|
||||
|
||||
shared function validationMessage() {
|
||||
if (error !== "") {
|
||||
return error
|
||||
}
|
||||
|
||||
if (required && isBlank()) {
|
||||
return requiredMessage
|
||||
}
|
||||
|
||||
if (isBelowMin()) {
|
||||
return minMessage
|
||||
}
|
||||
|
||||
if (isAboveMax()) {
|
||||
return maxMessage
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
shared function hasMessage() {
|
||||
return (
|
||||
helpText !== "" ||
|
||||
(showValidationMessage && isInvalid() && validationMessage() !== "")
|
||||
)
|
||||
}
|
||||
|
||||
shared function describedBy() {
|
||||
if (description !== "" && hasMessage()) {
|
||||
return descriptionId() + " " + messageId()
|
||||
}
|
||||
|
||||
if (description !== "") {
|
||||
return descriptionId()
|
||||
}
|
||||
|
||||
if (hasMessage()) {
|
||||
return messageId()
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
shared function decrementDisabled() {
|
||||
return (
|
||||
disabled ||
|
||||
readonly ||
|
||||
buttonsDisabled ||
|
||||
(hasMin() && !isBlank() && Number(currentValue) <= Number(min))
|
||||
)
|
||||
}
|
||||
|
||||
shared function incrementDisabled() {
|
||||
return (
|
||||
disabled ||
|
||||
readonly ||
|
||||
buttonsDisabled ||
|
||||
(hasMax() && !isBlank() && Number(currentValue) >= Number(max))
|
||||
)
|
||||
}
|
||||
|
||||
// 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,
|
||||
payload
|
||||
) {
|
||||
payload = {
|
||||
component: "InputNumber",
|
||||
name: name,
|
||||
value: currentValue,
|
||||
previousValue: previousValue,
|
||||
action: action,
|
||||
min: hasMin() ? Number(min) : null,
|
||||
max: hasMax() ? Number(max) : null,
|
||||
step: normalizedStep(),
|
||||
valid: !isInvalid()
|
||||
}
|
||||
|
||||
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) {
|
||||
previousValue = currentValue
|
||||
currentValue = clampValue(nextValue)
|
||||
committedValue = currentValue
|
||||
|
||||
dispatchInputNumberEvent(
|
||||
sourceEvent,
|
||||
"input",
|
||||
action,
|
||||
previousValue
|
||||
)
|
||||
dispatchInputNumberEvent(
|
||||
sourceEvent,
|
||||
"change",
|
||||
action,
|
||||
previousValue
|
||||
)
|
||||
dispatchInputNumberEvent(
|
||||
sourceEvent,
|
||||
action,
|
||||
action,
|
||||
previousValue
|
||||
)
|
||||
}
|
||||
|
||||
client function incrementValue(sourceEvent, nextValue) {
|
||||
if (incrementDisabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isBlank()) {
|
||||
nextValue = hasMin() ? Number(min) : normalizedStep()
|
||||
} else {
|
||||
nextValue =
|
||||
Number(currentValue) +
|
||||
normalizedStep() * (sourceEvent.shiftKey ? 10 : 1)
|
||||
}
|
||||
|
||||
applyControlValue(nextValue, "increment", sourceEvent)
|
||||
}
|
||||
|
||||
client function decrementValue(sourceEvent, nextValue) {
|
||||
if (decrementDisabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isBlank()) {
|
||||
nextValue = hasMax() ? Number(max) : -normalizedStep()
|
||||
} else {
|
||||
nextValue =
|
||||
Number(currentValue) -
|
||||
normalizedStep() * (sourceEvent.shiftKey ? 10 : 1)
|
||||
}
|
||||
|
||||
applyControlValue(nextValue, "decrement", sourceEvent)
|
||||
}
|
||||
|
||||
client function handleInput(sourceEvent, previousValue, nextValue) {
|
||||
sourceEvent.stopPropagation()
|
||||
previousValue = currentValue
|
||||
nextValue = sourceEvent.target.value
|
||||
|
||||
if (nextValue === "") {
|
||||
currentValue = ""
|
||||
} else if (!Number.isNaN(Number(nextValue))) {
|
||||
currentValue = roundValue(Number(nextValue))
|
||||
}
|
||||
|
||||
dispatchInputNumberEvent(
|
||||
sourceEvent,
|
||||
"input",
|
||||
"input",
|
||||
previousValue
|
||||
)
|
||||
}
|
||||
|
||||
client function handleChange(sourceEvent, previousValue) {
|
||||
sourceEvent.stopPropagation()
|
||||
previousValue = committedValue
|
||||
|
||||
if (!isBlank()) {
|
||||
currentValue = clamp
|
||||
? clampValue(currentValue)
|
||||
: roundValue(currentValue)
|
||||
}
|
||||
|
||||
committedValue = currentValue
|
||||
|
||||
dispatchInputNumberEvent(
|
||||
sourceEvent,
|
||||
"change",
|
||||
"change",
|
||||
previousValue
|
||||
)
|
||||
}
|
||||
|
||||
client function handleKeydown(sourceEvent) {
|
||||
if (
|
||||
!keyboard ||
|
||||
disabled ||
|
||||
readonly ||
|
||||
inputDisabled ||
|
||||
!allowInput
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (sourceEvent.key === "ArrowUp") {
|
||||
sourceEvent.preventDefault()
|
||||
incrementValue(sourceEvent)
|
||||
} else if (sourceEvent.key === "ArrowDown") {
|
||||
sourceEvent.preventDefault()
|
||||
decrementValue(sourceEvent)
|
||||
} else if (sourceEvent.key === "Home" && hasMin()) {
|
||||
sourceEvent.preventDefault()
|
||||
applyControlValue(Number(min), "decrement", sourceEvent)
|
||||
} else if (sourceEvent.key === "End" && hasMax()) {
|
||||
sourceEvent.preventDefault()
|
||||
applyControlValue(Number(max), "increment", sourceEvent)
|
||||
}
|
||||
}
|
||||
|
||||
client function handleWheel(sourceEvent) {
|
||||
if (
|
||||
!wheel ||
|
||||
disabled ||
|
||||
readonly ||
|
||||
inputDisabled ||
|
||||
!allowInput
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
sourceEvent.preventDefault()
|
||||
|
||||
if (sourceEvent.deltaY < 0) {
|
||||
incrementValue(sourceEvent)
|
||||
} else if (sourceEvent.deltaY > 0) {
|
||||
decrementValue(sourceEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<div
|
||||
{...attrs}
|
||||
data-wrn-input-number
|
||||
data-variant='{variant}'
|
||||
data-size='{size}'
|
||||
data-color='{color}'
|
||||
data-value='{currentValue}'
|
||||
data-invalid='{isInvalid() ? "true" : "false"}'
|
||||
data-disabled='{disabled ? "true" : "false"}'
|
||||
data-full-width='{fullWidth ? "true" : "false"}'
|
||||
style='--input-number-accent: {componentColor()};'
|
||||
class='wrn-input-number {class}'
|
||||
>
|
||||
{#if label !== "" && variant !== "labeled" && variant !== "seat"}
|
||||
<label
|
||||
for='{inputId()}'
|
||||
class='wrn-input-number__label'
|
||||
>
|
||||
<span>{label}</span>
|
||||
{#if required}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class='wrn-input-number__required'
|
||||
>*</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
{#if description !== "" && variant !== "labeled" && variant !== "seat"}
|
||||
<p
|
||||
id='{descriptionId()}'
|
||||
class='wrn-input-number__description'
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class='wrn-input-number__control'
|
||||
>
|
||||
{#if variant === "horizontal" && showButtons}
|
||||
<button
|
||||
type="button"
|
||||
aria-label='{decrementLabel}'
|
||||
aria-controls='{inputId()}'
|
||||
disabled='{decrementDisabled()}'
|
||||
@click='decrementValue(event)'
|
||||
class='wrn-input-number__button wrn-input-number__button--start'
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class='icon-[lucide--minus] wrn-input-number__icon'
|
||||
></span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class='wrn-input-number__content'
|
||||
>
|
||||
{#if variant === "labeled" || variant === "seat"}
|
||||
<div class='wrn-input-number__copy'>
|
||||
{#if label !== ""}
|
||||
<label
|
||||
for='{inputId()}'
|
||||
class='wrn-input-number__inner-label'
|
||||
>
|
||||
{label}
|
||||
{#if required}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class='wrn-input-number__required'
|
||||
>*</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
{#if description !== ""}
|
||||
<span
|
||||
id='{descriptionId()}'
|
||||
class='wrn-input-number__inner-description'
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class='wrn-input-number__field'
|
||||
>
|
||||
{#if prefix !== ""}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class='wrn-input-number__prefix'
|
||||
>
|
||||
{prefix}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
id='{inputId()}'
|
||||
name='{name}'
|
||||
type="number"
|
||||
value='{currentValue}'
|
||||
min='{min}'
|
||||
max='{max}'
|
||||
step='{normalizedStep()}'
|
||||
placeholder='{placeholder}'
|
||||
autocomplete='{autocomplete}'
|
||||
inputmode='{inputMode}'
|
||||
aria-label='{ariaLabel !== "" ? ariaLabel : label !== "" ? label : name}'
|
||||
aria-describedby='{describedBy()}'
|
||||
aria-invalid='{isInvalid() ? "true" : "false"}'
|
||||
aria-required='{required ? "true" : "false"}'
|
||||
aria-disabled='{disabled || inputDisabled ? "true" : "false"}'
|
||||
required='{required}'
|
||||
disabled='{disabled}'
|
||||
readonly='{readonly || inputDisabled || !allowInput}'
|
||||
tabindex='{inputDisabled ? "-1" : "0"}'
|
||||
@input='handleInput(event)'
|
||||
@change='handleChange(event)'
|
||||
@keydown='handleKeydown(event)'
|
||||
@wheel='handleWheel(event)'
|
||||
class='wrn-input-number__input'
|
||||
/>
|
||||
|
||||
{#if suffix !== ""}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class='wrn-input-number__suffix'
|
||||
>
|
||||
{suffix}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if variant === "horizontal" && showButtons}
|
||||
<button
|
||||
type="button"
|
||||
aria-label='{incrementLabel}'
|
||||
aria-controls='{inputId()}'
|
||||
disabled='{incrementDisabled()}'
|
||||
@click='incrementValue(event)'
|
||||
class='wrn-input-number__button wrn-input-number__button--end'
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class='icon-[lucide--plus] wrn-input-number__icon'
|
||||
></span>
|
||||
</button>
|
||||
{:else}
|
||||
{#if showButtons}
|
||||
<div
|
||||
role="group"
|
||||
aria-label='{controlsLabel}'
|
||||
class='wrn-input-number__buttons'
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label='{decrementLabel}'
|
||||
aria-controls='{inputId()}'
|
||||
disabled='{decrementDisabled()}'
|
||||
@click='decrementValue(event)'
|
||||
class='wrn-input-number__button wrn-input-number__button--decrement'
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class='icon-[lucide--minus] wrn-input-number__icon'
|
||||
></span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label='{incrementLabel}'
|
||||
aria-controls='{inputId()}'
|
||||
disabled='{incrementDisabled()}'
|
||||
@click='incrementValue(event)'
|
||||
class='wrn-input-number__button wrn-input-number__button--increment'
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class='icon-[lucide--plus] wrn-input-number__icon'
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showValidationMessage && isInvalid() && validationMessage() !== ""}
|
||||
<p
|
||||
id='{messageId()}'
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
class='wrn-input-number__message wrn-input-number__message--error'
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class='icon-[lucide--circle-alert] wrn-input-number__error-icon'
|
||||
></span>
|
||||
<span>{validationMessage()}</span>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if (!showValidationMessage || !isInvalid() || validationMessage() === "") && helpText !== ""}
|
||||
<p
|
||||
id='{messageId()}'
|
||||
class='wrn-input-number__message'
|
||||
>
|
||||
{helpText}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-input-number { position: relative; display: flex; width: 100%; max-width: 24rem; flex-direction: column; gap: 0.375rem; color: var(--wrn-color-text); }
|
||||
.wrn-input-number[data-full-width="true"] { max-width: none; }
|
||||
.wrn-input-number[data-variant="compact"] { width: fit-content; max-width: 100%; }
|
||||
.wrn-input-number[data-disabled="true"] { opacity: 0.6; }
|
||||
.wrn-input-number__label { display: inline-flex; align-items: center; gap: 0.25rem; color: var(--wrn-color-text); font-size: 0.875rem; font-weight: 600; line-height: 1.25rem; }
|
||||
.wrn-input-number__required { margin-left: 0.125rem; color: var(--wrn-color-danger); }
|
||||
.wrn-input-number__description, .wrn-input-number__message { margin: 0; color: var(--wrn-color-muted); font-size: 0.75rem; line-height: 1.25rem; }
|
||||
.wrn-input-number__control { display: flex; min-width: 0; min-height: 2.5rem; overflow: hidden; border: 1px solid var(--wrn-color-border); border-radius: var(--wrn-radius-sm); color: var(--wrn-color-text); background: var(--wrn-color-surface); box-shadow: var(--wrn-shadow-1); transition: border-color var(--wrn-motion-base), box-shadow var(--wrn-motion-base), background var(--wrn-motion-base); }
|
||||
.wrn-input-number__control:focus-within { border-color: var(--input-number-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--input-number-accent) 18%, transparent); }
|
||||
.wrn-input-number[data-invalid="true"] .wrn-input-number__control { border-color: var(--wrn-color-danger); }
|
||||
.wrn-input-number[data-invalid="true"] .wrn-input-number__control:focus-within { box-shadow: 0 0 0 3px color-mix(in srgb, var(--wrn-color-danger) 18%, transparent); }
|
||||
.wrn-input-number[data-disabled="true"] .wrn-input-number__control { cursor: not-allowed; background: var(--wrn-color-surface-2); }
|
||||
.wrn-input-number[data-variant="compact"] .wrn-input-number__control { border-radius: 999px; }
|
||||
.wrn-input-number[data-size="xs"] .wrn-input-number__control { min-height: 2rem; font-size: 0.75rem; }
|
||||
.wrn-input-number[data-size="sm"] .wrn-input-number__control { min-height: 2.25rem; font-size: 0.875rem; }
|
||||
.wrn-input-number[data-size="lg"] .wrn-input-number__control { min-height: 3rem; font-size: 1rem; }
|
||||
.wrn-input-number[data-size="xl"] .wrn-input-number__control { min-height: 3.5rem; font-size: 1.125rem; }
|
||||
.wrn-input-number__content { display: flex; min-width: 0; flex: 1; align-items: center; padding-inline: 0.75rem; }
|
||||
.wrn-input-number[data-size="xs"] .wrn-input-number__content { padding-inline: 0.5rem; }
|
||||
.wrn-input-number[data-size="sm"] .wrn-input-number__content { padding-inline: 0.625rem; }
|
||||
.wrn-input-number[data-size="lg"] .wrn-input-number__content { padding-inline: 1rem; }
|
||||
.wrn-input-number[data-size="xl"] .wrn-input-number__content { padding-inline: 1.25rem; }
|
||||
.wrn-input-number[data-variant="labeled"] .wrn-input-number__content { flex-direction: column; align-items: stretch; justify-content: center; gap: 0.125rem; }
|
||||
.wrn-input-number[data-variant="seat"] .wrn-input-number__content { justify-content: space-between; gap: 0.75rem; }
|
||||
.wrn-input-number__copy { min-width: 0; flex: 1; }
|
||||
.wrn-input-number__inner-label, .wrn-input-number__inner-description { display: block; overflow: hidden; color: var(--wrn-color-text); font-size: 0.875rem; font-weight: 600; line-height: 1rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.wrn-input-number__inner-description { color: var(--wrn-color-muted); font-size: 0.75rem; font-weight: 400; }
|
||||
.wrn-input-number[data-variant="labeled"] .wrn-input-number__inner-label { color: var(--wrn-color-muted); font-size: 0.75rem; font-weight: 500; }
|
||||
.wrn-input-number__field { display: flex; width: 100%; min-width: 0; align-items: center; }
|
||||
.wrn-input-number[data-variant="seat"] .wrn-input-number__field { width: auto; flex: none; }
|
||||
.wrn-input-number__prefix, .wrn-input-number__suffix { flex: none; color: var(--wrn-color-muted); }
|
||||
.wrn-input-number__prefix { padding-right: 0.375rem; } .wrn-input-number__suffix { padding-left: 0.375rem; }
|
||||
.wrn-input-number__input { width: 100%; min-width: 0; flex: 1; padding: 0; border: 0; outline: none; color: var(--wrn-color-text); background: transparent; font: inherit; font-weight: 500; line-height: 1; appearance: textfield; }
|
||||
.wrn-input-number__input::-webkit-inner-spin-button, .wrn-input-number__input::-webkit-outer-spin-button { appearance: none; }
|
||||
.wrn-input-number__input::placeholder { color: var(--wrn-color-muted); }
|
||||
.wrn-input-number__input:read-only { cursor: default; }
|
||||
.wrn-input-number__input:disabled { cursor: not-allowed; }
|
||||
.wrn-input-number:is([data-variant="horizontal"], [data-variant="compact"], [data-variant="seat"]) .wrn-input-number__input { text-align: center; }
|
||||
.wrn-input-number[data-variant="seat"] .wrn-input-number__input { width: 2.5rem; flex: none; }
|
||||
.wrn-input-number__buttons { display: flex; flex: none; border-left: 1px solid var(--wrn-color-border); }
|
||||
.wrn-input-number[data-variant="vertical"] .wrn-input-number__buttons { flex-direction: column; }
|
||||
.wrn-input-number__button { display: inline-flex; width: 2.5rem; flex: none; align-items: center; justify-content: center; padding: 0; border: 0; color: var(--wrn-color-muted); background: transparent; transition: color var(--wrn-motion-fast), background var(--wrn-motion-fast); }
|
||||
.wrn-input-number[data-size="xs"] .wrn-input-number__button { width: 2rem; } .wrn-input-number[data-size="sm"] .wrn-input-number__button { width: 2.25rem; } .wrn-input-number[data-size="lg"] .wrn-input-number__button { width: 3rem; } .wrn-input-number[data-size="xl"] .wrn-input-number__button { width: 3.5rem; }
|
||||
.wrn-input-number__button--start { border-right: 1px solid var(--wrn-color-border); } .wrn-input-number__button--end { border-left: 1px solid var(--wrn-color-border); }
|
||||
.wrn-input-number__button--decrement { border-right: 1px solid var(--wrn-color-border); }
|
||||
.wrn-input-number[data-variant="vertical"] .wrn-input-number__button--decrement { border-right: 0; border-bottom: 1px solid var(--wrn-color-border); }
|
||||
.wrn-input-number__button:hover { color: var(--input-number-accent); background: var(--wrn-color-surface-2); }
|
||||
.wrn-input-number__button:focus-visible { z-index: 1; outline: 2px solid var(--input-number-accent); outline-offset: -2px; }
|
||||
.wrn-input-number__button:disabled { cursor: not-allowed; opacity: 0.4; }
|
||||
.wrn-input-number__icon { width: 1rem; height: 1rem; }
|
||||
.wrn-input-number__message--error { display: flex; align-items: center; gap: 0.375rem; color: var(--wrn-color-danger); }
|
||||
.wrn-input-number__error-icon { width: 0.875rem; height: 0.875rem; flex: none; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<kbd {...attrs} class='wrn-kbd {class}' data-size='{size}' data-color='{color}'>
|
||||
<slot>{label}</slot>
|
||||
</kbd>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.6rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
/* The lower edge is what reads as a physical key. */
|
||||
border-bottom-width: 2px;
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface-soft);
|
||||
color: var(--wrn-color-text);
|
||||
font-family: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.wrn-kbd[data-size="sm"] {
|
||||
min-width: 1.4rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.wrn-kbd[data-size="lg"] {
|
||||
min-width: 2rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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 connected 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 {
|
||||
// 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 {
|
||||
color: string = "primary"
|
||||
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
|
||||
{...attrs}
|
||||
data-ui-component="LayoutSplitter"
|
||||
class='wrn-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="wrn-splitter__pane wrn-splitter__pane--start">
|
||||
<slot name="start"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="wrn-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="wrn-splitter__grip" aria-hidden="true"></span>
|
||||
</div>
|
||||
|
||||
<div class="wrn-splitter__pane wrn-splitter__pane--end">
|
||||
<slot name="end"></slot>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-splitter {
|
||||
--splitter-accent: var(--wrn-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;
|
||||
}
|
||||
|
||||
.wrn-splitter[data-color="secondary"] {
|
||||
--splitter-accent: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-splitter[data-color="success"] {
|
||||
--splitter-accent: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-splitter[data-color="danger"] {
|
||||
--splitter-accent: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-splitter[data-color="info"] {
|
||||
--splitter-accent: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-splitter[data-orientation="vertical"] {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: var(--wrn-split, 50%) auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.wrn-splitter__pane {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.wrn-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;
|
||||
}
|
||||
|
||||
.wrn-splitter[data-orientation="vertical"] .wrn-splitter__handle {
|
||||
padding: 0.25rem 0;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.wrn-splitter__grip {
|
||||
display: block;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
min-height: 1.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--wrn-color-border);
|
||||
transition: background 140ms ease;
|
||||
}
|
||||
|
||||
.wrn-splitter[data-orientation="vertical"] .wrn-splitter__grip {
|
||||
width: 100%;
|
||||
min-width: 1.5rem;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.wrn-splitter__handle:hover .wrn-splitter__grip,
|
||||
.wrn-splitter[data-wrn-splitter-dragging="true"] .wrn-splitter__grip {
|
||||
background: var(--splitter-accent);
|
||||
}
|
||||
|
||||
.wrn-splitter__handle:focus-visible {
|
||||
outline: 2px solid var(--splitter-accent);
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--wrn-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) {
|
||||
.wrn-splitter,
|
||||
.wrn-splitter[data-orientation="vertical"] {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto auto auto;
|
||||
}
|
||||
|
||||
.wrn-splitter__handle {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
component LegendIndicator {
|
||||
outputs {
|
||||
toggle(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)
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
title: string = "Legend Indicator"
|
||||
description: string = ""
|
||||
items: unknown[] = []
|
||||
variant: string = "default"
|
||||
class: string = ""
|
||||
}
|
||||
view {
|
||||
<section class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--legend-indicator wrn-next--variant-{variant} {class}">
|
||||
{#if title}<strong>{title}</strong>{/if}
|
||||
{#if description}<p>{description}</p>{/if}
|
||||
{#if items}<div class="wrn-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if}
|
||||
<slot />
|
||||
</section>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-next--legend-indicator {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius);
|
||||
color: var(--wrn-color-text);
|
||||
background: var(--wrn-color-surface);
|
||||
}
|
||||
.wrn-next--legend-indicator > p { margin: 0; color: var(--wrn-color-muted); }
|
||||
.wrn-next--legend-indicator > .wrn-next__items { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.wrn-next--legend-indicator > .wrn-next__items > span { padding: 0.35rem 0.6rem; border-radius: var(--wrn-radius-sm); background: var(--wrn-color-surface-2); }
|
||||
|
||||
/* Shared component foundation. */
|
||||
|
||||
.wrn-component {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
--wrn-component-scale: 1;
|
||||
margin: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-family: var(--wrn-font-sans, inherit);
|
||||
}
|
||||
|
||||
.wrn-component--color-primary {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-component--color-secondary {
|
||||
--wrn-component-color: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-component--color-success {
|
||||
--wrn-component-color: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-component--color-warning {
|
||||
--wrn-component-color: var(--wrn-color-warning);
|
||||
}
|
||||
|
||||
.wrn-component--color-danger {
|
||||
--wrn-component-color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-component--color-info {
|
||||
--wrn-component-color: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-component--size-xs {
|
||||
--wrn-component-scale: 0.78;
|
||||
}
|
||||
|
||||
.wrn-component--size-sm {
|
||||
--wrn-component-scale: 0.88;
|
||||
}
|
||||
|
||||
.wrn-component--size-default,
|
||||
.wrn-component--size-md {
|
||||
--wrn-component-scale: 1;
|
||||
}
|
||||
|
||||
.wrn-component--size-lg {
|
||||
--wrn-component-scale: 1.14;
|
||||
}
|
||||
|
||||
.wrn-component--size-xl {
|
||||
--wrn-component-scale: 1.28;
|
||||
}
|
||||
|
||||
.wrn-component:not(.wrn-btn) {
|
||||
font-size: calc(1em * var(--wrn-component-scale));
|
||||
}
|
||||
|
||||
.wrn-component :is(a, button, input, select, textarea):focus-visible {
|
||||
outline-color: var(--wrn-component-color);
|
||||
}
|
||||
|
||||
.wrn-component p {
|
||||
margin: 0;
|
||||
color: var(--wrn-color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// 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"
|
||||
color: string = "primary"
|
||||
label: string = "Link"
|
||||
href: string = "#"
|
||||
target: string = ""
|
||||
rel: string = ""
|
||||
underline: string = "hover"
|
||||
external: boolean = false
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<a
|
||||
{...attrs}
|
||||
data-ui-component="Link"
|
||||
class='wrn-link {class}'
|
||||
href='{href}'
|
||||
target='{target}'
|
||||
rel='{external ? (rel || "noopener noreferrer") : rel}'
|
||||
data-size='{size}'
|
||||
data-color='{color}'
|
||||
data-underline='{underline}'
|
||||
data-external='{external}'
|
||||
>
|
||||
<span class="wrn-link__label">{label}</span>
|
||||
<span
|
||||
class="icon-[lucide--external-link] wrn-link__icon"
|
||||
data-show="external"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<slot></slot>
|
||||
</a>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-link {
|
||||
--link-tone: var(--wrn-color-primary);
|
||||
--link-tone-hover: var(--wrn-color-primary-hover);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
color: var(--link-tone);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
text-underline-offset: 4px;
|
||||
transition: color 140ms ease;
|
||||
}
|
||||
|
||||
.wrn-link[data-color="secondary"] {
|
||||
--link-tone: var(--wrn-color-secondary);
|
||||
--link-tone-hover: var(--wrn-color-secondary-hover);
|
||||
}
|
||||
|
||||
.wrn-link[data-color="success"] {
|
||||
--link-tone: var(--wrn-color-success);
|
||||
--link-tone-hover: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-link[data-color="warning"] {
|
||||
--link-tone: var(--wrn-color-warning-text);
|
||||
--link-tone-hover: var(--wrn-color-warning-text);
|
||||
}
|
||||
|
||||
.wrn-link[data-color="danger"] {
|
||||
--link-tone: var(--wrn-color-danger);
|
||||
--link-tone-hover: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-link[data-color="info"] {
|
||||
--link-tone: var(--wrn-color-info);
|
||||
--link-tone-hover: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-link[data-color="neutral"] {
|
||||
--link-tone: var(--wrn-color-text);
|
||||
--link-tone-hover: var(--wrn-color-text);
|
||||
}
|
||||
|
||||
.wrn-link[data-size="xs"] {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.wrn-link[data-size="md"] {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.wrn-link[data-size="lg"] {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.wrn-link[data-underline="always"] {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.wrn-link[data-underline="hover"]:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.wrn-link:hover {
|
||||
color: var(--link-tone-hover);
|
||||
}
|
||||
|
||||
.wrn-link:focus-visible {
|
||||
outline: 2px solid var(--wrn-color-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* The label truncates; the outbound icon must not shrink with it. */
|
||||
.wrn-link__label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wrn-link__icon {
|
||||
flex: 0 0 auto;
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
component List {
|
||||
outputs {
|
||||
select(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
title: string = "List"
|
||||
description: string = ""
|
||||
items: unknown[] = []
|
||||
variant: string = "default"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<section
|
||||
data-ui-component="List"
|
||||
aria-label='{title}'
|
||||
class='wrn-list {class}'
|
||||
>
|
||||
{#if title || description}
|
||||
<header class="wrn-list__header">
|
||||
{#if title}
|
||||
<h3 class="wrn-list__title">{title}</h3>
|
||||
{/if}
|
||||
{#if description}
|
||||
<p class="wrn-list__description">{description}</p>
|
||||
{/if}
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<ul
|
||||
class="wrn-list__items"
|
||||
data-variant='{variant}'
|
||||
>
|
||||
{#each items as item, index}
|
||||
<li
|
||||
class="wrn-list__item"
|
||||
data-variant='{variant}'
|
||||
@click='if (!item.href) { output.select({ item: item, index: index }) }'
|
||||
>
|
||||
{#if item.href}
|
||||
<a
|
||||
href='{item.href}'
|
||||
class="wrn-list__row"
|
||||
data-size='{size}'
|
||||
@click='output.select({ item: item, index: index })'
|
||||
>
|
||||
{#if item.icon}
|
||||
<span class="wrn-list__leading-icon"><span class='{item.icon}' aria-hidden="true"></span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if item.imageSrc}
|
||||
<img src='{item.imageSrc}' alt='{item.imageAlt || ""}' class="wrn-list__image" loading="lazy" />
|
||||
{/if}
|
||||
<div class="wrn-list__body">
|
||||
<div class="wrn-list__heading">
|
||||
<p class="wrn-list__item-title">{item.title || item.label}</p>
|
||||
{#if item.meta}
|
||||
<span class="wrn-list__meta">{item.meta}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if item.description}
|
||||
<p class="wrn-list__item-description">{item.description}</p>
|
||||
{/if}
|
||||
{#if item.badge}
|
||||
<span class="wrn-list__badge">{item.badge}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="icon-[lucide--chevron-right] wrn-list__chevron" aria-hidden="true"></span>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="wrn-list__row" data-size='{size}'>
|
||||
{#if item.icon}
|
||||
<span class="wrn-list__leading-icon"><span class='{item.icon}' aria-hidden="true"></span>
|
||||
</span>
|
||||
{/if}
|
||||
<div class="wrn-list__body">
|
||||
<p class="wrn-list__item-title">{item.title || item.label}</p>
|
||||
{#if item.description}
|
||||
<p class="wrn-list__item-description">{item.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{:empty}
|
||||
<li class="wrn-list__empty">
|
||||
No items available.
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<slot></slot>
|
||||
</section>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-list { width: 100%; }
|
||||
.wrn-list__header { margin-bottom: 1rem; }
|
||||
.wrn-list__title { color: var(--wrn-color-text); font-size: 1.125rem; font-weight: 700; }
|
||||
.wrn-list__description { margin-top: 0.25rem; color: var(--wrn-color-text-muted); font-size: 0.875rem; line-height: 1.5rem; }
|
||||
.wrn-list__items { overflow: hidden; }
|
||||
.wrn-list__items:is([data-variant="default"], [data-variant="divided"]) > .wrn-list__item + .wrn-list__item { border-top: 1px solid var(--wrn-color-border); }
|
||||
.wrn-list__items[data-variant="card"] { border: 1px solid var(--wrn-color-border); border-radius: calc(var(--wrn-radius) * 1.5); background: var(--wrn-color-surface-raised); }
|
||||
.wrn-list__items[data-variant="separated"] { display: grid; gap: 0.75rem; }
|
||||
.wrn-list__item { min-width: 0; }
|
||||
.wrn-list__item[data-variant="separated"] { border: 1px solid var(--wrn-color-border); border-radius: var(--wrn-radius); background: var(--wrn-color-surface-raised); }
|
||||
.wrn-list__row { display: flex; min-width: 0; align-items: flex-start; gap: 0.75rem; padding: 0.75rem 1rem; outline: none; transition: background var(--wrn-motion-base); }
|
||||
a.wrn-list__row:hover { background: var(--wrn-color-surface-soft); }
|
||||
.wrn-list__row:focus-visible { box-shadow: inset 0 0 0 2px var(--wrn-color-focus); }
|
||||
.wrn-list__row[data-size="sm"] { padding: 0.625rem 0.75rem; }
|
||||
.wrn-list__row[data-size="lg"] { padding: 1rem 1.25rem; }
|
||||
.wrn-list__leading-icon { display: flex; width: 2.5rem; height: 2.5rem; flex: none; align-items: center; justify-content: center; border-radius: var(--wrn-radius); color: var(--wrn-color-primary); background: var(--wrn-color-primary-soft); }
|
||||
.wrn-list__leading-icon > span { width: 1.25rem; height: 1.25rem; }
|
||||
.wrn-list__image { width: 3rem; height: 3rem; flex: none; border-radius: var(--wrn-radius); object-fit: cover; }
|
||||
.wrn-list__body { min-width: 0; flex: 1; }
|
||||
.wrn-list__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.75rem; }
|
||||
.wrn-list__item-title { overflow: hidden; color: var(--wrn-color-text); font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.wrn-list__meta { flex: none; color: var(--wrn-color-text-muted); font-size: 0.75rem; }
|
||||
.wrn-list__item-description { display: -webkit-box; margin-top: 0.25rem; overflow: hidden; color: var(--wrn-color-text-muted); font-size: 0.875rem; line-height: 1.25rem; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||
.wrn-list__badge { display: inline-flex; margin-top: 0.5rem; padding: 0.25rem 0.625rem; border-radius: 999px; color: var(--wrn-color-primary); background: var(--wrn-color-primary-soft); font-size: 0.75rem; font-weight: 600; }
|
||||
.wrn-list__chevron { width: 1rem; height: 1rem; flex: none; margin-top: 0.25rem; color: var(--wrn-color-text-muted); transition: color var(--wrn-motion-base), transform var(--wrn-motion-base); }
|
||||
.wrn-list__row:hover .wrn-list__chevron { color: var(--wrn-color-primary); transform: translateX(0.125rem); }
|
||||
.wrn-list__empty { padding: 1.5rem; border: 1px dashed var(--wrn-color-border); border-radius: var(--wrn-radius); color: var(--wrn-color-text-muted); font-size: 0.875rem; text-align: center; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
component ListGroup {
|
||||
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)
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
title: string = "List Group"
|
||||
description: string = ""
|
||||
items: unknown[] = []
|
||||
variant: string = "default"
|
||||
class: string = ""
|
||||
}
|
||||
view {
|
||||
<section class="wrn-component wrn-component--color-{color} wrn-component--size-{size} wrn-next--list-group wrn-next--variant-{variant} {class}">
|
||||
{#if title}<strong>{title}</strong>{/if}
|
||||
{#if description}<p>{description}</p>{/if}
|
||||
{#if items}<div class="wrn-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if}
|
||||
<slot />
|
||||
</section>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-next--list-group {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius);
|
||||
color: var(--wrn-color-text);
|
||||
background: var(--wrn-color-surface);
|
||||
}
|
||||
.wrn-next--list-group > p { margin: 0; color: var(--wrn-color-muted); }
|
||||
.wrn-next--list-group > .wrn-next__items { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.wrn-next--list-group > .wrn-next__items > span { padding: 0.35rem 0.6rem; border-radius: var(--wrn-radius-sm); background: var(--wrn-color-surface-2); }
|
||||
|
||||
/* Shared component foundation. */
|
||||
|
||||
.wrn-component {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
--wrn-component-scale: 1;
|
||||
margin: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-family: var(--wrn-font-sans, inherit);
|
||||
}
|
||||
|
||||
.wrn-component--color-primary {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-component--color-secondary {
|
||||
--wrn-component-color: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-component--color-success {
|
||||
--wrn-component-color: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-component--color-warning {
|
||||
--wrn-component-color: var(--wrn-color-warning);
|
||||
}
|
||||
|
||||
.wrn-component--color-danger {
|
||||
--wrn-component-color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-component--color-info {
|
||||
--wrn-component-color: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-component--size-xs {
|
||||
--wrn-component-scale: 0.78;
|
||||
}
|
||||
|
||||
.wrn-component--size-sm {
|
||||
--wrn-component-scale: 0.88;
|
||||
}
|
||||
|
||||
.wrn-component--size-default,
|
||||
.wrn-component--size-md {
|
||||
--wrn-component-scale: 1;
|
||||
}
|
||||
|
||||
.wrn-component--size-lg {
|
||||
--wrn-component-scale: 1.14;
|
||||
}
|
||||
|
||||
.wrn-component--size-xl {
|
||||
--wrn-component-scale: 1.28;
|
||||
}
|
||||
|
||||
.wrn-component:not(.wrn-btn) {
|
||||
font-size: calc(1em * var(--wrn-component-scale));
|
||||
}
|
||||
|
||||
.wrn-component :is(a, button, input, select, textarea):focus-visible {
|
||||
outline-color: var(--wrn-component-color);
|
||||
}
|
||||
|
||||
.wrn-component p {
|
||||
margin: 0;
|
||||
color: var(--wrn-color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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"
|
||||
title: string = "Map"
|
||||
description: string = ""
|
||||
items: unknown[] = []
|
||||
variant: string = "default"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
view {
|
||||
<section
|
||||
data-ui-component="Map"
|
||||
aria-label='{title}'
|
||||
class='wrn-map {class}'
|
||||
>
|
||||
{#if title || description}
|
||||
<header class="wrn-map__header">
|
||||
<div>
|
||||
{#if title}
|
||||
<h3 class="wrn-map__title">{title}</h3>
|
||||
{/if}
|
||||
{#if description}
|
||||
<p class="wrn-map__description">{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="wrn-map__header-icon"><span class="icon-[lucide--map]" aria-hidden="true"></span>
|
||||
</span>
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="wrn-map__viewport"
|
||||
data-size='{size}'
|
||||
>
|
||||
<div
|
||||
class="wrn-map__grid"
|
||||
style="background-image: linear-gradient(var(--wrn-color-border) 1px, transparent 1px), linear-gradient(90deg, var(--wrn-color-border) 1px, transparent 1px); background-size: 32px 32px;"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<div class="wrn-map__content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
{#if items.length > 0}
|
||||
{#each items as item, index}
|
||||
<button
|
||||
type="button"
|
||||
aria-label='{item.label || item.title || "Map marker"}'
|
||||
class="wrn-map__marker"
|
||||
style='left: {item.x || (20 + index * 12)}%; top: {item.y || (30 + (index % 3) * 18)}%;'
|
||||
@click='output.markerClick(item); output.select(item)'
|
||||
>
|
||||
<span class='{item.icon || "icon-[lucide--map-pin]"}' aria-hidden="true"></span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<div class="wrn-map__controls">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Zoom in"
|
||||
class="wrn-map__control"
|
||||
@click='output.zoom({ direction: "in" })'
|
||||
>
|
||||
<span class="icon-[lucide--plus] wrn-map__control-icon" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Zoom out"
|
||||
class="wrn-map__control"
|
||||
@click='output.zoom({ direction: "out" })'
|
||||
>
|
||||
<span class="icon-[lucide--minus] wrn-map__control-icon" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-map { overflow: hidden; border: 1px solid var(--wrn-color-border); border-radius: calc(var(--wrn-radius) * 1.5); background: var(--wrn-color-surface-raised); box-shadow: var(--wrn-shadow-1); }
|
||||
.wrn-map__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; padding: 1.25rem; border-bottom: 1px solid var(--wrn-color-border); }
|
||||
.wrn-map__title { color: var(--wrn-color-text); font-size: 1.125rem; font-weight: 700; }
|
||||
.wrn-map__description { margin-top: 0.25rem; color: var(--wrn-color-text-muted); font-size: 0.875rem; line-height: 1.5rem; }
|
||||
.wrn-map__header-icon { display: flex; width: 2.5rem; height: 2.5rem; flex: none; align-items: center; justify-content: center; border-radius: var(--wrn-radius); color: var(--wrn-color-primary); background: var(--wrn-color-primary-soft); }
|
||||
.wrn-map__header-icon > span { width: 1.25rem; height: 1.25rem; }
|
||||
.wrn-map__viewport { position: relative; isolation: isolate; height: 20rem; overflow: hidden; background: var(--wrn-color-surface-soft); }
|
||||
.wrn-map__viewport[data-size="sm"] { height: 16rem; }
|
||||
.wrn-map__viewport[data-size="lg"] { height: 28rem; }
|
||||
.wrn-map__grid { position: absolute; inset: 0; opacity: 0.5; pointer-events: none; }
|
||||
.wrn-map__content { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 1.5rem; }
|
||||
.wrn-map__marker { position: absolute; display: inline-flex; width: 2.5rem; height: 2.5rem; align-items: center; justify-content: center; padding: 0; border: 4px solid var(--wrn-color-surface-raised); border-radius: 50%; color: var(--wrn-color-on-primary); background: var(--wrn-color-primary); box-shadow: var(--wrn-shadow-3); transition: transform var(--wrn-motion-base) var(--wrn-ease-standard); }
|
||||
.wrn-map__marker:hover { transform: scale(1.1); }
|
||||
.wrn-map__controls { position: absolute; right: 1rem; bottom: 1rem; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.wrn-map__control { display: inline-flex; width: 2.5rem; height: 2.5rem; align-items: center; justify-content: center; padding: 0; border: 1px solid var(--wrn-color-border); border-radius: var(--wrn-radius); color: var(--wrn-color-text); background: var(--wrn-color-surface-raised); box-shadow: var(--wrn-shadow-1); transition: background var(--wrn-motion-base); }
|
||||
.wrn-map__control:hover { background: var(--wrn-color-surface-soft); }
|
||||
.wrn-map__marker:focus-visible, .wrn-map__control:focus-visible { outline: 2px solid var(--wrn-color-focus); outline-offset: 2px; }
|
||||
.wrn-map__control-icon { width: 1rem; height: 1rem; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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"
|
||||
title: string = "Marquee"
|
||||
description: string = ""
|
||||
items: unknown[] = []
|
||||
variant: string = "default"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
state paused: boolean = false
|
||||
|
||||
view {
|
||||
<section
|
||||
data-ui-component="Marquee"
|
||||
aria-label='{title}'
|
||||
class='wrn-marquee {class}'
|
||||
>
|
||||
<div class="wrn-marquee__layout">
|
||||
{#if title}
|
||||
<div class="wrn-marquee__title">
|
||||
<span class="icon-[lucide--megaphone] wrn-marquee__icon" aria-hidden="true"></span>
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="wrn-marquee__window"
|
||||
@mouseenter='paused = true; output.pause({})'
|
||||
@mouseleave='paused = false; output.resume({})'
|
||||
@focusin='paused = true'
|
||||
@focusout='paused = false'
|
||||
>
|
||||
<div
|
||||
class="wrn-marquee__track"
|
||||
class:wrn-marquee-paused='paused'
|
||||
>
|
||||
{#each items as item}
|
||||
<a
|
||||
href='{item.href || "#"}'
|
||||
class="wrn-marquee__item"
|
||||
>
|
||||
<span
|
||||
class="wrn-marquee__dot"
|
||||
data-color='{item.color || "primary"}'
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span>{item.label || item.title}</span>
|
||||
{#if item.meta}
|
||||
<span class="wrn-marquee__meta">{item.meta}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{:empty}
|
||||
<p class="wrn-marquee__empty">{description || "No announcements available."}</p>
|
||||
{/each}
|
||||
|
||||
{#if items.length > 0}
|
||||
{#each items as item}
|
||||
<a
|
||||
href='{item.href || "#"}'
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
class="wrn-marquee__item"
|
||||
>
|
||||
<span class="wrn-marquee__dot" aria-hidden="true"></span>
|
||||
<span>{item.label || item.title}</span>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label='{paused ? "Resume announcements" : "Pause announcements"}'
|
||||
class="wrn-marquee__toggle"
|
||||
@click='paused = !paused; paused ? output.pause({}) : output.resume({})'
|
||||
>
|
||||
<span class="icon-[lucide--pause] wrn-marquee__icon" data-show='!paused' aria-hidden="true"></span>
|
||||
<span class="icon-[lucide--play] wrn-marquee__icon" data-show='paused' aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<slot></slot>
|
||||
</section>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-marquee { overflow: hidden; border-block: 1px solid var(--wrn-color-border); background: var(--wrn-color-surface-raised); }
|
||||
.wrn-marquee__layout { display: flex; align-items: stretch; }
|
||||
.wrn-marquee__title { position: relative; z-index: 1; display: flex; flex: none; align-items: center; gap: 0.5rem; padding: 0.75rem 1rem; border-right: 1px solid var(--wrn-color-border); color: var(--wrn-color-on-primary); background: var(--wrn-color-primary); font-size: 0.875rem; font-weight: 700; }
|
||||
.wrn-marquee__window { position: relative; min-width: 0; flex: 1; overflow: hidden; }
|
||||
.wrn-marquee__track {
|
||||
display: flex; width: max-content; min-width: 100%; align-items: center;
|
||||
animation: wrn-marquee 32s linear infinite;
|
||||
}
|
||||
.wrn-marquee-paused { animation-play-state: paused; }
|
||||
.wrn-marquee__item { display: flex; flex: none; align-items: center; gap: 0.75rem; padding: 0.75rem 1.25rem; color: var(--wrn-color-text); font-size: 0.875rem; font-weight: 500; transition: color var(--wrn-motion-base), background var(--wrn-motion-base); }
|
||||
.wrn-marquee__item:hover { color: var(--wrn-color-primary); background: var(--wrn-color-primary-soft); }
|
||||
.wrn-marquee__item:focus-visible, .wrn-marquee__toggle:focus-visible { outline: 2px solid var(--wrn-color-focus); outline-offset: -2px; }
|
||||
.wrn-marquee__dot { width: 0.5rem; height: 0.5rem; flex: none; border-radius: 50%; background: var(--wrn-color-primary); }
|
||||
.wrn-marquee__dot[data-color="danger"] { background: var(--wrn-color-danger); }
|
||||
.wrn-marquee__dot[data-color="warning"] { background: var(--wrn-color-warning); }
|
||||
.wrn-marquee__dot[data-color="success"] { background: var(--wrn-color-success); }
|
||||
.wrn-marquee__meta { color: var(--wrn-color-text-muted); font-size: 0.75rem; }
|
||||
.wrn-marquee__empty { padding: 0.75rem 1.25rem; color: var(--wrn-color-text-muted); font-size: 0.875rem; }
|
||||
.wrn-marquee__toggle { display: flex; flex: none; align-items: center; justify-content: center; padding-inline: 1rem; border: 0; border-left: 1px solid var(--wrn-color-border); color: var(--wrn-color-text-muted); background: transparent; transition: color var(--wrn-motion-base), background var(--wrn-motion-base); }
|
||||
.wrn-marquee__toggle:hover { color: var(--wrn-color-text); background: var(--wrn-color-surface-soft); }
|
||||
.wrn-marquee__icon { width: 1rem; height: 1rem; }
|
||||
@media (min-width: 40rem) { .wrn-marquee__title { padding-inline: 1.25rem; } }
|
||||
|
||||
.wrn-marquee-paused {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@keyframes wrn-marquee {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wrn-marquee__track {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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 })
|
||||
close(payload: { reason: string })
|
||||
select(payload: { item: object; value: string })
|
||||
}
|
||||
|
||||
props {
|
||||
color: string = "primary"
|
||||
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 {
|
||||
<div
|
||||
{...attrs}
|
||||
data-ui-component="MegaMenu"
|
||||
class='wrn-mega {class}'
|
||||
data-color='{color}'
|
||||
data-size='{size}'
|
||||
data-open='{visible}'
|
||||
@keydown='handleKeydown(event)'
|
||||
@mouseleave='hidePanel("pointer-leave")'
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-mega__trigger"
|
||||
aria-haspopup="true"
|
||||
aria-expanded='{visible}'
|
||||
@click='togglePanel(event)'
|
||||
@mouseenter='showPanel(event)'
|
||||
@focus='showPanel(event)'
|
||||
>
|
||||
<span class='wrn-mega__trigger-icon {icon}' data-show="icon" aria-hidden="true"></span>
|
||||
<span class="wrn-mega__trigger-label">{label}</span>
|
||||
<span class="wrn-mega__arrow" aria-hidden="true">›</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="wrn-mega__panel"
|
||||
data-wrn-anchored="true"
|
||||
data-show="visible"
|
||||
data-wrn-roving="both"
|
||||
>
|
||||
<div class="wrn-mega__columns">
|
||||
{#each columnList() as column}
|
||||
<section class="wrn-mega__column">
|
||||
<h3 class="wrn-mega__heading" data-show="column.heading">{column.heading}</h3>
|
||||
<ul class="wrn-mega__list">
|
||||
{#each itemsOf(column) as item}
|
||||
<li>
|
||||
<a
|
||||
class="wrn-mega__link"
|
||||
href='{item.href || "#"}'
|
||||
data-wrn-roving-item="true"
|
||||
aria-disabled='{item.disabled ? "true" : "false"}'
|
||||
@click='choose(item)'
|
||||
>
|
||||
<span
|
||||
class='wrn-mega__link-icon {item.icon}'
|
||||
data-show="item.icon"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="wrn-mega__link-body">
|
||||
<span class="wrn-mega__link-label">{item.label}</span>
|
||||
<span class="wrn-mega__link-description" data-show="item.description">
|
||||
{item.description}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="wrn-mega__footer" data-show="footer">{footer}</p>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-mega {
|
||||
--mega-accent: var(--wrn-color-primary);
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.wrn-mega[data-color="secondary"] {
|
||||
--mega-accent: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-mega[data-color="success"] {
|
||||
--mega-accent: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-mega[data-color="danger"] {
|
||||
--mega-accent: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-mega[data-color="info"] {
|
||||
--mega-accent: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-mega[data-size="sm"] {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.wrn-mega[data-size="lg"] {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.wrn-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(--wrn-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--wrn-color-text-muted);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wrn-mega__trigger:hover,
|
||||
.wrn-mega[data-open="true"] .wrn-mega__trigger {
|
||||
background: var(--wrn-color-surface-soft);
|
||||
color: var(--wrn-color-text);
|
||||
}
|
||||
|
||||
.wrn-mega__trigger:focus-visible {
|
||||
outline: 2px solid var(--mega-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.wrn-mega__arrow {
|
||||
display: inline-block;
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.wrn-mega[data-open="true"] .wrn-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.
|
||||
*/
|
||||
.wrn-mega__panel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 100%;
|
||||
height: 0.6rem;
|
||||
}
|
||||
|
||||
.wrn-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(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-lg);
|
||||
background: var(--wrn-color-surface);
|
||||
box-shadow: var(--wrn-shadow-1);
|
||||
}
|
||||
|
||||
.wrn-mega__columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.wrn-mega__column {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wrn-mega__heading {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--wrn-color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wrn-mega__list {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.wrn-mega__link {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.5rem;
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
color: var(--wrn-color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wrn-mega__link:hover {
|
||||
background: var(--wrn-color-surface-soft);
|
||||
}
|
||||
|
||||
.wrn-mega__link:focus-visible {
|
||||
outline: 2px solid var(--mega-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.wrn-mega__link[aria-disabled="true"] {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wrn-mega__link-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wrn-mega__link-label {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wrn-mega__link-description {
|
||||
color: var(--wrn-color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.wrn-mega__footer {
|
||||
margin: 1rem 0 0;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--wrn-color-border);
|
||||
color: var(--wrn-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) {
|
||||
.wrn-mega {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wrn-mega__panel {
|
||||
position: static;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.wrn-mega__columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,787 @@
|
||||
component Modal {
|
||||
outputs {
|
||||
open(payload: { sourceEvent: Event })
|
||||
close(payload: { reason: string; sourceEvent: Event })
|
||||
cancel(payload: { sourceEvent: Event })
|
||||
confirm(payload: { sourceEvent: Event })
|
||||
}
|
||||
|
||||
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 = ""
|
||||
}
|
||||
|
||||
state visible = defaultOpen
|
||||
|
||||
functions {
|
||||
shared function isOpen() {
|
||||
return open || visible
|
||||
}
|
||||
|
||||
client function showModal(sourceEvent) {
|
||||
visible = true
|
||||
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 wrn up
|
||||
// and no way to close somebody else's modal by accident.
|
||||
client function hideModal(reason, sourceEvent) {
|
||||
visible = false
|
||||
output.close({
|
||||
reason: reason,
|
||||
sourceEvent: sourceEvent
|
||||
})
|
||||
}
|
||||
|
||||
client function cancelModal(sourceEvent) {
|
||||
output.cancel({ sourceEvent: sourceEvent })
|
||||
if (closeOnCancel) {
|
||||
hideModal("cancel", sourceEvent)
|
||||
}
|
||||
}
|
||||
|
||||
client function confirmModal(sourceEvent) {
|
||||
if (confirmDisabled || confirmLoading) {
|
||||
return
|
||||
}
|
||||
output.confirm({ sourceEvent: sourceEvent })
|
||||
if (closeOnConfirm) {
|
||||
hideModal("confirm", sourceEvent)
|
||||
}
|
||||
}
|
||||
|
||||
client function handleKeydown(sourceEvent) {
|
||||
if (closeOnEscape && sourceEvent.key === "Escape") {
|
||||
sourceEvent.preventDefault()
|
||||
cancelModal(sourceEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<div
|
||||
{...attrs}
|
||||
data-ui-component="Modal"
|
||||
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='wrn-modal {class}'
|
||||
@wrnexus:modal:close='hideModal("api", event)'
|
||||
>
|
||||
{#if triggerLabel}
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-modal__trigger"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded='{isOpen() ? "true" : "false"}'
|
||||
@click='showModal(event)'
|
||||
>
|
||||
{#if triggerIcon}
|
||||
<span
|
||||
class='{triggerIcon}'
|
||||
aria-hidden="true"
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
<span>{triggerLabel}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<span
|
||||
class="wrn-modal__trigger-slot"
|
||||
@click='showModal(event)'
|
||||
>
|
||||
<slot
|
||||
name="trigger"
|
||||
>
|
||||
</slot>
|
||||
</span>
|
||||
|
||||
<div
|
||||
class="wrn-modal__layer"
|
||||
data-show="isOpen()"
|
||||
role="presentation"
|
||||
@keydown='handleKeydown(event)'
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-modal__backdrop"
|
||||
aria-label='{closeLabel}'
|
||||
@click='if (closeOnBackdrop) { cancelModal(event) }'
|
||||
>
|
||||
</button>
|
||||
|
||||
<section
|
||||
class="wrn-modal__panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label='{label || title}'
|
||||
tabindex="-1"
|
||||
>
|
||||
<header
|
||||
class="wrn-modal__header"
|
||||
>
|
||||
<div
|
||||
class="wrn-modal__heading"
|
||||
>
|
||||
{#if icon}
|
||||
<span
|
||||
class='wrn-modal__icon {icon}'
|
||||
aria-hidden="true"
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="wrn-modal__heading-copy"
|
||||
>
|
||||
<slot
|
||||
name="header"
|
||||
>
|
||||
</slot>
|
||||
{#if title}
|
||||
<h2>{title}</h2>
|
||||
{/if}
|
||||
{#if description}
|
||||
<p>{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showClose}
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-modal__close"
|
||||
aria-label='{closeLabel}'
|
||||
@click='hideModal("close-button", event)'
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="M6 6 18 18" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="wrn-modal__body"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
{#if showFooter}
|
||||
<footer
|
||||
class="wrn-modal__footer"
|
||||
>
|
||||
<div
|
||||
class="wrn-modal__custom-footer"
|
||||
>
|
||||
<slot
|
||||
name="footer"
|
||||
>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="wrn-modal__actions"
|
||||
>
|
||||
{#if cancelLabel}
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-modal__button wrn-modal__button--secondary"
|
||||
@click='cancelModal(event)'
|
||||
>
|
||||
{#if cancelIcon}
|
||||
<span
|
||||
class='{cancelIcon}'
|
||||
aria-hidden="true"
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
<span>{cancelLabel}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if confirmLabel}
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-modal__button wrn-modal__button--primary"
|
||||
disabled='{confirmDisabled || confirmLoading}'
|
||||
@click='confirmModal(event)'
|
||||
>
|
||||
{#if confirmLoading}
|
||||
<span
|
||||
class="wrn-modal__spinner"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</span>
|
||||
{:else if confirmIcon}
|
||||
<span
|
||||
class='{confirmIcon}'
|
||||
aria-hidden="true"
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
<span>{confirmLabel}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</footer>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-modal {
|
||||
--modal-accent: var(--wrn-color-primary);
|
||||
--modal-soft: var(--wrn-color-primary-soft);
|
||||
--modal-contrast: var(--wrn-color-primary-contrast);
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.wrn-modal[data-color="secondary"] {
|
||||
--modal-accent: var(--wrn-color-secondary);
|
||||
--modal-soft: var(--wrn-color-secondary-soft);
|
||||
--modal-contrast: var(--wrn-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(--wrn-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.
|
||||
*/
|
||||
.wrn-modal[data-color="info"] {
|
||||
--modal-accent: var(--wrn-color-info);
|
||||
--modal-soft: var(--wrn-color-info-soft);
|
||||
--modal-contrast: var(--wrn-color-info-contrast, white);
|
||||
}
|
||||
|
||||
.wrn-modal[data-color="success"] {
|
||||
--modal-accent: var(--wrn-color-success);
|
||||
--modal-soft: var(--wrn-color-success-soft);
|
||||
--modal-contrast: var(--wrn-color-success-contrast, white);
|
||||
}
|
||||
|
||||
.wrn-modal[data-color="warning"] {
|
||||
--modal-accent: var(--wrn-color-warning);
|
||||
--modal-soft: var(--wrn-color-warning-soft);
|
||||
--modal-contrast: var(--wrn-color-warning-text);
|
||||
}
|
||||
|
||||
.wrn-modal[data-color="danger"],
|
||||
.wrn-modal[data-destructive="true"] {
|
||||
--modal-accent: var(--wrn-color-danger);
|
||||
--modal-soft: var(--wrn-color-danger-soft);
|
||||
--modal-contrast: var(--wrn-color-on-danger, white);
|
||||
}
|
||||
|
||||
.wrn-modal__trigger,
|
||||
.wrn-modal__trigger-slot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.wrn-modal__trigger {
|
||||
appearance: none;
|
||||
min-height: 2.55rem;
|
||||
padding: 0.65rem 1rem;
|
||||
color: var(--modal-contrast);
|
||||
background: var(--modal-accent);
|
||||
border: 0;
|
||||
border-radius: 0.8rem;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms ease, transform 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.wrn-modal__trigger:hover {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.wrn-modal__trigger:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.wrn-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 wrn-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.
|
||||
*/
|
||||
.wrn-modal__layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1250;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 180ms ease, visibility 0s linear 180ms;
|
||||
}
|
||||
|
||||
.wrn-modal[data-open="true"] .wrn-modal__layer {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transition: opacity 180ms ease, visibility 0s linear 0s;
|
||||
}
|
||||
|
||||
.wrn-modal[data-placement="top"] .wrn-modal__layer {
|
||||
align-items: flex-start;
|
||||
padding-top: clamp(1rem, 8vh, 5rem);
|
||||
}
|
||||
|
||||
.wrn-modal[data-scroll="page"] .wrn-modal__layer {
|
||||
align-items: flex-start;
|
||||
overflow-y: auto;
|
||||
padding: 2.5rem 1rem;
|
||||
}
|
||||
|
||||
.wrn-modal__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
appearance: none;
|
||||
padding: 0;
|
||||
background: color-mix(in srgb, black 62%, transparent);
|
||||
border: 0;
|
||||
backdrop-filter: blur(9px);
|
||||
}
|
||||
|
||||
.wrn-modal__panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(32rem, calc(100vw - 2rem));
|
||||
max-height: min(88vh, 52rem);
|
||||
color: var(--wrn-color-text);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 100% 0%,
|
||||
color-mix(in srgb, var(--modal-accent) 9%, transparent),
|
||||
transparent 34%
|
||||
),
|
||||
var(--wrn-color-surface-raised);
|
||||
border: 1px solid color-mix(in srgb, var(--modal-accent) 18%, var(--wrn-color-border));
|
||||
border-radius: 1.35rem;
|
||||
box-shadow:
|
||||
0 1px 0 color-mix(in srgb, white 5%, transparent) inset,
|
||||
0 40px 110px color-mix(in srgb, black 38%, transparent);
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: scale(0.96) translateY(10px);
|
||||
transition: opacity 180ms ease, transform 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.wrn-modal[data-open="true"] .wrn-modal__panel {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.wrn-modal[data-size="xs"] .wrn-modal__panel {
|
||||
width: min(19rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.wrn-modal[data-size="sm"] .wrn-modal__panel {
|
||||
width: min(25rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.wrn-modal[data-size="lg"] .wrn-modal__panel {
|
||||
width: min(44rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.wrn-modal[data-size="xl"] .wrn-modal__panel {
|
||||
width: min(64rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.wrn-modal[data-size="full"] .wrn-modal__panel {
|
||||
width: calc(100vw - 2rem);
|
||||
height: calc(100vh - 2rem);
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.wrn-modal[data-scroll="page"] .wrn-modal__panel {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.wrn-modal[data-variant="soft"] .wrn-modal__panel {
|
||||
background:
|
||||
linear-gradient(145deg, var(--modal-soft), transparent 68%),
|
||||
var(--wrn-color-surface-raised);
|
||||
}
|
||||
|
||||
.wrn-modal[data-variant="outline"] .wrn-modal__panel {
|
||||
background: var(--wrn-color-surface-raised);
|
||||
box-shadow: 0 24px 72px color-mix(in srgb, black 24%, transparent);
|
||||
}
|
||||
|
||||
.wrn-modal[data-variant="solid"] .wrn-modal__panel {
|
||||
color: var(--modal-contrast);
|
||||
background: var(--modal-accent);
|
||||
border-color: color-mix(in srgb, white 20%, transparent);
|
||||
}
|
||||
|
||||
.wrn-modal__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.4rem 1.4rem 1.15rem;
|
||||
border-bottom: 1px solid var(--wrn-color-border);
|
||||
}
|
||||
|
||||
.wrn-modal__heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.9rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wrn-modal__icon {
|
||||
flex: 0 0 auto;
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
margin-top: 0.15rem;
|
||||
color: var(--modal-accent);
|
||||
}
|
||||
|
||||
.wrn-modal[data-variant="solid"] .wrn-modal__icon {
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.wrn-modal__heading-copy {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wrn-modal__heading-copy h2,
|
||||
.wrn-modal__heading-copy p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wrn-modal__heading-copy h2 {
|
||||
color: inherit;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.wrn-modal__heading-copy p {
|
||||
color: var(--wrn-color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.wrn-modal[data-variant="solid"] .wrn-modal__heading-copy p {
|
||||
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".
|
||||
*/
|
||||
.wrn-modal__close {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
padding: 0;
|
||||
width: 2.15rem;
|
||||
height: 2.15rem;
|
||||
color: var(--wrn-color-text-muted);
|
||||
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.
|
||||
*/
|
||||
.wrn-modal[data-variant="solid"] .wrn-modal__close {
|
||||
color: color-mix(in srgb, currentColor 82%, transparent);
|
||||
border-color: color-mix(in srgb, currentColor 35%, transparent);
|
||||
}
|
||||
|
||||
.wrn-modal[data-variant="solid"] .wrn-modal__close:hover {
|
||||
color: currentColor;
|
||||
background: color-mix(in srgb, black 18%, transparent);
|
||||
border-color: color-mix(in srgb, currentColor 55%, transparent);
|
||||
}
|
||||
|
||||
.wrn-modal__close:hover {
|
||||
color: var(--wrn-color-text);
|
||||
background: var(--wrn-color-surface-soft);
|
||||
}
|
||||
|
||||
.wrn-modal__close:focus-visible {
|
||||
color: var(--modal-accent);
|
||||
border-color: color-mix(in srgb, var(--modal-accent) 45%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.wrn-modal__close:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
/* Never let the glyph be shrunk by the flex container. */
|
||||
.wrn-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.
|
||||
*/
|
||||
.wrn-modal__body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 1.4rem;
|
||||
color: var(--wrn-color-text);
|
||||
}
|
||||
|
||||
/*
|
||||
* :where() keeps this at the specificity of .wrn-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 .wrn-modal__body p list would have quietly overridden those.
|
||||
*/
|
||||
.wrn-modal__body :where(p, li, dd, dt, h1, h2, h3, h4, h5, h6, span, label, code) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.wrn-modal[data-variant="solid"] .wrn-modal__body {
|
||||
color: var(--modal-contrast);
|
||||
}
|
||||
|
||||
.wrn-modal[data-scrollable="true"] .wrn-modal__body {
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.wrn-modal__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.4rem 1.4rem;
|
||||
border-top: 1px solid var(--wrn-color-border);
|
||||
}
|
||||
|
||||
.wrn-modal__custom-footer:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wrn-modal__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.65rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.wrn-modal__button {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 2.55rem;
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 0.78rem;
|
||||
font: inherit;
|
||||
font-size: 0.83rem;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease, opacity 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
|
||||
.wrn-modal__button:active {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
|
||||
.wrn-modal__button--secondary {
|
||||
color: var(--wrn-color-text);
|
||||
background: transparent;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
}
|
||||
|
||||
.wrn-modal__button--primary {
|
||||
color: var(--modal-contrast);
|
||||
background: var(--modal-accent);
|
||||
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.
|
||||
*/
|
||||
.wrn-modal[data-variant="solid"] .wrn-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;
|
||||
}
|
||||
|
||||
.wrn-modal__button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.wrn-modal__spinner {
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
border: 2px solid color-mix(in srgb, currentColor 35%, transparent);
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: wrn-modal-spin 750ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes wrn-modal-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.wrn-modal__layer {
|
||||
align-items: flex-end;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wrn-modal__panel,
|
||||
.wrn-modal[data-size="xs"] .wrn-modal__panel,
|
||||
.wrn-modal[data-size="sm"] .wrn-modal__panel,
|
||||
.wrn-modal[data-size="lg"] .wrn-modal__panel,
|
||||
.wrn-modal[data-size="xl"] .wrn-modal__panel {
|
||||
width: 100%;
|
||||
max-height: 92vh;
|
||||
border-radius: 1.25rem 1.25rem 0 0;
|
||||
}
|
||||
|
||||
.wrn-modal__footer {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wrn-modal__actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wrn-modal__button {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wrn-modal__button,
|
||||
.wrn-modal__spinner,
|
||||
.wrn-modal__layer,
|
||||
.wrn-modal__panel,
|
||||
.wrn-modal__close {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.wrn-modal__panel {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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: { item: object; value: 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 = ""
|
||||
}
|
||||
|
||||
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
|
||||
{...attrs}
|
||||
data-ui-component="Nav"
|
||||
class='wrn-nav {class}'
|
||||
data-orientation='{orientation}'
|
||||
data-color='{color}'
|
||||
data-size='{size}'
|
||||
data-expanded='{expanded}'
|
||||
role="navigation"
|
||||
aria-label='{label}'
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="wrn-nav__toggle"
|
||||
data-show="collapsible"
|
||||
aria-expanded='{expanded}'
|
||||
aria-label='{toggleLabel}'
|
||||
@click='toggleMenu()'
|
||||
>
|
||||
<span class="wrn-nav__toggle-bar" aria-hidden="true"></span>
|
||||
<span class="wrn-nav__toggle-text">{toggleLabel}</span>
|
||||
</button>
|
||||
|
||||
<ul class="wrn-nav__list" data-wrn-roving='{rovingAxis()}'>
|
||||
{#each itemList() as item}
|
||||
<li class="wrn-nav__item" data-has-children='{childrenOf(item).length > 0}'>
|
||||
<a
|
||||
class="wrn-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='wrn-nav__icon {item.icon}'
|
||||
data-show="item.icon"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="wrn-nav__label">{item.label}</span>
|
||||
<span class="wrn-nav__badge" data-show="item.badge">{item.badge}</span>
|
||||
<span
|
||||
class="wrn-nav__arrow"
|
||||
data-show="childrenOf(item).length > 0"
|
||||
aria-hidden="true"
|
||||
>›</span>
|
||||
</a>
|
||||
|
||||
<ul
|
||||
class="wrn-nav__submenu"
|
||||
data-wrn-anchored="true"
|
||||
data-show="childrenOf(item).length > 0"
|
||||
>
|
||||
{#each childrenOf(item) as child}
|
||||
<li class="wrn-nav__item" data-has-children='{childrenOf(child).length > 0}'>
|
||||
<a
|
||||
class="wrn-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='wrn-nav__icon {child.icon}'
|
||||
data-show="child.icon"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="wrn-nav__label">{child.label}</span>
|
||||
<span class="wrn-nav__badge" data-show="child.badge">{child.badge}</span>
|
||||
<span
|
||||
class="wrn-nav__arrow"
|
||||
data-show="childrenOf(child).length > 0"
|
||||
aria-hidden="true"
|
||||
>›</span>
|
||||
</a>
|
||||
|
||||
<ul
|
||||
class="wrn-nav__submenu wrn-nav__submenu--level3"
|
||||
data-wrn-anchored="true"
|
||||
data-show="childrenOf(child).length > 0"
|
||||
>
|
||||
{#each childrenOf(child) as leaf}
|
||||
<li class="wrn-nav__item">
|
||||
<a
|
||||
class="wrn-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='wrn-nav__icon {leaf.icon}'
|
||||
data-show="leaf.icon"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="wrn-nav__label">{leaf.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<slot />
|
||||
</nav>
|
||||
}
|
||||
|
||||
style {
|
||||
.wrn-nav {
|
||||
--nav-accent: var(--wrn-color-primary);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.wrn-nav[data-color="secondary"] {
|
||||
--nav-accent: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-nav[data-color="success"] {
|
||||
--nav-accent: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-nav[data-color="danger"] {
|
||||
--nav-accent: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-nav[data-color="info"] {
|
||||
--nav-accent: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-nav[data-size="sm"] {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.wrn-nav[data-size="lg"] {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.wrn-nav__list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.wrn-nav[data-orientation="vertical"] .wrn-nav__list {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.wrn-nav__item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wrn-nav__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
color: var(--wrn-color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wrn-nav__link:hover {
|
||||
background: var(--wrn-color-surface-soft);
|
||||
color: var(--wrn-color-text);
|
||||
}
|
||||
|
||||
.wrn-nav__link:focus-visible {
|
||||
outline: 2px solid var(--nav-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.wrn-nav__link[data-active="true"] {
|
||||
background: color-mix(in srgb, var(--nav-accent) 16%, transparent);
|
||||
color: var(--nav-accent);
|
||||
}
|
||||
|
||||
.wrn-nav__link[aria-disabled="true"] {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wrn-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;
|
||||
}
|
||||
|
||||
.wrn-nav__arrow {
|
||||
display: inline-block;
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.wrn-nav__item:hover > .wrn-nav__link > .wrn-nav__arrow,
|
||||
.wrn-nav__item:focus-within > .wrn-nav__link > .wrn-nav__arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.wrn-nav__submenu {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 12rem;
|
||||
margin: 0;
|
||||
padding: 0.35rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-md);
|
||||
background: var(--wrn-color-surface);
|
||||
box-shadow: var(--wrn-shadow-1);
|
||||
list-style: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.wrn-nav__item:hover > .wrn-nav__submenu,
|
||||
.wrn-nav__item:focus-within > .wrn-nav__submenu {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.wrn-nav__submenu--level3 {
|
||||
top: 0;
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.wrn-nav__toggle {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: var(--wrn-radius-sm);
|
||||
background: var(--wrn-color-surface);
|
||||
color: var(--wrn-color-text);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wrn-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) {
|
||||
.wrn-nav__toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.wrn-nav__list {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.wrn-nav[data-expanded="false"] .wrn-nav__list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wrn-nav__submenu,
|
||||
.wrn-nav__submenu--level3 {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
component Navbar {
|
||||
outputs {
|
||||
toggle(payload: { open: boolean })
|
||||
open(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
close(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
||||
select(payload: { item: string | number | boolean | null | object; value: string | number | boolean; level: string })
|
||||
action(payload: { item: string | number | boolean | null | object; value: string | number | boolean })
|
||||
}
|
||||
|
||||
props {
|
||||
size: string = "default"
|
||||
color: string = "primary"
|
||||
label: string = "Primary navigation"
|
||||
topbarLabel: string = "Utility navigation"
|
||||
brand: Record<string, unknown> = {}
|
||||
items: unknown[] = []
|
||||
actions: unknown[] = []
|
||||
active: string = ""
|
||||
sticky: boolean = false
|
||||
openOnHover: boolean = false
|
||||
maxWidth: string = "full"
|
||||
mobileLabel: string = "Toggle navigation"
|
||||
class: string = ""
|
||||
}
|
||||
|
||||
state mobileOpen: boolean = false
|
||||
|
||||
functions {
|
||||
client function toggleNavigation() {
|
||||
mobileOpen = !mobileOpen
|
||||
output.toggle({ open: mobileOpen })
|
||||
output[mobileOpen ? "open" : "close"]({ source: "mobile" })
|
||||
}
|
||||
|
||||
client function selectItem(item, level) {
|
||||
mobileOpen = false
|
||||
output.select({ item: item, value: item.value || "", level: level })
|
||||
}
|
||||
|
||||
client function selectAction(item) {
|
||||
mobileOpen = false
|
||||
output.action({ item: item, value: item.value || "" })
|
||||
}
|
||||
|
||||
shared function isItemActive(item) {
|
||||
if (!item) return false
|
||||
if (item.value && item.value === active) return true
|
||||
if (!item.children || !item.children.length) return false
|
||||
return item.children.some(function (child) {
|
||||
return isItemActive(child)
|
||||
})
|
||||
}
|
||||
|
||||
client function toggleDropdown(event, item) {
|
||||
output[event.currentTarget.open ? "open" : "close"]({
|
||||
source: "menu",
|
||||
item: item
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
view {
|
||||
<header class="wrn-navbar wrn-navbar--width-{maxWidth} wrn-component--color-{color} wrn-component--size-{size} {sticky ? 'wrn-navbar--sticky' : ''} {class}" data-wrn-navbar data-open-on-hover="{openOnHover}">
|
||||
<div class="wrn-navbar__topbar" aria-label="{topbarLabel}">
|
||||
<slot name="topbar" />
|
||||
</div>
|
||||
|
||||
<div class="wrn-navbar__main">
|
||||
<a class="wrn-navbar__brand" href="{brand.href || '/'}" aria-label="{brand.ariaLabel || brand.label || label}">
|
||||
{#if brand.logo}
|
||||
<img class="wrn-navbar__brand-logo" src="{brand.logo}" alt="{brand.logoAlt || brand.label || ''}" width="{brand.logoWidth || ''}" height="{brand.logoHeight || ''}" />
|
||||
{/if}
|
||||
{#if brand.icon}
|
||||
<span class="wrn-navbar__brand-icon {brand.icon}" aria-hidden="true"></span>
|
||||
{/if}
|
||||
{#if brand.label || brand.description}
|
||||
<span class="wrn-navbar__brand-copy">
|
||||
{#if brand.label}<strong>{brand.label}</strong>{/if}
|
||||
{#if brand.description}<small>{brand.description}</small>{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<button type="button" class="wrn-navbar__toggle" aria-label="{mobileLabel}" aria-expanded="{mobileOpen}" @click="toggleNavigation()">
|
||||
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
<div class="wrn-navbar__collapse {mobileOpen ? 'is-open' : ''}">
|
||||
<nav class="wrn-navbar__menus" aria-label="{label}" data-wrn-roving="horizontal">
|
||||
{#each items as item}
|
||||
{#if item.children && item.children.length}
|
||||
<details class="wrn-navbar__dropdown wrn-navbar__dropdown--{item.type || 'dropdown'}" name="wrn-navbar-menu" @toggle="toggleDropdown(event, item)">
|
||||
<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="wrn-navbar__chevron" aria-hidden="true"></span>
|
||||
</summary>
|
||||
<div class="wrn-navbar__panel wrn-navbar__panel--columns-{item.columns || 1}">
|
||||
{#if item.description}<p class="wrn-navbar__panel-intro">{item.description}</p>{/if}
|
||||
{#each item.children as child}
|
||||
<div class="wrn-navbar__group">
|
||||
{#if child.children && child.children.length}
|
||||
{#if child.label}<strong class="wrn-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' : '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' : '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>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
{:else}
|
||||
<a class="wrn-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>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="wrn-navbar__actions">
|
||||
{#each actions as item}
|
||||
<a class="wrn-navbar__action wrn-navbar__action--{item.variant || 'link'}" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" @click="selectAction(item)">
|
||||
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
|
||||
style {
|
||||
/* Full application navigation */
|
||||
.wrn-navbar {
|
||||
position: relative;
|
||||
z-index: 40;
|
||||
width: 100%;
|
||||
color: var(--wrn-color-text);
|
||||
background: color-mix(in srgb, var(--wrn-color-surface) 96%, transparent);
|
||||
border-bottom: 1px solid var(--wrn-color-border);
|
||||
}
|
||||
|
||||
.wrn-navbar--sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.wrn-navbar__topbar:empty,
|
||||
.wrn-navbar__topbar:not(:has(*)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wrn-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(--wrn-color-muted);
|
||||
background: var(--wrn-color-surface-2);
|
||||
border-bottom: 1px solid var(--wrn-color-border);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.wrn-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;
|
||||
}
|
||||
|
||||
.wrn-navbar--width-full .wrn-navbar__main {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.wrn-navbar--width-compact .wrn-navbar__main {
|
||||
width: min(86%, 80rem);
|
||||
}
|
||||
|
||||
.wrn-navbar__brand,
|
||||
.wrn-navbar__menu-link,
|
||||
.wrn-navbar__dropdown summary,
|
||||
.wrn-navbar__action,
|
||||
.wrn-navbar__panel a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wrn-navbar__brand {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.wrn-navbar__brand-logo {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 11rem;
|
||||
height: 2.5rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.wrn-navbar__brand-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
color: var(--wrn-component-color, var(--wrn-color-primary));
|
||||
}
|
||||
|
||||
.wrn-navbar__brand-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.wrn-navbar__brand-copy strong {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wrn-navbar__brand-copy small {
|
||||
margin-top: 0.2rem;
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.wrn-navbar__collapse,
|
||||
.wrn-navbar__menus,
|
||||
.wrn-navbar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wrn-navbar__collapse {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.wrn-navbar__menus {
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.wrn-navbar__actions {
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.wrn-navbar__menu-link,
|
||||
.wrn-navbar__dropdown > summary,
|
||||
.wrn-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;
|
||||
}
|
||||
|
||||
.wrn-navbar__menu-link:hover,
|
||||
.wrn-navbar__dropdown > summary:hover {
|
||||
color: var(--wrn-color-text);
|
||||
background: var(--wrn-color-surface-2);
|
||||
}
|
||||
|
||||
.wrn-navbar__menu-link[aria-current="page"],
|
||||
.wrn-navbar__dropdown > summary[aria-current="page"] {
|
||||
color: var(--wrn-component-color, var(--wrn-color-primary));
|
||||
font-weight: 600;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--wrn-component-color, var(--wrn-color-primary)) 12%,
|
||||
transparent
|
||||
);
|
||||
box-shadow: inset 0 -2px 0 var(--wrn-component-color, var(--wrn-color-primary));
|
||||
}
|
||||
|
||||
.wrn-navbar__menu-link[aria-current="page"]:hover,
|
||||
.wrn-navbar__dropdown > summary[aria-current="page"]:hover {
|
||||
color: var(--wrn-component-color, var(--wrn-color-primary));
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--wrn-component-color, var(--wrn-color-primary)) 16%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.wrn-navbar__dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wrn-navbar__dropdown > summary {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.wrn-navbar__dropdown > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wrn-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(--wrn-motion-fast, 150ms) ease;
|
||||
}
|
||||
|
||||
.wrn-navbar__dropdown[open] .wrn-navbar__chevron {
|
||||
transform: rotate(225deg) translate(-0.1rem, -0.1rem);
|
||||
}
|
||||
|
||||
.wrn-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(--wrn-color-border);
|
||||
border-radius: 0.9rem;
|
||||
color: var(--wrn-color-text);
|
||||
background: var(--wrn-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;
|
||||
}
|
||||
|
||||
.wrn-navbar__panel::before {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
height: 0.65rem;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.wrn-navbar__dropdown:not([open]) > .wrn-navbar__panel {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-0.5rem) scale(0.98);
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
.wrn-navbar__dropdown[open] > .wrn-navbar__panel {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-0.5rem) scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.wrn-navbar__dropdown--mega {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.wrn-navbar__dropdown--mega .wrn-navbar__panel {
|
||||
right: auto;
|
||||
left: 50%;
|
||||
width: min(calc(100vw - 2rem), 64rem);
|
||||
}
|
||||
|
||||
.wrn-navbar__panel--columns-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wrn-navbar__panel--columns-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wrn-navbar__panel--columns-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wrn-navbar__panel-intro {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.65rem;
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.wrn-navbar__group {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.wrn-navbar__group-title {
|
||||
padding: 0.55rem 0.65rem 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wrn-navbar__group > small {
|
||||
padding: 0 0.65rem 0.4rem;
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.wrn-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;
|
||||
}
|
||||
|
||||
.wrn-navbar__panel a:hover {
|
||||
background: var(--wrn-color-surface-2);
|
||||
}
|
||||
|
||||
.wrn-navbar__panel a span:last-child {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.wrn-navbar__panel a strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.wrn-navbar__panel a small {
|
||||
color: var(--wrn-color-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.wrn-navbar__action {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.wrn-navbar__action--primary {
|
||||
color: var(--wrn-color-on-primary, #fff);
|
||||
background: var(--wrn-component-color, var(--wrn-color-primary));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wrn-navbar__action--outline {
|
||||
border-color: var(--wrn-color-border);
|
||||
}
|
||||
|
||||
.wrn-navbar__toggle {
|
||||
display: none;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
margin-left: auto;
|
||||
padding: 0.65rem;
|
||||
border: 1px solid var(--wrn-color-border);
|
||||
border-radius: 0.65rem;
|
||||
color: inherit;
|
||||
background: var(--wrn-color-surface);
|
||||
}
|
||||
|
||||
.wrn-navbar__toggle span {
|
||||
display: block;
|
||||
height: 2px;
|
||||
margin: 0.25rem 0;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.wrn-navbar--width-compact .wrn-navbar__main {
|
||||
width: 100%;
|
||||
}
|
||||
.wrn-navbar__toggle {
|
||||
display: block;
|
||||
}
|
||||
.wrn-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(--wrn-color-surface);
|
||||
border-bottom: 1px solid var(--wrn-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;
|
||||
}
|
||||
.wrn-navbar__collapse.is-open {
|
||||
display: flex;
|
||||
}
|
||||
.wrn-navbar__menus,
|
||||
.wrn-navbar__actions {
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.wrn-navbar__menu-link,
|
||||
.wrn-navbar__dropdown > summary,
|
||||
.wrn-navbar__action {
|
||||
width: 100%;
|
||||
}
|
||||
.wrn-navbar__panel,
|
||||
.wrn-navbar__dropdown--mega .wrn-navbar__panel {
|
||||
position: static;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin-top: 0.25rem;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
overflow: hidden;
|
||||
animation: wrn-navbar-mobile-panel 180ms ease both;
|
||||
}
|
||||
.wrn-navbar__dropdown:not([open]) > .wrn-navbar__panel,
|
||||
.wrn-navbar__dropdown[open] > .wrn-navbar__panel {
|
||||
transform: none;
|
||||
}
|
||||
.wrn-navbar__panel[class*="wrn-navbar__panel--columns-"] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.wrn-navbar__panel::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.wrn-navbar__brand-copy small {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wrn-navbar-mobile-panel {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.35rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Shared component foundation. */
|
||||
|
||||
.wrn-component {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
--wrn-component-scale: 1;
|
||||
margin: 0;
|
||||
color: var(--wrn-color-text);
|
||||
font-family: var(--wrn-font-sans, inherit);
|
||||
}
|
||||
|
||||
.wrn-component--color-primary {
|
||||
--wrn-component-color: var(--wrn-color-primary);
|
||||
}
|
||||
|
||||
.wrn-component--color-secondary {
|
||||
--wrn-component-color: var(--wrn-color-secondary);
|
||||
}
|
||||
|
||||
.wrn-component--color-success {
|
||||
--wrn-component-color: var(--wrn-color-success);
|
||||
}
|
||||
|
||||
.wrn-component--color-warning {
|
||||
--wrn-component-color: var(--wrn-color-warning);
|
||||
}
|
||||
|
||||
.wrn-component--color-danger {
|
||||
--wrn-component-color: var(--wrn-color-danger);
|
||||
}
|
||||
|
||||
.wrn-component--color-info {
|
||||
--wrn-component-color: var(--wrn-color-info);
|
||||
}
|
||||
|
||||
.wrn-component--size-xs {
|
||||
--wrn-component-scale: 0.78;
|
||||
}
|
||||
|
||||
.wrn-component--size-sm {
|
||||
--wrn-component-scale: 0.88;
|
||||
}
|
||||
|
||||
.wrn-component--size-default,
|
||||
.wrn-component--size-md {
|
||||
--wrn-component-scale: 1;
|
||||
}
|
||||
|
||||
.wrn-component--size-lg {
|
||||
--wrn-component-scale: 1.14;
|
||||
}
|
||||
|
||||
.wrn-component--size-xl {
|
||||
--wrn-component-scale: 1.28;
|
||||
}
|
||||
|
||||
.wrn-component:not(.wrn-btn) {
|
||||
font-size: calc(1em * var(--wrn-component-scale));
|
||||
}
|
||||
|
||||
.wrn-component :is(a, button, input, select, textarea):focus-visible {
|
||||
outline-color: var(--wrn-component-color);
|
||||
}
|
||||
|
||||
.wrn-component p {
|
||||
margin: 0;
|
||||
color: var(--wrn-color-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user