Compare commits
256
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dd7681bf7 | ||
|
|
843db2815f | ||
|
|
17aa3b98eb | ||
|
|
442a3106ed | ||
|
|
3115277e9d | ||
|
|
02fdfa3aee | ||
|
|
5f66c8129c | ||
|
|
01a3b3b4e9 | ||
|
|
a184f1a3be | ||
|
|
06df9d66ae | ||
|
|
d269772a79 | ||
|
|
df2ee036eb | ||
|
|
b405025f37 | ||
|
|
cd07f0f8e2 | ||
|
|
ce67d0d1d2 | ||
|
|
468f63f378 | ||
|
|
a6570d7f68 | ||
|
|
4ef2ef14ff | ||
|
|
d78707be9f | ||
|
|
1d16ef1e82 | ||
|
|
12a1014db2 | ||
|
|
52b3e6d378 | ||
|
|
f88dd47408 | ||
|
|
f0447fddb0 | ||
|
|
5106256401 | ||
|
|
94a6b436f5 | ||
|
|
2425a5146f | ||
|
|
fc2c526d70 | ||
|
|
fcd552969f | ||
|
|
5deac48131 | ||
|
|
2e1b667285 | ||
|
|
8393a953b7 | ||
|
|
2459a0b5a7 | ||
|
|
4e5949aedc | ||
|
|
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 |
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "basic-app",
|
||||||
|
"runtimeExecutable": "bun",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"run",
|
||||||
|
"packages/cli/src/index.ts",
|
||||||
|
"dev",
|
||||||
|
"examples/basic-app",
|
||||||
|
"--port=3520"
|
||||||
|
],
|
||||||
|
"port": 3520
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "component-showcase",
|
||||||
|
"runtimeExecutable": "bun",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"run",
|
||||||
|
"packages/cli/src/index.ts",
|
||||||
|
"dev",
|
||||||
|
"examples/component-showcase",
|
||||||
|
"--port=3400"
|
||||||
|
],
|
||||||
|
"port": 3400
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Enforce LF in the working tree regardless of a contributor's core.autocrlf.
|
||||||
|
# Without this, Git on Windows smudges every text file to CRLF on clone, stash
|
||||||
|
# pop, or checkout, which fails `bun run format:check` (prettier endOfLine: lf).
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Binary assets Git must not touch.
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.webp binary
|
||||||
|
*.ico binary
|
||||||
|
*.pdf binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.db binary
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
name: Quality
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest]
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.14
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: editors/vscode/package-lock.json
|
||||||
|
- name: Install framework dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
- name: Install editor dependencies
|
||||||
|
run: npm ci --prefix editors/vscode
|
||||||
|
- name: Typecheck
|
||||||
|
run: bun run typecheck
|
||||||
|
- name: Lint
|
||||||
|
run: bun run lint
|
||||||
|
- name: Formatting
|
||||||
|
run: bun run format:check
|
||||||
|
- name: Package and application tests
|
||||||
|
run: bun run test:all
|
||||||
|
- name: Package contracts
|
||||||
|
run: bun run check:public-api && bun run check:ui-visual && bun run audit:packages && bun run test:package-kits && bun run validate:staging
|
||||||
|
- name: Stage and test publishable packages
|
||||||
|
if: matrix.os == 'ubuntu-latest'
|
||||||
|
run: bun run stage:packages && bun run test:staged-consumers
|
||||||
|
- name: Framework validation
|
||||||
|
run: bun run validate:0.8
|
||||||
|
- name: Dependency audit
|
||||||
|
run: bun audit
|
||||||
|
- name: Editor dependency audit
|
||||||
|
run: npm audit --prefix editors/vscode --audit-level=high
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.wrnexus/
|
.wrnexus/
|
||||||
|
.wrnexus-*/
|
||||||
*.log
|
*.log
|
||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
@@ -19,3 +20,8 @@ bun.lockb
|
|||||||
# Local focused typecheck helpers must never enter the repository.
|
# Local focused typecheck helpers must never enter the repository.
|
||||||
focus-shims.d.ts
|
focus-shims.d.ts
|
||||||
tsconfig.focus.json
|
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
|
||||||
+12
-2
@@ -1,8 +1,10 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
**/dist/
|
**/dist/
|
||||||
|
**/.wrnexus/**
|
||||||
|
**/.wrnexus-*/**
|
||||||
.publish/
|
.publish/
|
||||||
**/.wirefw/
|
**/.wrnfw/
|
||||||
coverage/
|
coverage/
|
||||||
bun.lock
|
bun.lock
|
||||||
bun.lockb
|
bun.lockb
|
||||||
@@ -10,9 +12,14 @@ bun.lockb
|
|||||||
|
|
||||||
# Generated code (queries.gen.ts, routes.gen.ts, etc.)
|
# Generated code (queries.gen.ts, routes.gen.ts, etc.)
|
||||||
**/*.gen.ts
|
**/*.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/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
|
*.svg
|
||||||
**/.vscodeignore
|
**/.vscodeignore
|
||||||
|
|
||||||
@@ -21,3 +28,6 @@ focus-shims.d.ts
|
|||||||
**/focus-shims.d.ts
|
**/focus-shims.d.ts
|
||||||
tsconfig.focus.json
|
tsconfig.focus.json
|
||||||
**/tsconfig.focus.json
|
**/tsconfig.focus.json
|
||||||
|
|
||||||
|
# SDD scratch workspace (git-ignored controller artifacts)
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
# @wrnexus/ai
|
|
||||||
|
|
||||||
> 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.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
`@wrnexus/ai` is a thin, dependency-free wrapper over the Anthropic **Messages API**,
|
|
||||||
built on `fetch` (Bun-native, no SDK). Use it in API routes, jobs, or middleware to
|
|
||||||
call Claude. It defaults to the most capable model, **`claude-opus-4-8`**, reads your
|
|
||||||
key from `ANTHROPIC_API_KEY`, and supports both one-shot generation and streaming.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun add @wrnexus/ai
|
|
||||||
```
|
|
||||||
|
|
||||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
|
||||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
|
||||||
|
|
||||||
Set your key in the environment (e.g. `.env`):
|
|
||||||
|
|
||||||
```
|
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
|
||||||
```
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
### `createAI(config?)`
|
|
||||||
|
|
||||||
Creates a client. The key is read at call time, so it's safe to create at import.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createAI } from "@wrnexus/ai";
|
|
||||||
const ai = createAI(); // or createAI({ apiKey, model, maxTokens, baseURL, version })
|
|
||||||
```
|
|
||||||
|
|
||||||
`AIConfig` fields (all optional):
|
|
||||||
|
|
||||||
| Field | Default | Description |
|
|
||||||
| ----------- | --------------------------- | -------------------------- |
|
|
||||||
| `apiKey` | `ANTHROPIC_API_KEY` | Anthropic API key |
|
|
||||||
| `model` | `"claude-opus-4-8"` | Model id |
|
|
||||||
| `maxTokens` | `4096` | Default max output tokens |
|
|
||||||
| `baseURL` | `https://api.anthropic.com` | API base URL |
|
|
||||||
| `version` | `"2023-06-01"` | `anthropic-version` header |
|
|
||||||
|
|
||||||
### `ai.generate(prompt, opts?): Promise<string>`
|
|
||||||
|
|
||||||
One-shot text generation. `prompt` is a string or a `Message[]` history.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const text = await ai.generate("Write a haiku about Bun.");
|
|
||||||
|
|
||||||
const reply = await ai.generate(
|
|
||||||
[
|
|
||||||
{ role: "user", content: "My name is Ada." },
|
|
||||||
{ role: "assistant", content: "Hi Ada!" },
|
|
||||||
{ role: "user", content: "What's my name?" },
|
|
||||||
],
|
|
||||||
{ system: "You are concise." },
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### `ai.stream(prompt, opts?): AsyncGenerator<string>`
|
|
||||||
|
|
||||||
Yields text deltas as they arrive.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
for await (const chunk of ai.stream("Tell me a story.")) {
|
|
||||||
process.stdout.write(chunk);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `ai.streamResponse(prompt, opts?): Response`
|
|
||||||
|
|
||||||
Returns a streaming `text/plain` `Response` — drop it straight into an API route.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// app/api/chat.ts
|
|
||||||
import { createAI } from "@wrnexus/ai";
|
|
||||||
const ai = createAI();
|
|
||||||
|
|
||||||
export const POST = async (ctx) => {
|
|
||||||
const { prompt } = await ctx.req.json();
|
|
||||||
return ai.streamResponse(prompt);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### `GenerateOptions`
|
|
||||||
|
|
||||||
| Option | Type | Description |
|
|
||||||
| ----------- | ------------------------------------------------- | ---------------------------------------------------- |
|
|
||||||
| `system` | `string` | System prompt |
|
|
||||||
| `model` | `string` | Override the model for this call |
|
|
||||||
| `maxTokens` | `number` | Override max output tokens |
|
|
||||||
| `thinking` | `boolean` | Enable adaptive extended thinking (deeper reasoning) |
|
|
||||||
| `effort` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | Reasoning effort / token spend |
|
|
||||||
| `messages` | `Message[]` | Full history — supersedes `prompt` |
|
|
||||||
| `signal` | `AbortSignal` | Cancel the request |
|
|
||||||
|
|
||||||
> `temperature` / `top_p` are intentionally **not** exposed — the current Claude
|
|
||||||
> models reject them (400). Steer output with prompting instead.
|
|
||||||
|
|
||||||
### `AIError`
|
|
||||||
|
|
||||||
Thrown on non-2xx responses or a model refusal. Carries `.status` and `.type`
|
|
||||||
(e.g. `"authentication_error"`, `"rate_limit_error"`, `"refusal"`).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { AIError } from "@wrnexus/ai";
|
|
||||||
try {
|
|
||||||
await ai.generate("...");
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof AIError && e.type === "rate_limit_error") {
|
|
||||||
/* back off */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Return generated JSON from an API route
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// app/api/summarize.ts — summarize posted text
|
|
||||||
import { createAI } from "@wrnexus/ai";
|
|
||||||
const ai = createAI();
|
|
||||||
|
|
||||||
export const POST = async (ctx) => {
|
|
||||||
const { text } = await ctx.req.json().catch(() => ({}));
|
|
||||||
if (!text) return Response.json({ error: "Provide 'text'." }, { status: 400 });
|
|
||||||
const summary = await ai.generate(`Summarize in one sentence:\n\n${text}`, {
|
|
||||||
system: "You are a precise summarizer.",
|
|
||||||
});
|
|
||||||
return Response.json({ summary });
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Stream a chat response to the browser
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// app/api/chat.ts
|
|
||||||
import { createAI } from "@wrnexus/ai";
|
|
||||||
|
|
||||||
const ai = createAI({ model: "claude-sonnet-5" });
|
|
||||||
|
|
||||||
export const POST = async (ctx) => {
|
|
||||||
const { messages } = await ctx.req.json();
|
|
||||||
return ai.streamResponse(messages, {
|
|
||||||
system: "Answer using concise Markdown.",
|
|
||||||
maxTokens: 1_500,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Requirements / Notes
|
|
||||||
|
|
||||||
- **Bun-only.** Uses `fetch`, `ReadableStream`, `TextDecoder`/`TextEncoder`, and
|
|
||||||
reads `ANTHROPIC_API_KEY` from `Bun.env` (falls back to `process.env`).
|
|
||||||
- **Zero dependencies** — no `@anthropic-ai/sdk`; talks to the Messages API directly.
|
|
||||||
- Defaults to `claude-opus-4-8`. Pass `{ model }` for a different model (e.g.
|
|
||||||
`"claude-sonnet-5"` for speed/cost, `"claude-haiku-4-5"` for the fastest).
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@wrnexus/ai",
|
|
||||||
"version": "0.7.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
|
|
||||||
"license": "MIT",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
# @wrnexus/authz
|
|
||||||
|
|
||||||
> Composable authorization for WrNexus — role-based (RBAC), policy-based (PBAC), and attribute-based (ABAC) access control that reduces to a boolean check plus an `authorize()` guard.
|
|
||||||
|
|
||||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
`@wrnexus/authz` is a small, server-side authorization toolkit. It gives you three
|
|
||||||
interchangeable models — RBAC (roles → permissions), PBAC (policy predicates), and
|
|
||||||
ABAC (attribute matchers) — that all collapse to a `boolean | Promise<boolean>` decision.
|
|
||||||
Wrap any decision in a `Middleware` guard (`authorize`, `requireRole`, `requirePermission`)
|
|
||||||
to protect WrNexus routes. Reach for it whenever a route or action needs to be gated on who
|
|
||||||
the user is, what roles they hold, or attributes of the user and the resource. It plugs into
|
|
||||||
`@wrnexus/core` by reading `ctx.user` as the authorization subject.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun add @wrnexus/authz
|
|
||||||
```
|
|
||||||
|
|
||||||
> 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 (`@wrnexus/authz`) exporting the following.
|
|
||||||
|
|
||||||
### Types
|
|
||||||
|
|
||||||
| Symbol | Description |
|
|
||||||
| ---------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
||||||
| `Subject` | The authorized principal: `{ id?: string; roles?: string[]; [attribute: string]: unknown }`. |
|
|
||||||
| `Rbac` | An RBAC checker: `{ can(subject, permission): boolean; permissionsFor(roles): Set<string> }`. |
|
|
||||||
| `Policy<S = Subject, R = unknown>` | A predicate `(subject: S, resource?: R) => boolean \| Promise<boolean>`. |
|
|
||||||
|
|
||||||
### RBAC
|
|
||||||
|
|
||||||
#### `defineRbac(roles: Record<string, string[]>): Rbac`
|
|
||||||
|
|
||||||
Builds an RBAC checker from a role → permissions map. Supported permission forms:
|
|
||||||
|
|
||||||
- `"*"` — grants every permission.
|
|
||||||
- `"ns:*"` — namespace wildcard (e.g. `"post:*"` grants `"post:write"`).
|
|
||||||
- `"role:<name>"` — inherits all permissions of another role (resolved recursively, cycle-safe).
|
|
||||||
|
|
||||||
The returned `Rbac` provides:
|
|
||||||
|
|
||||||
- `can(subject, permission)` — `true` if any of `subject.roles` grants `permission` (honouring `*` and namespace wildcards). Returns `false` when the subject has no roles.
|
|
||||||
- `permissionsFor(roles)` — the resolved `Set<string>` of all permissions granted to a set of roles.
|
|
||||||
|
|
||||||
#### `hasRole(subject: Subject | undefined, ...required: string[]): boolean`
|
|
||||||
|
|
||||||
`true` if the subject holds **all** of the given roles.
|
|
||||||
|
|
||||||
### PBAC / ABAC combinators
|
|
||||||
|
|
||||||
- `any<S, R>(...policies: Policy<S, R>[]): Policy<S, R>` — allow if **any** policy passes (OR); awaits async policies.
|
|
||||||
- `all<S, R>(...policies: Policy<S, R>[]): Policy<S, R>` — allow only if **all** policies pass (AND); awaits async policies.
|
|
||||||
- `attr<S extends Subject>(name: string, match: unknown | ((value: unknown) => boolean)): Policy<S>` — ABAC helper that allows when `subject[name]` equals `match`, or when `match` is a function, when `match(value)` is truthy.
|
|
||||||
|
|
||||||
### Guards (middleware)
|
|
||||||
|
|
||||||
Each guard returns a `@wrnexus/core` `Middleware`. A denied request short-circuits with
|
|
||||||
`Response.json({ ok: false, error: "Forbidden" }, { status: 403 })`.
|
|
||||||
|
|
||||||
- `authorize(policy: (ctx: Context) => boolean | Promise<boolean>): Middleware` — runs `policy` against the request `Context`; calls `next()` when it resolves truthy, otherwise returns 403.
|
|
||||||
- `requireRole(...roles: string[]): Middleware` — allows when `ctx.user` holds **any** of the listed roles.
|
|
||||||
- `requirePermission(rbac: Rbac, permission: string): Middleware` — allows when `rbac.can(ctx.user, permission)` is `true`.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### RBAC
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineRbac, hasRole } from "@wrnexus/authz";
|
|
||||||
|
|
||||||
const rbac = defineRbac({
|
|
||||||
admin: ["*"],
|
|
||||||
editor: ["post:read", "post:write"],
|
|
||||||
viewer: ["post:read"],
|
|
||||||
// role inheritance: lead gets everything an editor has, plus post:publish
|
|
||||||
lead: ["role:editor", "post:publish"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = { id: "u1", roles: ["editor"] };
|
|
||||||
|
|
||||||
rbac.can(user, "post:write"); // true
|
|
||||||
rbac.can(user, "post:delete"); // false
|
|
||||||
rbac.permissionsFor(["lead"]); // Set { "post:read", "post:write", "post:publish" }
|
|
||||||
hasRole(user, "editor"); // true
|
|
||||||
```
|
|
||||||
|
|
||||||
### Guarding routes
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { authorize, requireRole, requirePermission, defineRbac } from "@wrnexus/authz";
|
|
||||||
|
|
||||||
const rbac = defineRbac({ admin: ["*"], editor: ["post:read", "post:write"] });
|
|
||||||
|
|
||||||
// Only admins or editors
|
|
||||||
app.get("/dashboard", requireRole("admin", "editor"), handler);
|
|
||||||
|
|
||||||
// Requires a specific permission
|
|
||||||
app.post("/posts", requirePermission(rbac, "post:write"), handler);
|
|
||||||
|
|
||||||
// Arbitrary policy over the request context
|
|
||||||
app.delete(
|
|
||||||
"/posts/:id",
|
|
||||||
authorize((ctx) => hasRole(ctx.user, "admin")),
|
|
||||||
handler,
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### PBAC / ABAC policies
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { any, all, attr, authorize, type Policy } from "@wrnexus/authz";
|
|
||||||
|
|
||||||
interface User {
|
|
||||||
id: string;
|
|
||||||
department?: string;
|
|
||||||
roles?: string[];
|
|
||||||
}
|
|
||||||
interface Post {
|
|
||||||
authorId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ownership policy (subject + resource)
|
|
||||||
const ownsPost: Policy<User, Post> = (u, post) => u.id === post?.authorId;
|
|
||||||
|
|
||||||
// ABAC: attribute equality, or a predicate
|
|
||||||
const inEngineering = attr<User>("department", "engineering");
|
|
||||||
const isVerified = attr<User>("verified", (v) => v === true);
|
|
||||||
|
|
||||||
// Compose: allow if the user owns the post OR is in engineering AND verified
|
|
||||||
const canEdit = any(ownsPost, all(inEngineering, isVerified));
|
|
||||||
|
|
||||||
app.put(
|
|
||||||
"/posts/:id",
|
|
||||||
authorize((ctx) => canEdit(ctx.user as User, loadPost(ctx))),
|
|
||||||
handler,
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Requirements / Notes
|
|
||||||
|
|
||||||
- **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).
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@wrnexus/authz",
|
|
||||||
"version": "0.7.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "@wrnexus/authz — part of the WrNexus framework.",
|
|
||||||
"license": "MIT",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
# @wrnexus/cli
|
|
||||||
|
|
||||||
> 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.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
`@wrnexus/cli` provides the `wrnexus` executable — the single entry point for developing a WrNexus app. It runs the HMR dev server, produces a self-contained production build, scaffolds apps/pages/components, drives database migrations, regenerates typed routes and queries, runs tests, and manages configuration profiles. It also scaffolds multi-app monorepos and serves them behind a domain-routing gateway. This is a CLI/build-time package (it shells out to the Bun binary for the dev child and tests) and it also exports the workspace config types via a subpath.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun add @wrnexus/cli
|
|
||||||
```
|
|
||||||
|
|
||||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
|
||||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
|
||||||
|
|
||||||
Once installed, invoke it from an app directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bunx wrnexus dev
|
|
||||||
# or add scripts: "dev": "wrnexus dev .", "build": "wrnexus build ."
|
|
||||||
```
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
Every command accepts an optional `[app-dir]` (defaults to `.`). Commands that read config or `.env` also accept `--profile=<name>` (see [Profiles](#profiles)).
|
|
||||||
|
|
||||||
| Command | Purpose |
|
|
||||||
| ------------------------------------- | ---------------------------------------------------------------------- |
|
|
||||||
| `wrnexus dev [app-dir] [--port=3000]` | Start the development server with live reload / HMR. |
|
|
||||||
| `wrnexus build [app-dir]` | Build a self-contained production server bundle + assets into `dist/`. |
|
|
||||||
| `wrnexus create <app-name>` | Scaffold a new single app from an inline template. |
|
|
||||||
| `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 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 help` | Print usage. |
|
|
||||||
|
|
||||||
`wrnexus g` is an alias for `wrnexus generate`.
|
|
||||||
|
|
||||||
### `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`).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wrnexus dev . --port=8080
|
|
||||||
```
|
|
||||||
|
|
||||||
### `wrnexus build`
|
|
||||||
|
|
||||||
Emits into `<app-dir>/dist/`:
|
|
||||||
|
|
||||||
- `server.js` — a single, minified, self-contained Bun server with a **static** manifest of every page / api / realtime / middleware / component / layout module (no runtime filesystem scan or on-the-fly bundling).
|
|
||||||
- `reactive.js`, `theme.css`, `theme.js`, `ui.css`, and (if present) `styles.css` — hashed, minified browser assets.
|
|
||||||
- `public/` — copied verbatim.
|
|
||||||
|
|
||||||
Before bundling, it regenerates typed queries for the default and every named database. Run the output with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun dist/server.js # PORT env var optional
|
|
||||||
# Generated apps also provide: npm start
|
|
||||||
# Build and start together: npm run production
|
|
||||||
```
|
|
||||||
|
|
||||||
### `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.
|
|
||||||
|
|
||||||
### `wrnexus update`
|
|
||||||
|
|
||||||
`wrnexus update --latest` performs a complete project upgrade. It hands control to the exact target CLI, backs up important project files under `.wrnexus/update-backups/`, updates every `@wrnexus/*` dependency, refreshes framework-owned references, and applies every versioned syntax/config/file migration between the project version and target version. After installation it runs the project's `check` and `build` scripts; the new version is recorded only after verification succeeds.
|
|
||||||
|
|
||||||
Use `--dry-run` to preview an upgrade or `--no-verify` when verification is intentionally handled elsewhere. Migrations never overwrite user-owned configuration wholesale: each release must provide a focused, idempotent transformation for any changed syntax or config contract.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wrnexus create my-app
|
|
||||||
```
|
|
||||||
|
|
||||||
### `wrnexus generate`
|
|
||||||
|
|
||||||
Scaffolds a single file from a template, refusing to overwrite an existing file. Types (with aliases): `page`/`p`, `component`/`c`, `api`/`a`, `schema`/`s`. Nested names create nested paths.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wrnexus generate page about # app/pages/about.wrn
|
|
||||||
wrnexus generate component user-card # app/components/user-card.wrn
|
|
||||||
wrnexus generate api users/list # app/api/users/list.ts
|
|
||||||
wrnexus generate schema signup # app/schemas/signup.ts
|
|
||||||
wrnexus generate routes # regenerate app/routes.gen.ts
|
|
||||||
wrnexus generate docker # Dockerfile + compose + .dockerignore
|
|
||||||
wrnexus generate mobile --mode=webview --app-id=com.example.app --app-name="Example" --url=https://app.example.com
|
|
||||||
wrnexus generate mobile --mode=native
|
|
||||||
```
|
|
||||||
|
|
||||||
The mobile generator creates a separate `mobile/` package and reads
|
|
||||||
`config.mobile.mode`. `webview` creates a Capacitor shell that renders the hosted
|
|
||||||
WrNexus application. `native` creates a WebView-free Expo/React Native app whose
|
|
||||||
screens call the shared backend through `mobile/src/wrnexus.ts`. Native screens
|
|
||||||
do not render `.wrn` HTML. In either mode, run `bun install` in `mobile/`; iOS
|
|
||||||
device builds require macOS and Xcode.
|
|
||||||
|
|
||||||
Install official or community Capacitor plugins through the root CLI:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wrnexus mobile add @capacitor/camera @capacitor/haptics
|
|
||||||
wrnexus mobile sync
|
|
||||||
wrnexus mobile assets # generate native icons from config.mobile.icon
|
|
||||||
```
|
|
||||||
|
|
||||||
In native mode, `mobile add` runs `expo install` and `mobile sync` runs Expo
|
|
||||||
prebuild. In WebView mode they retain the Capacitor install/sync behavior.
|
|
||||||
`wrnexus mobile compile` maps portable `app/pages/**/*.wrn` pages to Expo Router
|
|
||||||
TSX routes. Native `bun run start` invokes this compilation automatically.
|
|
||||||
|
|
||||||
Browser code can access installed plugins through the SSR-safe
|
|
||||||
`@wrnexus/mobile` bridge. The command adds each plugin to both the WrNexus app
|
|
||||||
(JavaScript proxy) and `mobile/` (native synchronization).
|
|
||||||
|
|
||||||
`wrnexus mobile sync` also configures Android so only true network failures use
|
|
||||||
the local connection-error screen. HTTP errors such as 404 and 500 keep their
|
|
||||||
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.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wrnexus eject button card modal
|
|
||||||
```
|
|
||||||
|
|
||||||
### `wrnexus db`
|
|
||||||
|
|
||||||
Database migrations and tooling. Without a flag, commands target the **default** database (`db` in `wrnexus.config.ts`, files under `app/db/`). Pass `--db=<name>` to target a named database (`databases.<name>`, files under `app/db/<name>/`).
|
|
||||||
|
|
||||||
| Subcommand | Purpose |
|
|
||||||
| ------------------------------- | ----------------------------------------------------------------------------------- |
|
|
||||||
| `db new <name> [--from-models]` | Scaffold a migration; `--from-models` derives it from the TS models in `schema.ts`. |
|
|
||||||
| `db migrate` | Apply all pending migrations. |
|
|
||||||
| `db rollback` | Revert the last applied migration. |
|
|
||||||
| `db status` | List applied / pending migrations. |
|
|
||||||
| `db generate` | Regenerate typed queries (`queries/*.sql` → `queries.gen.ts`). |
|
|
||||||
| `db seed` | Run the database's `seed.ts` (default export / `seed` function). |
|
|
||||||
| `db studio [table]` | Inspect tables — list row counts, or dump the first 50 rows of one table. |
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wrnexus db new create_users --from-models
|
|
||||||
wrnexus db migrate
|
|
||||||
wrnexus db studio users
|
|
||||||
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).
|
|
||||||
|
|
||||||
```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
|
|
||||||
wrnexus workspace add reports --domain=reports.localhost
|
|
||||||
bun install
|
|
||||||
```
|
|
||||||
|
|
||||||
Development gateways bind to `127.0.0.1` by default for reliable access on Windows,
|
|
||||||
macOS, and Linux. Open the configured app domain on the gateway port (for example
|
|
||||||
`http://localhost:3000` or `http://admin.localhost:3000`), not the internal child ports
|
|
||||||
printed while apps start. Pass `--host=0.0.0.0` to accept connections from other devices.
|
|
||||||
|
|
||||||
### `wrnexus test`
|
|
||||||
|
|
||||||
Runs the app's tests with `bun test`. Defaults to the `test` profile (config + `.env.test`). Pass `--watch` to re-run on change; extra flags pass straight through to `bun test`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wrnexus test . --watch
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Create and run a single application
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bunx @wrnexus/cli create customer-portal
|
|
||||||
cd customer-portal
|
|
||||||
bun install
|
|
||||||
bun run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Add routes and shared UI to an existing app
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wrnexus generate page reports/monthly
|
|
||||||
wrnexus generate api reports/export
|
|
||||||
wrnexus generate component report-filter
|
|
||||||
wrnexus generate routes
|
|
||||||
```
|
|
||||||
|
|
||||||
### Create a multi-app workspace and add another app
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wrnexus workspace company-suite
|
|
||||||
cd company-suite
|
|
||||||
wrnexus workspace add reports --domain=reports.localhost
|
|
||||||
bun install
|
|
||||||
wrnexus gateway --port=3000
|
|
||||||
```
|
|
||||||
|
|
||||||
Open `http://reports.localhost:3000`; the gateway selects `apps/reports` from the
|
|
||||||
request host.
|
|
||||||
|
|
||||||
### Upgrade with migrations and verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wrnexus update --latest --dry-run
|
|
||||||
wrnexus update --latest
|
|
||||||
wrnexus doctor
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wrnexus dev --profile=uat
|
|
||||||
wrnexus profiles # ● development (config, .env.development)
|
|
||||||
# ○ production
|
|
||||||
# ○ uat (config, .env.uat)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Subpath exports
|
|
||||||
|
|
||||||
`@wrnexus/cli/workspace` exposes the workspace configuration types used by `wrnexus.workspace.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { WorkspaceConfig, WorkspaceApp } from "@wrnexus/cli/workspace";
|
|
||||||
|
|
||||||
const config: WorkspaceConfig = {
|
|
||||||
security: { trustedHostsOnly: true, headers: true, accessLog: true },
|
|
||||||
apps: [{ name: "web", dir: "apps/web", domains: ["localhost", "web.localhost"] }],
|
|
||||||
};
|
|
||||||
|
|
||||||
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`.
|
|
||||||
- Reads `wrnexus.config.ts` for `db` / `databases`, `theme`, `styles`, `seo`, `security`, `i18n`, and `profiles`, and `wrnexus.workspace.ts` for the gateway.
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@wrnexus/cli",
|
|
||||||
"version": "0.7.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "@wrnexus/cli — part of the WrNexus framework.",
|
|
||||||
"license": "MIT",
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"./workspace": {
|
|
||||||
"types": "./dist/workspace.d.ts",
|
|
||||||
"import": "./dist/workspace.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"wrnexus": "./dist/index.js"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@wrnexus/core": "^0.7.0",
|
|
||||||
"@wrnexus/router": "^0.7.0",
|
|
||||||
"@wrnexus/csr": "^0.7.0",
|
|
||||||
"@wrnexus/compiler": "^0.7.0",
|
|
||||||
"@wrnexus/styles": "^0.7.0",
|
|
||||||
"@wrnexus/dev-server": "^0.7.0",
|
|
||||||
"@wrnexus/ui": "^0.7.0",
|
|
||||||
"@wrnexus/validation": "^0.7.0",
|
|
||||||
"@wrnexus/i18n": "^0.7.0",
|
|
||||||
"@wrnexus/db": "^0.7.0",
|
|
||||||
"@wrnexus/plugin": "^0.7.0",
|
|
||||||
"@wrnexus/syntax": "^0.7.0"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
# @wrnexus/compiler
|
|
||||||
|
|
||||||
> 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.
|
|
||||||
|
|
||||||
## 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
|
|
||||||
bun add @wrnexus/compiler
|
|
||||||
```
|
|
||||||
|
|
||||||
> 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 exports come from the package root (`@wrnexus/compiler`).
|
|
||||||
|
|
||||||
### `compileWireFile(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.
|
|
||||||
|
|
||||||
### `compile(source: string): CompileResult`
|
|
||||||
|
|
||||||
Richer entry point that returns the generated code, the AST, and any diagnostics.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface CompileResult {
|
|
||||||
code: string;
|
|
||||||
ast: PageAst;
|
|
||||||
diagnostics: string[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
On a `ParseError` it pushes the message into `diagnostics` and re-throws.
|
|
||||||
|
|
||||||
### `parse(source: string): PageAst`
|
|
||||||
|
|
||||||
Run the lexer + recursive-descent parser and return the AST. Throws `ParseError` (lexer `LexError`s are caught and rethrown as `ParseError`).
|
|
||||||
|
|
||||||
### `generate(ast: PageAst): string`
|
|
||||||
|
|
||||||
Lower a `PageAst` to TypeScript. `page` ASTs become a default-export page component (plus `meta`, optional `layout`, `__wrnexusApi`/method handlers, `websocket`, and SSR/CSR data bindings); `component` ASTs become a module exporting `render(props)` and `__wrnexusComponent`.
|
|
||||||
|
|
||||||
### `Lexer`
|
|
||||||
|
|
||||||
On-demand lexer for `.wrn`. Yields structural tokens and exposes raw-span readers for the parser.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
class Lexer {
|
|
||||||
pos: number;
|
|
||||||
constructor(src: string);
|
|
||||||
next(): Token; // consume next structural token
|
|
||||||
peek(): Token; // look ahead without consuming
|
|
||||||
readPath(): string; // route path, e.g. /users/[id]
|
|
||||||
readToLineEnd(): string; // rest of line (state/prop initializers)
|
|
||||||
readBalancedBraces(): string; // inner text of a { ... } block, string-aware
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`Token` is `{ type: TokenType; value: string; pos: number }`, where `TokenType` is one of `ident`, `string`, `lbrace`, `rbrace`, `lparen`, `rparen`, `at`, `eq`, `comma`, `eof`.
|
|
||||||
|
|
||||||
### 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. |
|
|
||||||
|
|
||||||
### AST types
|
|
||||||
|
|
||||||
Exported type-only symbols describing the parsed tree:
|
|
||||||
|
|
||||||
| 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";
|
|
||||||
|
|
||||||
const ts = compileWireFile(`
|
|
||||||
page Home {
|
|
||||||
state count = 0
|
|
||||||
seo { title = "Home" description = "Welcome" }
|
|
||||||
view {
|
|
||||||
<button @click="count++">Clicked {count} times</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
// ts is a TypeScript module: exports `meta`, and a default page component
|
|
||||||
// returning an HTML string, wrapped in a data-scope for the reactive runtime.
|
|
||||||
```
|
|
||||||
|
|
||||||
Inspect the AST and diagnostics:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { compile, ParseError } from "@wrnexus/compiler";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { code, ast, diagnostics } = compile(source);
|
|
||||||
console.log(ast.kind, ast.name, ast.states.length);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ParseError) console.error(err.message);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Drive the parse/codegen stages directly:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { parse, generate } from "@wrnexus/compiler";
|
|
||||||
|
|
||||||
const ast = parse(componentSource); // ast.kind === "component"
|
|
||||||
const module = generate(ast); // exports render(props) + __wrnexusComponent
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the lexer standalone:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { Lexer } from "@wrnexus/compiler";
|
|
||||||
|
|
||||||
const lx = new Lexer("page Home {");
|
|
||||||
lx.next(); // { type: "ident", value: "page", pos: 0 }
|
|
||||||
lx.next(); // { type: "ident", value: "Home", pos: 5 }
|
|
||||||
lx.next(); // { type: "lbrace", value: "{", pos: 10 }
|
|
||||||
```
|
|
||||||
|
|
||||||
## The `.wrn` language (as parsed)
|
|
||||||
|
|
||||||
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).
|
|
||||||
- `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.
|
|
||||||
- `seo { key = "value" ... }` — metadata merged into the generated `meta`.
|
|
||||||
- `style { <raw css> }` — inlined page/component stylesheet (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.
|
|
||||||
|
|
||||||
`view` markup is parsed by a lenient dedicated HTML parser (`parseHtmlView`); HTML void elements (`<br>`, `<img>`, …) take no closing tag. Line comments (`//`) are skipped by the lexer.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@wrnexus/compiler",
|
|
||||||
"version": "0.7.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "@wrnexus/compiler — part of the WrNexus framework.",
|
|
||||||
"license": "MIT",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@wrnexus/syntax": "^0.7.0",
|
|
||||||
"@wrnexus/store": "^0.7.0"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
# @wrnexus/core
|
|
||||||
|
|
||||||
> The framework core: the request `Context`, middleware contract, and the security, session, caching, streaming, realtime, and JSX primitives every other WrNexus package builds on.
|
|
||||||
|
|
||||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
`@wrnexus/core` is the shared foundation of WrNexus. It defines the `Context`
|
|
||||||
object that flows through every middleware, page, and API route, plus the
|
|
||||||
`Middleware`/`Next` contract they implement. On top of that it ships the
|
|
||||||
building blocks a real app needs: cookie-backed sessions, password auth, CSRF
|
|
||||||
protection, rate limiting, request logging, HTTP + in-memory caching, file
|
|
||||||
uploads, streaming/SSE responses, WebSocket "rooms", security headers/CORS, and
|
|
||||||
a server-side JSX runtime that renders to HTML strings. Everything here is
|
|
||||||
**server-side** and Bun-native (it uses `Bun.password`, `Bun.write`, the
|
|
||||||
web-standard `Request`/`Response`, and `crypto`). You depend on it directly and
|
|
||||||
transitively through the rest of the framework.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun add @wrnexus/core
|
|
||||||
```
|
|
||||||
|
|
||||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
|
||||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
### Context & middleware — `@wrnexus/core`
|
|
||||||
|
|
||||||
The `Context` (`ctx`) is the single value passed to middleware and handlers.
|
|
||||||
|
|
||||||
| Export | Kind | Description |
|
|
||||||
| ------------------------------ | ---- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| `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). |
|
|
||||||
| `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`, … |
|
|
||||||
| `TFunction` | type | `(key, params?) => string` — translate a key for `ctx.lang`, interpolating `{param}` placeholders. |
|
|
||||||
|
|
||||||
Key `Context` fields:
|
|
||||||
|
|
||||||
- `ctx.locals` — per-request scratch space for passing values between middleware.
|
|
||||||
- `ctx.user` — the authenticated user (populated by `sessionAuth`/`logIn`), or `null`.
|
|
||||||
- `ctx.ip` — the direct socket peer IP (not spoofable via headers).
|
|
||||||
- `ctx.cookies` / `ctx.session` / `ctx.localStorage` — see **Storage** below.
|
|
||||||
|
|
||||||
### Authentication — `@wrnexus/core`
|
|
||||||
|
|
||||||
Passwords are hashed with argon2id via `Bun.password`; sessions ride the
|
|
||||||
cookie-backed `SessionStore`.
|
|
||||||
|
|
||||||
| Export | Signature | Notes |
|
|
||||||
| -------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `hashPassword(password)` | `(string) => Promise<string>` | argon2id hash to store. |
|
|
||||||
| `verifyPassword(password, hash)` | `(string, string) => Promise<boolean>` | Constant-safe; returns `false` on bad/empty hash. |
|
|
||||||
| `logIn(ctx, user)` | `(Context, U) => void` | Regenerates the session id (fixation defense), stores the user, sets `ctx.user`. |
|
|
||||||
| `logOut(ctx)` | `(Context) => void` | Clears the session and `ctx.user`. |
|
|
||||||
| `getUser(ctx)` | `(Context) => U \| null` | Current user from `ctx.user`, falling back to the session. |
|
|
||||||
| `sessionAuth()` | `() => Middleware` | Hydrates `ctx.user` from the session each request. Register early. |
|
|
||||||
| `requireAuth(options?)` | `(RequireAuthOptions?) => Middleware` | Guard: API/fetch requests get `401 JSON`, page navigations get `302` to `loginPath` (default `/login`) with `?next=`. |
|
|
||||||
| `SESSION_USER_KEY` | `"user"` | Session key holding the user. |
|
|
||||||
|
|
||||||
`RequireAuthOptions`: `{ loginPath?: string }`.
|
|
||||||
|
|
||||||
### CSRF — `@wrnexus/core`
|
|
||||||
|
|
||||||
Double-submit cookie pattern: a readable `wire-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. |
|
|
||||||
|
|
||||||
### Rate limiting — `@wrnexus/core`
|
|
||||||
|
|
||||||
Fixed-window limiter that returns `429` with `Retry-After` and emits
|
|
||||||
`RateLimit-Limit`/`-Remaining`/`-Reset` headers.
|
|
||||||
|
|
||||||
| Export | Signature | Notes |
|
|
||||||
| --------------------- | ----------------------------------- | ---------------------------------------------------------------------- |
|
|
||||||
| `rateLimit(options?)` | `(RateLimitOptions?) => Middleware` | Main middleware. |
|
|
||||||
| `peerKey(ctx)` | `(Context) => string` | Non-spoofable key from `ctx.ip` (default). |
|
|
||||||
| `proxyKey(ctx)` | `(Context) => string` | Trusts `x-forwarded-for`/`x-real-ip`. Use only behind a trusted proxy. |
|
|
||||||
| `defaultKey` | — | **Deprecated** alias of `proxyKey`. |
|
|
||||||
|
|
||||||
`RateLimitOptions`: `windowMs` (default `60_000`), `max` (default `60`),
|
|
||||||
`key`, `trustProxy` (default `false` → keys on `peerKey`; `true` → `proxyKey`),
|
|
||||||
`message`, `headers` (default `true`), `store`.
|
|
||||||
|
|
||||||
`RateLimitStore` is pluggable — implement `hit(key, windowMs, now) => Bucket | Promise<Bucket>`
|
|
||||||
(a `Bucket` is `{ count, resetAt }`) to back limits with Redis/SQL across
|
|
||||||
instances. The default store is process-local memory.
|
|
||||||
|
|
||||||
### Request logging — `@wrnexus/core`
|
|
||||||
|
|
||||||
| Export | Signature | Notes |
|
|
||||||
| ------------------------- | --------------------------------------- | -------------------------------------------------------------------------------- |
|
|
||||||
| `requestLogger(options?)` | `(RequestLoggerOptions?) => Middleware` | One record per request with a request id (stored on `ctx.locals[requestIdKey]`). |
|
|
||||||
|
|
||||||
`RequestLoggerOptions`: `format` (`"pretty"` default \| `"json"`), `sink(line, record)`
|
|
||||||
(default `console.log`), `requestIdKey` (default `"requestId"`), `now`.
|
|
||||||
`RequestRecord` = `{ time, id, method, path, status, durationMs }`.
|
|
||||||
|
|
||||||
### Caching — `@wrnexus/core`
|
|
||||||
|
|
||||||
| Export | Kind | Notes |
|
|
||||||
| -------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| `TTLCache<V>` | class | In-memory TTL cache: `get`, `set`, `getOrLoad(key, loader, ttlMs?)`, `delete`, `clear`, `size`. Constructor takes a default `ttlMs` (60s). |
|
|
||||||
| `cacheControl(options)` | fn | Build a `Cache-Control` value from `CacheControlOptions`. |
|
|
||||||
| `withCacheControl(res, options)` | fn | Apply `Cache-Control` to a response. |
|
|
||||||
| `etag(body, weak?)` | fn | Stable quoted FNV-1a ETag (weak by default). |
|
|
||||||
| `notModified(req, tag)` | fn | `true` when `If-None-Match` matches — send a `304`. |
|
|
||||||
|
|
||||||
`CacheControlOptions`: `maxAge`, `sMaxAge`, `private`, `noStore`, `noCache`,
|
|
||||||
`staleWhileRevalidate`, `immutable`.
|
|
||||||
|
|
||||||
### File uploads — `@wrnexus/core`
|
|
||||||
|
|
||||||
Bun parses `multipart/form-data` via `Request.formData()`; these helpers
|
|
||||||
validate and persist the resulting `File`s.
|
|
||||||
|
|
||||||
| Export | Signature | Notes |
|
|
||||||
| --------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
|
||||||
| `collectUploads(form)` | `(FormData) => { field, file }[]` | Every non-empty `File` in a parsed form. |
|
|
||||||
| `saveUpload(file, options)` | `(File, SaveUploadOptions) => Promise<SavedUpload>` | Validates size/type, sanitizes the name, writes via `Bun.write`. Throws `UploadError`. |
|
|
||||||
| `sanitizeFilename(name)` | `(string) => string` | Strips separators, traversal, control/illegal chars; caps at 255. |
|
|
||||||
| `UploadError` | class | Thrown on rejected uploads. |
|
|
||||||
|
|
||||||
`SaveUploadOptions`: `dir` (required), `maxBytes`, `allowedTypes` (MIME types
|
|
||||||
like `"image/png"` and/or extensions like `".png"`), `filename(file)`.
|
|
||||||
`SavedUpload` = `{ path, filename, size, type }`.
|
|
||||||
|
|
||||||
### Streaming & SSE — `@wrnexus/core`
|
|
||||||
|
|
||||||
| Export | Signature | Notes |
|
|
||||||
| ------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
|
||||||
| `streamResponse(source, init?)` | `(Iterable\|AsyncIterable<string\|Uint8Array>, StreamResponseInit?) => Response` | Streaming `Response` from a chunk source (basis for streaming SSR). |
|
|
||||||
| `sse(source)` | `(Iterable\|AsyncIterable<ServerSentEvent>) => Response` | `text/event-stream` response. |
|
|
||||||
|
|
||||||
`StreamResponseInit`: `status`, `headers`, `contentType` (default
|
|
||||||
`"text/html; charset=utf-8"`). `ServerSentEvent`: `{ data, event?, id?, retry? }`.
|
|
||||||
|
|
||||||
### Realtime rooms — `@wrnexus/core`
|
|
||||||
|
|
||||||
WebSocket rooms. A file in `app/realtime/` exports
|
|
||||||
`default defineRoom({ ... })` and is served at `ws://host/realtime/<name>`.
|
|
||||||
|
|
||||||
| Export | Signature | Notes |
|
|
||||||
| --------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------- |
|
|
||||||
| `defineRoom(handlers)` | `(RoomHandlers) => RoomDefinition` | Define a room. Export the result as `default`. |
|
|
||||||
| `isRoomDefinition(value)` | `(unknown) => boolean` | Type guard for a room definition. |
|
|
||||||
| `createRealtimeRegistry()` | `() => RealtimeRegistry` | Server-side connection manager mapping sockets ↔ rooms. |
|
|
||||||
| `bridgeRealtime(registry, bus, topic?)` | `(RealtimeRegistry, RealtimeBus, string?) => () => void` | Bridge broadcasts/`toUser` sends across processes via a pub/sub bus. |
|
|
||||||
|
|
||||||
`RoomHandlers`: `authorize(info) => boolean` (gate before accept — return
|
|
||||||
`false` to reject with 403), `onConnect(client)`, `onMessage(client, message)`
|
|
||||||
(JSON auto-parsed), `onLeave(client)`. A handler receives a `RoomClient` with
|
|
||||||
`id`, `user`, `query`, `data`, `room`, and `send` / `broadcast` /
|
|
||||||
`to(id)` / `toUser(user)` / `close`. The `Room` API adds `state`, `clients()`,
|
|
||||||
`count()`, and `broadcast`. `RealtimeBus` is structurally satisfied by
|
|
||||||
`@wrnexus/pubsub`. Legacy `RealtimeHandler`/`RealtimeSocket` raw handlers are
|
|
||||||
still exported. Connection-targeted sends (`send`, `to(id)`) stay local; room
|
|
||||||
broadcasts and `toUser` cross the bridge.
|
|
||||||
|
|
||||||
### Error pages — `@wrnexus/core`
|
|
||||||
|
|
||||||
| Export | Signature | Notes |
|
|
||||||
| ------------------------------ | -------------------------------- | ----------------------------------------------------- |
|
|
||||||
| `renderError(err, mode)` | `(unknown, Mode) => Response` | Dev page (with stack) or generic prod page by `mode`. |
|
|
||||||
| `renderDevError(err, status?)` | `(unknown, number?) => Response` | Readable HTML error page including the stack trace. |
|
|
||||||
| `renderProdError(status?)` | `(number?) => Response` | Generic page that never leaks file paths. |
|
|
||||||
| `renderNotFound()` | `() => Response` | Simple 404 page. |
|
|
||||||
|
|
||||||
`Mode` = `"development" | "production"`.
|
|
||||||
|
|
||||||
### Security headers & CORS — `@wrnexus/core`
|
|
||||||
|
|
||||||
| Export | Signature | Notes |
|
|
||||||
| -------------------------------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `withSecurityHeaders(req, res, mode, security?, nonce?)` | → `Response` | Applies CORS + CSP, HSTS, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, COOP, Trusted Types, and `extraHeaders`. |
|
|
||||||
| `createCorsPreflightResponse(req, security?)` | → `Response \| null` | Builds a `204`/`403` preflight response for CORS `OPTIONS` requests. |
|
|
||||||
| `isWebSocketOriginAllowed(req, security?)` | → `boolean` | Guards WS upgrades against cross-site hijacking (allows same-origin, configured CORS origins, and non-browser clients). |
|
|
||||||
|
|
||||||
Config types: `SecurityConfig` (top-level), `CorsConfig`/`CorsOrigin`,
|
|
||||||
`ContentSecurityPolicyConfig`/`CspDirectiveValue`, `HstsConfig`,
|
|
||||||
`TrustedTypesConfig`, `PermissionsPolicyConfig`. WrNexus applies sensible
|
|
||||||
defaults (self-only CSP, `frame-ancestors 'none'`, restrictive Permissions-Policy,
|
|
||||||
HSTS in production, Trusted Types in production); each is individually
|
|
||||||
overridable or disable-able via `false`.
|
|
||||||
|
|
||||||
### Storage: cookies, sessions, localStorage — `@wrnexus/core`
|
|
||||||
|
|
||||||
These back the `ctx.cookies`, `ctx.session`, and `ctx.localStorage` fields.
|
|
||||||
|
|
||||||
| Export | Kind | Notes |
|
|
||||||
| --------------------------------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `setSessionBackend(backend)` | fn | Swap the **sync** session persistence backend (`SessionBackend`) — e.g. `bun:sqlite`. Default is process-local memory. Call once at startup. |
|
|
||||||
| `loadSession(backend, options?)` | fn → `Middleware` | Back `ctx.session` with an **async** store (`AsyncSessionBackend`: `load`/`save`/`destroy`) — loads before the request, saves after. `options.ttlMs` default 24h. |
|
|
||||||
| `CookieStore` | type | `get`/`getAll`/`has`/`set(name, value, opts?)`/`delete`/`headers`. |
|
|
||||||
| `SessionStore` | type | `id`/`get`/`getAll`/`set`/`delete`/`regenerate`/`clear`. |
|
|
||||||
| `LocalStorageSnapshot` | type | Read-only view of the browser's localStorage sent via header for CSR bindings. |
|
|
||||||
| `CookieOptions` | type | `path`, `domain`, `maxAge`, `expires`, `httpOnly`, `secure`, `sameSite`. |
|
|
||||||
| `SessionEntry` / `SessionBackend` / `AsyncSessionBackend` | types | Session persistence contracts. |
|
|
||||||
|
|
||||||
### Low-level security helpers — `@wrnexus/core`
|
|
||||||
|
|
||||||
| Export | Signature | Notes |
|
|
||||||
| ----------------------------- | --------------------- | --------------------------------------------------- |
|
|
||||||
| `escapeHtml(value)` | `(string) => string` | Escape for HTML text/attributes. |
|
|
||||||
| `isSafeIslandName(name)` | `(string) => boolean` | Allow only a conservative `[A-Za-z0-9_-]+` charset. |
|
|
||||||
| `isSafeRequestPath(pathname)` | `(string) => boolean` | Reject NULs, `..` traversal, and backslashes. |
|
|
||||||
|
|
||||||
### JSX runtime — `@wrnexus/core`, `@wrnexus/core/jsx-runtime`, `@wrnexus/core/jsx-dev-runtime`
|
|
||||||
|
|
||||||
A server-side JSX runtime that renders to HTML **strings** (no virtual DOM).
|
|
||||||
Point `tsconfig`'s `jsxImportSource` at `@wrnexus/core`.
|
|
||||||
|
|
||||||
| Export | Kind | Notes |
|
|
||||||
| ------------------------------------------ | ------ | --------------------------------------------------------------------------------------- |
|
|
||||||
| `jsx` / `jsxs` | fn | The runtime factory (TypeScript calls these automatically). Returns an `Html` instance. |
|
|
||||||
| `Fragment` | symbol | JSX fragment marker. |
|
|
||||||
| `Html` | class | Wraps a raw, already-safe HTML string (`toString()` returns it). |
|
|
||||||
| `mustache(expr)` | fn | Emit a `{{expr}}` placeholder (tagged-template or string form) for the client binder. |
|
|
||||||
| `JSXComponent` / `JSXProps` / `Renderable` | types | Component signature and renderable value types. |
|
|
||||||
|
|
||||||
Values interpolated as children are HTML-escaped unless they are an `Html`
|
|
||||||
instance; use `dangerouslySetInnerHTML={{ __html }}` for trusted markup. Void
|
|
||||||
elements render without a closing tag; `className`→`class`, `htmlFor`→`for`, and
|
|
||||||
`style` objects are serialized to CSS text.
|
|
||||||
|
|
||||||
The subpath exports map to the runtime TypeScript's JSX transform expects:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
// tsconfig.json
|
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"jsxImportSource": "@wrnexus/core",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### A minimal middleware chain
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import {
|
|
||||||
createContext,
|
|
||||||
withContextHeaders,
|
|
||||||
sessionAuth,
|
|
||||||
requireAuth,
|
|
||||||
requestLogger,
|
|
||||||
rateLimit,
|
|
||||||
csrfProtection,
|
|
||||||
type Middleware,
|
|
||||||
} from "@wrnexus/core";
|
|
||||||
|
|
||||||
const chain: Middleware[] = [
|
|
||||||
requestLogger({ format: "json" }),
|
|
||||||
rateLimit({ max: 100, windowMs: 60_000 }),
|
|
||||||
csrfProtection(),
|
|
||||||
sessionAuth(),
|
|
||||||
requireAuth({ loginPath: "/login" }),
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
### Password auth
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { hashPassword, verifyPassword, logIn, getUser } from "@wrnexus/core";
|
|
||||||
|
|
||||||
// Registration
|
|
||||||
const passwordHash = await hashPassword(form.password);
|
|
||||||
|
|
||||||
// Login
|
|
||||||
if (await verifyPassword(form.password, user.passwordHash)) {
|
|
||||||
logIn(ctx, { id: user.id, email: user.email });
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = getUser<{ id: string }>(ctx); // or null
|
|
||||||
```
|
|
||||||
|
|
||||||
### HTTP caching with ETags
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { etag, notModified, withCacheControl } from "@wrnexus/core";
|
|
||||||
|
|
||||||
const body = JSON.stringify(data);
|
|
||||||
const tag = etag(body);
|
|
||||||
if (notModified(ctx.req, tag)) {
|
|
||||||
return new Response(null, { status: 304, headers: { ETag: tag } });
|
|
||||||
}
|
|
||||||
const res = new Response(body, { headers: { ETag: tag, "content-type": "application/json" } });
|
|
||||||
return withCacheControl(res, { maxAge: 60, staleWhileRevalidate: 300 });
|
|
||||||
```
|
|
||||||
|
|
||||||
### Streaming SSE
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { sse } from "@wrnexus/core";
|
|
||||||
|
|
||||||
async function* ticks() {
|
|
||||||
for (let n = 0; ; n++) {
|
|
||||||
yield { event: "tick", data: String(n) };
|
|
||||||
await Bun.sleep(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export default (ctx) => sse(ticks());
|
|
||||||
```
|
|
||||||
|
|
||||||
### A realtime room
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// app/realtime/chat.ts
|
|
||||||
import { defineRoom } from "@wrnexus/core";
|
|
||||||
|
|
||||||
export default defineRoom({
|
|
||||||
authorize: (info) => !!info.user, // require auth
|
|
||||||
onConnect(client) {
|
|
||||||
client.user = client.query.user;
|
|
||||||
client.room.broadcast({ type: "join", id: client.id });
|
|
||||||
},
|
|
||||||
onMessage(client, msg) {
|
|
||||||
client.broadcast({ type: "say", from: client.id, text: msg.text });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Scale it across processes:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createRealtimeRegistry, bridgeRealtime } from "@wrnexus/core";
|
|
||||||
import { createPubSub } from "@wrnexus/pubsub";
|
|
||||||
import { redisDriver } from "@wrnexus/pubsub/redis";
|
|
||||||
|
|
||||||
const registry = createRealtimeRegistry();
|
|
||||||
bridgeRealtime(registry, createPubSub(redisDriver(process.env.REDIS_URL)));
|
|
||||||
```
|
|
||||||
|
|
||||||
### JSX rendering
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Html } from "@wrnexus/core";
|
|
||||||
|
|
||||||
function Card({ title, body }: { title: string; body: string }) {
|
|
||||||
return (
|
|
||||||
<article class="card">
|
|
||||||
<h2>{title}</h2>
|
|
||||||
<p>{body}</p>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const html: Html = <Card title="Hi" body="<b>escaped</b> automatically" />;
|
|
||||||
return new Response(html.toString(), { headers: { "content-type": "text/html" } });
|
|
||||||
```
|
|
||||||
|
|
||||||
## Requirements / Notes
|
|
||||||
|
|
||||||
- **Bun-only.** Uses `Bun.password` (argon2id), `Bun.write`, web-standard
|
|
||||||
`Request`/`Response`/`FormData`/`ReadableStream`, and the global `crypto`.
|
|
||||||
Node is not supported.
|
|
||||||
- Session and rate-limit backends default to **process-local memory**. For
|
|
||||||
multi-instance deployments, swap in a shared backend: `setSessionBackend` (sync,
|
|
||||||
e.g. `bun:sqlite`) or `loadSession` (async, e.g. Redis) for sessions, a custom
|
|
||||||
`RateLimitStore` for limits, and `bridgeRealtime` for realtime.
|
|
||||||
- Works with the rest of the framework: realtime bridging is structurally
|
|
||||||
compatible with [`@wrnexus/pubsub`](../pubsub); the security, auth, and JSX
|
|
||||||
primitives here are consumed by the WrNexus server/router packages.
|
|
||||||
- Subpath exports: `@wrnexus/core/jsx-runtime` and `@wrnexus/core/jsx-dev-runtime`
|
|
||||||
for TypeScript's automatic JSX transform.
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@wrnexus/core",
|
|
||||||
"version": "0.7.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "@wrnexus/core — part of the WrNexus framework.",
|
|
||||||
"license": "MIT",
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"./jsx-runtime": {
|
|
||||||
"types": "./dist/jsx-runtime.d.ts",
|
|
||||||
"import": "./dist/jsx-runtime.js"
|
|
||||||
},
|
|
||||||
"./jsx-dev-runtime": {
|
|
||||||
"types": "./dist/jsx-dev-runtime.d.ts",
|
|
||||||
"import": "./dist/jsx-dev-runtime.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
# @wrnexus/csr
|
|
||||||
|
|
||||||
> 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.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
`@wrnexus/csr` holds the three client runtimes that WrNexus serves to the browser. Components are authored as `.wrn` files and rendered on the **server**; this package provides the single, generic runtime that **hydrates** that HTML in the browser — there are no per-component browser bundles. Each runtime is exported as a plain-JS string (no build step, no imports) intended to be served verbatim from a well-known URL:
|
|
||||||
|
|
||||||
- **reactive** at `/__wrnexus/reactive.js` — reactive directives (`data-scope`, `data-text`, `data-for`, …)
|
|
||||||
- **nav** at `/__wrnexus/nav.js` — SPA-style client navigation with graceful fallback
|
|
||||||
- **realtime** at `/__wrnexus/realtime.js` — WebSocket "rooms", declarative or programmatic
|
|
||||||
|
|
||||||
The package itself runs on the server (it just returns strings); the strings it returns run in the browser. A dev/prod server (see `@wrnexus/core`) is responsible for actually serving them.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun add @wrnexus/csr
|
|
||||||
```
|
|
||||||
|
|
||||||
> 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 exports come from the package root (`@wrnexus/csr`). The runtime source is delivered as strings, so the "API" on the server side is small; the real surface is the browser directives/globals each string installs.
|
|
||||||
|
|
||||||
### Runtime strings
|
|
||||||
|
|
||||||
| Export | Type | Served at | Contents |
|
|
||||||
| ------------------ | -------- | ------------------------ | ------------------------------ |
|
|
||||||
| `REACTIVE_RUNTIME` | `string` | `/__wrnexus/reactive.js` | Reactive directive runtime |
|
|
||||||
| `NAV_RUNTIME` | `string` | `/__wrnexus/nav.js` | Client-side navigation runtime |
|
|
||||||
| `REALTIME_RUNTIME` | `string` | `/__wrnexus/realtime.js` | Realtime rooms runtime |
|
|
||||||
|
|
||||||
### Accessor functions
|
|
||||||
|
|
||||||
Convenience getters that return the same strings.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
getReactiveRuntime(): string // → REACTIVE_RUNTIME
|
|
||||||
getNavRuntime(): string // → NAV_RUNTIME
|
|
||||||
getRealtimeRuntime(): string // → REALTIME_RUNTIME
|
|
||||||
```
|
|
||||||
|
|
||||||
### Browser: reactive directives
|
|
||||||
|
|
||||||
Applied to any subtree containing `data-scope`. Expressions are parsed by a tiny eval-free evaluator, so a strict CSP with no `unsafe-eval` works.
|
|
||||||
|
|
||||||
| Directive | Purpose |
|
|
||||||
| -------------------------------------------------------- | ----------------------------------------------------------------------- |
|
|
||||||
| `data-scope="count: 0, name: 'x'"` | Declare reactive state on a subtree |
|
|
||||||
| `data-on-<event>="count++"` | Run a statement in scope on a DOM event |
|
|
||||||
| `data-text="expr"` | Bind an element's `textContent` to an expression |
|
|
||||||
| `data-show="expr"` | Toggle visibility (`display`) on truthiness |
|
|
||||||
| `data-for="item in list"` (opt. index and `key item.id`) | Per-item rendering; stable keys preserve DOM identity during reorder |
|
|
||||||
| `data-key="item.id"` | Alternative key declaration for `data-for` templates |
|
|
||||||
| `{{expr}}` or `{expr}` | Interpolation inside text nodes and attribute values |
|
|
||||||
| `data-wrnexus-csr="id"` | Target for a generated CSR fetch binding (fetches `/__wrnexus/csr?...`) |
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Browser globals installed: `window.__wrnexusHydrateScopes(root)` and `window.__wrnexusHydrateCsrFetches(root)` — both idempotent, so re-running after a DOM swap or HMR morph is safe. Both run automatically on `DOMContentLoaded`.
|
|
||||||
|
|
||||||
### Browser: navigation
|
|
||||||
|
|
||||||
Intercepts same-origin `<a>` clicks, fetches the target page, and swaps the `#app` container in place (via `importNode` — not `innerHTML` — so it works under a Trusted-Types CSP), updating history, title, and scroll, then re-hydrates. Cross-origin links, modified clicks, `download`/`data-no-nav`/`rel="external"`/`target` links, non-HTML responses, or a missing `#app` fall back to a full browser navigation.
|
|
||||||
|
|
||||||
- Programmatic navigation: `window.__wrnexusNavigate(url)`
|
|
||||||
- Emits a `wrnexus:navigated` `CustomEvent` (`detail.url`) after each swap
|
|
||||||
- Sends `x-wrnexus-nav: 1` on fetches so the server can return the page fragment
|
|
||||||
- Appends any `/__wrnexus/*` runtime scripts the incoming page needs but the current document lacks
|
|
||||||
|
|
||||||
### Browser: realtime rooms
|
|
||||||
|
|
||||||
Connects to `/realtime/<name>` over WebSocket (`ws`/`wss` chosen from `location.protocol`). Two usage modes.
|
|
||||||
|
|
||||||
Programmatic API via `window.wire`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
wire.room(name): Room // open (or reuse) a room connection
|
|
||||||
wire.bindRooms(root?) // (re)bind declarative [data-room] containers
|
|
||||||
|
|
||||||
interface Room {
|
|
||||||
name: string;
|
|
||||||
send(obj: object | string): Room; // JSON-stringifies objects; queues until open
|
|
||||||
on(type: string, cb): Room; // filter by msg.type; "*" or a fn = all messages
|
|
||||||
on(cb): Room;
|
|
||||||
close(): Room;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Internal lifecycle messages are emitted to listeners as `{ type }`: `__open`, `__close`, `__error`, and `__raw` (non-JSON frames, with `data`). Reconnect uses exponential backoff capped at 5s; queued sends flush on reconnect.
|
|
||||||
|
|
||||||
Declarative binding (zero JS) on a `data-room="<name>"` container:
|
|
||||||
|
|
||||||
| Attribute | On | Purpose |
|
|
||||||
| ------------------------------------ | --------------- | ------------------------------------------------------------------------ |
|
|
||||||
| `data-room="<name>"` | container | Connect to room `<name>` |
|
|
||||||
| `data-room-user="<id>"` | container | Identify the connection (`?user=<id>`) |
|
|
||||||
| `data-room-log` | element | Where incoming messages are appended |
|
|
||||||
| `<template data-room-item="<type>">` | template | Row template for messages of that `type` (empty = fallback) |
|
|
||||||
| `%field%` | inside template | Placeholder filled from the message field (text/attr only, HTML-escaped) |
|
|
||||||
| `data-room-status` | element | Reflects connection state text (`connected`/`disconnected`/`error`) |
|
|
||||||
| `data-room-status-class` | status element | Base class; a state variant (`is-connected`, …) is appended |
|
|
||||||
| `<form data-room-send>` | form | Submits named fields as a JSON message |
|
|
||||||
| `data-room-reset` | form field | Clears that field after send |
|
|
||||||
|
|
||||||
Rebinds on `wrnexus:navigated` and closes rooms whose container has left the page.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
Server side — serve the runtime strings from your router (example with `Bun.serve`):
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { getReactiveRuntime, getNavRuntime, getRealtimeRuntime } from "@wrnexus/csr";
|
|
||||||
|
|
||||||
const routes: Record<string, string> = {
|
|
||||||
"/__wrnexus/reactive.js": getReactiveRuntime(),
|
|
||||||
"/__wrnexus/nav.js": getNavRuntime(),
|
|
||||||
"/__wrnexus/realtime.js": getRealtimeRuntime(),
|
|
||||||
};
|
|
||||||
|
|
||||||
Bun.serve({
|
|
||||||
fetch(req) {
|
|
||||||
const body = routes[new URL(req.url).pathname];
|
|
||||||
if (body) {
|
|
||||||
return new Response(body, {
|
|
||||||
headers: { "content-type": "text/javascript; charset=utf-8" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return new Response("Not found", { status: 404 });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Browser side — server-rendered HTML that the reactive runtime hydrates:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div data-scope="count: 0, showPassword: false">
|
|
||||||
<button data-on-click="count++">+1</button>
|
|
||||||
<span data-text="count"></span>
|
|
||||||
<p>Total: {{count}}</p>
|
|
||||||
<input type="{showPassword ? 'text' : 'password'}" />
|
|
||||||
<button
|
|
||||||
data-on-click="showPassword = !showPassword"
|
|
||||||
aria-label="{showPassword ? 'Hide password' : 'Show password'}"
|
|
||||||
>
|
|
||||||
Toggle password
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<script src="/__wrnexus/reactive.js"></script>
|
|
||||||
```
|
|
||||||
|
|
||||||
State interpolation in ordinary attributes is reactive. The compiler keeps the
|
|
||||||
initial SSR value and emits an internal binding so attributes such as `type`,
|
|
||||||
`aria-label`, `aria-pressed`, `class`, and `href` update after state changes.
|
|
||||||
|
|
||||||
A realtime chat, fully declarative:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div data-room="lobby" data-room-user="ada">
|
|
||||||
<div data-room-status></div>
|
|
||||||
<ul data-room-log></ul>
|
|
||||||
<template data-room-item="chat"><li>%user%: %text%</li></template>
|
|
||||||
<form data-room-send>
|
|
||||||
<input name="text" data-room-reset />
|
|
||||||
<input type="hidden" name="type" value="chat" />
|
|
||||||
<button>Send</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<script src="/__wrnexus/realtime.js"></script>
|
|
||||||
```
|
|
||||||
|
|
||||||
Or drive a room from code:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const room = wire.room("lobby");
|
|
||||||
room.on("chat", (msg) => console.log(msg.user, msg.text));
|
|
||||||
room.send({ type: "chat", user: "ada", text: "hi" });
|
|
||||||
```
|
|
||||||
|
|
||||||
## Requirements / Notes
|
|
||||||
|
|
||||||
- **Bun-only** on the server (the package integrates with Bun-based WrNexus servers); the emitted strings are plain browser JS with no dependencies.
|
|
||||||
- Browser runtimes are **self-contained** (no imports, no build step) and **idempotent**, so re-hydration after navigation or HMR is safe.
|
|
||||||
- Designed for a **strict CSP**: the reactive expression evaluator avoids `eval`/`new Function` (no `unsafe-eval`), and DOM swaps use `importNode`/attribute writes rather than `innerHTML` (Trusted-Types friendly).
|
|
||||||
- Peer packages: rendered `.wrn` components and the serving layer come from `@wrnexus/core` (the sole dependency); pages are rendered by the WrNexus dev/prod server.
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@wrnexus/csr",
|
|
||||||
"version": "0.7.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "@wrnexus/csr — part of the WrNexus framework.",
|
|
||||||
"license": "MIT",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@wrnexus/core": "^0.7.0"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
# @wrnexus/db
|
|
||||||
|
|
||||||
> 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.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
`@wrnexus/db` is the server-side data layer. You describe tables as TypeScript
|
|
||||||
models (the `v` column builder + `table()`); those models drive migrations,
|
|
||||||
coerce raw DB rows into typed objects, and feed the query generator. A thin
|
|
||||||
`Driver` interface is implemented by adapters for SQLite (`bun:sqlite`),
|
|
||||||
Postgres/MySQL (`Bun.SQL`), and MongoDB. The `Db` client adds ergonomics —
|
|
||||||
model-mapped `all`/`one`, transactions, `createTable`, pagination, and batched
|
|
||||||
relation loading. A process-wide registry (`getDb`/`setDb`) exposes configured
|
|
||||||
connections to pages and API routes. Reach for it whenever a WrNexus app needs
|
|
||||||
persistence.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun add @wrnexus/db
|
|
||||||
```
|
|
||||||
|
|
||||||
> 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 core entry (`@wrnexus/db`) is dependency-free; adapters and connectors live
|
|
||||||
in subpaths so importing the core doesn't pull in every driver.
|
|
||||||
|
|
||||||
| Subpath | Exports |
|
|
||||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `@wrnexus/db` | `v`, `table`, `Column`, `createDb`, `createTableSql`, the client registry (`setDb`/`getDb`/…), migrations, the query generator, and query helpers |
|
|
||||||
| `@wrnexus/db/connect` | `connectFromConfig`, `resolveDbUrl`, `DbConfig` — resolve a config to a live SQL `Db` |
|
|
||||||
| `@wrnexus/db/session` | `sqliteSessionStore` — a `bun:sqlite` session backend for `@wrnexus/core` |
|
|
||||||
| `@wrnexus/db/sqlite` | `sqlite(url?)` driver |
|
|
||||||
| `@wrnexus/db/postgres` | `postgres(url)` driver |
|
|
||||||
| `@wrnexus/db/mysql` | `mysql(url)` driver |
|
|
||||||
| `@wrnexus/db/mongo` | `mongo(url, dbName?)` document API |
|
|
||||||
|
|
||||||
### Schema — `v`, `table`, `Column`
|
|
||||||
|
|
||||||
`table(name, columns)` returns a `Model<T>`. Columns are built with `v`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { v, table } from "@wrnexus/db";
|
|
||||||
|
|
||||||
const users = table("users", {
|
|
||||||
id: v.id(), // auto-increment primary key
|
|
||||||
email: v.text().unique(),
|
|
||||||
name: v.text().optional(), // NULLable
|
|
||||||
age: v.int().default(0),
|
|
||||||
active: v.bool().default(true),
|
|
||||||
createdAt: v.timestamp().default("now"), // CURRENT_TIMESTAMP
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Column builders: `v.id`, `v.text` (alias `v.string`), `v.int`, `v.real` (alias
|
|
||||||
`v.number`), `v.bool` (alias `v.boolean`), `v.timestamp`, `v.json`. `BaseType`
|
|
||||||
values are `"id" | "text" | "int" | "real" | "bool" | "timestamp" | "json"`.
|
|
||||||
|
|
||||||
`Column` modifiers (chainable): `.optional()`, `.unique()`, `.default(value)`
|
|
||||||
(use the sentinel `"now"` for a current-timestamp default), `.primaryKey()`,
|
|
||||||
`.references(table, column = "id")`. `.coerce(raw)` converts a raw DB value to
|
|
||||||
its JS type.
|
|
||||||
|
|
||||||
A `Model<T>` exposes: `name`, `columns`, `parse(row)` (coerces a raw row into a
|
|
||||||
typed `T`; unknown columns pass through), and `describe()` (returns each
|
|
||||||
column's `ColumnDef`, for migrations and the generator).
|
|
||||||
|
|
||||||
### Driver & client — `createDb`, `Db`, `Driver`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
createDb(driver: Driver): Db
|
|
||||||
```
|
|
||||||
|
|
||||||
A `Driver` (implemented by adapters) exposes `dialect`, `query(sql, params?)`,
|
|
||||||
`exec(sql, params?)`, `transaction(fn)`, and `close()`. `createDb` wraps it in a
|
|
||||||
`Db`:
|
|
||||||
|
|
||||||
- `all<T>(sql, params?, model?)` — all rows, mapped through `model.parse` when a model is given.
|
|
||||||
- `one<T>(sql, params?, model?)` — first row or `null`.
|
|
||||||
- `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()`.
|
|
||||||
|
|
||||||
Every query is parameterized (positional params). `createTableSql(model, dialect, ifNotExists?)`
|
|
||||||
renders `CREATE TABLE` directly; `Dialect` is `"sqlite" | "postgres" | "mysql"`.
|
|
||||||
|
|
||||||
### Client registry — `getDb` / `setDb`
|
|
||||||
|
|
||||||
A process-wide registry the runtime configures at startup from `wrnexus.config.ts`
|
|
||||||
(the `db` setting is the default; `databases.<name>` entries are named).
|
|
||||||
|
|
||||||
- `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()`.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const users = await getDb().all("SELECT * FROM users");
|
|
||||||
const events = await getDb("analytics").all("SELECT * FROM hits");
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adapters
|
|
||||||
|
|
||||||
- `@wrnexus/db/sqlite` — `sqlite(url = ":memory:")`. `url` may be `file:./dev.db`, a raw path, or `:memory:`. Built on `bun:sqlite`; no external service.
|
|
||||||
- `@wrnexus/db/postgres` — `postgres(url)` (e.g. `postgres://user:pass@host:5432/db`, placeholders `$N`).
|
|
||||||
- `@wrnexus/db/mysql` — `mysql(url)` (e.g. `mysql://user:pass@host:3306/db`, placeholders `?`). Postgres/MySQL both use Bun's native `Bun.SQL` client and its pooled `begin()` for transactions.
|
|
||||||
- `@wrnexus/db/mongo` — `mongo(url, dbName?)`. A document API, not SQL: `db.collection(model)` returns a `MongoRepo<T>` with `find`, `findOne`, `insert`, `insertMany`, `update`, `delete`, `count`. Reads are coerced through `model.parse` (`_id` is mapped to `id`). The `mongodb` driver is imported lazily — install it to use Mongo.
|
|
||||||
|
|
||||||
### Migrations
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
- `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`.
|
|
||||||
- `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.
|
|
||||||
|
|
||||||
### Query generator (sqlc-style)
|
|
||||||
|
|
||||||
Turns annotated SQL into typed TS functions; params and result types are
|
|
||||||
inferred from the models, and rows map back through `model.parse` when the
|
|
||||||
selected columns are model columns.
|
|
||||||
|
|
||||||
- `parseQueries(content)` → `QueryDef[]` from `-- name: X :one|:many|:exec` blocks.
|
|
||||||
- `generateQueriesFile(queries, models, dialect)` → the `queries.gen.ts` source. `models` is a `ModelRef[]` (`{ varName, model }`). Rewrites `:name` placeholders to positional (`$N`/`?`) form.
|
|
||||||
|
|
||||||
`QueryKind` is `"one" | "many" | "exec"`.
|
|
||||||
|
|
||||||
### Query helpers
|
|
||||||
|
|
||||||
- `paginate(db, { sql, params?, countSql?, model? }, opts?)` — offset pagination. Pass the base SELECT **without** a LIMIT; it appends the page window and derives `total` via a COUNT subquery. `PageOptions`: `{ page?, perPage?, maxPerPage? }` (defaults page 1, perPage 20, maxPerPage 100). Returns `Paginated<T>` (`items, page, perPage, total, totalPages, hasNext, hasPrev`).
|
|
||||||
- `loadRelated(db, parents, opts)` — load a relation for many parents in ONE query and attach it (no N+1). `RelationOptions`: `{ table, foreignKey, as, localKey?, single?, model? }` — `single: true` attaches one child (belongsTo), otherwise an array (hasMany). Table/foreign-key names are validated as identifiers.
|
|
||||||
|
|
||||||
### Session store
|
|
||||||
|
|
||||||
`@wrnexus/db/session` exports `sqliteSessionStore(path = "sessions.db")`, a
|
|
||||||
persistent, process-shared `SessionBackend` (from `@wrnexus/core`) backed by
|
|
||||||
`bun:sqlite` (WAL mode). Sessions survive restarts and are shared by every
|
|
||||||
worker on the same file.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
Define models, connect, create tables, and query with typed results:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { v, table, createDb } from "@wrnexus/db";
|
|
||||||
import { sqlite } from "@wrnexus/db/sqlite";
|
|
||||||
|
|
||||||
const users = table<{ id: number; email: string; name: string | null }>("users", {
|
|
||||||
id: v.id(),
|
|
||||||
email: v.text().unique(),
|
|
||||||
name: v.text().optional(),
|
|
||||||
createdAt: v.timestamp().default("now"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const db = createDb(sqlite("file:./dev.db"));
|
|
||||||
await db.createTable(users);
|
|
||||||
|
|
||||||
await db.exec("INSERT INTO users (email) VALUES (?)", ["a@b.com"]);
|
|
||||||
const list = await db.all("SELECT * FROM users", [], users); // rows typed + coerced
|
|
||||||
const one = await db.one("SELECT * FROM users WHERE id = ?", [1], users);
|
|
||||||
|
|
||||||
await db.tx(async (tx) => {
|
|
||||||
await tx.exec("UPDATE users SET name = ? WHERE id = ?", ["Ada", 1]);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Resolve a config to a live SQL `Db`, and register it:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { connectFromConfig } from "@wrnexus/db/connect";
|
|
||||||
import { setDb, getDb } from "@wrnexus/db";
|
|
||||||
|
|
||||||
setDb(connectFromConfig({ driver: "sqlite", url: "file:./dev.db" }, process.cwd()));
|
|
||||||
const rows = await getDb().all("SELECT * FROM users");
|
|
||||||
```
|
|
||||||
|
|
||||||
Run migrations and paginate:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { migrate, paginate } from "@wrnexus/db";
|
|
||||||
|
|
||||||
await migrate(db, "app/db/migrations");
|
|
||||||
const pageTwo = await paginate(
|
|
||||||
db,
|
|
||||||
{ sql: "SELECT * FROM users ORDER BY id", model: users },
|
|
||||||
{ page: 2 },
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
MongoDB (document API):
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { mongo } from "@wrnexus/db/mongo";
|
|
||||||
|
|
||||||
const mdb = await mongo(process.env.MONGO_URL!, "app");
|
|
||||||
const repo = mdb.collection(users);
|
|
||||||
await repo.insert({ email: "a@b.com" });
|
|
||||||
const active = await repo.find({ active: true });
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
`connectFromConfig` (and the runtime) read a `DbConfig` (`{ driver, url }`)
|
|
||||||
where `driver` is `sqlite | postgres | mysql`. `resolveDbUrl(url, appRoot?)`
|
|
||||||
resolves a relative `file:`/`sqlite:` URL against the app root. MongoDB is not a
|
|
||||||
SQL driver — use `@wrnexus/db/mongo` directly.
|
|
||||||
|
|
||||||
## Requirements / Notes
|
|
||||||
|
|
||||||
- **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
|
|
||||||
`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.
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@wrnexus/db",
|
|
||||||
"version": "0.7.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "@wrnexus/db — part of the WrNexus framework.",
|
|
||||||
"license": "MIT",
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"./connect": {
|
|
||||||
"types": "./dist/connect.d.ts",
|
|
||||||
"import": "./dist/connect.js"
|
|
||||||
},
|
|
||||||
"./session": {
|
|
||||||
"types": "./dist/session-store.d.ts",
|
|
||||||
"import": "./dist/session-store.js"
|
|
||||||
},
|
|
||||||
"./sqlite": {
|
|
||||||
"types": "./dist/adapters/sqlite.d.ts",
|
|
||||||
"import": "./dist/adapters/sqlite.js"
|
|
||||||
},
|
|
||||||
"./postgres": {
|
|
||||||
"types": "./dist/adapters/postgres.d.ts",
|
|
||||||
"import": "./dist/adapters/postgres.js"
|
|
||||||
},
|
|
||||||
"./mysql": {
|
|
||||||
"types": "./dist/adapters/mysql.d.ts",
|
|
||||||
"import": "./dist/adapters/mysql.js"
|
|
||||||
},
|
|
||||||
"./mongo": {
|
|
||||||
"types": "./dist/adapters/mongo.d.ts",
|
|
||||||
"import": "./dist/adapters/mongo.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
# @wrnexus/dev-server
|
|
||||||
|
|
||||||
> The WrNexus HTTP + WebSocket server runtime — request dispatch, SSR document assembly, live-reload (HMR), and the portable production handler.
|
|
||||||
|
|
||||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
|
||||||
|
|
||||||
## 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`).
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun add @wrnexus/dev-server
|
|
||||||
```
|
|
||||||
|
|
||||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
|
||||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported for the full server; the `node:http` adapter is for WinterCG embedding only).
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
### 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. |
|
|
||||||
|
|
||||||
Exported types: `ServeOptions`, `RunningServer`, `RuntimeDeps`, `AssetServer`, `WsData`, `GatewayApp`, `GatewayOptions`, `GatewayAuth`, `GatewaySecurity`, `RunningGateway`, `FetchHandler`.
|
|
||||||
|
|
||||||
### `startServer(opts)`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface ServeOptions {
|
|
||||||
appDir: string; // absolute/relative path to the app/ dir
|
|
||||||
port?: number; // default 3000
|
|
||||||
hostname?: string; // default "localhost"
|
|
||||||
mode?: Mode; // "development" | "production"; default "development"
|
|
||||||
hmr?: boolean; // inject live-reload client; default (mode === "development")
|
|
||||||
styleEntry?: string | null; // resolved absolute path to the global CSS entry
|
|
||||||
stylesConfig?: StylesConfig; // custom styles processor (e.g. Tailwind/PostCSS)
|
|
||||||
head?: string; // raw HTML appended to every page <head>
|
|
||||||
seo?: SeoConfig; // global SEO defaults
|
|
||||||
security?: SecurityConfig; // security headers + CORS policy
|
|
||||||
theme?: ThemeConfig; // design-token theme (merged over built-in light/dark)
|
|
||||||
i18n?: I18nConfig; // default language + supported locales
|
|
||||||
db?: { driver: string; url: string }; // default db → getDb(); dev auto-migrates
|
|
||||||
databases?: Record<string, { driver: string; url: string }>; // named dbs → getDb("<name>")
|
|
||||||
realtime?: { scale?: boolean; redisUrl?: string }; // bridge rooms over Redis across processes
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RunningServer {
|
|
||||||
port: number;
|
|
||||||
hostname: string;
|
|
||||||
url: string;
|
|
||||||
router: Router;
|
|
||||||
stop(): void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
### `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).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface RuntimeDeps {
|
|
||||||
mode: Mode;
|
|
||||||
hmr: boolean; // inject the live-reload client into pages
|
|
||||||
router: Router;
|
|
||||||
loadModule(file: string): Promise<Record<string, unknown>>;
|
|
||||||
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)
|
|
||||||
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>
|
|
||||||
assetVersion?: string; // cache-busting ?v= on framework asset URLs
|
|
||||||
head?: string; // raw HTML appended to every page <head>
|
|
||||||
seo?: SeoConfig;
|
|
||||||
security?: SecurityConfig;
|
|
||||||
maxBodyBytes?: number; // 413 above this; default 10 MB
|
|
||||||
hub?: HmrHub; // browser HMR sockets (dev only)
|
|
||||||
realtimeBus?: RealtimeBus; // cross-process room bridge (Redis pub/sub)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Handlers {
|
|
||||||
fetch(req: Request, server: UpgradeServer): Promise<Response | undefined>;
|
|
||||||
websocket: { open; message; close; drain };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`WsData` is the per-connection socket tag — a discriminated union of `{ kind: "realtime"; handler }`, `{ kind: "room"; meta }`, or `{ kind: "hmr" }`.
|
|
||||||
|
|
||||||
### `createProductionServer(manifest, opts)` / `createProductionHandlers(manifest, opts)`
|
|
||||||
|
|
||||||
Production runs the _same_ request runtime as dev, but with no filesystem scan and no runtime bundling. `wrnexus build` emits an entry that statically imports every route/component/layout module and passes them as a `ProdManifest`; the route-matching tables are rebuilt from the raw patterns.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface ProdManifest {
|
|
||||||
pages: { raw: string; mod: RouteModule }[];
|
|
||||||
api: { raw: string; mod: RouteModule }[];
|
|
||||||
realtime: { raw: string; mod: RouteModule }[];
|
|
||||||
middleware: Middleware[];
|
|
||||||
components: { name: string; mod: RouteModule }[];
|
|
||||||
layouts: { name: string; mod: RouteModule }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProdOptions {
|
|
||||||
stylesPath?: string;
|
|
||||||
inlineStyles?: string;
|
|
||||||
reactivePath?: string;
|
|
||||||
themePath?: string;
|
|
||||||
themeJsPath?: string;
|
|
||||||
theme?: ResolvedTheme;
|
|
||||||
uiCssPath?: string;
|
|
||||||
schemasJs?: string;
|
|
||||||
i18n?: ResolvedI18n;
|
|
||||||
db?: { driver: string; url: string };
|
|
||||||
databases?: Record<string, { driver: string; url: string }>;
|
|
||||||
realtime?: { scale?: boolean; redisUrl?: string };
|
|
||||||
assetVersion?: string;
|
|
||||||
publicDir?: string;
|
|
||||||
head?: string;
|
|
||||||
seo?: SeoConfig;
|
|
||||||
security?: SecurityConfig;
|
|
||||||
port?: number;
|
|
||||||
hostname?: string;
|
|
||||||
maxBodyBytes?: number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`createProductionServer` also loads the `.env` cascade for the `production` profile, installs `SIGTERM`/`SIGINT` graceful shutdown, and binds `0.0.0.0` (port from `opts.port` or `$PORT`, default 3000). Migrations are **not** run here — apply them first (`wrnexus db migrate`). `createProductionHandlers` returns the bare handlers for edge/serverless/`node:http` deployment.
|
|
||||||
|
|
||||||
### `startGateway(opts)` — multi-app gateway
|
|
||||||
|
|
||||||
Serves several apps behind one port and routes each request to the right app by its `Host` header. Each app runs as its own child process (full isolation); the gateway is a thin host-based reverse proxy for HTTP and WebSocket. In development, normal application edits are applied inside the existing child and sent through its existing HMR connection. The child supervisor remains as crash recovery rather than the normal update path. Apps communicate at runtime via `@wrnexus/pubsub` (use the Redis driver so messages cross processes).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface GatewayOptions {
|
|
||||||
port?: number; // default 3000
|
|
||||||
hostname?: string; // dev: "127.0.0.1"; production: "0.0.0.0"
|
|
||||||
mode?: "development" | "production";
|
|
||||||
apps: GatewayApp[];
|
|
||||||
security?: GatewaySecurity;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GatewayApp {
|
|
||||||
name: string; // app id (for logs)
|
|
||||||
dir: string; // app root (contains app/ + wrnexus.config.ts)
|
|
||||||
domains: string[]; // host names routed here
|
|
||||||
port?: number; // fixed internal port; else assigned
|
|
||||||
auth?: GatewayAuth; // per-app edge access control
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GatewayAuth {
|
|
||||||
basic?: { user: string; pass: string } | Array<{ user: string; pass: string }>;
|
|
||||||
allowIps?: string[]; // exact-match IP allowlist
|
|
||||||
forward?: { url: string }; // forward-auth (SSO): 2xx allows
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GatewaySecurity {
|
|
||||||
trustedHostsOnly?: boolean; // 404 unknown hosts instead of first app
|
|
||||||
rateLimit?: { max: number; windowMs?: number }; // global by client IP (429)
|
|
||||||
headers?: boolean; // add baseline edge security headers
|
|
||||||
forwardedHeaders?: boolean; // set X-Forwarded-* (default true)
|
|
||||||
accessLog?: boolean;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Forward auth is a verification hook, not a login page. Configure `forward.url` with a
|
|
||||||
dedicated endpoint such as `http://sso.localhost:3000/api/verify`. The gateway forwards
|
|
||||||
the request's `Cookie` and `Authorization` headers plus `X-Forwarded-Host`,
|
|
||||||
`X-Forwarded-Proto`, `X-Original-Method`, and `X-Original-Uri` (including its query
|
|
||||||
string). The verifier must return 2xx only for an authenticated session and 401/403
|
|
||||||
otherwise. Pointing forward auth at an SSO home page that always returns 200 allows
|
|
||||||
every request and does not implement SSO.
|
|
||||||
|
|
||||||
For browser SSO, the verifier may return a `302`/`303`/`307`/`308` with a `Location`
|
|
||||||
header pointing to its login page. The gateway passes that redirect to the browser. The
|
|
||||||
login flow should validate a signed `returnTo` value before redirecting back; API clients
|
|
||||||
should receive `401`/`403` instead of an HTML login redirect.
|
|
||||||
|
|
||||||
Open the gateway URL (normally `http://127.0.0.1:3000`), not an app's internal
|
|
||||||
port. The gateway exposes `/__gateway/health` (JSON list of routed apps) and returns a
|
|
||||||
`RunningGateway` (`{ port, url, stop() }`). Use `--host=0.0.0.0` when other devices need
|
|
||||||
to reach a development gateway.
|
|
||||||
|
|
||||||
### `node:http` adapter (from `./adapters/node.ts`)
|
|
||||||
|
|
||||||
For embedding the WinterCG handler behind an existing Node server or a WinterCG host. Note the full app still needs Bun-compatible globals (`Bun.file`, `bun:sqlite`, etc.); only the `Request`/`Response` conversion is fully portable.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type FetchHandler = (req: Request) => Response | undefined | Promise<Response | undefined>;
|
|
||||||
|
|
||||||
toRequest(req: IncomingMessage, opts?): Promise<Request>
|
|
||||||
writeResponse(res: ServerResponse, response: Response): Promise<void> // preserves multiple Set-Cookie
|
|
||||||
nodeListener(handler: FetchHandler, opts?): (req, res) => Promise<void>
|
|
||||||
serveNode(handler: FetchHandler, opts?): Promise<Server>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Subpath export: `@wrnexus/dev-server/serve-entry`
|
|
||||||
|
|
||||||
The child process the dev supervisor launches:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run serve-entry.ts <appDir> <port> <mode>
|
|
||||||
```
|
|
||||||
|
|
||||||
It loads the optional `wrnexus.config.ts`, resolves the style entry, calls `startServer`, and prints the route table (Pages / API / Realtime / Components). Because it runs in its own process, every restart re-imports all route modules fresh — that is how the supervisor delivers live reload of edited server code. `startGateway` resolves this entry via `import.meta.resolve("@wrnexus/dev-server/serve-entry")` to spawn each dev app.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Programmatic dev server
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { startServer } from "@wrnexus/dev-server";
|
|
||||||
|
|
||||||
const server = await startServer({
|
|
||||||
appDir: "./app",
|
|
||||||
port: 3000,
|
|
||||||
mode: "development",
|
|
||||||
theme: {/* design tokens */},
|
|
||||||
db: { driver: "sqlite", url: "file:./data/app.db" },
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Running at ${server.url}`);
|
|
||||||
// server.stop();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production server from a build manifest
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createProductionServer } from "@wrnexus/dev-server";
|
|
||||||
import { manifest } from "./dist/manifest.js"; // generated by `wrnexus build`
|
|
||||||
|
|
||||||
createProductionServer(manifest, {
|
|
||||||
stylesPath: "./dist/styles.css",
|
|
||||||
reactivePath: "./dist/reactive.js",
|
|
||||||
assetVersion: process.env.BUILD_ID,
|
|
||||||
db: { driver: "postgres", url: process.env.DATABASE_URL! },
|
|
||||||
port: Number(process.env.PORT) || 3000,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Embedding the handler on `node:http`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createProductionHandlers, serveNode } from "@wrnexus/dev-server";
|
|
||||||
|
|
||||||
const handlers = createProductionHandlers(manifest, opts);
|
|
||||||
await serveNode(handlers.fetch, { port: 8080 });
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multi-app gateway
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { startGateway } from "@wrnexus/dev-server";
|
|
||||||
|
|
||||||
await startGateway({
|
|
||||||
port: 3000,
|
|
||||||
apps: [
|
|
||||||
{ name: "web", dir: "./apps/web", domains: ["localhost", "web.localhost"] },
|
|
||||||
{
|
|
||||||
name: "admin",
|
|
||||||
dir: "./apps/admin",
|
|
||||||
domains: ["admin.localhost"],
|
|
||||||
auth: { basic: { user: "root", pass: "s3cret" } },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
security: { trustedHostsOnly: true, rateLimit: { max: 600 } },
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Framework asset routes
|
|
||||||
|
|
||||||
The runtime serves these framework-owned paths (dev builds them live; prod serves pre-built/immutable versions):
|
|
||||||
|
|
||||||
- `/__wrnexus/nav.js`, `/__wrnexus/reactive.js`, `/__wrnexus/realtime.js` — client runtimes
|
|
||||||
- `/__wrnexus/validate.js`, `/__wrnexus/schemas.js`, `/__wrnexus/i18n.js` — validation + i18n runtimes
|
|
||||||
- `/__wrnexus/theme.css`, `/__wrnexus/theme.js`, `/__wrnexus/ui.css`, `/__wrnexus/styles.css` — styles
|
|
||||||
- `/__wrnexus/hmr` — dev-only HMR WebSocket
|
|
||||||
- `/__wrnexus/csr` — server-evaluated CSR bindings for browser-side API fetches
|
|
||||||
|
|
||||||
Pages get only the scripts they use: `nav.js` always, `reactive.js` when a page has a `data-scope`/CSR fetch, plus theme/validation/i18n/realtime runtimes when the relevant markup is present.
|
|
||||||
|
|
||||||
## Requirements / Notes
|
|
||||||
|
|
||||||
- **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.
|
|
||||||
- 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>
|
|
||||||
|
|
||||||
</invoke>
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@wrnexus/dev-server",
|
|
||||||
"version": "0.7.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "@wrnexus/dev-server — part of the WrNexus framework.",
|
|
||||||
"license": "MIT",
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"./serve-entry": {
|
|
||||||
"types": "./dist/serve-entry.d.ts",
|
|
||||||
"import": "./dist/serve-entry.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@wrnexus/core": "^0.7.0",
|
|
||||||
"@wrnexus/dev-toolbar": "^0.7.0",
|
|
||||||
"@wrnexus/router": "^0.7.0",
|
|
||||||
"@wrnexus/ssr": "^0.7.0",
|
|
||||||
"@wrnexus/csr": "^0.7.0",
|
|
||||||
"@wrnexus/compiler": "^0.7.0",
|
|
||||||
"@wrnexus/styles": "^0.7.0",
|
|
||||||
"@wrnexus/ui": "^0.7.0",
|
|
||||||
"@wrnexus/validation": "^0.7.0",
|
|
||||||
"@wrnexus/i18n": "^0.7.0",
|
|
||||||
"@wrnexus/db": "^0.7.0",
|
|
||||||
"@wrnexus/pubsub": "^0.7.0",
|
|
||||||
"@wrnexus/uploader": "^0.7.0",
|
|
||||||
"@wrnexus/plugin": "^0.7.0",
|
|
||||||
"@wrnexus/store": "^0.7.0",
|
|
||||||
"@wrnexus/security": "^0.7.0",
|
|
||||||
"@wrnexus/observability": "^0.7.0"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# @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
|
|
||||||
- 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`.
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@wrnexus/dev-toolbar",
|
|
||||||
"version": "0.7.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "@wrnexus/dev-toolbar — part of the WrNexus framework.",
|
|
||||||
"license": "MIT",
|
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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.7.0",
|
|
||||||
"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.3.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.7.0",
|
|
||||||
"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.3.0"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://registry.npmjs.org/",
|
|
||||||
"access": "restricted"
|
|
||||||
},
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@wrnexus/core": "^0.7.0"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
# @wrnexus/i18n
|
|
||||||
|
|
||||||
> Per-request translations plus locale-aware number, date, and currency formatting for WrNexus apps.
|
|
||||||
|
|
||||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
`@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
|
|
||||||
```
|
|
||||||
|
|
||||||
> 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
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import {
|
|
||||||
loadLocales,
|
|
||||||
resolveI18n,
|
|
||||||
resolveLang,
|
|
||||||
makeT,
|
|
||||||
translateHtml,
|
|
||||||
LANG_COOKIE,
|
|
||||||
} 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"] });
|
|
||||||
|
|
||||||
// Per request:
|
|
||||||
const lang = resolveLang(i18n, req.cookies?.[LANG_COOKIE], req.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);
|
|
||||||
```
|
|
||||||
|
|
||||||
`app/locales/en.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nav": { "home": "Home" },
|
|
||||||
"greeting": "Hello, {name}"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Views: translation markers
|
|
||||||
|
|
||||||
```html
|
|
||||||
<h1 data-t="nav.home">Home</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`).
|
|
||||||
|
|
||||||
### Client: language switcher
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { renderI18nData, I18N_RUNTIME, I18N_JS_HREF } from "@wrnexus/i18n";
|
|
||||||
|
|
||||||
// In the document <head>:
|
|
||||||
const head = `
|
|
||||||
<script>${renderI18nData(i18n, lang)}</script>
|
|
||||||
<script src="${I18N_JS_HREF}"></script>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import {
|
|
||||||
formatNumber,
|
|
||||||
formatCurrency,
|
|
||||||
formatDate,
|
|
||||||
formatRelativeTime,
|
|
||||||
plural,
|
|
||||||
} 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"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@wrnexus/i18n",
|
|
||||||
"version": "0.7.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "@wrnexus/i18n — part of the WrNexus framework.",
|
|
||||||
"license": "MIT",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@wrnexus/core": "^0.7.0"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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.7.0",
|
|
||||||
"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.3.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.7.0",
|
|
||||||
"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.3.0"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://registry.npmjs.org/",
|
|
||||||
"access": "restricted"
|
|
||||||
},
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@wrnexus/native": "^0.7.0"
|
|
||||||
},
|
|
||||||
"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.7.0",
|
|
||||||
"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.3.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.7.0",
|
|
||||||
"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.3.0"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://registry.npmjs.org/",
|
|
||||||
"access": "restricted"
|
|
||||||
},
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# @wrnexus/plugin
|
|
||||||
|
|
||||||
Deterministic WRNexusJS plugin contracts for configuration, AST/code transforms,
|
|
||||||
diagnostics, development servers, production builds, and DevToolbar extensions.
|
|
||||||
|
|
||||||
Use `definePlugin()` and declare `enforce`, `before`, or `after` when ordering matters.
|
|
||||||
Duplicate names and dependency cycles are rejected.
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@wrnexus/plugin",
|
|
||||||
"version": "0.7.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "@wrnexus/plugin — part of the WrNexus framework.",
|
|
||||||
"license": "MIT",
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"./types": {
|
|
||||||
"types": "./dist/types.d.ts",
|
|
||||||
"import": "./dist/types.js"
|
|
||||||
},
|
|
||||||
"./manifest": {
|
|
||||||
"types": "./dist/manifest.d.ts",
|
|
||||||
"import": "./dist/manifest.js"
|
|
||||||
},
|
|
||||||
"./discovery": {
|
|
||||||
"types": "./dist/discovery.d.ts",
|
|
||||||
"import": "./dist/discovery.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@wrnexus/syntax": "^0.7.0"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
# @wrnexus/pubsub
|
|
||||||
|
|
||||||
> Topic-based publish/subscribe with a pluggable driver — in-process by default, Redis for cross-process messaging.
|
|
||||||
|
|
||||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
`@wrnexus/pubsub` is a small server-side pub/sub bus. You publish messages to a
|
|
||||||
topic and subscribe with topic patterns; handlers fire for matching topics. The
|
|
||||||
default driver keeps everything in-process, and you can swap in the Redis driver
|
|
||||||
(`@wrnexus/pubsub/redis`) to fan messages out across processes or hosts. It also
|
|
||||||
backs `@wrnexus/core`'s realtime bridge for horizontal scaling.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun add @wrnexus/pubsub
|
|
||||||
```
|
|
||||||
|
|
||||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
|
||||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
### `createPubSub(driver?): PubSub`
|
|
||||||
|
|
||||||
Creates a bus over a driver. Defaults to `memoryDriver()` (in-process).
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface PubSub {
|
|
||||||
publish<T = unknown>(topic: string, message: T): Promise<void>;
|
|
||||||
subscribe<T = unknown>(pattern: string, handler: Handler<T>): () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Handler<T = unknown> = (message: T, topic: string) => void | Promise<void>;
|
|
||||||
```
|
|
||||||
|
|
||||||
- `publish(topic, message)` — resolves once the driver has dispatched the message.
|
|
||||||
- `subscribe(pattern, handler)` — returns an unsubscribe function.
|
|
||||||
|
|
||||||
### Pattern matching
|
|
||||||
|
|
||||||
Subscription patterns match in three ways:
|
|
||||||
|
|
||||||
- **Exact** — `"order:created"` matches only that topic.
|
|
||||||
- **Prefix** — `"order:*"` matches any topic starting with `"order:"`.
|
|
||||||
- **Everything** — `"*"` matches all topics.
|
|
||||||
|
|
||||||
### `memoryDriver(): PubSubDriver`
|
|
||||||
|
|
||||||
The default in-process driver. Handlers are invoked synchronously (fire-and-forget
|
|
||||||
for async handlers) whenever a published topic matches a registered pattern.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface PubSubDriver {
|
|
||||||
publish(topic: string, message: unknown): void | Promise<void>;
|
|
||||||
subscribe(pattern: string, handler: Handler): () => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `@wrnexus/pubsub/redis` — `redisDriver(url?)`
|
|
||||||
|
|
||||||
A cross-process driver backed by Redis. It speaks RESP over a raw TCP socket via
|
|
||||||
`Bun.connect`, so it adds **no npm dependency**. `url` defaults to `$REDIS_URL`,
|
|
||||||
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 };
|
|
||||||
```
|
|
||||||
|
|
||||||
- Exact topics use Redis `SUBSCRIBE`; wildcard patterns (`ns:*`, `*`) use
|
|
||||||
`PSUBSCRIBE`, whose glob semantics line up with this library's matching.
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
### RESP codec (internal)
|
|
||||||
|
|
||||||
`redis.ts` uses a minimal RESP implementation exported from `resp.ts`
|
|
||||||
(`encodeCommand`, `parseReply`, `concat`, and the `RespValue` type). These are
|
|
||||||
implementation details of the Redis driver, not part of the public package entry.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
In-process (default):
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createPubSub } from "@wrnexus/pubsub";
|
|
||||||
|
|
||||||
const bus = createPubSub();
|
|
||||||
|
|
||||||
const off = bus.subscribe("order:*", (msg, topic) => {
|
|
||||||
console.log(topic, msg);
|
|
||||||
});
|
|
||||||
|
|
||||||
await bus.publish("order:created", { id: 7 });
|
|
||||||
|
|
||||||
off(); // unsubscribe
|
|
||||||
```
|
|
||||||
|
|
||||||
Cross-process with Redis:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { createPubSub } from "@wrnexus/pubsub";
|
|
||||||
import { redisDriver } from "@wrnexus/pubsub/redis";
|
|
||||||
|
|
||||||
const driver = redisDriver("redis://localhost:6379");
|
|
||||||
const bus = createPubSub(driver);
|
|
||||||
|
|
||||||
bus.subscribe("order:*", (msg, topic) => {
|
|
||||||
// received on any app process subscribed to this pattern
|
|
||||||
});
|
|
||||||
|
|
||||||
await bus.publish("order:created", { id: 7 });
|
|
||||||
|
|
||||||
// on shutdown
|
|
||||||
driver.close();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Requirements / Notes
|
|
||||||
|
|
||||||
- **Bun-only.** The Redis driver depends on `Bun.connect`; it throws
|
|
||||||
`redisDriver requires the Bun runtime (Bun.connect).` outside Bun. The default
|
|
||||||
in-memory driver has no runtime dependencies.
|
|
||||||
- The Redis driver reads `REDIS_URL` from the environment when no `url` is passed.
|
|
||||||
- Backs [`@wrnexus/core`](../core)'s realtime bridge for horizontal scaling.
|
|
||||||
- No external npm dependencies — the Redis client is a self-contained RESP codec.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@wrnexus/pubsub",
|
|
||||||
"version": "0.7.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "@wrnexus/pubsub — part of the WrNexus framework.",
|
|
||||||
"license": "MIT",
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"./redis": {
|
|
||||||
"types": "./dist/redis.d.ts",
|
|
||||||
"import": "./dist/redis.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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.7.0",
|
|
||||||
"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.3.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.7.0",
|
|
||||||
"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.3.0"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://registry.npmjs.org/",
|
|
||||||
"access": "restricted"
|
|
||||||
},
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
# @wrnexus/router
|
|
||||||
|
|
||||||
> File-based router that maps an `app/` directory onto route tables and matches request paths against them.
|
|
||||||
|
|
||||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
`@wrnexus/router` scans an application's `app/` directory once at startup and builds route tables for pages, API endpoints, realtime channels, middleware, server-rendered `.wrn` components, layouts, and validation schemas. It also compiles URL patterns (`/users/[id]`) into RegExps and matches request paths against them. Request input is never turned into a file path, which makes the router immune to path traversal. This is a server-side package used by the WrNexus runtime to resolve incoming requests, plus a codegen helper for compile-time typed links.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun add @wrnexus/router
|
|
||||||
```
|
|
||||||
|
|
||||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
|
||||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
|
||||||
|
|
||||||
## Directory conventions
|
|
||||||
|
|
||||||
The router maps files under `appDir` onto routes:
|
|
||||||
|
|
||||||
```
|
|
||||||
app/pages/index.tsx -> GET /
|
|
||||||
app/pages/about.tsx -> GET /about
|
|
||||||
app/pages/users/[id].tsx -> GET /users/:id
|
|
||||||
app/api/hello.ts -> /api/hello
|
|
||||||
app/realtime/chat.ts -> /realtime/chat
|
|
||||||
app/pages/*.wrn (api) -> embedded /api/* routes
|
|
||||||
app/pages/*.wrn (rt) -> embedded /realtime/* routes
|
|
||||||
app/middleware/*.ts -> global middleware (alphabetical)
|
|
||||||
app/components/*.wrn -> server-rendered components (by basename)
|
|
||||||
app/layouts/*.wrn -> named page layouts
|
|
||||||
app/schemas/*.ts -> validation schemas
|
|
||||||
```
|
|
||||||
|
|
||||||
Allowed route extensions are `.ts`, `.tsx`, and `.wrn`. Dotfiles and underscore-prefixed files are ignored. A trailing `index` segment is dropped from the route. `.wrn` pages may embed `api` and `realtime` blocks, which the router extracts and mounts under `/api/*` and `/realtime/*`.
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
### `buildRouter(appDir, opts?): Router`
|
|
||||||
|
|
||||||
Scan an app directory and build all route tables.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
function buildRouter(appDir: string, opts?: RouterOptions): Router;
|
|
||||||
|
|
||||||
interface RouterOptions {
|
|
||||||
/** Extra dirs scanned for `.wrn` components (e.g. `@wrnexus/ui`), before
|
|
||||||
* `app/components`, so an app component of the same name wins. */
|
|
||||||
componentDirs?: string[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The returned `Router` exposes the built tables plus per-kind matchers:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface Router {
|
|
||||||
pages: Route[];
|
|
||||||
api: Route[];
|
|
||||||
realtime: Route[];
|
|
||||||
/** Absolute paths of middleware modules, in execution order (alphabetical). */
|
|
||||||
middlewareFiles: string[];
|
|
||||||
/** Server-rendered `.wrn` components, mounted via `data-component`. */
|
|
||||||
components: ComponentRef[];
|
|
||||||
/** Named page layouts (`app/layouts/<name>.wrn`); a page picks one via `layout`. */
|
|
||||||
layouts: ComponentRef[];
|
|
||||||
/** Validation schemas (`app/schemas/<name>.ts`) shared by API + forms. */
|
|
||||||
schemas: ComponentRef[];
|
|
||||||
matchPage(pathname: string): RouteMatch | null;
|
|
||||||
matchApi(pathname: string): RouteMatch | null;
|
|
||||||
matchRealtime(pathname: string): RouteMatch | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ComponentRef {
|
|
||||||
/** Validated component name (matches a `data-component` attribute). */
|
|
||||||
name: string;
|
|
||||||
/** Absolute path to the component's `.wrn` module. */
|
|
||||||
file: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Component, layout, and schema names are validated with `isSafeIslandName` from `@wrnexus/core`; unsafe names are skipped with a warning. Realtime channel names are validated the same way.
|
|
||||||
|
|
||||||
### Route matching
|
|
||||||
|
|
||||||
| Export | Signature | Description |
|
|
||||||
| --------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `compileRoutePattern` | `(raw: string) => Pick<Route, "regex" \| "paramNames">` | Compile a `/users/[id]` pattern into a RegExp (with optional trailing slash) plus ordered param names. |
|
|
||||||
| `matchRoute` | `(routes: Route[], pathname: string) => RouteMatch \| null` | Return the first route whose regex matches; captured params are `decodeURIComponent`-decoded. |
|
|
||||||
| `sortRoutes` | `(routes: Route[]) => Route[]` | Order routes so static routes win over dynamic ones (fewer params first), then longer/more specific patterns first. |
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface Route {
|
|
||||||
raw: string; // e.g. "/users/[id]"
|
|
||||||
file: string; // absolute path to the handling module
|
|
||||||
regex: RegExp; // compiled matcher
|
|
||||||
paramNames: string[]; // ordered dynamic param names
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RouteMatch {
|
|
||||||
route: Route;
|
|
||||||
params: Record<string, string>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Typed-routes codegen
|
|
||||||
|
|
||||||
```ts
|
|
||||||
function generateRoutesFile(pages: Route[]): string;
|
|
||||||
```
|
|
||||||
|
|
||||||
Emits the source for `app/routes.gen.ts`: a `Routes` map (each page path → its `[param]` types), a `RoutePath` union, and an `href()` builder that fills params and rejects unknown paths at compile time. Entries are de-duplicated and sorted by path.
|
|
||||||
|
|
||||||
### Re-exports
|
|
||||||
|
|
||||||
`Middleware` (the type from `@wrnexus/core`) is re-exported for callers that load middleware modules themselves.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { buildRouter } from "@wrnexus/router";
|
|
||||||
|
|
||||||
const router = buildRouter("./app", {
|
|
||||||
componentDirs: ["./node_modules/@wrnexus/ui/components"],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Resolve an incoming request.
|
|
||||||
const match = router.matchPage("/users/42");
|
|
||||||
if (match) {
|
|
||||||
console.log(match.route.file); // absolute path to the page module
|
|
||||||
console.log(match.params); // { id: "42" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const api = router.matchApi("/api/hello");
|
|
||||||
const rt = router.matchRealtime("/realtime/chat");
|
|
||||||
```
|
|
||||||
|
|
||||||
Generating the typed-routes file (as `wrnexus dev` does):
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { generateRoutesFile } from "@wrnexus/router";
|
|
||||||
import { writeFileSync } from "node:fs";
|
|
||||||
|
|
||||||
const router = buildRouter("./app");
|
|
||||||
writeFileSync("./app/routes.gen.ts", generateRoutesFile(router.pages));
|
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Then, in app code, links are checked at compile time:
|
|
||||||
import { href } from "./routes.gen.ts";
|
|
||||||
|
|
||||||
href("/users/[id]", { id: "42" }); // "/users/42"
|
|
||||||
href("/about"); // "/about"
|
|
||||||
href("/nope"); // type error: unknown path
|
|
||||||
```
|
|
||||||
|
|
||||||
Lower-level pattern matching, if you need it directly:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { compileRoutePattern, matchRoute, sortRoutes, type Route } from "@wrnexus/router";
|
|
||||||
|
|
||||||
const { regex, paramNames } = compileRoutePattern("/posts/[slug]");
|
|
||||||
const routes = sortRoutes([{ raw: "/posts/[slug]", file: "…", regex, paramNames }]);
|
|
||||||
const m = matchRoute(routes, "/posts/hello"); // { route, params: { slug: "hello" } }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Requirements / Notes
|
|
||||||
|
|
||||||
- Scanning uses `node:fs` (`existsSync`, `readdirSync`, `statSync`) and `node:path` — runs under Bun.
|
|
||||||
- Depends on [`@wrnexus/compiler`](../compiler) to `parse` `.wrn` pages and extract embedded `api` / `realtime` blocks.
|
|
||||||
- Depends on [`@wrnexus/core`](../core) for `isSafeIslandName` (name validation) and the `Middleware` type.
|
|
||||||
- Missing route directories are tolerated — a route kind you don't use simply yields an empty table.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@wrnexus/router",
|
|
||||||
"version": "0.7.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "@wrnexus/router — part of the WrNexus framework.",
|
|
||||||
"license": "MIT",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@wrnexus/compiler": "^0.7.0",
|
|
||||||
"@wrnexus/core": "^0.7.0"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
# @wrnexus/ssr
|
|
||||||
|
|
||||||
> Server-side rendering: wraps a page's HTML body in a complete HTML document with a metadata-driven `<head>`.
|
|
||||||
|
|
||||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Pages in WrNexus return an HTML string for the body. `@wrnexus/ssr` takes that body and produces a full HTML document — building the `<head>` from page metadata and global SEO defaults, resolving canonical/Open Graph/Twitter tags, and injecting module preloads and `<script type="module">` tags. It is deliberately server-only: nothing in this package touches the DOM or ships to the browser, keeping server code genuinely server-only. Reach for it on the server when turning a rendered page body into a response document.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun add @wrnexus/ssr
|
|
||||||
```
|
|
||||||
|
|
||||||
> 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 export.
|
|
||||||
|
|
||||||
### `renderDocument(opts: RenderOptions): string`
|
|
||||||
|
|
||||||
Renders a complete HTML document as a string, beginning with `<!doctype html>`. All metadata is HTML-escaped (via `escapeHtml` from `@wrnexus/core`), so a malicious title or description cannot break out of its element or attribute. The body is placed inside `<div id="app">`.
|
|
||||||
|
|
||||||
#### `RenderOptions`
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
| -------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `meta` | `PageMeta` | Page metadata for the document head (required). |
|
|
||||||
| `body` | `string` | Rendered HTML for the body, placed inside `#app` (required). |
|
|
||||||
| `seo` | `SeoConfig` | Global SEO defaults, typically from `wrnexus.config.ts`. |
|
|
||||||
| `url` | `URL` | Current request URL, used to resolve canonical/Open Graph URLs. |
|
|
||||||
| `scripts` | `string[]` | URLs of `<script type="module">` tags to load (e.g. per-island chunks or the reactive runtime). Each also gets a `<link rel="modulepreload">`. |
|
|
||||||
| `defaultTitle` | `string` | Default document title used when `meta.title` is absent. |
|
|
||||||
| `extraHead` | `string` | Raw HTML injected at the end of `<head>` (trusted, framework-controlled — not escaped). |
|
|
||||||
| `extraBody` | `string` | Raw HTML injected at the end of `<body>` (trusted, framework-controlled — not escaped). |
|
|
||||||
| `htmlAttrs` | `string` | Attributes for the `<html>` element, e.g. ` data-theme="dark"` (trusted). |
|
|
||||||
|
|
||||||
`PageMeta` and `SeoConfig` come from `@wrnexus/core`. `PageMeta` is an alias of `SeoConfig`, whose fields are all optional:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type SeoConfig = {
|
|
||||||
title?: string;
|
|
||||||
titleTemplate?: string; // e.g. "%s — My Site"; %s is replaced with the page title
|
|
||||||
description?: string;
|
|
||||||
canonical?: string;
|
|
||||||
canonicalBase?: string; // origin used to absolutize canonical/image URLs
|
|
||||||
robots?: string;
|
|
||||||
keywords?: string | string[];
|
|
||||||
image?: string;
|
|
||||||
siteName?: string;
|
|
||||||
type?: string; // Open Graph type; defaults to "website"
|
|
||||||
locale?: string;
|
|
||||||
twitterCard?: string; // defaults to "summary"
|
|
||||||
twitterSite?: string;
|
|
||||||
themeColor?: string;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Metadata resolution
|
|
||||||
|
|
||||||
`renderDocument` merges page metadata (`meta`) over global defaults (`seo`), field by field, so per-page values win. Notable behavior:
|
|
||||||
|
|
||||||
- **Title**: uses `meta.title`, else `seo.title`, else `defaultTitle`, else `"WrNexus"`. When the page sets its own title and `seo.titleTemplate` contains `%s`, the template is applied.
|
|
||||||
- **Canonical / image URLs**: resolved against `canonicalBase` (or the request `url`'s origin) into absolute URLs when possible.
|
|
||||||
- **Keywords**: an array is joined with `", "`.
|
|
||||||
- **Emitted tags**: `<title>`, and as applicable `description`, `robots`, `keywords`, `theme-color`, and `canonical` link, plus Open Graph (`og:title`, `og:description`, `og:type`, `og:url`, `og:site_name`, `og:locale`, `og:image`) and Twitter (`twitter:card`, `twitter:title`, `twitter:description`, `twitter:image`, `twitter:site`) meta tags. The document always includes `charset`, `viewport`, and a `/favicon.ico` icon link.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Render an SEO-ready application page
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { renderDocument } from "@wrnexus/ssr";
|
|
||||||
|
|
||||||
const html = renderDocument({
|
|
||||||
meta: {
|
|
||||||
title: "About Us",
|
|
||||||
description: "Learn more about our team.",
|
|
||||||
},
|
|
||||||
seo: {
|
|
||||||
titleTemplate: "%s — Acme",
|
|
||||||
siteName: "Acme",
|
|
||||||
canonicalBase: "https://acme.example",
|
|
||||||
twitterSite: "@acme",
|
|
||||||
},
|
|
||||||
url: new URL("https://acme.example/about"),
|
|
||||||
body: "<h1>About Us</h1>",
|
|
||||||
scripts: ["/_wire/runtime.js", "/_wire/islands/about.js"],
|
|
||||||
htmlAttrs: ' data-theme="dark"',
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(html, {
|
|
||||||
headers: { "content-type": "text/html; charset=utf-8" },
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
The produced document has `<title>About Us — Acme</title>`, the SEO/Open Graph/Twitter tags derived from the merged metadata, a `modulepreload` link and module `<script>` for each entry in `scripts`, and the body wrapped in `<div id="app">`.
|
|
||||||
|
|
||||||
### Add trusted framework assets and boot data
|
|
||||||
|
|
||||||
Use `extraHead` and `extraBody` only for HTML generated by your application or the
|
|
||||||
framework. User-provided values belong in `meta`, where they are escaped.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const html = renderDocument({
|
|
||||||
meta: { title: "Dashboard", robots: "noindex" },
|
|
||||||
body: dashboardHtml,
|
|
||||||
url: ctx.url,
|
|
||||||
extraHead: '<link rel="stylesheet" href="/_wrnexus/admin.css">',
|
|
||||||
extraBody: `<script type="application/json" id="boot">${JSON.stringify(bootData).replaceAll("<", "\\u003c")}</script>`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
||||||
```
|
|
||||||
|
|
||||||
## Requirements / Notes
|
|
||||||
|
|
||||||
- **Server-only.** This module never imports or touches the DOM and is safe to keep out of client bundles.
|
|
||||||
- **Depends on [`@wrnexus/core`](../core)** for `escapeHtml` and the `PageMeta` / `SeoConfig` types.
|
|
||||||
- **Bun-only** — like the rest of WrNexus, this package targets the Bun runtime (Node is not supported).
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@wrnexus/ssr",
|
|
||||||
"version": "0.7.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "@wrnexus/ssr — part of the WrNexus framework.",
|
|
||||||
"license": "MIT",
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"./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.7.0",
|
|
||||||
"@wrnexus/store": "^0.7.0",
|
|
||||||
"@wrnexus/security": "^0.7.0"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
# @wrnexus/styles
|
|
||||||
|
|
||||||
> Global CSS bundling, the `--wire-*` design-token theme system, and the `wrnexus.config.ts` app-config loader for WrNexus apps.
|
|
||||||
|
|
||||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
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.
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun add @wrnexus/styles
|
|
||||||
```
|
|
||||||
|
|
||||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
|
||||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
Everything is exported from the package root (`@wrnexus/styles`).
|
|
||||||
|
|
||||||
### Config loading
|
|
||||||
|
|
||||||
| Export | Signature | Purpose |
|
|
||||||
| ---------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `loadAppConfig` | `(appRoot: string, profile?: string) => Promise<AppConfig>` | Load `wrnexus.config.*` with the active profile deep-merged in (`profiles` stripped from the result). |
|
|
||||||
| `loadRawConfig` | `(appRoot: string) => Promise<AppConfig>` | Load the raw config with the `profiles` map intact; returns `{}` if no config file exists. |
|
|
||||||
| `resolveProfile` | `(options?: { explicit?; mode? }) => string` | Resolve the active profile: explicit arg > `WRNEXUS_PROFILE` env var > mode-based default (`production` in prod, else `development`). |
|
|
||||||
| `loadEnv` | `(appRoot: string, profile: string) => Record<string, string>` | Load the `.env` cascade for a profile into `process.env` without clobbering real env vars. Returns what it loaded. |
|
|
||||||
| `headToString` | `(head?: string \| string[]) => string` | Flatten `AppConfig.head` into a single HTML string. |
|
|
||||||
|
|
||||||
Config file names probed, in order: `wrnexus.config.ts`, `wrnexus.config.js`, `wrnexus.config.mjs`.
|
|
||||||
|
|
||||||
`.env` cascade precedence (low → high): `.env` < `.env.<profile>` < `.env.local` < `.env.<profile>.local`. Variables already present in the real environment always win.
|
|
||||||
|
|
||||||
### `AppConfig`
|
|
||||||
|
|
||||||
The type of the object your `wrnexus.config.ts` default-exports. Every field is optional.
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
| ----------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `head` | `string \| string[]` | Raw HTML appended to every page's `<head>` (e.g. CDN stylesheet/script links). |
|
|
||||||
| `seo` | `SeoConfig` | Global SEO defaults, merged with each page's exported `meta`. (from `@wrnexus/core`) |
|
|
||||||
| `security` | `SecurityConfig` | Framework security headers and optional CORS policy. (from `@wrnexus/core`) |
|
|
||||||
| `styles` | `StylesConfig` | Global stylesheet pipeline config (see below). |
|
|
||||||
| `theme` | `ThemeConfig` | Design-token themes, deep-merged over the built-in light/dark. |
|
|
||||||
| `i18n` | `{ default?: string; locales?: string[] }` | Default language + supported locales (strings live in `app/locales/*.json`). |
|
|
||||||
| `db` | `{ driver: "sqlite" \| "postgres" \| "mysql" \| "mongo"; url: string }` | Default database connection; reached with `getDb()`. |
|
|
||||||
| `databases` | `Record<string, { driver; url }>` | Additional named databases, reached with `getDb("<name>")`; each has its own `app/db/<name>/` migrations/queries. |
|
|
||||||
| `realtime` | `{ scale?: boolean; redisUrl?: string }` | When `scale` is true (or `redisUrl` is set), room broadcasts bridge over Redis pub/sub so they reach clients on every app process. |
|
|
||||||
| `port` | `number` | Default server port. |
|
|
||||||
| `profiles` | `Record<string, Partial<Omit<AppConfig, "profiles">>>` | Named profiles (dev, prod, uat, test, …). The active profile's overrides are deep-merged over the base config. Selected via `--profile=<name>` or `WRNEXUS_PROFILE`. |
|
|
||||||
|
|
||||||
### Styles pipeline
|
|
||||||
|
|
||||||
| Export | Signature | Purpose |
|
|
||||||
| ---------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `findStyleEntry` | `(appDir, appRoot, override?) => string \| null` | Resolve the CSS entry: `override` (relative to `appRoot`) → `app/styles/global.css` → an aggregate of all `app/styles/*.css` (written to `app/.wrnexus/styles-entry.css`). `null` if the app has no styles. |
|
|
||||||
| `bundleCss` | `(entryPath: string, mode: Mode) => Promise<string>` | Bundle an entry with `Bun.build` (CSS bundler). Resolves `@import` (local + node_modules), handles nesting, minifies when `mode === "production"`. |
|
|
||||||
| `renderStyles` | `(ctx: StyleProcessContext, styles?: StylesConfig) => Promise<string>` | Produce final CSS: runs `styles.process(ctx)` if provided, else `bundleCss`. Returns `""` when `ctx.entryPath` is null. |
|
|
||||||
|
|
||||||
`StylesConfig`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface StylesConfig {
|
|
||||||
/** CSS entry path relative to the app root. Default: app/styles/global.css */
|
|
||||||
entry?: string;
|
|
||||||
/** Custom processor — return the final CSS string (Tailwind/PostCSS/Sass). */
|
|
||||||
process?: (ctx: StyleProcessContext) => string | Promise<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StyleProcessContext {
|
|
||||||
entryPath: string | null; // resolved absolute CSS entry, or null
|
|
||||||
appDir: string;
|
|
||||||
appRoot: string;
|
|
||||||
mode: Mode; // "development" | "production"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Theme system
|
|
||||||
|
|
||||||
| 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_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). |
|
|
||||||
| `resolveThemeName` | `(cookieValue: string \| undefined, theme: ResolvedTheme) => string` | Pick a valid theme name from a cookie, falling back to `theme.default`. |
|
|
||||||
| `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.
|
|
||||||
|
|
||||||
`ThemeConfig` / `ThemeTokens` / `ResolvedTheme`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResolvedTheme {
|
|
||||||
default: string;
|
|
||||||
names: string[];
|
|
||||||
themes: Record<string, ThemeTokens>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
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.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.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### `wrnexus.config.ts`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import type { AppConfig } from "@wrnexus/styles";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
head: [
|
|
||||||
'<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5/dist/css/bootstrap.min.css">',
|
|
||||||
],
|
|
||||||
port: 3000,
|
|
||||||
db: { driver: "sqlite", url: "app.db" },
|
|
||||||
theme: {
|
|
||||||
palette: "violet",
|
|
||||||
default: "dark",
|
|
||||||
themes: {
|
|
||||||
light: { "color-primary": "#7c3aed" }, // override one token; rest inherited
|
|
||||||
brand: {
|
|
||||||
// add a whole new theme
|
|
||||||
"color-scheme": "dark",
|
|
||||||
"color-bg": "#0a0a0a",
|
|
||||||
"color-primary": "#22d3ee",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
styles: {
|
|
||||||
entry: "app/styles/main.css",
|
|
||||||
},
|
|
||||||
profiles: {
|
|
||||||
production: {
|
|
||||||
db: { driver: "postgres", url: process.env.DATABASE_URL! },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies AppConfig;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Loading config + producing CSS
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import {
|
|
||||||
loadAppConfig,
|
|
||||||
resolveProfile,
|
|
||||||
loadEnv,
|
|
||||||
findStyleEntry,
|
|
||||||
renderStyles,
|
|
||||||
} from "@wrnexus/styles";
|
|
||||||
|
|
||||||
const appRoot = process.cwd();
|
|
||||||
const mode = "production" as const;
|
|
||||||
|
|
||||||
const profile = resolveProfile({ mode });
|
|
||||||
loadEnv(appRoot, profile);
|
|
||||||
|
|
||||||
const config = await loadAppConfig(appRoot, profile);
|
|
||||||
|
|
||||||
const appDir = `${appRoot}/app`;
|
|
||||||
const entryPath = findStyleEntry(appDir, appRoot, config.styles?.entry);
|
|
||||||
const css = await renderStyles({ entryPath, appDir, appRoot, mode }, config.styles);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rendering the theme
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import {
|
|
||||||
resolveThemeConfig,
|
|
||||||
resolveThemeName,
|
|
||||||
renderThemeCss,
|
|
||||||
renderThemeRuntime,
|
|
||||||
THEME_COOKIE,
|
|
||||||
} from "@wrnexus/styles";
|
|
||||||
|
|
||||||
const theme = resolveThemeConfig(config.theme);
|
|
||||||
|
|
||||||
// Server: pick the active theme from the request cookie (no flash).
|
|
||||||
const active = resolveThemeName(cookies[THEME_COOKIE], theme);
|
|
||||||
// → render <html data-theme={active}>
|
|
||||||
|
|
||||||
const themeCss = renderThemeCss(theme); // served at THEME_CSS_HREF
|
|
||||||
const themeJs = renderThemeRuntime(theme); // served at THEME_JS_HREF
|
|
||||||
```
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```html
|
|
||||||
<button data-wire-theme-toggle>Toggle theme</button>
|
|
||||||
<button data-wire-theme-set="brand">Brand theme</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Requirements / Notes
|
|
||||||
|
|
||||||
- **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.
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@wrnexus/styles",
|
|
||||||
"version": "0.7.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "@wrnexus/styles — part of the WrNexus framework.",
|
|
||||||
"license": "MIT",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@wrnexus/uploader": "^0.7.0",
|
|
||||||
"@wrnexus/core": "^0.7.0",
|
|
||||||
"@wrnexus/plugin": "^0.7.0"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# @wrnexus/syntax
|
|
||||||
|
|
||||||
Canonical WRN lexer, parser, AST, language metadata, source positions, and stable
|
|
||||||
diagnostics. Framework tooling should import this package instead of implementing a
|
|
||||||
separate `.wrn` parser.
|
|
||||||
|
|
||||||
See `docs/WRN-LANGUAGE-SPEC-1.0.md` in the WRNexusJS repository.
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@wrnexus/syntax",
|
|
||||||
"version": "0.7.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "@wrnexus/syntax — part of the WrNexus framework.",
|
|
||||||
"license": "MIT",
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"./parser": {
|
|
||||||
"types": "./dist/parser.d.ts",
|
|
||||||
"import": "./dist/parser.js"
|
|
||||||
},
|
|
||||||
"./tokenizer": {
|
|
||||||
"types": "./dist/tokenizer.d.ts",
|
|
||||||
"import": "./dist/tokenizer.js"
|
|
||||||
},
|
|
||||||
"./types": {
|
|
||||||
"types": "./dist/types.d.ts",
|
|
||||||
"import": "./dist/types.js"
|
|
||||||
},
|
|
||||||
"./diagnostics": {
|
|
||||||
"types": "./dist/diagnostics.d.ts",
|
|
||||||
"import": "./dist/diagnostics.js"
|
|
||||||
},
|
|
||||||
"./spec": {
|
|
||||||
"types": "./dist/spec.d.ts",
|
|
||||||
"import": "./dist/spec.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
# @wrnexus/test
|
|
||||||
|
|
||||||
> Testing utilities for WrNexus apps — component rendering, reactive-DOM mounting, route handler calls, and a full in-process app harness, plus a one-import re-export of `bun:test`.
|
|
||||||
|
|
||||||
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
`@wrnexus/test` is the server-side test toolkit you reach for when writing tests
|
|
||||||
for a WrNexus app. It runs under `bun test` (invoked via `wrnexus test`) and gives
|
|
||||||
you a single import surface: the `bun:test` primitives (`test`, `expect`, `mock`,
|
|
||||||
…) re-exported alongside WrNexus-aware helpers that compile `.wrn` components,
|
|
||||||
hydrate server HTML in a DOM, invoke API route handlers, and boot the real app on
|
|
||||||
an ephemeral port for integration tests.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun add @wrnexus/test
|
|
||||||
```
|
|
||||||
|
|
||||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
|
||||||
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
### Re-exported test primitives
|
|
||||||
|
|
||||||
For one-import DX, the following are re-exported straight from `bun:test`:
|
|
||||||
|
|
||||||
`test`, `expect`, `describe`, `it`, `beforeEach`, `afterEach`, `beforeAll`,
|
|
||||||
`afterAll`, `mock`, `spyOn`.
|
|
||||||
|
|
||||||
`createContext` is also re-exported from `@wrnexus/core`.
|
|
||||||
|
|
||||||
### `renderComponent(source, props?)`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
function renderComponent(source: string, props?: Record<string, unknown>): Promise<string>;
|
|
||||||
```
|
|
||||||
|
|
||||||
Compiles a `.wrn` component `source` string (via `@wrnexus/compiler`) and renders
|
|
||||||
it to an HTML string with the given `props`. Throws if the compiled module has no
|
|
||||||
`render` export.
|
|
||||||
|
|
||||||
### `mountHtml(html)`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
function mountHtml(html: string): {
|
|
||||||
document: Document;
|
|
||||||
window: unknown;
|
|
||||||
querySelector: (sel: string) => Element | null;
|
|
||||||
querySelectorAll: (sel: string) => Element[];
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Mounts server-rendered `html` in a `happy-dom` window with the reactive runtime
|
|
||||||
hydrated, so you can test `data-scope` / `data-text` / `data-for` / `data-show`
|
|
||||||
behaviour. Returns the window plus `document` and query helpers; assert on those.
|
|
||||||
|
|
||||||
> `happy-dom` is loaded lazily (via `require`), so importing this package never
|
|
||||||
> requires it unless you actually call `mountHtml`.
|
|
||||||
|
|
||||||
### `callRoute(handler, request)`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
function callRoute(
|
|
||||||
handler: (ctx: Context) => Response | Promise<Response>,
|
|
||||||
request: Request,
|
|
||||||
): Promise<Response>;
|
|
||||||
```
|
|
||||||
|
|
||||||
Calls an API route `handler` with a `Context` built from a `Request` (using
|
|
||||||
`createContext`). Returns the handler's `Response`.
|
|
||||||
|
|
||||||
### `createHarness(projectRoot, options?)`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
function createHarness(projectRoot: string, options?: HarnessOptions): Promise<Harness>;
|
|
||||||
|
|
||||||
interface HarnessOptions {
|
|
||||||
/** Config/env profile. Default "test". */
|
|
||||||
profile?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Harness {
|
|
||||||
/** Base URL of the ephemeral test server. */
|
|
||||||
url: string;
|
|
||||||
/** Fetch a path on the app (relative to `url`). */
|
|
||||||
fetch(path: string, init?: RequestInit): Promise<Response>;
|
|
||||||
/** The scanned router (pages/api/realtime/components). */
|
|
||||||
router: unknown;
|
|
||||||
/** Stop the server. */
|
|
||||||
close(): void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Boots the app at `projectRoot` on an ephemeral port (`port: 0`) for integration
|
|
||||||
tests covering pages, API routes, middleware, and the full request pipeline. Loads
|
|
||||||
env and app config for the given `profile` (default `"test"`) so it picks up your
|
|
||||||
test database/env. The server runs in `development` mode with HMR disabled.
|
|
||||||
Remember to `await app.close()` when done.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { test, expect, renderComponent, mountHtml, createHarness } from "@wrnexus/test";
|
|
||||||
|
|
||||||
test("counter renders its label", async () => {
|
|
||||||
const html = await renderComponent(SRC, { start: 3, label: "Hits" });
|
|
||||||
expect(html).toContain("Hits");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reactive scope hydrates", () => {
|
|
||||||
const { querySelector } = mountHtml(serverHtml);
|
|
||||||
expect(querySelector("[data-text]")?.textContent).toBe("3");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("home page responds", async () => {
|
|
||||||
const app = await createHarness("examples/basic-app");
|
|
||||||
const res = await app.fetch("/");
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Calling an API route handler directly:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { test, expect, callRoute } from "@wrnexus/test";
|
|
||||||
import { GET } from "../app/api/health.ts";
|
|
||||||
|
|
||||||
test("health endpoint", async () => {
|
|
||||||
const res = await callRoute(GET, new Request("http://test/api/health"));
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Requirements / Notes
|
|
||||||
|
|
||||||
- **Bun-only.** Runs under `bun test` (via `wrnexus test`); uses Bun's module
|
|
||||||
loading and the `bun:test` runtime.
|
|
||||||
- `mountHtml` requires **`happy-dom`** to be available in the workspace (loaded
|
|
||||||
lazily; it's a dev dependency, not a runtime dependency of this package).
|
|
||||||
- Works with the rest of the WrNexus toolchain:
|
|
||||||
[`@wrnexus/compiler`](../compiler) (compiles `.wrn` sources),
|
|
||||||
[`@wrnexus/core`](../core) (`Context` / `createContext`),
|
|
||||||
[`@wrnexus/csr`](../csr) (reactive runtime for `mountHtml`),
|
|
||||||
[`@wrnexus/dev-server`](../dev-server) (`startServer` behind `createHarness`),
|
|
||||||
and [`@wrnexus/styles`](../styles) (config/env/profile loading for the harness).
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@wrnexus/test",
|
|
||||||
"version": "0.7.0",
|
|
||||||
"type": "module",
|
|
||||||
"description": "@wrnexus/test — part of the WrNexus framework.",
|
|
||||||
"license": "MIT",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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.7.0",
|
|
||||||
"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.3.0"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"registry": "https://registry.npmjs.org/",
|
|
||||||
"access": "restricted"
|
|
||||||
},
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,997 +0,0 @@
|
|||||||
# WRNexus UI component reference
|
|
||||||
|
|
||||||
This reference is generated from the 108 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)`
|
|
||||||
|
|
||||||
### CopyMarkup
|
|
||||||
|
|
||||||
Theme-aware, responsive copy markup component.
|
|
||||||
|
|
||||||
- Mount: `data-component="CopyMarkup"`
|
|
||||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Copy Markup"`, `name: string = ""`, `value: string = ""`, `placeholder: string = ""`, `type: string = "text"`, `min: string = ""`, `max: string = ""`, `step: string = ""`, `disabled: boolean = false`, `required: boolean = false`, `class: string = ""`
|
|
||||||
- Slots: None
|
|
||||||
- Outputs: `copy({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `success({ 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: None
|
|
||||||
|
|
||||||
### 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: None
|
|
||||||
|
|
||||||
### 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: None
|
|
||||||
|
|
||||||
### Toast
|
|
||||||
|
|
||||||
Theme-aware, responsive toast component.
|
|
||||||
|
|
||||||
- Mount: `data-component="Toast"`
|
|
||||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Toast"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
|
|
||||||
- Slots: `default`
|
|
||||||
- Outputs: `dismiss({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `action({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
|
||||||
|
|
||||||
### TreeView
|
|
||||||
|
|
||||||
Theme-aware, responsive tree view component.
|
|
||||||
|
|
||||||
- Mount: `data-component="TreeView"`
|
|
||||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Tree View"`, `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)`, `toggle({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `expand({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `collapse({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
|
||||||
|
|
||||||
## 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[])`
|
|
||||||
|
|
||||||
## 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: None
|
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
### AdvancedDatePicker
|
|
||||||
|
|
||||||
Theme-aware, responsive advanced date picker component.
|
|
||||||
|
|
||||||
- Mount: `data-component="AdvancedDatePicker"`
|
|
||||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Advanced Date Picker"`, `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)`, `open({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `close({ sourceEvent?: Event; [key: string]: 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)`
|
|
||||||
|
|
||||||
### AdvancedRangeSlider
|
|
||||||
|
|
||||||
Theme-aware, responsive advanced range slider component.
|
|
||||||
|
|
||||||
- Mount: `data-component="AdvancedRangeSlider"`
|
|
||||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Advanced Range Slider"`, `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)`, `start({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `end({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
|
||||||
|
|
||||||
### Chart
|
|
||||||
|
|
||||||
Theme-aware, responsive chart component.
|
|
||||||
|
|
||||||
- Mount: `data-component="Chart"`
|
|
||||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Chart"`, `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)`, `dataPointClick({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `legendToggle({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`
|
|
||||||
|
|
||||||
### Clipboard
|
|
||||||
|
|
||||||
Theme-aware, responsive clipboard component.
|
|
||||||
|
|
||||||
- Mount: `data-component="Clipboard"`
|
|
||||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Clipboard"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
|
|
||||||
- Slots: `default`
|
|
||||||
- Outputs: `copy({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `success({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `error({ error: Error | string; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | Error | string)`
|
|
||||||
|
|
||||||
### Confetti
|
|
||||||
|
|
||||||
Theme-aware, responsive confetti component.
|
|
||||||
|
|
||||||
- Mount: `data-component="Confetti"`
|
|
||||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Confetti"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
|
|
||||||
- Slots: `default`
|
|
||||||
- Outputs: `start({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `complete({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
|
||||||
|
|
||||||
### 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)`
|
|
||||||
|
|
||||||
### DataTable
|
|
||||||
|
|
||||||
Theme-aware, responsive data table component.
|
|
||||||
|
|
||||||
- Mount: `data-component="DataTable"`
|
|
||||||
- Props: `size: string = "default"`, `color: string = "primary"`, `caption: string = "Data Table"`, `columns: unknown[] = []`, `rows: unknown[] = []`, `striped: boolean = true`, `class: string = ""`
|
|
||||||
- Slots: `default`
|
|
||||||
- Outputs: `sort({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `select({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `rowClick({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `pageChange({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`
|
|
||||||
|
|
||||||
### DragAndDrop
|
|
||||||
|
|
||||||
Theme-aware, responsive drag and drop component.
|
|
||||||
|
|
||||||
- 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)`
|
|
||||||
|
|
||||||
### FileUpload
|
|
||||||
|
|
||||||
Theme-aware, responsive file upload component.
|
|
||||||
|
|
||||||
- Mount: `data-component="FileUpload"`
|
|
||||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "File Upload"`, `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)`, `upload({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `progress({ progress: number; [key: string]: string | number | boolean | null | object } | number)`, `success({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `error({ error: Error | string; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | Error | string)`, `cancel({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `remove({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
|
||||||
|
|
||||||
### 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: None
|
|
||||||
|
|
||||||
### ToastNotifications
|
|
||||||
|
|
||||||
Theme-aware, responsive toast notifications component.
|
|
||||||
|
|
||||||
- Mount: `data-component="ToastNotifications"`
|
|
||||||
- Props: `size: string = "default"`, `color: string = "primary"`, `title: string = "Toast Notifications"`, `description: string = ""`, `items: unknown[] = []`, `variant: string = "default"`, `class: string = ""`
|
|
||||||
- Slots: `default`
|
|
||||||
- Outputs: `add({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `dismiss({ sourceEvent?: Event; [key: string]: 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)`, `action({ 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"`, `class: string = ""`
|
|
||||||
- Slots: `default`
|
|
||||||
- Outputs: None
|
|
||||||
|
|
||||||
### CustomScrollbar
|
|
||||||
|
|
||||||
Theme-aware, responsive custom scrollbar component.
|
|
||||||
|
|
||||||
- Mount: `data-component="CustomScrollbar"`
|
|
||||||
- Props: `size: string = "default"`, `color: string = "primary"`, `columns: number = 2`, `gap: string = "md"`, `maxWidth: string = "xl"`, `class: string = ""`
|
|
||||||
- Slots: `default`
|
|
||||||
- Outputs: `scroll({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`
|
|
||||||
|
|
||||||
### Divider
|
|
||||||
|
|
||||||
Separate related horizontal or vertical content with optional labels, sizes, and semantic colors.
|
|
||||||
|
|
||||||
- Mount: `data-component="Divider"`
|
|
||||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = ""`, `orientation: string = "horizontal"`, `class: string = ""`
|
|
||||||
- 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"`, `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"`, `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: `size: string = "default"`, `color: string = "primary"`, `columns: number = 2`, `gap: string = "md"`, `maxWidth: string = "xl"`, `class: string = ""`
|
|
||||||
- Slots: `default`
|
|
||||||
- Outputs: `resizeStart({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `resize({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`, `resizeEnd({ sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])`
|
|
||||||
|
|
||||||
### Link
|
|
||||||
|
|
||||||
Render an accessible internal or external link with target, relation, size, color, and public focus or click events.
|
|
||||||
|
|
||||||
- Mount: `data-component="Link"`
|
|
||||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Link"`, `href: string = "#"`, `target: string = ""`, `rel: string = ""`, `external: boolean = false`, `class: string = ""`
|
|
||||||
- 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: `size: string = "default"`, `color: string = "primary"`, `label: string = "Mega Menu"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""`
|
|
||||||
- Slots: `default`
|
|
||||||
- Outputs: `open({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `close({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `select({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`
|
|
||||||
|
|
||||||
### Nav
|
|
||||||
|
|
||||||
Theme-aware, responsive nav component.
|
|
||||||
|
|
||||||
- Mount: `data-component="Nav"`
|
|
||||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Nav"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""`
|
|
||||||
- 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)`
|
|
||||||
|
|
||||||
### 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: `size: string = "default"`, `color: string = "primary"`, `label: string = "Pagination"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""`
|
|
||||||
- Slots: `default`
|
|
||||||
- Outputs: `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `previous({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `next({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
|
||||||
|
|
||||||
### Scrollspy
|
|
||||||
|
|
||||||
Theme-aware, responsive scrollspy component.
|
|
||||||
|
|
||||||
- Mount: `data-component="Scrollspy"`
|
|
||||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Scrollspy"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""`
|
|
||||||
- Slots: `default`
|
|
||||||
- Outputs: `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`
|
|
||||||
|
|
||||||
### Sidebar
|
|
||||||
|
|
||||||
Theme-aware, responsive sidebar component.
|
|
||||||
|
|
||||||
- Mount: `data-component="Sidebar"`
|
|
||||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Sidebar"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `mobileLabel: string = "Open navigation"`, `class: string = ""`
|
|
||||||
- Slots: `default`
|
|
||||||
- Outputs: `toggle({ open: boolean })`, `open({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `close({ source: string })`, `select({ item: string | number | boolean | null | object; value: string | number | boolean; level: string })`
|
|
||||||
|
|
||||||
### Stepper
|
|
||||||
|
|
||||||
Theme-aware, responsive stepper component.
|
|
||||||
|
|
||||||
- Mount: `data-component="Stepper"`
|
|
||||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Stepper"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""`
|
|
||||||
- Slots: `default`
|
|
||||||
- Outputs: `change({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `previous({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `next({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`, `complete({ sourceEvent?: Event; [key: string]: string | number | boolean | null | object })`
|
|
||||||
|
|
||||||
### Tabs
|
|
||||||
|
|
||||||
Switch between related responsive content panels with horizontal or vertical orientation and selection events.
|
|
||||||
|
|
||||||
- Mount: `data-component="Tabs"`
|
|
||||||
- Props: `size: string = "default"`, `color: string = "primary"`, `label: string = "Tabs"`, `items: unknown[] = []`, `active: string = ""`, `orientation: string = "horizontal"`, `class: string = ""`
|
|
||||||
- Slots: `default`
|
|
||||||
- Outputs: None
|
|
||||||
|
|
||||||
## 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`, `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`, `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
|
|
||||||
|
|
||||||
### Table
|
|
||||||
|
|
||||||
Theme-aware, responsive table component.
|
|
||||||
|
|
||||||
- Mount: `data-component="Table"`
|
|
||||||
- Props: `size: string = "default"`, `color: string = "primary"`, `caption: string = "Table"`, `columns: unknown[] = []`, `rows: unknown[] = []`, `striped: boolean = true`, `class: string = ""`
|
|
||||||
- Slots: `default`
|
|
||||||
- Outputs: `sort({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `select({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`, `rowClick({ value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)`
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
# @wrnexus/ui
|
|
||||||
|
|
||||||
> First-party Wire 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.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
`@wrnexus/ui` ships a library of server-rendered `.wrn` components (layout, form
|
|
||||||
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
|
|
||||||
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
|
|
||||||
bun add @wrnexus/ui
|
|
||||||
```
|
|
||||||
|
|
||||||
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
|
||||||
> (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
|
|
||||||
(see [Auto-discovery](#auto-discovery)).
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
| Name | Purpose | Key props |
|
|
||||||
| ----------- | ---------------------------------- | ----------- |
|
|
||||||
| `container` | Max-width centered content wrapper | `class` |
|
|
||||||
| `stack` | Vertical column with gap | `gap` (0–8) |
|
|
||||||
| `hstack` | Horizontal row with gap | `gap` (0–8) |
|
|
||||||
| `grid` | CSS grid container | see source |
|
|
||||||
| `divider` | Horizontal rule | `class` |
|
|
||||||
| `spacer` | Flexible/empty spacing element | see source |
|
|
||||||
|
|
||||||
### 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` |
|
|
||||||
|
|
||||||
### Additional controls & data display
|
|
||||||
|
|
||||||
Also shipped: `select`, `radio`, `switch`, `progress`, `tag`, `skeleton`,
|
|
||||||
`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 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 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
|
|
||||||
|
|
||||||
`package.json` also exposes the raw stylesheet as a subpath asset:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"exports": {
|
|
||||||
".": "./src/index.ts",
|
|
||||||
"./ui.css": "./ui.css"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The framework serves this stylesheet once at `/__wrnexus/ui.css`, so pages get all
|
|
||||||
component styles from a single request.
|
|
||||||
|
|
||||||
### Tailwind and motion
|
|
||||||
|
|
||||||
Components use static Tailwind utility classes alongside the shared `.wire-*`
|
|
||||||
layer. If the package is consumed by a separate Tailwind build, include its
|
|
||||||
component sources so every utility is generated:
|
|
||||||
|
|
||||||
```css
|
|
||||||
@import "tailwindcss";
|
|
||||||
@source "../node_modules/@wrnexus/ui/components/*.wrn";
|
|
||||||
```
|
|
||||||
|
|
||||||
The shared stylesheet gives all component boundaries consistent, GPU-friendly
|
|
||||||
entry and interaction motion. Override `--wire-motion-fast`,
|
|
||||||
`--wire-motion-base`, `--wire-motion-slow`, `--wire-ease-standard`, or
|
|
||||||
`--wire-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(--wire-color-surface);
|
|
||||||
color: var(--wire-color-text);
|
|
||||||
border: 1px solid var(--wire-color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-card__action {
|
|
||||||
background: var(--wire-color-primary);
|
|
||||||
color: var(--wire-color-primary-contrast);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Stable no-spacing helper classes are also available: `wire-bg-page`,
|
|
||||||
`wire-bg-surface`, `wire-bg-surface-2`, `wire-bg-primary`, `wire-bg-secondary`,
|
|
||||||
`wire-text`, `wire-text-muted`, `wire-text-primary`, `wire-text-success`,
|
|
||||||
`wire-text-warning`, `wire-text-danger`, and `wire-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
|
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { buildRouter } from "@wrnexus/router";
|
|
||||||
import { uiComponentsDir } from "@wrnexus/ui";
|
|
||||||
|
|
||||||
const router = buildRouter(appDir, { componentDirs: [uiComponentsDir()] });
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mounting components in a page
|
|
||||||
|
|
||||||
Once discovered, mount any component by name via `data-component`. Quoted
|
|
||||||
attributes (other than `data-component`) become string props:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div data-component="card">
|
|
||||||
<div data-component="badge" label="New"></div>
|
|
||||||
<button data-component="button" label="Save" variant="primary" size="lg"></button>
|
|
||||||
<div data-component="alert" variant="success" title="Done" message="Saved."></div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Overrides
|
|
||||||
|
|
||||||
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
|
|
||||||
(including across theme switches).
|
|
||||||
2. **App CSS** — redefine a `.wire-*` 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
|
|
||||||
the base styles.
|
|
||||||
4. **`wrnexus eject <name>`** — copy the component's `.wrn` source into your
|
|
||||||
`app/components`, where (because app components shadow library ones) you fully
|
|
||||||
own and can edit it. Use `uiComponentNames()` for the list of ejectable names.
|
|
||||||
|
|
||||||
## Requirements / Notes
|
|
||||||
|
|
||||||
- **Bun-only** — the package uses standard fs/path/url APIs but is published and
|
|
||||||
consumed within the Bun-native WrNexus toolchain (Node is not supported).
|
|
||||||
- Peer packages: components are discovered and rendered by
|
|
||||||
[`@wrnexus/router`](../router) (via `componentDirs`) and served by
|
|
||||||
[`@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.
|
|
||||||
@@ -1,545 +0,0 @@
|
|||||||
{
|
|
||||||
"sourceDocuments": ["Screenshot-directed WRNexus UI catalog reset"],
|
|
||||||
"components": [
|
|
||||||
{
|
|
||||||
"name": "Accordion",
|
|
||||||
"category": "base",
|
|
||||||
"purpose": "Theme-aware, responsive accordion component."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "AdvancedDatePicker",
|
|
||||||
"category": "integrations",
|
|
||||||
"purpose": "Theme-aware, responsive advanced date picker component."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "AdvancedRangeSlider",
|
|
||||||
"category": "integrations",
|
|
||||||
"purpose": "Theme-aware, responsive advanced range slider 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": "CopyMarkup",
|
|
||||||
"category": "advanced-forms",
|
|
||||||
"purpose": "Theme-aware, responsive copy markup component."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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": "integrations",
|
|
||||||
"purpose": "Theme-aware, responsive data table component."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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": "FileUpload",
|
|
||||||
"category": "integrations",
|
|
||||||
"purpose": "Theme-aware, responsive file upload 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": "Table",
|
|
||||||
"category": "tables",
|
|
||||||
"purpose": "Theme-aware, responsive table 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": "Toast",
|
|
||||||
"category": "base",
|
|
||||||
"purpose": "Theme-aware, responsive toast component."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ToastNotifications",
|
|
||||||
"category": "integrations",
|
|
||||||
"purpose": "Theme-aware, responsive toast notifications 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."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"generatedFrom": "full component catalog reset",
|
|
||||||
"removedCount": 0,
|
|
||||||
"replacements": {}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,206 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
client function dispatchAccordionEvent(sourceEvent, eventName, value, item, root, customEvent) {
|
|
||||||
root = sourceEvent.currentTarget.closest("[data-wrn-accordion]")
|
|
||||||
|
|
||||||
if (!root) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
customEvent = document.createEvent("CustomEvent")
|
|
||||||
customEvent.initCustomEvent(eventName, true, false, {
|
|
||||||
component: "Accordion",
|
|
||||||
value: value,
|
|
||||||
item: item,
|
|
||||||
open: isOpen(value),
|
|
||||||
openValues: openValues
|
|
||||||
})
|
|
||||||
root.dispatchEvent(customEvent)
|
|
||||||
}
|
|
||||||
|
|
||||||
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="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--accordion wire-next--accordion-{variant} {bordered ? 'wire-next--accordion-bordered' : ''} {separated ? 'wire-next--accordion-separated' : ''} {flush ? 'wire-next--accordion-flush' : ''} {disabled ? 'wire-next--disabled' : ''} {class}"
|
|
||||||
>
|
|
||||||
{#each items as item, index}
|
|
||||||
<section
|
|
||||||
class="wire-next__accordion-item"
|
|
||||||
data-open="{isOpen(itemValue(item, index)) ? 'true' : 'false'}"
|
|
||||||
data-disabled="{disabled || item.disabled ? 'true' : 'false'}"
|
|
||||||
>
|
|
||||||
<h3 class="wire-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="wire-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="wire-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="wire-next__accordion-panel"
|
|
||||||
role="region"
|
|
||||||
aria-labelledby="{id}-trigger-{index}"
|
|
||||||
aria-hidden="{isOpen(itemValue(item, index)) ? 'false' : 'true'}"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div class="wire-next__accordion-content {contentItalic ? 'wire-next__accordion-content-italic' : ''}">
|
|
||||||
{#if item.content}<p>{item.content}</p>{/if}
|
|
||||||
|
|
||||||
{#if item.children && item.children.length > 0}
|
|
||||||
<div class="wire-next__accordion-nested">
|
|
||||||
{#each item.children as child, childIndex}
|
|
||||||
<section
|
|
||||||
class="wire-next__accordion-item"
|
|
||||||
data-open="{isOpen(nestedValue(item, index, child, childIndex)) ? 'true' : 'false'}"
|
|
||||||
>
|
|
||||||
<h4 class="wire-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="wire-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="wire-next__accordion-panel"
|
|
||||||
role="region"
|
|
||||||
aria-labelledby="{id}-trigger-{index}-{childIndex}"
|
|
||||||
aria-hidden="{isOpen(nestedValue(item, index, child, childIndex)) ? 'false' : 'true'}"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div class="wire-next__accordion-content {contentItalic ? 'wire-next__accordion-content-italic' : ''}">
|
|
||||||
<p>{child.content}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
component AdvancedDatePicker {
|
|
||||||
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)
|
|
||||||
open(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
|
||||||
close(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
props {
|
|
||||||
size: string = "default"
|
|
||||||
color: string = "primary"
|
|
||||||
title: string = "Advanced Date Picker"
|
|
||||||
description: string = ""
|
|
||||||
items: unknown[] = []
|
|
||||||
variant: string = "default"
|
|
||||||
class: string = ""
|
|
||||||
}
|
|
||||||
view {
|
|
||||||
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--advanced-date-picker wire-next--variant-{variant} {class}">
|
|
||||||
{#if title}<strong>{title}</strong>{/if}
|
|
||||||
{#if description}<p>{description}</p>{/if}
|
|
||||||
{#if items}<div class="wire-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if}
|
|
||||||
<slot />
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
component AdvancedRangeSlider {
|
|
||||||
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)
|
|
||||||
start(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
|
||||||
end(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
|
||||||
}
|
|
||||||
|
|
||||||
props {
|
|
||||||
size: string = "default"
|
|
||||||
color: string = "primary"
|
|
||||||
title: string = "Advanced Range Slider"
|
|
||||||
description: string = ""
|
|
||||||
items: unknown[] = []
|
|
||||||
variant: string = "default"
|
|
||||||
class: string = ""
|
|
||||||
}
|
|
||||||
view {
|
|
||||||
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--advanced-range-slider wire-next--variant-{variant} {class}">
|
|
||||||
{#if title}<strong>{title}</strong>{/if}
|
|
||||||
{#if description}<p>{description}</p>{/if}
|
|
||||||
{#if items}<div class="wire-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if}
|
|
||||||
<slot />
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,459 +0,0 @@
|
|||||||
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="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--advanced-select {open ? 'wire-next--open' : ''} {fixed ? 'wire-next--advanced-select-fixed' : ''} {invalid ? 'wire-next--invalid' : ''} {disabled ? 'wire-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="wire-next__row">
|
|
||||||
<label id="{name}-label" for="{name}-trigger">{label}</label>
|
|
||||||
<small data-show="showCounter" data-text="counterText()">{counterText()}</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="wire-next__select-control">
|
|
||||||
<button
|
|
||||||
{...attrs}
|
|
||||||
id="{name}-trigger"
|
|
||||||
type="button"
|
|
||||||
class="wire-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="wire-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="wire-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="wire-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="wire-next__placeholder">{placeholder}</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="wire-next__select-chevron icon-[lucide--chevrons-up-down]"
|
|
||||||
aria-hidden="true"
|
|
||||||
></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="wire-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="wire-next__select-dropdown {fixed ? 'wire-next__select-dropdown--fixed' : ''}"
|
|
||||||
data-show="open"
|
|
||||||
style="{open ? '' : 'display: none'}"
|
|
||||||
>
|
|
||||||
{#if searchable}
|
|
||||||
<label class="wire-next__select-search">
|
|
||||||
<span class="wire-visually-hidden">{searchPlaceholder}</span>
|
|
||||||
<span class="wire-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="wire-next__select-message"
|
|
||||||
data-show="remote && query.length !== 0 && query.length < minSearchLength"
|
|
||||||
>Enter at least {minSearchLength} characters.</div>
|
|
||||||
<div class="wire-next__select-message" data-show="loading" role="status">
|
|
||||||
<i class="wire-spinner wire-spinner--inline" aria-hidden="true"></i>
|
|
||||||
{loadingLabel}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
id="{name}-listbox"
|
|
||||||
class="wire-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="wire-next__option-group" role="group" aria-label="{group.label}">
|
|
||||||
<div class="wire-next__group-label">{group.label}</div>
|
|
||||||
{#each group.options || [] as option}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="wire-next__select-option {isSelected(option) ? 'wire-next__select-option--selected' : ''} {optionIndex(option) === activeIndex ? 'wire-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="wire-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="wire-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="wire-next__select-option {isSelected(option) ? 'wire-next__select-option--selected' : ''} {optionIndex(option) === activeIndex ? 'wire-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="wire-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="wire-next__check icon-[lucide--check]"
|
|
||||||
data-show="isSelected(option)"
|
|
||||||
aria-hidden="true"
|
|
||||||
></i>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if !loading && !flatVisibleOptions().length}
|
|
||||||
<div class="wire-next__select-message">{emptyLabel}</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if tags && query && !allOptions().some((option) => option.label.toLowerCase() === query.toLowerCase())}
|
|
||||||
<button type="button" class="wire-next__create-option">{createLabel} “{query}”</button>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="wire-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="wire-next__validation"
|
|
||||||
data-error="{name}"
|
|
||||||
>{validationMessage}</small>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
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(sourceEvent, root, customEvent) {
|
|
||||||
overflowOpen = !overflowOpen
|
|
||||||
root = sourceEvent.currentTarget.closest("[data-wrn-avatar-group]")
|
|
||||||
|
|
||||||
customEvent = document.createEvent("CustomEvent")
|
|
||||||
customEvent.initCustomEvent("overflow", true, false, {
|
|
||||||
component: "AvatarGroup",
|
|
||||||
open: overflowOpen,
|
|
||||||
hiddenCount: hiddenMembers().length
|
|
||||||
})
|
|
||||||
root.dispatchEvent(customEvent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
view {
|
|
||||||
<div
|
|
||||||
{...attrs}
|
|
||||||
class="wire-next wire-next--avatar-group {class}"
|
|
||||||
data-wrn-avatar-group
|
|
||||||
data-layout="{layout}"
|
|
||||||
data-shape="{shape}"
|
|
||||||
data-size="{size}"
|
|
||||||
style="--wire-avatar-group-columns:{columns}; --wire-avatar-group-ring:{borderColor || 'var(--wire-color-bg)'}"
|
|
||||||
role="group"
|
|
||||||
aria-label="Avatar group"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="wire-next__avatar-group-members"
|
|
||||||
>
|
|
||||||
{#each visibleMembers() as item}
|
|
||||||
<span
|
|
||||||
class="wire-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="wire-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="wire-next__avatar-group-tooltip"
|
|
||||||
role="tooltip"
|
|
||||||
>
|
|
||||||
{item.tooltip || item.name}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if hiddenMembers().length > 0}
|
|
||||||
<span
|
|
||||||
class="wire-next__avatar-group-overflow"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="wire-next__avatar-group-overflow-button"
|
|
||||||
aria-label="{overflowLabel}"
|
|
||||||
aria-expanded="{overflowOpen ? 'true' : 'false'}"
|
|
||||||
@click="toggleOverflow(event)"
|
|
||||||
>
|
|
||||||
+{hiddenMembers().length}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span
|
|
||||||
class="wire-next__avatar-group-menu"
|
|
||||||
role="menu"
|
|
||||||
data-show="overflowOpen"
|
|
||||||
aria-hidden="{overflowOpen ? 'false' : 'true'}"
|
|
||||||
>
|
|
||||||
{#each hiddenMembers() as item}
|
|
||||||
<span
|
|
||||||
class="wire-next__avatar-group-menu-item"
|
|
||||||
role="menuitem"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="wire-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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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="wire-next wire-next--blockquote wire-next--color-{color} wire-next--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="wire-next__blockquote-mark" aria-hidden="true">“</span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="wire-next__blockquote-copy">
|
|
||||||
{#if quote}
|
|
||||||
<p>{quote}</p>
|
|
||||||
{:else}
|
|
||||||
<slot />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</blockquote>
|
|
||||||
|
|
||||||
{#if citation || citationTitle || avatarSrc}
|
|
||||||
<figcaption class="wire-next__blockquote-citation">
|
|
||||||
{#if avatarSrc}
|
|
||||||
<img
|
|
||||||
class="wire-next__blockquote-avatar"
|
|
||||||
src="{avatarSrc}"
|
|
||||||
alt="{avatarAlt}"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<span class="wire-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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
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='wire-breadcrumb {class}'
|
|
||||||
>
|
|
||||||
<ol class="wire-breadcrumb__list">
|
|
||||||
{#if showHome}
|
|
||||||
<li class="wire-breadcrumb__item wire-breadcrumb__item--home">
|
|
||||||
<a
|
|
||||||
href='{homeHref || "/"}'
|
|
||||||
class="wire-breadcrumb__link wire-breadcrumb__home"
|
|
||||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: { label: homeLabel, href: homeHref || "/", value: "home" }, itemIndex: -1 } }))'
|
|
||||||
>
|
|
||||||
{#if homeIcon === "icon-[lucide--house]"}
|
|
||||||
<span class="icon-[lucide--house] wire-breadcrumb__icon" aria-hidden="true"></span>
|
|
||||||
{:else if homeIcon}
|
|
||||||
<span class='{homeIcon} wire-breadcrumb__icon' aria-hidden="true"></span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<span class="wire-breadcrumb__home-label">{homeLabel}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#each items as item, itemIndex}
|
|
||||||
<li
|
|
||||||
class="wire-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="wire-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] wire-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="wire-breadcrumb__link"
|
|
||||||
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: item, itemIndex: itemIndex } }))'
|
|
||||||
>
|
|
||||||
{#if item.icon}
|
|
||||||
<span class='{item.icon} wire-breadcrumb__icon' aria-hidden="true"></span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<span class="wire-breadcrumb__label">{item.label || item.title}</span>
|
|
||||||
|
|
||||||
{#if item.external}
|
|
||||||
<span class="icon-[lucide--arrow-up-right] wire-breadcrumb__external" aria-hidden="true"></span>
|
|
||||||
{/if}
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<span
|
|
||||||
class="wire-breadcrumb__current"
|
|
||||||
aria-current='{item.active || item.current || (active && active === (item.value || item.label || item.title)) || (!active && itemIndex === items.length - 1) ? "page" : ""}'
|
|
||||||
aria-disabled='{item.disabled ? "true" : "false"}'
|
|
||||||
>
|
|
||||||
{#if item.icon}
|
|
||||||
<span class='{item.icon} wire-breadcrumb__icon' aria-hidden="true"></span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<span class="wire-breadcrumb__label">{item.label || item.title}</span>
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
}
|
|
||||||
|
|
||||||
style {
|
|
||||||
.wire-breadcrumb {
|
|
||||||
--breadcrumb-accent: var(--wire-color-primary);
|
|
||||||
--breadcrumb-soft: var(--wire-color-primary-soft);
|
|
||||||
--breadcrumb-muted: var(--wire-color-primary-muted);
|
|
||||||
--breadcrumb-text: var(--wire-color-primary-text);
|
|
||||||
|
|
||||||
min-width: 0;
|
|
||||||
color: var(--wire-color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb[data-color="secondary"] {
|
|
||||||
--breadcrumb-accent: var(--wire-color-secondary);
|
|
||||||
--breadcrumb-soft: var(--wire-color-secondary-soft);
|
|
||||||
--breadcrumb-muted: var(--wire-color-secondary-muted);
|
|
||||||
--breadcrumb-text: var(--wire-color-secondary-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb[data-color="info"] {
|
|
||||||
--breadcrumb-accent: var(--wire-color-info);
|
|
||||||
--breadcrumb-soft: var(--wire-color-info-soft);
|
|
||||||
--breadcrumb-muted: var(--wire-color-info-muted);
|
|
||||||
--breadcrumb-text: var(--wire-color-info-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb[data-color="success"] {
|
|
||||||
--breadcrumb-accent: var(--wire-color-success);
|
|
||||||
--breadcrumb-soft: var(--wire-color-success-soft);
|
|
||||||
--breadcrumb-muted: var(--wire-color-success-muted);
|
|
||||||
--breadcrumb-text: var(--wire-color-success-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb[data-color="warning"] {
|
|
||||||
--breadcrumb-accent: var(--wire-color-warning);
|
|
||||||
--breadcrumb-soft: var(--wire-color-warning-soft);
|
|
||||||
--breadcrumb-muted: var(--wire-color-warning-muted);
|
|
||||||
--breadcrumb-text: var(--wire-color-warning-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb[data-color="danger"] {
|
|
||||||
--breadcrumb-accent: var(--wire-color-danger);
|
|
||||||
--breadcrumb-soft: var(--wire-color-danger-soft);
|
|
||||||
--breadcrumb-muted: var(--wire-color-danger-muted);
|
|
||||||
--breadcrumb-text: var(--wire-color-danger-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb[data-variant="outline"] {
|
|
||||||
width: fit-content;
|
|
||||||
max-width: 100%;
|
|
||||||
padding: 0.45rem 0.65rem;
|
|
||||||
background: var(--wire-color-surface-raised);
|
|
||||||
border: 1px solid var(--wire-color-border);
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
box-shadow: var(--wire-shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb[data-variant="contrast"] {
|
|
||||||
color: rgba(255, 255, 255, 0.78);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb__list::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb__item {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb__separator {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-inline: 0.45rem;
|
|
||||||
color: var(--wire-color-text-subtle);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
opacity: 0.72;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb__separator-icon {
|
|
||||||
width: 0.9rem;
|
|
||||||
height: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb__link,
|
|
||||||
.wire-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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb[data-size="sm"] .wire-breadcrumb__link,
|
|
||||||
.wire-breadcrumb[data-size="sm"] .wire-breadcrumb__current {
|
|
||||||
min-height: 1.75rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb[data-size="lg"] .wire-breadcrumb__link,
|
|
||||||
.wire-breadcrumb[data-size="lg"] .wire-breadcrumb__current {
|
|
||||||
min-height: 2.25rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb__link {
|
|
||||||
color: var(--wire-color-text-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
transition:
|
|
||||||
color 160ms ease,
|
|
||||||
background 160ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb__link:hover {
|
|
||||||
color: var(--breadcrumb-accent);
|
|
||||||
background: var(--breadcrumb-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb__link:focus-visible {
|
|
||||||
color: var(--breadcrumb-accent);
|
|
||||||
outline: 2px solid var(--breadcrumb-accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb__current {
|
|
||||||
max-width: 22rem;
|
|
||||||
color: var(--wire-color-text);
|
|
||||||
font-weight: 750;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb__item[data-current="true"] .wire-breadcrumb__current {
|
|
||||||
color: var(--breadcrumb-text);
|
|
||||||
background: var(--breadcrumb-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb__current[aria-disabled="true"] {
|
|
||||||
opacity: 0.58;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb__icon,
|
|
||||||
.wire-breadcrumb__external {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: 0.95rem;
|
|
||||||
height: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb__home {
|
|
||||||
color: var(--breadcrumb-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb__label {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb[data-variant="contrast"] .wire-breadcrumb__link,
|
|
||||||
.wire-breadcrumb[data-variant="contrast"] .wire-breadcrumb__current,
|
|
||||||
.wire-breadcrumb[data-variant="contrast"] .wire-breadcrumb__home {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb[data-variant="contrast"] .wire-breadcrumb__link:hover,
|
|
||||||
.wire-breadcrumb[data-variant="contrast"] .wire-breadcrumb__item[data-current="true"] .wire-breadcrumb__current {
|
|
||||||
color: #ffffff;
|
|
||||||
background: rgba(255, 255, 255, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb[data-variant="contrast"] .wire-breadcrumb__separator {
|
|
||||||
color: rgba(255, 255, 255, 0.62);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 639px) {
|
|
||||||
.wire-breadcrumb__home-label {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-breadcrumb__current {
|
|
||||||
max-width: 13rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.wire-breadcrumb__link {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
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="wire-next wire-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="wire-btn wire-btn--variant-{item.variant || variant} wire-btn--color-{item.color || color} wire-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="wire-btn__icon {item.icon}" aria-hidden="true"></span>
|
|
||||||
{/if}
|
|
||||||
<span class="wire-btn__label">{item.label}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
// 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(".wire-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(".wire-next__carousel-viewport")
|
|
||||||
slides = root.querySelectorAll(".wire-next__carousel-slide")
|
|
||||||
slide = slides[index]
|
|
||||||
if (viewport && slide) {
|
|
||||||
viewport.scrollTo({
|
|
||||||
left: slide.offsetLeft - (viewport.clientWidth - slide.clientWidth) / 2,
|
|
||||||
behavior: "smooth"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
thumbnails = root.querySelectorAll(".wire-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(".wire-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(".wire-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="wire-next wire-next--carousel wire-next--color-{color} wire-next--size-{size} wire-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="wire-next__carousel-header">
|
|
||||||
{#if title}<h3>{title}</h3>{/if}
|
|
||||||
{#if description}<p>{description}</p>{/if}
|
|
||||||
</header>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="wire-next__carousel-layout">
|
|
||||||
{#if thumbnails === "vertical"}
|
|
||||||
<div class="wire-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="wire-next__carousel-main">
|
|
||||||
<div class="wire-next__carousel-stage">
|
|
||||||
<div
|
|
||||||
class="wire-next__carousel-viewport"
|
|
||||||
tabindex="0"
|
|
||||||
@scroll="handleSnapScroll(event)"
|
|
||||||
@pointerdown="beginDrag(event)"
|
|
||||||
@pointermove="moveDrag(event)"
|
|
||||||
@pointerup="endDrag(event)"
|
|
||||||
@pointercancel="cancelDrag()"
|
|
||||||
@lostpointercapture="cancelDrag()"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="wire-next__carousel-track"
|
|
||||||
style="--wire-carousel-index: {currentIndex}; --wire-carousel-per-view: {slidesPerView}; --wire-carousel-gap: {gap}; --wire-carousel-drag-offset: {dragOffset}px"
|
|
||||||
>
|
|
||||||
{#each items as item, index}
|
|
||||||
<article
|
|
||||||
class="wire-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="wire-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="wire-next__carousel-control wire-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="wire-next__carousel-control wire-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="wire-next__carousel-counter" aria-live="polite">
|
|
||||||
{currentIndex + 1} / {items.length}
|
|
||||||
</output>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if showPagination}
|
|
||||||
<div class="wire-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="wire-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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
component Chart {
|
|
||||||
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)
|
|
||||||
dataPointClick(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
|
|
||||||
legendToggle(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"
|
|
||||||
title: string = "Chart"
|
|
||||||
description: string = ""
|
|
||||||
items: unknown[] = []
|
|
||||||
variant: string = "default"
|
|
||||||
class: string = ""
|
|
||||||
}
|
|
||||||
view {
|
|
||||||
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--chart wire-next--variant-{variant} {class}">
|
|
||||||
{#if title}<strong>{title}</strong>{/if}
|
|
||||||
{#if description}<p>{description}</p>{/if}
|
|
||||||
{#if items}<div class="wire-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if}
|
|
||||||
<slot />
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
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="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--chat-bubble wire-next--variant-{variant} {class}"
|
|
||||||
data-one-sided="{oneSided}"
|
|
||||||
role="log"
|
|
||||||
aria-label="{ariaLabel}"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
{#if title || description}
|
|
||||||
<header class="wire-next__chat-header">
|
|
||||||
{#if title}<h3>{title}</h3>{/if}
|
|
||||||
{#if description}<p>{description}</p>{/if}
|
|
||||||
</header>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if items.length}
|
|
||||||
<div class="wire-next__chat-thread">
|
|
||||||
{#each items as item, index}
|
|
||||||
<article
|
|
||||||
class="wire-next__chat-message"
|
|
||||||
data-direction="{messageDirection(item)}"
|
|
||||||
aria-label="{messageDirection(item) === 'outgoing' ? 'Sent message' : 'Received message'}"
|
|
||||||
>
|
|
||||||
<div class="wire-next__chat-row">
|
|
||||||
{#if showAvatars && (item.avatarSrc || item.avatarFallback)}
|
|
||||||
<button
|
|
||||||
class="wire-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="wire-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="wire-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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
component Clipboard {
|
|
||||||
outputs {
|
|
||||||
copy(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
|
|
||||||
success(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"
|
|
||||||
title: string = "Clipboard"
|
|
||||||
description: string = ""
|
|
||||||
items: unknown[] = []
|
|
||||||
variant: string = "default"
|
|
||||||
class: string = ""
|
|
||||||
}
|
|
||||||
view {
|
|
||||||
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--clipboard wire-next--variant-{variant} {class}">
|
|
||||||
{#if title}<strong>{title}</strong>{/if}
|
|
||||||
{#if description}<p>{description}</p>{/if}
|
|
||||||
{#if items}<div class="wire-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if}
|
|
||||||
<slot />
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
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="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--collapse {class}"
|
|
||||||
data-mode="{mode}"
|
|
||||||
aria-label="{ariaLabel}"
|
|
||||||
>
|
|
||||||
{#each items as item, index}
|
|
||||||
<article class="wire-next__collapse-item" data-open="{isOpen(index)}">
|
|
||||||
{#if mode === "inline" && item.preview}
|
|
||||||
<p class="wire-next__collapse-preview">{item.preview}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="wire-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] wire-next__collapse-chevron" aria-hidden="true"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
id="{item.id || 'collapse-panel-' + index}"
|
|
||||||
class="wire-next__collapse-panel"
|
|
||||||
data-open="{isOpen(index)}"
|
|
||||||
aria-hidden="{isOpen(index) ? 'false' : 'true'}"
|
|
||||||
inert="{!isOpen(index)}"
|
|
||||||
>
|
|
||||||
<div class="wire-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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--field wire-next--color-picker {class}" data-variant="{variant}" data-inline="{inline}" data-invalid="{error ? 'true' : 'false'}">
|
|
||||||
<div class="wire-next__field-heading"><label class="{hiddenLabel ? 'wire-next__sr-only' : ''}" for="{id || name}">{label}</label>{#if cornerHint}<span class="wire-next__field-hint">{cornerHint}</span>{/if}</div>
|
|
||||||
<div class="wire-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="wire-next__field-help">{helperText}</small>{/if}<small class="wire-next__field-error" data-error="{name}">{error}</small>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
component Columns {
|
|
||||||
props {
|
|
||||||
size: string = "default"
|
|
||||||
color: string = "primary"
|
|
||||||
columns: number = 2
|
|
||||||
gap: string = "md"
|
|
||||||
maxWidth: string = "xl"
|
|
||||||
class: string = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
view {
|
|
||||||
<div
|
|
||||||
data-ui-component="Columns"
|
|
||||||
data-size='{size}'
|
|
||||||
data-color='{color}'
|
|
||||||
data-columns='{columns}'
|
|
||||||
data-gap='{gap}'
|
|
||||||
class='grid w-full grid-cols-1 items-start {class}'
|
|
||||||
class:max-w-3xl='maxWidth === "md"'
|
|
||||||
class:max-w-5xl='maxWidth === "lg"'
|
|
||||||
class:max-w-7xl='maxWidth === "xl"'
|
|
||||||
class:max-w-screen-2xl='maxWidth === "2xl"'
|
|
||||||
class:max-w-none='maxWidth === "full"'
|
|
||||||
class:md:grid-cols-2='columns >= 2'
|
|
||||||
class:lg:grid-cols-3='columns === 3'
|
|
||||||
class:lg:grid-cols-4='columns === 4'
|
|
||||||
class:gap-3='gap === "sm"'
|
|
||||||
class:gap-6='gap === "md"'
|
|
||||||
class:gap-10='gap === "lg"'
|
|
||||||
class:gap-14='gap === "xl"'
|
|
||||||
>
|
|
||||||
<slot></slot>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
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="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--advanced-select wire-next--combobox {open ? 'wire-next--open' : ''} {fixed ? 'wire-next--advanced-select-fixed' : ''} {invalid ? 'wire-next--invalid' : ''} {disabled ? 'wire-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="wire-next__select-control wire-next__combobox-control">
|
|
||||||
<span class="wire-next__search-icon icon-[lucide--search]" aria-hidden="true"></span>
|
|
||||||
<input
|
|
||||||
{...attrs}
|
|
||||||
id="{name}-input"
|
|
||||||
class="wire-next__select-trigger wire-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="wire-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="wire-next__combobox-toggle"
|
|
||||||
aria-label="{toggleLabel}"
|
|
||||||
tabindex="-1"
|
|
||||||
disabled="{disabled}"
|
|
||||||
@click="toggle()"
|
|
||||||
>
|
|
||||||
<span class="wire-next__select-chevron icon-[lucide--chevrons-up-down]" aria-hidden="true"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="wire-next__select-dropdown {fixed ? 'wire-next__select-dropdown--fixed' : ''}"
|
|
||||||
data-show="shouldShowDropdown()"
|
|
||||||
style="{shouldShowDropdown() ? '' : 'display: none'}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="wire-next__select-message"
|
|
||||||
data-show="remote && query.length !== 0 && query.length < minSearchLength"
|
|
||||||
>Enter at least {minSearchLength} characters.</div>
|
|
||||||
<div class="wire-next__select-message" data-show="loading" role="status">
|
|
||||||
<i class="wire-spinner wire-spinner--inline" aria-hidden="true"></i>
|
|
||||||
{loadingLabel}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
id="{name}-listbox"
|
|
||||||
class="wire-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="wire-next__option-group" role="group" aria-label="{group.label}">
|
|
||||||
<div class="wire-next__group-label">{group.label}</div>
|
|
||||||
{#each group.options || [] as option}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="wire-next__select-option {isSelected(option) ? 'wire-next__select-option--selected' : ''} {optionIndex(option) === activeIndex ? 'wire-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="wire-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="wire-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="wire-next__select-option {isSelected(option) ? 'wire-next__select-option--selected' : ''} {optionIndex(option) === activeIndex ? 'wire-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="wire-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="wire-next__check icon-[lucide--check]" data-show="isSelected(option)" aria-hidden="true"></i>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="wire-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="wire-next__validation"
|
|
||||||
data-error="{name}"
|
|
||||||
>{validationMessage}</small>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
component Confetti {
|
|
||||||
outputs {
|
|
||||||
start(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
|
||||||
complete(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
|
||||||
}
|
|
||||||
|
|
||||||
props {
|
|
||||||
size: string = "default"
|
|
||||||
color: string = "primary"
|
|
||||||
title: string = "Confetti"
|
|
||||||
description: string = ""
|
|
||||||
items: unknown[] = []
|
|
||||||
variant: string = "default"
|
|
||||||
class: string = ""
|
|
||||||
}
|
|
||||||
view {
|
|
||||||
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--confetti wire-next--variant-{variant} {class}">
|
|
||||||
{#if title}<strong>{title}</strong>{/if}
|
|
||||||
{#if description}<p>{description}</p>{/if}
|
|
||||||
{#if items}<div class="wire-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if}
|
|
||||||
<slot />
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,521 +0,0 @@
|
|||||||
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) {
|
|
||||||
positionX = Math.max(12, Math.min(sourceEvent.clientX || 12, window.innerWidth - 340))
|
|
||||||
positionY = Math.max(12, Math.min(sourceEvent.clientY || 12, window.innerHeight - 420))
|
|
||||||
}
|
|
||||||
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(".wire-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='wire-context-menu {class}'
|
|
||||||
style='--wire-context-x:{positionX}px;--wire-context-y:{positionY}px;--wire-context-min-width:{minWidth};--wire-context-max-width:{maxWidth};'
|
|
||||||
@keydown='handleKeydown(event)'
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="wire-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="wire-context-menu__dismiss-layer"
|
|
||||||
data-show='{open || visible}'
|
|
||||||
aria-label="Close context menu"
|
|
||||||
@click='hideMenu("outside", event)'
|
|
||||||
></button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="wire-context-menu__panel"
|
|
||||||
data-show='{open || visible}'
|
|
||||||
role="menu"
|
|
||||||
aria-label='{label}'
|
|
||||||
>
|
|
||||||
{#if title || description}
|
|
||||||
<div class="wire-context-menu__header">
|
|
||||||
{#if title}
|
|
||||||
<strong>{title}</strong>
|
|
||||||
{/if}
|
|
||||||
{#if description}
|
|
||||||
<span>{description}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<slot name="header"></slot>
|
|
||||||
|
|
||||||
<div class="wire-context-menu__items">
|
|
||||||
{#each items as item, itemIndex}
|
|
||||||
{#if item.type === "divider"}
|
|
||||||
<div class="wire-context-menu__divider" role="separator"></div>
|
|
||||||
{:else if item.type === "header"}
|
|
||||||
<div class="wire-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="wire-context-menu__item"
|
|
||||||
@click='chooseItem(item, itemIndex, event)'
|
|
||||||
>
|
|
||||||
{#if item.icon}
|
|
||||||
<span class='wire-context-menu__icon {item.icon}' aria-hidden="true"></span>
|
|
||||||
{/if}
|
|
||||||
<span class="wire-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] wire-context-menu__status" aria-hidden="true"></span>
|
|
||||||
{:else if item.external}
|
|
||||||
<span class="icon-[lucide--arrow-up-right] wire-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="wire-context-menu__item"
|
|
||||||
@click='chooseItem(item, itemIndex, event)'
|
|
||||||
>
|
|
||||||
{#if item.icon}
|
|
||||||
<span class='wire-context-menu__icon {item.icon}' aria-hidden="true"></span>
|
|
||||||
{/if}
|
|
||||||
<span class="wire-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] wire-context-menu__status" aria-hidden="true"></span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<slot></slot>
|
|
||||||
<slot name="footer"></slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
style {
|
|
||||||
.wire-context-menu {
|
|
||||||
--context-accent: var(--wire-color-primary);
|
|
||||||
--context-soft: var(--wire-color-primary-soft);
|
|
||||||
position: relative;
|
|
||||||
display: block;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu[data-color="secondary"] {
|
|
||||||
--context-accent: var(--wire-color-secondary);
|
|
||||||
--context-soft: var(--wire-color-secondary-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu[data-color="info"] {
|
|
||||||
--context-accent: var(--wire-color-info);
|
|
||||||
--context-soft: var(--wire-color-info-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu[data-color="success"] {
|
|
||||||
--context-accent: var(--wire-color-success);
|
|
||||||
--context-soft: var(--wire-color-success-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu[data-color="warning"] {
|
|
||||||
--context-accent: var(--wire-color-warning);
|
|
||||||
--context-soft: var(--wire-color-warning-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu[data-color="danger"] {
|
|
||||||
--context-accent: var(--wire-color-danger);
|
|
||||||
--context-soft: var(--wire-color-danger-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu__trigger {
|
|
||||||
display: block;
|
|
||||||
min-width: 0;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu__trigger:focus-visible {
|
|
||||||
border-radius: 0.6rem;
|
|
||||||
outline: 2px solid var(--wire-color-focus);
|
|
||||||
outline-offset: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu__dismiss-layer {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1090;
|
|
||||||
appearance: none;
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu__panel {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 1091;
|
|
||||||
top: calc(100% + 0.5rem);
|
|
||||||
left: 0;
|
|
||||||
width: max-content;
|
|
||||||
min-width: var(--wire-context-min-width);
|
|
||||||
max-width: min(var(--wire-context-max-width), calc(100vw - 1.5rem));
|
|
||||||
padding: 0.45rem;
|
|
||||||
color: var(--wire-color-text);
|
|
||||||
background:
|
|
||||||
linear-gradient(145deg, color-mix(in srgb, var(--context-accent) 5%, transparent), transparent 58%),
|
|
||||||
color-mix(in srgb, var(--wire-color-surface-raised) 96%, transparent);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--context-accent) 18%, var(--wire-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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu[data-placement="pointer"] .wire-context-menu__panel {
|
|
||||||
position: fixed;
|
|
||||||
top: var(--wire-context-y);
|
|
||||||
left: var(--wire-context-x);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu[data-placement="bottom-end"] .wire-context-menu__panel {
|
|
||||||
right: 0;
|
|
||||||
left: auto;
|
|
||||||
transform-origin: top right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu[data-placement="top-start"] .wire-context-menu__panel {
|
|
||||||
top: auto;
|
|
||||||
bottom: calc(100% + 0.5rem);
|
|
||||||
transform-origin: bottom left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu[data-placement="top-end"] .wire-context-menu__panel {
|
|
||||||
top: auto;
|
|
||||||
right: 0;
|
|
||||||
bottom: calc(100% + 0.5rem);
|
|
||||||
left: auto;
|
|
||||||
transform-origin: bottom right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu[data-variant="soft"] .wire-context-menu__panel {
|
|
||||||
background:
|
|
||||||
linear-gradient(145deg, var(--context-soft), transparent 72%),
|
|
||||||
var(--wire-color-surface-raised);
|
|
||||||
box-shadow: 0 18px 48px color-mix(in srgb, black 16%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu[data-variant="outline"] .wire-context-menu__panel {
|
|
||||||
background: var(--wire-color-surface-raised);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu__header {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.2rem;
|
|
||||||
padding: 0.65rem 0.75rem 0.75rem;
|
|
||||||
border-bottom: 1px solid var(--wire-color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu__header strong {
|
|
||||||
font-size: 0.86rem;
|
|
||||||
font-weight: 650;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu__header span {
|
|
||||||
color: var(--wire-color-text-muted);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu__items {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.15rem;
|
|
||||||
padding-block: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu__section-label {
|
|
||||||
padding: 0.55rem 0.75rem 0.3rem;
|
|
||||||
color: var(--wire-color-text-muted);
|
|
||||||
font-size: 0.68rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu__divider {
|
|
||||||
height: 1px;
|
|
||||||
margin: 0.3rem 0.45rem;
|
|
||||||
background: var(--wire-color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-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(--wire-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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu__item:hover,
|
|
||||||
.wire-context-menu__item:focus-visible,
|
|
||||||
.wire-context-menu__item[data-selected="true"] {
|
|
||||||
color: var(--context-accent);
|
|
||||||
background: var(--context-soft);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu__item:active {
|
|
||||||
transform: scale(0.985);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu__item[data-danger="true"] {
|
|
||||||
color: var(--wire-color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu__item[data-danger="true"]:hover,
|
|
||||||
.wire-context-menu__item[data-danger="true"]:focus-visible {
|
|
||||||
background: var(--wire-color-danger-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu__item[disabled],
|
|
||||||
.wire-context-menu__item[aria-disabled="true"] {
|
|
||||||
opacity: 0.48;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu__icon,
|
|
||||||
.wire-context-menu__status {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
color: currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu__copy {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.12rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu__copy strong {
|
|
||||||
overflow: hidden;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.3;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu__copy small {
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--wire-color-text-muted);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
line-height: 1.35;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu kbd {
|
|
||||||
padding: 0.16rem 0.38rem;
|
|
||||||
color: var(--wire-color-text-muted);
|
|
||||||
background: var(--wire-color-surface-soft);
|
|
||||||
border: 1px solid var(--wire-color-border);
|
|
||||||
border-radius: 0.36rem;
|
|
||||||
font-size: 0.64rem;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu[data-size="sm"] .wire-context-menu__item {
|
|
||||||
padding: 0.52rem 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-context-menu[data-size="lg"] .wire-context-menu__item {
|
|
||||||
padding: 0.78rem 0.82rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 639px) {
|
|
||||||
.wire-context-menu[data-placement="pointer"] .wire-context-menu__panel {
|
|
||||||
right: 0.75rem;
|
|
||||||
bottom: 0.75rem;
|
|
||||||
left: 0.75rem;
|
|
||||||
top: auto;
|
|
||||||
width: auto;
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.wire-context-menu__item {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
component CopyMarkup {
|
|
||||||
outputs {
|
|
||||||
copy(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
|
|
||||||
success(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 = "Copy Markup"
|
|
||||||
name: string = ""
|
|
||||||
value: string = ""
|
|
||||||
placeholder: string = ""
|
|
||||||
type: string = "text"
|
|
||||||
min: string = ""
|
|
||||||
max: string = ""
|
|
||||||
step: string = ""
|
|
||||||
disabled: boolean = false
|
|
||||||
required: boolean = false
|
|
||||||
class: string = ""
|
|
||||||
}
|
|
||||||
view {
|
|
||||||
<label class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--field wire-next--copy-markup {class}"><span>{label}</span><input {...attrs} type="{type}" name="{name}" value="{value}" placeholder="{placeholder}" min="{min}" max="{max}" step="{step}" disabled="{disabled}" required="{required}" /></label>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
component CustomScrollbar {
|
|
||||||
outputs {
|
|
||||||
scroll(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
|
|
||||||
}
|
|
||||||
|
|
||||||
props {
|
|
||||||
size: string = "default"
|
|
||||||
color: string = "primary"
|
|
||||||
columns: number = 2
|
|
||||||
gap: string = "md"
|
|
||||||
maxWidth: string = "xl"
|
|
||||||
class: string = ""
|
|
||||||
}
|
|
||||||
view {
|
|
||||||
<div class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--custom-scrollbar wire-next--gap-{gap} wire-next--columns-{columns} wire-next--max-{maxWidth} {class}"><slot /></div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--data-map wire-next--variant-{variant} {class}">
|
|
||||||
{#if title}<strong>{title}</strong>{/if}
|
|
||||||
{#if description}<p>{description}</p>{/if}
|
|
||||||
{#if items}<div class="wire-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if}
|
|
||||||
<slot />
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
component DataTable {
|
|
||||||
outputs {
|
|
||||||
sort(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
|
|
||||||
select(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
|
|
||||||
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)
|
|
||||||
rowClick(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
|
|
||||||
pageChange(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
|
|
||||||
}
|
|
||||||
|
|
||||||
props {
|
|
||||||
size: string = "default"
|
|
||||||
color: string = "primary"
|
|
||||||
caption: string = "Data Table"
|
|
||||||
columns: unknown[] = []
|
|
||||||
rows: unknown[] = []
|
|
||||||
striped: boolean = true
|
|
||||||
class: string = ""
|
|
||||||
}
|
|
||||||
view {
|
|
||||||
<div class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--table {class}"><table><caption>{caption}</caption><thead><tr>{#each columns as column}<th>{column.label}</th>{/each}</tr></thead><tbody>{#each rows as row}<tr>{#each columns as column}<td>{row[column.key]}</td>{/each}</tr>{/each}</tbody></table><slot /></div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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(".wire-next--date-picker"); input = root.querySelector(".wire-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(".wire-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="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--field wire-next--date-picker {class}" data-variant="{variant}" data-inline="{inline}" data-invalid="{error ? 'true' : 'false'}" data-expanded="{expanded}">
|
|
||||||
<div class="wire-next__field-heading"><label for="{id || name}">{label}</label>{#if cornerHint}<span class="wire-next__field-hint">{cornerHint}</span>{/if}</div>
|
|
||||||
<div class="wire-next__picker-control"><input id="{id || name}" class="wire-next__sr-only wire-next__picker-value" type="text" name="{name}" value="{currentValue}" required="{required}" readonly aria-hidden="true" tabindex="-1" @invalid="handleInvalid(event)" /><button type="button" class="wire-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="wire-next__picker-clear" aria-label="Clear date" @click="clearDate(event)">×</button>{/if}</div>
|
|
||||||
<div class="wire-next__calendar" role="dialog" aria-label="{label}"><div class="wire-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="wire-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="wire-next__date-done" disabled="{!currentValue}" @click="finishDate(event)">Done</button></div>
|
|
||||||
{#if helperText}<small class="wire-next__field-help">{helperText}</small>{/if}<small class="wire-next__field-error" data-error="{name}">{error}</small>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--device-frame wire-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="wire-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="wire-next__device-shell">{#if src || srcdoc}<iframe title="{frameTitle}" src="{src}" srcdoc="{srcdoc}" allow="{allow}"></iframe>{:else}<div class="wire-next__device-content"><slot /></div>{/if}</div>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--drag-and-drop wire-next--variant-{variant} {class}">
|
|
||||||
{#if title}<strong>{title}</strong>{/if}
|
|
||||||
{#if description}<p>{description}</p>{/if}
|
|
||||||
{#if items}<div class="wire-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if}
|
|
||||||
<slot />
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,453 +0,0 @@
|
|||||||
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
|
|
||||||
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='wire-drawer {class}'
|
|
||||||
>
|
|
||||||
{#if triggerLabel}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="wire-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="wire-drawer__trigger-slot" @click='showDrawer(event)'>
|
|
||||||
<slot name="trigger"></slot>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="wire-drawer__layer"
|
|
||||||
data-show='{open || visible}'
|
|
||||||
role="presentation"
|
|
||||||
@keydown='handleKeydown(event)'
|
|
||||||
>
|
|
||||||
{#if overlay}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="wire-drawer__backdrop"
|
|
||||||
aria-label='{closeLabel}'
|
|
||||||
@click='if (closeOnBackdrop) { cancelDrawer(event) }'
|
|
||||||
></button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<section
|
|
||||||
class="wire-drawer__panel"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label='{label || title}'
|
|
||||||
tabindex="-1"
|
|
||||||
>
|
|
||||||
<div class="wire-drawer__handle" aria-hidden="true"></div>
|
|
||||||
|
|
||||||
<header class="wire-drawer__header">
|
|
||||||
<div class="wire-drawer__heading">
|
|
||||||
{#if icon}
|
|
||||||
<span class='wire-drawer__icon {icon}' aria-hidden="true"></span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="wire-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="wire-drawer__close"
|
|
||||||
aria-label='{closeLabel}'
|
|
||||||
@click='hideDrawer("close-button", event)'
|
|
||||||
>
|
|
||||||
<span class="icon-[lucide--x]" aria-hidden="true"></span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="wire-drawer__body">
|
|
||||||
<slot></slot>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="wire-drawer__footer">
|
|
||||||
<slot name="footer"></slot>
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
style {
|
|
||||||
.wire-drawer {
|
|
||||||
--drawer-accent: var(--wire-color-primary);
|
|
||||||
--drawer-soft: var(--wire-color-primary-soft);
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer[data-color="secondary"] {
|
|
||||||
--drawer-accent: var(--wire-color-secondary);
|
|
||||||
--drawer-soft: var(--wire-color-secondary-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer[data-color="info"] {
|
|
||||||
--drawer-accent: var(--wire-color-info);
|
|
||||||
--drawer-soft: var(--wire-color-info-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer[data-color="success"] {
|
|
||||||
--drawer-accent: var(--wire-color-success);
|
|
||||||
--drawer-soft: var(--wire-color-success-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer[data-color="warning"] {
|
|
||||||
--drawer-accent: var(--wire-color-warning);
|
|
||||||
--drawer-soft: var(--wire-color-warning-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer[data-color="danger"] {
|
|
||||||
--drawer-accent: var(--wire-color-danger);
|
|
||||||
--drawer-soft: var(--wire-color-danger-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer__trigger,
|
|
||||||
.wire-drawer__trigger-slot {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.55rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer__trigger {
|
|
||||||
appearance: none;
|
|
||||||
min-height: 2.55rem;
|
|
||||||
padding: 0.65rem 1rem;
|
|
||||||
color: var(--wire-color-primary-contrast);
|
|
||||||
background: var(--drawer-accent);
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0.8rem;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 650;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer__layer {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1200;
|
|
||||||
display: flex;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer[data-overlay="false"] .wire-drawer__backdrop {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-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(--wire-color-text);
|
|
||||||
background:
|
|
||||||
radial-gradient(
|
|
||||||
circle at 100% 0%,
|
|
||||||
color-mix(in srgb, var(--drawer-accent) 8%, transparent),
|
|
||||||
transparent 34%
|
|
||||||
),
|
|
||||||
var(--wire-color-surface-raised);
|
|
||||||
border-left: 1px solid color-mix(in srgb, var(--drawer-accent) 18%, var(--wire-color-border));
|
|
||||||
box-shadow: -28px 0 80px color-mix(in srgb, black 28%, transparent);
|
|
||||||
pointer-events: auto;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer[data-size="sm"] .wire-drawer__panel {
|
|
||||||
width: min(22rem, calc(100vw - 1rem));
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer[data-size="lg"] .wire-drawer__panel {
|
|
||||||
width: min(38rem, calc(100vw - 1rem));
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer[data-size="xl"] .wire-drawer__panel {
|
|
||||||
width: min(52rem, calc(100vw - 1rem));
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer[data-size="full"] .wire-drawer__panel {
|
|
||||||
width: 100vw;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer[data-placement="left"] .wire-drawer__panel {
|
|
||||||
margin-right: auto;
|
|
||||||
margin-left: 0;
|
|
||||||
border-right: 1px solid color-mix(in srgb, var(--drawer-accent) 18%, var(--wire-color-border));
|
|
||||||
border-left: 0;
|
|
||||||
box-shadow: 28px 0 80px color-mix(in srgb, black 28%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer[data-placement="top"] .wire-drawer__layer,
|
|
||||||
.wire-drawer[data-placement="bottom"] .wire-drawer__layer {
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer[data-placement="top"] .wire-drawer__panel,
|
|
||||||
.wire-drawer[data-placement="bottom"] .wire-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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer[data-placement="top"] .wire-drawer__panel {
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--drawer-accent) 18%, var(--wire-color-border));
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer[data-placement="bottom"] .wire-drawer__layer {
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer[data-placement="bottom"] .wire-drawer__panel {
|
|
||||||
border-top: 1px solid color-mix(in srgb, var(--drawer-accent) 18%, var(--wire-color-border));
|
|
||||||
border-radius: 1.3rem 1.3rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer[data-variant="soft"] .wire-drawer__panel {
|
|
||||||
background:
|
|
||||||
linear-gradient(145deg, var(--drawer-soft), transparent 65%),
|
|
||||||
var(--wire-color-surface-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer[data-variant="solid"] .wire-drawer__panel {
|
|
||||||
color: var(--wire-color-primary-contrast);
|
|
||||||
background: var(--drawer-accent);
|
|
||||||
border-color: color-mix(in srgb, white 18%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer__handle {
|
|
||||||
display: none;
|
|
||||||
width: 2.75rem;
|
|
||||||
height: 0.28rem;
|
|
||||||
margin: 0.55rem auto 0;
|
|
||||||
background: color-mix(in srgb, var(--wire-color-text-muted) 48%, transparent);
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer[data-placement="bottom"] .wire-drawer__handle {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-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(--wire-color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer__heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.85rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer__icon {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
margin-top: 0.15rem;
|
|
||||||
color: var(--drawer-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer[data-variant="solid"] .wire-drawer__icon {
|
|
||||||
color: currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer__heading-copy {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.3rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer__heading-copy h2,
|
|
||||||
.wire-drawer__heading-copy p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer__heading-copy h2 {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 650;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer__heading-copy p {
|
|
||||||
color: var(--wire-color-text-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer[data-variant="solid"] .wire-drawer__heading-copy p {
|
|
||||||
color: color-mix(in srgb, currentColor 76%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer__close {
|
|
||||||
appearance: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: 2.35rem;
|
|
||||||
height: 2.35rem;
|
|
||||||
color: var(--wire-color-text-muted);
|
|
||||||
background: var(--wire-color-surface-soft);
|
|
||||||
border: 1px solid var(--wire-color-border);
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer__close:hover,
|
|
||||||
.wire-drawer__close:focus-visible {
|
|
||||||
color: var(--drawer-accent);
|
|
||||||
border-color: color-mix(in srgb, var(--drawer-accent) 34%, var(--wire-color-border));
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer__body {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 1.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer[data-scrollable="true"] .wire-drawer__body {
|
|
||||||
overflow: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer__footer {
|
|
||||||
padding: 1rem 1.35rem 1.35rem;
|
|
||||||
border-top: 1px solid var(--wire-color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer__footer:empty {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 639px) {
|
|
||||||
.wire-drawer[data-placement="right"] .wire-drawer__panel,
|
|
||||||
.wire-drawer[data-placement="left"] .wire-drawer__panel {
|
|
||||||
width: min(24rem, 100vw);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-drawer__header,
|
|
||||||
.wire-drawer__body,
|
|
||||||
.wire-drawer__footer {
|
|
||||||
padding-inline: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,572 +0,0 @@
|
|||||||
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(".wire-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(".wire-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(".wire-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='wire-dropdown {class}'
|
|
||||||
@keydown='handleKeydown(event)'
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="wire-dropdown__trigger"
|
|
||||||
disabled='{disabled}'
|
|
||||||
aria-haspopup="menu"
|
|
||||||
aria-expanded='{open || visible ? "true" : "false"}'
|
|
||||||
@click='toggleMenu(event)'
|
|
||||||
>
|
|
||||||
<slot name="trigger"></slot>
|
|
||||||
{#if icon}
|
|
||||||
<span class='wire-dropdown__trigger-icon {icon}' aria-hidden="true"></span>
|
|
||||||
{/if}
|
|
||||||
{#if label}
|
|
||||||
<span class="wire-dropdown__trigger-label">{label}</span>
|
|
||||||
{/if}
|
|
||||||
{#if showChevron}
|
|
||||||
<span class="icon-[lucide--chevron-down] wire-dropdown__chevron" aria-hidden="true"></span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if closeOnOutside}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="wire-dropdown__dismiss-layer"
|
|
||||||
data-show='{open || visible}'
|
|
||||||
aria-label="Close menu"
|
|
||||||
@click='hideMenu("outside", event)'
|
|
||||||
></button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="wire-dropdown__panel"
|
|
||||||
data-show='{open || visible}'
|
|
||||||
role="menu"
|
|
||||||
aria-label='{menuLabel}'
|
|
||||||
>
|
|
||||||
<slot name="header"></slot>
|
|
||||||
|
|
||||||
<div class="wire-dropdown__items">
|
|
||||||
{#each items as item, itemIndex}
|
|
||||||
{#if item.type === "divider"}
|
|
||||||
<div class="wire-dropdown__divider" role="separator"></div>
|
|
||||||
{:else if item.type === "header"}
|
|
||||||
<div class="wire-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="wire-dropdown__item"
|
|
||||||
@click='chooseItem(item, itemIndex, event)'
|
|
||||||
>
|
|
||||||
{#if item.icon}
|
|
||||||
<span class='wire-dropdown__item-icon {item.icon}' aria-hidden="true"></span>
|
|
||||||
{/if}
|
|
||||||
<span class="wire-dropdown__copy">
|
|
||||||
<strong>{item.label || item.title}</strong>
|
|
||||||
{#if item.description}
|
|
||||||
<small>{item.description}</small>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{#if item.badge}
|
|
||||||
<span class="wire-dropdown__badge">{item.badge}</span>
|
|
||||||
{:else if item.shortcut}
|
|
||||||
<kbd>{item.shortcut}</kbd>
|
|
||||||
{:else if item.checked || item.selected}
|
|
||||||
<span class="icon-[lucide--check] wire-dropdown__status" aria-hidden="true"></span>
|
|
||||||
{:else if item.external}
|
|
||||||
<span class="icon-[lucide--arrow-up-right] wire-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="wire-dropdown__item"
|
|
||||||
@click='chooseItem(item, itemIndex, event)'
|
|
||||||
>
|
|
||||||
{#if item.icon}
|
|
||||||
<span class='wire-dropdown__item-icon {item.icon}' aria-hidden="true"></span>
|
|
||||||
{/if}
|
|
||||||
<span class="wire-dropdown__copy">
|
|
||||||
<strong>{item.label || item.title}</strong>
|
|
||||||
{#if item.description}
|
|
||||||
<small>{item.description}</small>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{#if item.badge}
|
|
||||||
<span class="wire-dropdown__badge">{item.badge}</span>
|
|
||||||
{:else if item.shortcut}
|
|
||||||
<kbd>{item.shortcut}</kbd>
|
|
||||||
{:else if item.checked || item.selected}
|
|
||||||
<span class="icon-[lucide--check] wire-dropdown__status" aria-hidden="true"></span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{:empty}
|
|
||||||
<div class="wire-dropdown__empty">{emptyLabel}</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<slot></slot>
|
|
||||||
<slot name="footer"></slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
style {
|
|
||||||
.wire-dropdown {
|
|
||||||
--dropdown-accent: var(--wire-color-primary);
|
|
||||||
--dropdown-soft: var(--wire-color-primary-soft);
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown[data-color="secondary"] {
|
|
||||||
--dropdown-accent: var(--wire-color-secondary);
|
|
||||||
--dropdown-soft: var(--wire-color-secondary-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown[data-color="info"] {
|
|
||||||
--dropdown-accent: var(--wire-color-info);
|
|
||||||
--dropdown-soft: var(--wire-color-info-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown[data-color="success"] {
|
|
||||||
--dropdown-accent: var(--wire-color-success);
|
|
||||||
--dropdown-soft: var(--wire-color-success-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown[data-color="warning"] {
|
|
||||||
--dropdown-accent: var(--wire-color-warning);
|
|
||||||
--dropdown-soft: var(--wire-color-warning-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown[data-color="danger"] {
|
|
||||||
--dropdown-accent: var(--wire-color-danger);
|
|
||||||
--dropdown-soft: var(--wire-color-danger-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-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(--wire-color-text);
|
|
||||||
background: var(--wire-color-surface-raised);
|
|
||||||
border: 1px solid var(--wire-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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown__trigger:hover,
|
|
||||||
.wire-dropdown__trigger:focus-visible,
|
|
||||||
.wire-dropdown[data-open="true"] .wire-dropdown__trigger {
|
|
||||||
color: var(--dropdown-accent);
|
|
||||||
background: var(--dropdown-soft);
|
|
||||||
border-color: color-mix(in srgb, var(--dropdown-accent) 38%, var(--wire-color-border));
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown__trigger:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown__trigger-icon,
|
|
||||||
.wire-dropdown__chevron {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown__chevron {
|
|
||||||
transition: transform 160ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown[data-open="true"] .wire-dropdown__chevron {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown[data-size="sm"] .wire-dropdown__trigger {
|
|
||||||
min-height: 2.2rem;
|
|
||||||
padding: 0.5rem 0.72rem;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown[data-size="lg"] .wire-dropdown__trigger {
|
|
||||||
min-height: 2.9rem;
|
|
||||||
padding: 0.78rem 1.05rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown__dismiss-layer {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1100;
|
|
||||||
appearance: none;
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-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(--wire-color-text);
|
|
||||||
background:
|
|
||||||
linear-gradient(145deg, color-mix(in srgb, var(--dropdown-accent) 5%, transparent), transparent 60%),
|
|
||||||
color-mix(in srgb, var(--wire-color-surface-raised) 97%, transparent);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--dropdown-accent) 18%, var(--wire-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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown[data-width="trigger"] .wire-dropdown__panel {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown[data-width="sm"] .wire-dropdown__panel {
|
|
||||||
width: 12rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown[data-width="md"] .wire-dropdown__panel {
|
|
||||||
width: 17rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown[data-width="lg"] .wire-dropdown__panel {
|
|
||||||
width: 22rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown[data-placement="bottom-end"] .wire-dropdown__panel {
|
|
||||||
right: 0;
|
|
||||||
left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown[data-placement="top-start"] .wire-dropdown__panel {
|
|
||||||
top: auto;
|
|
||||||
bottom: calc(100% + 0.55rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown[data-placement="top-end"] .wire-dropdown__panel {
|
|
||||||
top: auto;
|
|
||||||
right: 0;
|
|
||||||
bottom: calc(100% + 0.55rem);
|
|
||||||
left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown[data-variant="soft"] .wire-dropdown__panel {
|
|
||||||
background:
|
|
||||||
linear-gradient(145deg, var(--dropdown-soft), transparent 70%),
|
|
||||||
var(--wire-color-surface-raised);
|
|
||||||
box-shadow: 0 18px 48px color-mix(in srgb, black 16%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown[data-variant="outline"] .wire-dropdown__panel {
|
|
||||||
background: var(--wire-color-surface-raised);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown__items {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown__section-label {
|
|
||||||
padding: 0.55rem 0.72rem 0.3rem;
|
|
||||||
color: var(--wire-color-text-muted);
|
|
||||||
font-size: 0.68rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown__divider {
|
|
||||||
height: 1px;
|
|
||||||
margin: 0.3rem 0.4rem;
|
|
||||||
background: var(--wire-color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-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(--wire-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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown__item:hover,
|
|
||||||
.wire-dropdown__item:focus-visible,
|
|
||||||
.wire-dropdown__item[data-selected="true"] {
|
|
||||||
color: var(--dropdown-accent);
|
|
||||||
background: var(--dropdown-soft);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown__item:active {
|
|
||||||
transform: scale(0.985);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown__item[data-danger="true"] {
|
|
||||||
color: var(--wire-color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown__item[data-danger="true"]:hover,
|
|
||||||
.wire-dropdown__item[data-danger="true"]:focus-visible {
|
|
||||||
background: var(--wire-color-danger-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown__item[disabled],
|
|
||||||
.wire-dropdown__item[aria-disabled="true"] {
|
|
||||||
opacity: 0.48;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown__item-icon,
|
|
||||||
.wire-dropdown__status {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown__copy {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.12rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown__copy strong {
|
|
||||||
overflow: hidden;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.3;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown__copy small {
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--wire-color-text-muted);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
line-height: 1.35;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown kbd {
|
|
||||||
padding: 0.16rem 0.38rem;
|
|
||||||
color: var(--wire-color-text-muted);
|
|
||||||
background: var(--wire-color-surface-soft);
|
|
||||||
border: 1px solid var(--wire-color-border);
|
|
||||||
border-radius: 0.36rem;
|
|
||||||
font-size: 0.64rem;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wire-dropdown__empty {
|
|
||||||
padding: 1rem;
|
|
||||||
color: var(--wire-color-text-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 639px) {
|
|
||||||
.wire-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) {
|
|
||||||
.wire-dropdown__trigger,
|
|
||||||
.wire-dropdown__chevron,
|
|
||||||
.wire-dropdown__item {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--field wire-next--file-input {class}" data-variant="{variant}" data-inline="{inline}" data-invalid="{error ? 'true' : 'false'}">
|
|
||||||
<div class="wire-next__field-heading"><label class="{hiddenLabel ? 'wire-next__sr-only' : ''}" for="{id || name}">{label}</label>{#if cornerHint}<span class="wire-next__field-hint">{cornerHint}</span>{/if}</div>
|
|
||||||
<div class="wire-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="wire-next__field-help">{helperText}</small>{/if}<small class="wire-next__field-error" data-error="{name}">{error}</small>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
component FileUpload {
|
|
||||||
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)
|
|
||||||
upload(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
|
||||||
progress(payload: { progress: number; [key: string]: string | number | boolean | null | object } | number)
|
|
||||||
success(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)
|
|
||||||
cancel(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
|
||||||
remove(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
|
|
||||||
}
|
|
||||||
|
|
||||||
props {
|
|
||||||
size: string = "default"
|
|
||||||
color: string = "primary"
|
|
||||||
title: string = "File Upload"
|
|
||||||
description: string = ""
|
|
||||||
items: unknown[] = []
|
|
||||||
variant: string = "default"
|
|
||||||
class: string = ""
|
|
||||||
}
|
|
||||||
view {
|
|
||||||
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--file-upload wire-next--variant-{variant} {class}">
|
|
||||||
{#if title}<strong>{title}</strong>{/if}
|
|
||||||
{#if description}<p>{description}</p>{/if}
|
|
||||||
{#if items}<div class="wire-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if}
|
|
||||||
<slot />
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--file-upload-progress {class}" data-status="{currentStatus}" aria-live="polite">
|
|
||||||
<div class="wire-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="wire-next__upload-status"><span class="wire-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="wire-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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
component Image {
|
|
||||||
props {
|
|
||||||
size: string = "default"
|
|
||||||
color: string = "primary"
|
|
||||||
src: string = ""
|
|
||||||
alt: string = ""
|
|
||||||
width: string = ""
|
|
||||||
height: string = ""
|
|
||||||
loading: string = "lazy"
|
|
||||||
rounded: boolean = false
|
|
||||||
class: string = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
view {
|
|
||||||
<figure
|
|
||||||
data-ui-component="Image"
|
|
||||||
class='relative m-0 overflow-hidden bg-[var(--wire-color-surface-soft)] {class}'
|
|
||||||
class:rounded-2xl='rounded'
|
|
||||||
>
|
|
||||||
{#if src}
|
|
||||||
<img
|
|
||||||
src='{src}'
|
|
||||||
alt='{alt}'
|
|
||||||
width='{width}'
|
|
||||||
height='{height}'
|
|
||||||
loading='{loading}'
|
|
||||||
decoding="async"
|
|
||||||
class="block h-auto w-full object-cover transition duration-300"
|
|
||||||
class:aspect-square='size === "square"'
|
|
||||||
class:aspect-video='size === "video"'
|
|
||||||
class:aspect-[4/3]='size === "landscape"'
|
|
||||||
class:aspect-[3/4]='size === "portrait"'
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="flex min-h-48 w-full items-center justify-center text-[var(--wire-color-text-muted)]"
|
|
||||||
role="img"
|
|
||||||
aria-label='{alt || "Image placeholder"}'
|
|
||||||
>
|
|
||||||
<span class="icon-[lucide--image] size-8" aria-hidden="true"></span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<slot></slot>
|
|
||||||
</figure>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--field wire-next--input-group {class}" data-variant="{variant}" data-inline="{inline}" data-invalid="{error ? 'true' : 'false'}">
|
|
||||||
<div class="wire-next__field-heading"><label class="{hiddenLabel ? 'wire-next__sr-only' : ''}" for="{id || name}">{label}</label>{#if cornerHint}<span class="wire-next__field-hint">{cornerHint}</span>{/if}</div>
|
|
||||||
<div class="wire-next__input-group-control">
|
|
||||||
{#if startText}<span class="wire-next__input-addon">{startText}</span>{/if}
|
|
||||||
{#if icon}<span class="{icon} wire-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="wire-next__input-addon">{endText}</span>{/if}
|
|
||||||
{#if actionLabel}<button type="button" disabled="{disabled}" @click="handleAction(event)">{actionLabel}</button>{/if}
|
|
||||||
</div>
|
|
||||||
{#if helperText}<small class="wire-next__field-help">{helperText}</small>{/if}<small class="wire-next__field-error" data-error="{name}">{error}</small>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,626 +0,0 @@
|
|||||||
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(--wire-color-secondary)"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (color === "success") {
|
|
||||||
return "var(--wire-color-success)"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (color === "warning") {
|
|
||||||
return "var(--wire-color-warning)"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (color === "danger") {
|
|
||||||
return "var(--wire-color-danger)"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (color === "info") {
|
|
||||||
return "var(--wire-color-info)"
|
|
||||||
}
|
|
||||||
|
|
||||||
return "var(--wire-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))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
client function dispatchInputNumberEvent(
|
|
||||||
sourceEvent,
|
|
||||||
eventName,
|
|
||||||
action,
|
|
||||||
previousValue,
|
|
||||||
root,
|
|
||||||
customEvent
|
|
||||||
) {
|
|
||||||
root = sourceEvent.currentTarget.closest("[data-wrn-input-number]")
|
|
||||||
|
|
||||||
if (!root && sourceEvent.target) {
|
|
||||||
root = sourceEvent.target.closest("[data-wrn-input-number]")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!root) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
customEvent = document.createEvent("CustomEvent")
|
|
||||||
customEvent.initCustomEvent(eventName, true, false, {
|
|
||||||
component: "InputNumber",
|
|
||||||
name: name,
|
|
||||||
value: currentValue,
|
|
||||||
previousValue: previousValue,
|
|
||||||
action: action,
|
|
||||||
min: hasMin() ? Number(min) : null,
|
|
||||||
max: hasMax() ? Number(max) : null,
|
|
||||||
step: normalizedStep(),
|
|
||||||
valid: !isInvalid()
|
|
||||||
})
|
|
||||||
root.dispatchEvent(customEvent)
|
|
||||||
}
|
|
||||||
|
|
||||||
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"}'
|
|
||||||
style='--input-number-accent: {componentColor()};'
|
|
||||||
class='relative flex flex-col gap-1.5 text-[var(--wire-color-text)] {fullWidth ? "w-full" : variant === "compact" ? "w-fit max-w-full" : "w-full max-w-sm"} {disabled ? "opacity-60" : ""} {class}'
|
|
||||||
>
|
|
||||||
{#if label !== "" && variant !== "labeled" && variant !== "seat"}
|
|
||||||
<label
|
|
||||||
for='{inputId()}'
|
|
||||||
class='inline-flex items-center gap-1 text-sm font-semibold leading-5 text-[var(--wire-color-text)]'
|
|
||||||
>
|
|
||||||
<span>{label}</span>
|
|
||||||
{#if required}
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
class='text-[var(--wire-color-danger)]'
|
|
||||||
>*</span>
|
|
||||||
{/if}
|
|
||||||
</label>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if description !== "" && variant !== "labeled" && variant !== "seat"}
|
|
||||||
<p
|
|
||||||
id='{descriptionId()}'
|
|
||||||
class='m-0 text-xs leading-5 text-[var(--wire-color-muted)]'
|
|
||||||
>
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div
|
|
||||||
class='group flex min-w-0 overflow-hidden border bg-[var(--wire-color-surface)] text-[var(--wire-color-text)] shadow-[var(--wire-shadow-1)] transition-[border-color,box-shadow,background-color] duration-[var(--wire-motion-base)] ease-[var(--wire-ease-standard)] focus-within:border-[var(--input-number-accent)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--input-number-accent)_18%,transparent)] {variant === "compact" ? "rounded-full" : "rounded-[var(--wire-radius-sm)]"} {size === "xs" ? "min-h-8 text-xs" : size === "sm" ? "min-h-9 text-sm" : size === "lg" ? "min-h-12 text-base" : size === "xl" ? "min-h-14 text-lg" : "min-h-10 text-sm"} {isInvalid() ? "border-[var(--wire-color-danger)] focus-within:border-[var(--wire-color-danger)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--wire-color-danger)_18%,transparent)]" : "border-[var(--wire-color-border)]"} {disabled ? "cursor-not-allowed bg-[var(--wire-color-surface-2)]" : ""}'
|
|
||||||
>
|
|
||||||
{#if variant === "horizontal" && showButtons}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label='{decrementLabel}'
|
|
||||||
aria-controls='{inputId()}'
|
|
||||||
disabled='{decrementDisabled()}'
|
|
||||||
@click='decrementValue(event)'
|
|
||||||
class='inline-flex shrink-0 items-center justify-center border-r border-[var(--wire-color-border)] bg-transparent text-[var(--wire-color-muted)] transition-[color,background-color] duration-[var(--wire-motion-fast)] hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--input-number-accent)] focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--input-number-accent)] disabled:cursor-not-allowed disabled:opacity-40 {size === "xs" ? "w-8" : size === "sm" ? "w-9" : size === "lg" ? "w-12" : size === "xl" ? "w-14" : "w-10"}'
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
class='icon-[lucide--minus] size-4'
|
|
||||||
></span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div
|
|
||||||
class='flex min-w-0 flex-1 {variant === "labeled" ? "flex-col items-stretch justify-center gap-0.5" : variant === "seat" ? "items-center justify-between gap-3" : "items-center"} {size === "xs" ? "px-2" : size === "sm" ? "px-2.5" : size === "lg" ? "px-4" : size === "xl" ? "px-5" : "px-3"}'
|
|
||||||
>
|
|
||||||
{#if variant === "labeled" || variant === "seat"}
|
|
||||||
<div class='min-w-0 flex-1'>
|
|
||||||
{#if label !== ""}
|
|
||||||
<label
|
|
||||||
for='{inputId()}'
|
|
||||||
class='block truncate font-semibold leading-4 text-[var(--wire-color-text)] {variant === "labeled" ? "text-xs font-medium text-[var(--wire-color-muted)]" : "text-sm"}'
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
{#if required}
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
class='ml-0.5 text-[var(--wire-color-danger)]'
|
|
||||||
>*</span>
|
|
||||||
{/if}
|
|
||||||
</label>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if description !== ""}
|
|
||||||
<span
|
|
||||||
id='{descriptionId()}'
|
|
||||||
class='block truncate text-xs leading-4 text-[var(--wire-color-muted)]'
|
|
||||||
>
|
|
||||||
{description}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div
|
|
||||||
class='flex min-w-0 items-center {variant === "seat" ? "w-auto shrink-0" : "w-full"}'
|
|
||||||
>
|
|
||||||
{#if prefix !== ""}
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
class='shrink-0 pr-1.5 text-[var(--wire-color-muted)]'
|
|
||||||
>
|
|
||||||
{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='min-w-0 flex-1 appearance-none border-0 bg-transparent p-0 font-medium leading-none text-[var(--wire-color-text)] outline-none placeholder:text-[var(--wire-color-muted)] read-only:cursor-default disabled:cursor-not-allowed [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none {variant === "horizontal" || variant === "compact" || variant === "seat" ? "text-center" : "text-left"} {variant === "seat" ? "w-10 flex-none" : "w-full"}'
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if suffix !== ""}
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
class='shrink-0 pl-1.5 text-[var(--wire-color-muted)]'
|
|
||||||
>
|
|
||||||
{suffix}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if variant === "horizontal" && showButtons}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label='{incrementLabel}'
|
|
||||||
aria-controls='{inputId()}'
|
|
||||||
disabled='{incrementDisabled()}'
|
|
||||||
@click='incrementValue(event)'
|
|
||||||
class='inline-flex shrink-0 items-center justify-center border-l border-[var(--wire-color-border)] bg-transparent text-[var(--wire-color-muted)] transition-[color,background-color] duration-[var(--wire-motion-fast)] hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--input-number-accent)] focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--input-number-accent)] disabled:cursor-not-allowed disabled:opacity-40 {size === "xs" ? "w-8" : size === "sm" ? "w-9" : size === "lg" ? "w-12" : size === "xl" ? "w-14" : "w-10"}'
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
class='icon-[lucide--plus] size-4'
|
|
||||||
></span>
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
{#if showButtons}
|
|
||||||
<div
|
|
||||||
role="group"
|
|
||||||
aria-label='{controlsLabel}'
|
|
||||||
class='flex shrink-0 border-l border-[var(--wire-color-border)] {variant === "vertical" ? "flex-col" : "flex-row"}'
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label='{decrementLabel}'
|
|
||||||
aria-controls='{inputId()}'
|
|
||||||
disabled='{decrementDisabled()}'
|
|
||||||
@click='decrementValue(event)'
|
|
||||||
class='inline-flex items-center justify-center bg-transparent text-[var(--wire-color-muted)] transition-[color,background-color] duration-[var(--wire-motion-fast)] hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--input-number-accent)] focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--input-number-accent)] disabled:cursor-not-allowed disabled:opacity-40 {variant === "vertical" ? "flex-1 border-b border-[var(--wire-color-border)]" : "border-r border-[var(--wire-color-border)]"} {size === "xs" ? "w-8" : size === "sm" ? "w-9" : size === "lg" ? "w-12" : size === "xl" ? "w-14" : "w-10"}'
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
class='icon-[lucide--minus] size-4'
|
|
||||||
></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label='{incrementLabel}'
|
|
||||||
aria-controls='{inputId()}'
|
|
||||||
disabled='{incrementDisabled()}'
|
|
||||||
@click='incrementValue(event)'
|
|
||||||
class='inline-flex items-center justify-center bg-transparent text-[var(--wire-color-muted)] transition-[color,background-color] duration-[var(--wire-motion-fast)] hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--input-number-accent)] focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--input-number-accent)] disabled:cursor-not-allowed disabled:opacity-40 {variant === "vertical" ? "flex-1" : ""} {size === "xs" ? "w-8" : size === "sm" ? "w-9" : size === "lg" ? "w-12" : size === "xl" ? "w-14" : "w-10"}'
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
class='icon-[lucide--plus] size-4'
|
|
||||||
></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if showValidationMessage && isInvalid() && validationMessage() !== ""}
|
|
||||||
<p
|
|
||||||
id='{messageId()}'
|
|
||||||
role="alert"
|
|
||||||
aria-live="polite"
|
|
||||||
class='m-0 flex items-center gap-1.5 text-xs leading-5 text-[var(--wire-color-danger)]'
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
class='icon-[lucide--circle-alert] size-3.5 shrink-0'
|
|
||||||
></span>
|
|
||||||
<span>{validationMessage()}</span>
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if (!showValidationMessage || !isInvalid() || validationMessage() === "") && helpText !== ""}
|
|
||||||
<p
|
|
||||||
id='{messageId()}'
|
|
||||||
class='m-0 text-xs leading-5 text-[var(--wire-color-muted)]'
|
|
||||||
>
|
|
||||||
{helpText}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
component Kbd {
|
|
||||||
props {
|
|
||||||
size: string = "default"
|
|
||||||
color: string = "primary"
|
|
||||||
label: string = "⌘ K"
|
|
||||||
class: string = ""
|
|
||||||
}
|
|
||||||
view { <kbd class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--kbd {class}"><slot>{label}</slot></kbd> }
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
component LayoutSplitter {
|
|
||||||
outputs {
|
|
||||||
resizeStart(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
|
|
||||||
resize(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
|
|
||||||
resizeEnd(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
|
|
||||||
}
|
|
||||||
|
|
||||||
props {
|
|
||||||
size: string = "default"
|
|
||||||
color: string = "primary"
|
|
||||||
columns: number = 2
|
|
||||||
gap: string = "md"
|
|
||||||
maxWidth: string = "xl"
|
|
||||||
class: string = ""
|
|
||||||
}
|
|
||||||
view {
|
|
||||||
<div class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--layout-splitter wire-next--gap-{gap} wire-next--columns-{columns} wire-next--max-{maxWidth} {class}"><slot /></div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--legend-indicator wire-next--variant-{variant} {class}">
|
|
||||||
{#if title}<strong>{title}</strong>{/if}
|
|
||||||
{#if description}<p>{description}</p>{/if}
|
|
||||||
{#if items}<div class="wire-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if}
|
|
||||||
<slot />
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
component Link {
|
|
||||||
props {
|
|
||||||
size: string = "default"
|
|
||||||
color: string = "primary"
|
|
||||||
label: string = "Link"
|
|
||||||
href: string = "#"
|
|
||||||
target: string = ""
|
|
||||||
rel: string = ""
|
|
||||||
external: boolean = false
|
|
||||||
class: string = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
view {
|
|
||||||
<a
|
|
||||||
data-ui-component="Link"
|
|
||||||
href='{href}'
|
|
||||||
target='{target}'
|
|
||||||
rel='{external ? (rel || "noopener noreferrer") : rel}'
|
|
||||||
class='inline-flex min-w-0 items-center gap-1.5 rounded-md font-semibold underline-offset-4 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--wire-color-background)] {class}'
|
|
||||||
class:text-xs='size === "xs"'
|
|
||||||
class:text-sm='size === "sm" || size === "default"'
|
|
||||||
class:text-base='size === "md"'
|
|
||||||
class:text-lg='size === "lg"'
|
|
||||||
class:text-[var(--wire-color-primary)]='color === "primary"'
|
|
||||||
class:hover:text-[var(--wire-color-primary-hover)]='color === "primary"'
|
|
||||||
class:text-[var(--wire-color-secondary)]='color === "secondary"'
|
|
||||||
class:hover:text-[var(--wire-color-secondary-hover)]='color === "secondary"'
|
|
||||||
class:text-[var(--wire-color-success)]='color === "success"'
|
|
||||||
class:text-[var(--wire-color-warning-text)]='color === "warning"'
|
|
||||||
class:text-[var(--wire-color-danger)]='color === "danger"'
|
|
||||||
class:text-[var(--wire-color-info)]='color === "info"'
|
|
||||||
class:text-[var(--wire-color-text)]='color === "neutral"'
|
|
||||||
class:hover:underline='external === false'
|
|
||||||
>
|
|
||||||
<span class="truncate">{label}</span>
|
|
||||||
|
|
||||||
{#if external}
|
|
||||||
<span
|
|
||||||
class="icon-[lucide--external-link] size-3.5 shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
></span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<slot></slot>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user