Compare commits

...
29 Commits
Author SHA1 Message Date
Clintchiz 72e4d3eceb release: WRNexusJS 0.8.0
Quality / quality (ubuntu-latest) (push) Failing after 12m19s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-04 12:19:09 +05:30
Clintchiz 4cebacadfe release: WRNexusJS 0.8.3
Quality / quality (ubuntu-latest) (push) Failing after 12m9s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 19:47:30 +05:30
Clintchiz e8f630f12d fix: format generated docs before release verification
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 02:32:11 +05:30
Clintchiz 4550a11460 release: WRNexusJS 0.8.2
Quality / quality (ubuntu-latest) (push) Failing after 22s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 02:14:54 +05:30
Clintchiz 3c6b659f36 release: WRNexusJS 0.8.1
Quality / quality (ubuntu-latest) (push) Failing after 13m28s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 01:34:56 +05:30
Clintchiz 1a1d2e9d08 perf: accelerate production request hot paths
Quality / quality (ubuntu-latest) (push) Failing after 12m23s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 01:08:43 +05:30
Clintchiz fed1d5d3f4 perf: omit unused UI CSS and minify final bundles
Quality / quality (ubuntu-latest) (push) Failing after 22s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 01:03:33 +05:30
Clintchiz b61020babd fix: update PWA workers without reloading pages
Quality / quality (ubuntu-latest) (push) Failing after 12m52s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 00:54:29 +05:30
Clintchiz 379f80cbd0 fix: force PWA worker updates
Quality / quality (ubuntu-latest) (push) Failing after 12m26s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 00:46:52 +05:30
Clintchiz 649e3d9127 fix: constrain PWA caching and extend font CSP
Quality / quality (ubuntu-latest) (push) Failing after 22s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 00:38:23 +05:30
Clintchiz b3e9b99e13 fix: harden generated identifiers and types
Quality / quality (ubuntu-latest) (push) Failing after 12m50s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 00:02:37 +05:30
Clintchiz 586a6db8ff release: WRNexusJS 0.8.0
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-02 23:18:51 +05:30
Clintchiz 87507edf59 release: WRNexusJS 0.7.0 2026-08-01 10:04:42 +05:30
Clintchiz c54144f2e4 fix(release): format generated UI references before verification 2026-08-01 07:14:45 +05:30
Clintchiz 2b06b00d35 fix(release): format generated UI references before verification 2026-08-01 07:14:16 +05:30
Clintchiz 28d931dfff docs(ui): refresh component reference 2026-08-01 07:11:54 +05:30
Clintchiz e2f7bea299 fix(release): stabilize generated UI references 2026-08-01 07:02:22 +05:30
Clintchiz d5e834bc78 release: WRNexusJS 0.6.0 2026-08-01 06:50:37 +05:30
Clintchiz 687d345882 release: WRNexusJS 0.6.0 2026-08-01 01:09:58 +05:30
Clintchiz 3e565e8d03 Pre Release New Changes 2026-07-31 16:30:13 +05:30
Clintchiz 358a520bc5 release: WRNexusJS 0.5.14 2026-07-30 21:29:25 +05:30
Clintchiz 9dffe83f32 release: WRNexusJS 0.5.13 2026-07-30 20:46:47 +05:30
Clintchiz d64e899993 release: WRNexusJS 0.5.12 2026-07-30 18:59:01 +05:30
Clintchiz f37303b22d release: WRNexusJS 0.5.11 2026-07-30 15:03:52 +05:30
Clintchiz d1b0c55b53 release: WRNexusJS 0.5.10 2026-07-30 13:36:29 +05:30
Clintchiz 8fc6f15402 feat(wrn): support native structured prop expressions 2026-07-29 16:46:18 +05:30
Clintchiz 6afe32f63f release: WRNexusJS 0.5.0 2026-07-29 12:51:10 +05:30
Clintchiz 76c768099d release: WRNexusJS 0.4.0 2026-07-27 12:54:43 +05:30
Clintchiz 30e5721e84 release: WRNexusJS 0.4.0 2026-07-27 12:42:18 +05:30
1267 changed files with 371097 additions and 18545 deletions
View File
+50
View File
@@ -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
+12
View File
@@ -7,3 +7,15 @@ dist/
*.db-wal *.db-wal
.DS_Store .DS_Store
bun.lockb bun.lockb
.publish/
# Environment files may contain secrets. Commit only templates.
.env
.env.*
!.env.example
!.env.*.example
# Local focused typecheck helpers must never enter the repository.
focus-shims.d.ts
tsconfig.focus.json
+3
View File
@@ -0,0 +1,3 @@
@wrnexus:registry=https://registry.npmjs.org/
audit=true
fund=false
+11
View File
@@ -10,8 +10,19 @@ 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 .wire 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
# Local focused typecheck helpers (never part of a release)
focus-shims.d.ts
**/focus-shims.d.ts
tsconfig.focus.json
**/tsconfig.focus.json
+32
View File
@@ -1,5 +1,9 @@
# @wrnexus/ai # @wrnexus/ai
Provider-neutral AI orchestration for OpenAI, Anthropic, Google and local OpenAI-compatible models,
with streaming, structured output, tools, embeddings, vector search/RAG, conversation persistence,
templates, guardrails, usage events, fallback, rate limits and evaluation reports.
> A tiny, zero-dependency Claude (Anthropic) client for WrNexus apps — generate and stream text with Claude from any server-side code. > 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. Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
@@ -120,6 +124,34 @@ try {
} }
``` ```
### Multi-provider client
`createAIClient` adds named-provider selection and fallback, capability discovery,
validated JSON output, validated tool execution, abort-aware exponential retries,
and per-provider circuit breakers. Attempt events intentionally contain metadata
only: prompts, credentials, and raw model responses are never passed to telemetry.
```ts
import { anthropicProvider, createAIClient } from "@wrnexus/ai";
const ai = createAIClient({
providers: [anthropicProvider()],
retry: { attempts: 3, baseDelayMs: 100, maxDelayMs: 2_000 },
circuitBreaker: { failureThreshold: 5, resetAfterMs: 30_000 },
});
const result = await ai.generateObject<{ title: string }>("Return a JSON title", {
validate: (value): value is { title: string } =>
typeof value === "object" && value !== null && "title" in value,
});
```
Providers can return normalized `usage` (`inputTokens`, `outputTokens`,
`totalTokens`, and `costUsd`) and `toolCalls`. Use `executeTools` with a named,
validated tool registry; unknown tools and invalid arguments are rejected before
application code runs. `deterministicAIProvider` supplies ordered or computed
offline responses for tests and examples without API keys or network calls.
## Usage ## Usage
### Return generated JSON from an API route ### Return generated JSON from an API route
+23 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/ai", "name": "@wrnexus/ai",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.", "description": "Zero-dependency Claude (Anthropic) client for WrNexus apps.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/ai"
},
"homepage": "https://wrnexusjs.dev/packages/ai",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"ai"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -18,9 +34,14 @@
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
},
"./platform": {
"types": "./dist/platform.d.ts",
"import": "./dist/platform.js"
} }
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+19 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/authz", "name": "@wrnexus/authz",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/authz — part of the WrNexus framework.", "description": "@wrnexus/authz — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/authz"
},
"homepage": "https://wrnexusjs.dev/packages/authz",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"authz"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -21,6 +37,7 @@
} }
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+54 -2
View File
@@ -1,5 +1,18 @@
# @wrnexus/cli # @wrnexus/cli
Production parity commands:
```bash
wrnexus build .
wrnexus preview . --port=3000
wrnexus dev . --production-runtime
```
`preview` refuses to start without `dist/server.js` and executes that exact
artifact with the production profile. Production-runtime development rebuilds
the same minified artifact after app, public, or configuration changes and
keeps the last good server running when a rebuild fails.
> The `wrnexus` command-line tool that scaffolds, runs, builds, tests, and manages WrNexus apps. > 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. Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
@@ -26,6 +39,30 @@ bunx wrnexus dev
## Commands ## Commands
### Local production services
`wrnexus dev . --services` starts the application and the bounded local database,
cache, mail, SMS, webhook, storage, queue, cron, authentication and metrics simulator.
It generates a localhost/`*.localhost` development certificate under
`.wrnexus/certificates/` and serves both the application and service console over HTTPS.
Trust that certificate locally to remove the browser warning. Use `--services-http` only
when an external development proxy already terminates TLS.
### Exact production runtime with live updates
`wrnexus dev . --production-runtime` rebuilds and executes `dist/server.js` with
production resolution, serialization, caching, headers and assets. The supervisor keeps
the last good process when a build fails. On a successful rebuild the opt-in production
HMR socket reconnects, requests the new document and morphs it into the browser; ordinary
`wrnexus preview` and deployed production servers never include that client.
### API platform
`wrnexus api generate [app-dir]` (or `api docs`) derives operations from file routes and
emits `generated/api/openapi.json`, safe static documentation, Postman collection, curl examples,
and TypeScript, JavaScript, Java, Go and Python SDKs. Generate one client with
`wrnexus sdk generate <language> [app-dir]`.
Every command accepts an optional `[app-dir]` (defaults to `.`). Commands that read config or `.env` also accept `--profile=<name>` (see [Profiles](#profiles)). Every command accepts an optional `[app-dir]` (defaults to `.`). Commands that read config or `.env` also accept `--profile=<name>` (see [Profiles](#profiles)).
| Command | Purpose | | Command | Purpose |
@@ -46,10 +83,17 @@ Every command accepts an optional `[app-dir]` (defaults to `.`). Commands that r
| `wrnexus db <cmd>` | Database migrations and tooling (see [db](#wrnexus-db)). | | `wrnexus 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 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 profiles [app-dir]` | List config profiles and their `.env` files, marking the active one. |
| `wrnexus compatibility check` | Check whether behavior defaults are explicitly pinned and current. |
| `wrnexus compatibility explain` | Explain configured, effective, and current compatibility behavior. |
| `wrnexus compatibility upgrade` | Back up config and explicitly opt into reviewed current behavior. |
| `wrnexus help` | Print usage. | | `wrnexus help` | Print usage. |
`wrnexus g` is an alias for `wrnexus generate`. `wrnexus g` is an alias for `wrnexus generate`.
Compatibility upgrades never happen implicitly. New applications pin
`compatibilityDate` and `frameworkBehaviour`; existing applications use
`wrnexus compatibility explain` before the backed-up, idempotent upgrade command.
### `wrnexus dev` ### `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`). Supervises a child dev-server process (from `@wrnexus/dev-server`). The child owns file watching and HMR: CSS and client-island edits update the live page over a WebSocket with no restart; when a server module changes, the child exits with a restart code and the supervisor respawns it (the browser reconnects and morphs in the new HTML). On startup it regenerates typed DB queries and typed routes (best effort). Use `--port=` to change the port (default `3000`).
@@ -76,7 +120,9 @@ bun dist/server.js # PORT env var optional
### `wrnexus create` ### `wrnexus create`
Scaffolds a new app from an inline (dependency-free) template — `package.json`, `.gitignore`, config, and starter `app/` files. Use `npm run dev` during development, `npm run build && npm start` for production, or `npm run production` to build and start in one command. The generated production server currently requires Bun even when npm is used to manage packages and scripts. Scaffolds a complete v0.8 app from an inline template. The generated project includes strict TypeScript, ESLint and Prettier, editor recommendations, environment templates, database migrations, locales, schemas, tests, API/middleware/realtime examples, Tailwind and Iconify, PWA/mobile defaults, and the framework package kits. Its `wrnexus.config.ts` documents the current imports, types, stores, performance, observability, tenancy, build, navigation, theme, i18n, database, storage, realtime, security, and profile configuration.
Use `bun run dev` during development, `bun run check` for the complete typecheck/lint/test/format gate, `bun run build && bun run start` for production, or `bun run production` to build and start in one command.
### `wrnexus update` ### `wrnexus update`
@@ -162,7 +208,7 @@ wrnexus db status --db=analytics
### `wrnexus workspace` and `wrnexus gateway` ### `wrnexus workspace` and `wrnexus gateway`
`workspace <name>` scaffolds a monorepo: several WrNexus apps under `apps/*` and shared libraries under `packages/*`, plus a `wrnexus.workspace.ts` that maps each app to the domains it serves. `gateway` runs every app behind one port and routes by `Host` header, with optional per-app auth and gateway-wide security (trusted hosts, rate limit, security headers, access log). `workspace <name>` scaffolds a monorepo: complete v0.8 apps under `apps/*`, shared libraries under `packages/*`, root TypeScript/lint/format/editor/environment tooling, and a `wrnexus.workspace.ts` that maps each app to the domains it serves. `gateway` runs every app behind one port and routes by `Host` header, with optional per-app auth and gateway-wide security (trusted hosts, rate limit, security headers, access log). Newly added workspace apps use the same current scaffold.
```bash ```bash
wrnexus workspace acme wrnexus workspace acme
@@ -241,6 +287,12 @@ wrnexus update --latest
wrnexus doctor wrnexus doctor
``` ```
Use `wrnexus doctor --fix [app-dir]` to apply conservative repairs before the
health check: create missing `app/pages` and a default config, align skewed
`@wrnexus/*` dependency ranges, record the current migration marker, and format
only syntax-valid `.wrn` files. Invalid JSON or WRN sources are reported/skipped
instead of overwritten; repeat runs are idempotent.
## Profiles ## 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`. 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`.
+36 -14
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/cli", "name": "@wrnexus/cli",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/cli — part of the WrNexus framework.", "description": "@wrnexus/cli — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/cli"
},
"homepage": "https://wrnexusjs.dev/packages/cli",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"cli"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -28,20 +44,26 @@
"wrnexus": "./dist/index.js" "wrnexus": "./dist/index.js"
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.3.6", "@wrnexus/core": "^0.8.4",
"@wrnexus/router": "^0.3.6", "@wrnexus/router": "^0.8.4",
"@wrnexus/csr": "^0.3.6", "@wrnexus/csr": "^0.8.4",
"@wrnexus/compiler": "^0.3.6", "@wrnexus/compiler": "^0.8.4",
"@wrnexus/styles": "^0.3.6", "@wrnexus/styles": "^0.8.4",
"@wrnexus/dev-server": "^0.3.6", "@wrnexus/dev-server": "^0.8.4",
"@wrnexus/ui": "^0.3.6", "@wrnexus/ui": "^0.8.4",
"@wrnexus/validation": "^0.3.6", "@wrnexus/validation": "^0.8.4",
"@wrnexus/i18n": "^0.3.6", "@wrnexus/i18n": "^0.8.4",
"@wrnexus/db": "^0.3.6", "@wrnexus/mcp": "^0.8.4",
"@wrnexus/plugin": "^0.3.6", "@wrnexus/playground": "^0.8.4",
"@wrnexus/syntax": "^0.3.6" "@wrnexus/db": "^0.8.4",
"@wrnexus/plugin": "^0.8.4",
"@wrnexus/syntax": "^0.8.4",
"@wrnexus/typecheck": "^0.8.4",
"@wrnexus/security": "^0.8.4",
"selfsigned": "^5.5.0"
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+37 -2
View File
@@ -1,9 +1,44 @@
# @wrnexus/compiler # @wrnexus/compiler
## Partial-static rendering
Pages can select `render = "partial-static"` and divide their view with `<Static>` and
`<Dynamic>` boundaries. The compiler emits a build-only shell renderer that never evaluates
dynamic-boundary children. `wrnexus build` expands static component mounts into
`dist/partial-shells.json`, records byte/region evidence in `build-report.json`, and embeds
the shell in the production route manifest. At request time the production runtime retains
request-aware layouts, locale/theme metadata and security nonces while streaming dynamic
regions into stable placeholders.
> Compiler for the `.wrn` language — tokenizes, parses, and lowers `.wrn` page and component files to TypeScript. > 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. Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
Production adapters use `analyzeRuntimeImports` before bundling. Edge, worker,
service-worker, and browser targets reject Node filesystem, TCP, and process
modules with `WRN-RUNTIME-CAPABILITY`. Package manifests can declare supported
`wrnexus.runtimes` and required `wrnexus.requires` capabilities; discovery fails
when the selected deployment cannot satisfy them.
## Server actions
```wrn
action createUser using CreateUserSchema {
const user = await users.create(input)
invalidate("users")
return user
}
view {
<form @submit="createUser">...</form>
}
```
The compiler produces a schema-aware server registry, a fully inferred action
client, and progressively enhanced form metadata. The shared runtime performs
validation, authentication/permission checks, CSRF verification, serialization,
invalidation reporting, and browser lifecycle events.
## Overview ## 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. `@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.
@@ -164,8 +199,8 @@ A file opens with `page <Name>` or `component <Name>` followed by a `{ ... }` bo
- `types { <TypeScript declarations> }` — reusable interfaces and aliases for the current file. - `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. - `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)" />`. - `@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. The annotation is optional for backward compatibility. - `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, hyphenated attributes, boolean attributes, `@event="..."` client bindings, and `<!-- comments -->`. Attribute expressions that reference `state` keep an SSR value and update reactively in the browser. - `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`. - `seo { key = "value" ... }` — metadata merged into the generated `meta`.
- `style { <raw css> }` — inlined page/component stylesheet (repeatable). - `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. - `functions { <TypeScript> }` — helpers with typed parameters and return values. Types remain in server output and are safely erased from browser behavior code.
+23 -3
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/compiler", "name": "@wrnexus/compiler",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/compiler — part of the WrNexus framework.", "description": "@wrnexus/compiler — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/compiler"
},
"homepage": "https://wrnexusjs.dev/packages/compiler",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"compiler"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -21,9 +37,13 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/syntax": "^0.3.6" "@wrnexus/csr": "^0.8.4",
"@wrnexus/syntax": "^0.8.4",
"@wrnexus/store": "^0.8.4",
"@wrnexus/validation": "^0.8.4"
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+31
View File
@@ -110,6 +110,37 @@ instances. The default store is process-local memory.
(default `console.log`), `requestIdKey` (default `"requestId"`), `now`. (default `console.log`), `requestIdKey` (default `"requestId"`), `now`.
`RequestRecord` = `{ time, id, method, path, status, durationMs }`. `RequestRecord` = `{ time, id, method, path, status, durationMs }`.
### Resilience — `@wrnexus/core`
`resilientCall` standardizes cancellation-aware timeouts, controlled retries,
fixed or exponential backoff, fallback responses, circuit breaking, and bounded
concurrency. Reuse a declarative circuit/bulkhead options object, or an explicit
`CircuitBreaker`/`Bulkhead` instance, wherever calls must share health and
capacity state.
```ts
import { resilientCall } from "@wrnexus/core";
const paymentCircuit = { failures: 5, resetAfter: "30s" } as const;
const status = await resilientCall({
timeout: "5s",
retries: 3,
retryDelay: "100ms",
backoff: "exponential",
circuitBreaker: paymentCircuit,
bulkhead: { concurrency: 20, queue: 100 },
run: (signal) => paymentProvider.checkStatus({ signal }),
fallback: () => ({ state: "unavailable" }),
});
```
`CircuitBreaker.snapshot()` reports `closed`, `open`, or `half-open`, failure
and success counts, and the remaining retry delay for health endpoints and
development tooling. Fail-fast conditions use stable `WRN-RESILIENCE-*` codes.
Core's existing `HealthRegistry`, `withIdempotency`, and pluggable stores/locks
cover health reporting, idempotent requests, and distributed coordination.
### Caching — `@wrnexus/core` ### Caching — `@wrnexus/core`
| Export | Kind | Notes | | Export | Kind | Notes |
+19 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/core", "name": "@wrnexus/core",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/core — part of the WrNexus framework.", "description": "@wrnexus/core — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/core"
},
"homepage": "https://wrnexusjs.dev/packages/core",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"core"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -29,6 +45,7 @@
} }
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+36 -2
View File
@@ -1,5 +1,34 @@
# @wrnexus/csr # @wrnexus/csr
## Navigation state preservation
Pages can opt into restoration across client navigation:
```wrn
page Users {
navigation {
preserve = ["filters", "pagination", "scroll", "tabs", "expanded"]
}
}
```
Form-like categories restore named inputs, selects, and textareas. Password,
file, hidden, CSRF/token/secret/credential fields, and elements marked
`data-no-preserve` are never saved. For tab, expanded, or component UI state,
mark stable elements with `data-wrn-preserve="key"`; their value and ARIA
selected/expanded state are restored. State is scoped to pathname plus query.
## Typed server actions
`createActionClient<Input, Output>(route, name)` supports programmatic calls.
Schema-backed WRN actions also export `__wrnexusActionClients`, whose input and
output are inferred automatically. Enhanced forms expose
`data-wrn-action-state="pending|success|error"` and dispatch bubbling
`wrnexus:action:optimistic`, `:pending`, `:success`, and `:error` events.
Success details contain returned data and invalidated cache tags; error details
contain field errors. Without JavaScript, the same form posts to its page and
receives a 303 redirect or accessible validation response.
> The browser-side client runtime for WrNexus — generic, self-contained JS that hydrates server-rendered pages with reactivity, client-side navigation, and realtime rooms. > 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. Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
@@ -50,11 +79,16 @@ getRealtimeRuntime(): string // → REALTIME_RUNTIME
Applied to any subtree containing `data-scope`. Expressions are parsed by a tiny eval-free evaluator, so a strict CSP with no `unsafe-eval` works. 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 | | Directive | Purpose |
| -------------------------------------------------------- | ----------------------------------------------------------------------- | | ---------------------------------- | ---------------------------------------------------- |
| `data-scope="count: 0, name: 'x'"` | Declare reactive state on a subtree | | `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-on-<event>="count++"` | Run a statement in scope on a DOM event |
| `data-text="expr"` | Bind an element's `textContent` to an expression | | `data-text="expr"` | Bind an element's `textContent` to an expression |
| `data-show="expr"` | Toggle visibility (`display`) on truthiness | | `data-show="expr"` | Toggle visibility while preserving interactive state |
Compiled conditional rendering and dynamic component cases omit inactive elements from the live
DOM. `data-show` is a visibility directive for stateful controls and keeps its element mounted.
Neither mechanism is authorization: never place secrets in client-rendered branches. Authorize on
the server and return only data the current request may access.
| `data-for="item in list"` (opt. index and `key item.id`) | Per-item rendering; stable keys preserve DOM identity during reorder | | `data-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 | | `data-key="item.id"` | Alternative key declaration for `data-for` templates |
| `{{expr}}` or `{expr}` | Interpolation inside text nodes and attribute values | | `{{expr}}` or `{expr}` | Interpolation inside text nodes and attribute values |
+20 -3
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/csr", "name": "@wrnexus/csr",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/csr — part of the WrNexus framework.", "description": "@wrnexus/csr — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/csr"
},
"homepage": "https://wrnexusjs.dev/packages/csr",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"csr"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -21,9 +37,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.3.6" "@wrnexus/core": "^0.8.4"
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+75 -4
View File
@@ -1,5 +1,14 @@
# @wrnexus/db # @wrnexus/db
## Rollout-safe migrations
Run `wrnexus db check` in CI before deployment. The analyzer reports stable
diagnostics for drops, renames, type changes, new/enforced required columns,
and potentially blocking index creation, with an expand/backfill/switch/contract
recommendation. `wrnexus db migrate` blocks critical issues in pending
migrations. `--allow-breaking` is an explicit operator override; already-applied
migrations do not block later releases.
> The database layer for WrNexus: TS models as the single source of truth for DDL, validation, and result typing, plus a driver-based `Db` client, migrations, and a sqlc-style query generator. > 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. Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
@@ -85,7 +94,8 @@ A `Driver` (implemented by adapters) exposes `dialect`, `query(sql, params?)`,
- `exec(sql, params?)``Promise<ExecResult>` (`{ changes, lastInsertId? }`). - `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. - `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. - `createTable(model)` — runs the model's `CREATE TABLE IF NOT EXISTS` DDL.
- `close()`. - `close()` — idempotently rejects new top-level work, drains active queries and
transactions, then closes the underlying pool.
Every query is parameterized (positional params). `createTableSql(model, dialect, ifNotExists?)` Every query is parameterized (positional params). `createTableSql(model, dialect, ifNotExists?)`
renders `CREATE TABLE` directly; `Dialect` is `"sqlite" | "postgres" | "mysql"`. renders `CREATE TABLE` directly; `Dialect` is `"sqlite" | "postgres" | "mysql"`.
@@ -98,7 +108,9 @@ A process-wide registry the runtime configures at startup from `wrnexus.config.t
- `setDb(db)` / `setDb(name, db)` — set the default or a named connection. - `setDb(db)` / `setDb(name, db)` — set the default or a named connection.
- `registerDb(name, db)` — alias of `setDb(name, db)`. - `registerDb(name, db)` — alias of `setDb(name, db)`.
- `getDb(name = "default")` — the default or a named `Db` (throws if unconfigured). - `getDb(name = "default")` — the default or a named `Db` (throws if unconfigured).
- `hasDb(name?)`, `databaseNames()`, `closeDatabases()`. - `hasDb(name?)`, `databaseNames()`, `closeDatabases()`. Registry shutdown clears
registrations first, attempts every open database, and reports close failures
together with `AggregateError` instead of leaking later pools.
```ts ```ts
const users = await getDb().all("SELECT * FROM users"); const users = await getDb().all("SELECT * FROM users");
@@ -121,8 +133,8 @@ Migrations are `.sql` files (in e.g. `app/db/migrations`), each split into
- `parseMigration(name, content)``Migration` (`{ name, up, down }`). - `parseMigration(name, content)``Migration` (`{ name, up, down }`).
- `loadMigrations(dir)` — parse all `.sql` files, sorted by filename. - `loadMigrations(dir)` — parse all `.sql` files, sorted by filename.
- `appliedMigrations(db)` — applied names, oldest first. - `appliedMigrations(db)` — applied names, oldest first.
- `migrate(db, dir)` — apply all pending (each in a transaction); returns applied names. - `migrate(db, dir, options?)` — apply all pending (each in a transaction); returns applied names.
- `rollback(db, dir)` — roll back the most recent; returns its name or `null`. - `rollback(db, dir, options?)` — roll back the most recent; returns its name or `null`.
- `status(db, dir)``{ name, applied }[]` for every migration file. - `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. - `scaffoldMigration(dir, name, dialect, models?)` — write a new numbered migration; with `models` it generates `CREATE`/`DROP` for every table (referenced tables first via topological sort). Returns the file path.
@@ -199,6 +211,21 @@ const pageTwo = await paginate(
); );
``` ```
For deployments, `{ dryRun: true }` reports pending names without applying
their SQL, `signal` cancels safely between migrations, and the default
database-backed lock prevents concurrent deploy runners. A live lock produces
`WRN-DB-MIGRATION-LOCKED`; crash-stale locks expire after `lockTimeoutMs` (five
minutes by default). Disable it with `lock: false` only when an external deploy
coordinator already guarantees exclusivity.
```ts
const pending = await migrate(db, "app/db/migrations", { dryRun: true });
await migrate(db, "app/db/migrations", {
signal: shutdownController.signal,
lockTimeoutMs: 10 * 60_000,
});
```
MongoDB (document API): MongoDB (document API):
```ts ```ts
@@ -226,3 +253,47 @@ SQL driver — use `@wrnexus/db/mongo` directly.
`wrnexus.config.ts`. `wrnexus.config.ts`.
- The `mongodb` npm package is an optional, lazily-imported peer — install it - 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. only if you use `@wrnexus/db/mongo`. The core package stays dependency-free.
## Repository and transaction helpers
Repositories accept an immutable equality `scope`, normally `{ column: "tenant_id", value:
ctx.tenant.id }`. The scope is injected into every read, count, update and delete, while create
overwrites any caller-supplied tenant value. This makes accidental cross-tenant CRUD through the
repository API fail closed.
```ts
import { createRepository, retryTransaction, databaseHealth, batch } from "@wrnexus/db";
const users = createRepository<User>(db, {
table: "users",
allowedColumns: ["email", "name", "active"],
});
const user = await users.require(42);
await users.update(42, { active: true });
```
Repository identifiers are validated, writes may be restricted to an allowlist, and values always use query parameters. Infrastructure packages remain helper-only and do not add UI dependencies to server code.
## 0.8 repository and transaction helpers
```ts
import { createRepository, databaseHealth, firstOrThrow, retryTransaction } from "@wrnexus/db";
const usersRepo = createRepository<User>(db, {
table: "users",
allowedColumns: ["email", "name", "active"],
maxListLimit: 250,
});
const users = await usersRepo.all({
orderBy: "name",
direction: "asc",
limit: 50,
offset: 0,
});
```
Repository SQL identifiers are validated and values remain parameterized. Placeholder generation is dialect-aware: PostgreSQL uses `$1`, `$2`, and SQLite/MySQL use `?`. List limits are bounded.
`retryTransaction()` retries recognized serialization, deadlock, and database-lock errors by default. Supply `shouldRetry` for application-specific retryable errors; ordinary validation or business errors are not retried automatically.
+20 -3
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/db", "name": "@wrnexus/db",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/db — part of the WrNexus framework.", "description": "Typed database drivers, migrations, instrumentation, repositories, pagination, and transaction helpers.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/db"
},
"homepage": "https://wrnexusjs.dev/packages/db",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"db"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -45,6 +61,7 @@
} }
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+9 -1
View File
@@ -66,6 +66,12 @@ interface RunningServer {
In development, `startServer` also connects `app/db/migrations` (and `app/db/<name>/migrations`) and auto-applies migrations, then starts an in-process file watcher. CSS edits hot-swap live. Page, component, layout, API, middleware, realtime, schema, locale, and public-asset edits invalidate only their cached modules, rescan routes where necessary, and morph fresh HTML through the existing HMR WebSocket. The server process and active gateway stay running. In development, `startServer` also connects `app/db/migrations` (and `app/db/<name>/migrations`) and auto-applies migrations, then starts an in-process file watcher. CSS edits hot-swap live. Page, component, layout, API, middleware, realtime, schema, locale, and public-asset edits invalidate only their cached modules, rescan routes where necessary, and morph fresh HTML through the existing HMR WebSocket. The server process and active gateway stay running.
`getWrnCompileMetrics()` exposes cumulative content-addressed compiler cache
`hits`, `misses`, successful `compilations`, `errors`, `totalDurationMs`, and
`lastDurationMs` for the DevToolbar or custom diagnostics. Tests and embedded
servers can call `resetWrnCompileMetrics()` to establish a fresh measurement
window.
### `createHandlers(deps)` ### `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). The core runtime shared by dev and prod. It handles CORS preflight, `/healthz` and `/__wrnexus/health`, request-body size limits (413), HMR socket upgrades (`/__wrnexus/hmr`), realtime WebSocket upgrades (`defineRoom` default export or a raw `websocket` export), the middleware pipeline, API routes (`/api/*`), framework assets (`/__wrnexus/*`), public assets, and full SSR page rendering (component mounts, layouts, slots, i18n markers, per-page script selection, ETag/304, gzip).
@@ -296,7 +302,9 @@ Pages get only the scripts they use: `nav.js` always, `reactive.js` when a page
- **Bun-only.** Uses `Bun.serve` (HTTP + WebSocket), `Bun.file`, and `Bun.gzipSync`. The full app also relies on `bun:sqlite` / `Bun.SQL` via `@wrnexus/db`. - **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). - Orchestrates the whole framework: `@wrnexus/core` (context, security, realtime registry), `@wrnexus/router`, `@wrnexus/ssr` (`renderDocument`), `@wrnexus/csr` (client runtimes), `@wrnexus/compiler` (`.wrn` → TS), `@wrnexus/styles`, `@wrnexus/ui`, `@wrnexus/validation`, `@wrnexus/i18n`, `@wrnexus/db`, and `@wrnexus/pubsub` (Redis-backed cross-process realtime).
- `.wrn` files are compiled to TypeScript into a hidden sibling `.wrnexus/` cache dir and dynamically imported; the module cache means each edited server module needs a fresh process (dev) — hence the restart-on-change model. - `.wrn` files compile into a content-addressed hidden `.wrnexus/` cache. Targeted
invalidation gives changed modules a fresh import identity without restarting
the development server.
- Responses are gzipped when the client accepts it and the body is a buffered, compressible payload ≥ 1 KB; streaming/SSE responses opt out via `Cache-Control: no-transform`. - 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> </content>
+38 -16
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/dev-server", "name": "@wrnexus/dev-server",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/dev-server — part of the WrNexus framework.", "description": "@wrnexus/dev-server — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/dev-server"
},
"homepage": "https://wrnexusjs.dev/packages/dev-server",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"dev-server"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -25,22 +41,28 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.3.6", "@wrnexus/core": "^0.8.4",
"@wrnexus/dev-toolbar": "^0.3.6", "@wrnexus/dev-toolbar": "^0.8.4",
"@wrnexus/router": "^0.3.6", "@wrnexus/router": "^0.8.4",
"@wrnexus/ssr": "^0.3.6", "@wrnexus/ssr": "^0.8.4",
"@wrnexus/csr": "^0.3.6", "@wrnexus/csr": "^0.8.4",
"@wrnexus/compiler": "^0.3.6", "@wrnexus/compiler": "^0.8.4",
"@wrnexus/styles": "^0.3.6", "@wrnexus/styles": "^0.8.4",
"@wrnexus/ui": "^0.3.6", "@wrnexus/ui": "^0.8.4",
"@wrnexus/validation": "^0.3.6", "@wrnexus/validation": "^0.8.4",
"@wrnexus/i18n": "^0.3.6", "@wrnexus/i18n": "^0.8.4",
"@wrnexus/db": "^0.3.6", "@wrnexus/db": "^0.8.4",
"@wrnexus/pubsub": "^0.3.6", "@wrnexus/pubsub": "^0.8.4",
"@wrnexus/uploader": "^0.3.6", "@wrnexus/uploader": "^0.8.4",
"@wrnexus/plugin": "^0.3.6" "@wrnexus/plugin": "^0.8.4",
"@wrnexus/store": "^0.8.4",
"@wrnexus/security": "^0.8.4",
"@wrnexus/observability": "^0.8.4",
"@wrnexus/cache": "^0.8.4",
"@wrnexus/pwa": "^0.8.4"
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+7
View File
@@ -7,6 +7,9 @@ Development-only page quality toolbar for WRNexusJS.
- Runtime, resource and unhandled promise error capture - Runtime, resource and unhandled promise error capture
- Accessibility, SEO, image, media, color, HTML, form, link, responsive and security checks - Accessibility, SEO, image, media, color, HTML, form, link, responsive and security checks
- Performance and network observations - Performance and network observations
- First-class application tabs for runtime, stores, cache, accessibility, SEO, performance,
security, images, links, and JavaScript
- Plugin-contributed applications with badges, descriptions, issue feeds, and structured data
- Element highlighting and issue filtering - Element highlighting and issue filtering
- Server-side issue collector - Server-side issue collector
- Development-only asset strings for direct serving by `@wrnexus/dev-server` - Development-only asset strings for direct serving by `@wrnexus/dev-server`
@@ -21,3 +24,7 @@ Serve `DEV_TOOLBAR_RUNTIME` at `/__wrnexus/dev-toolbar.js` and `DEV_TOOLBAR_CSS`
``` ```
The browser runtime exposes `window.__wrnexusDevToolbar`. The browser runtime exposes `window.__wrnexusDevToolbar`.
Plugin panels returned through `devToolbarPanels()` are automatically added to the application
strip. Their issue category is filterable, and structured `data` is rendered as escaped diagnostic
content so a plugin never needs to inject toolbar HTML.
+19 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/dev-toolbar", "name": "@wrnexus/dev-toolbar",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/dev-toolbar — part of the WrNexus framework.", "description": "@wrnexus/dev-toolbar — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/dev-toolbar"
},
"homepage": "https://wrnexusjs.dev/packages/dev-toolbar",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"dev-toolbar"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -37,6 +53,7 @@
} }
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+50 -63
View File
@@ -1,80 +1,67 @@
# @wrnexus/encryption # @wrnexus/encryption
> Dependency-free crypto helpers for WrNexus: authenticated symmetric encryption (AES-256-GCM), hashing, and HMAC signing. Authenticated encryption, hashing, HMAC, key rotation, and optional encrypted HTTP exchanges for WRNexusJS.
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. ## Core helpers
## Overview - `generateKey()` — random 256-bit AES key encoded as base64.
- `deriveKey(password, salt)` — PBKDF2-derived AES key.
- `encrypt(plaintext, key)` / `decrypt(payload, key)` — AES-256-GCM.
- `sha256(data)` — SHA-256 digest.
- `hmacSign(data, secret)` / `hmacVerify(...)` — HMAC-SHA256.
- `createKeyring(keys)` — active/previous key management.
- `seal()` / `open()` — versioned ciphertext with key ID.
This package provides small, focused cryptographic primitives for server-side use: encrypting secrets/tokens/database fields at rest with AES-256-GCM, deriving keys from passwords via PBKDF2, computing SHA-256 digests, and signing/verifying payloads with HMAC-SHA256. It is built entirely on the standard **Web Crypto API** (`crypto.subtle`) plus `btoa`/`atob` and `TextEncoder`/`TextDecoder` — no third-party dependencies. Reach for it whenever you need to protect sensitive values or verify webhook signatures. All functions are `async` (Web Crypto is promise-based). ## Encrypted HTTP envelope
## 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 ```ts
import { generateKey, encrypt, decrypt } from "@wrnexus/encryption"; import {
createEncryptedRequest,
createKeyring,
createMemoryReplayStore,
decryptEncryptedResponse,
encryptedExchange,
} from "@wrnexus/encryption";
const key = await generateKey(); // store this safely (env/secret manager) const keyring = createKeyring([{ id: "2026-08", secret: process.env.API_BODY_KEY!, active: true }]);
const box = await encrypt("card #1234", key); // opaque base64 string, safe to persist const replayStore = createMemoryReplayStore();
const plain = await decrypt(box, key); // "card #1234"
// Server middleware.
app.use(
encryptedExchange({
keyring,
replayStore,
maxAgeMs: 60_000,
maxBodyBytes: 1_048_576,
}),
);
// Controlled service/native client.
const request = await createEncryptedRequest(
"https://api.example.com/private/report",
{ reportId: "report-1" },
{ method: "POST", keyring },
);
const response = await fetch(request);
const result = await decryptEncryptedResponse(response, request, { keyring });
``` ```
Deriving a key from a user password instead of a random key: The envelope binds authenticated ciphertext to:
```ts - HTTP method
import { deriveKey, encrypt } from "@wrnexus/encryption"; - URL path and query
- request ID
- timestamp and expiry window
- encryption key ID
- optional replay-store consumption
const key = await deriveKey("correct horse battery staple", "per-user-salt"); `encryptedBody()` decrypts request bodies only. `encryptedExchange()` also encrypts successful downstream responses while allowing application exceptions to propagate normally. `encryptedFetch()` provides a convenient controlled-client call.
const box = await encrypt("secret note", key);
```
Hashing and webhook signature verification: ## Security boundary
```ts Encrypted HTTP bodies **do not replace TLS/HTTPS**. Always use HTTPS.
import { sha256, hmacSign, hmacVerify } from "@wrnexus/encryption";
const digest = await sha256("some content"); // 64-char hex string This layer is appropriate for service-to-service traffic, native/mobile applications, controlled agents, and selected fields protected with server-managed keys. It cannot conceal data from an end user when browser JavaScript receives the decryption key. Never ship a long-lived server encryption key to a browser.
const signature = await hmacSign(rawBody, webhookSecret); Use a shared replay store such as Redis in multi-instance deployments. The memory replay store is process-local.
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.
+23 -3
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/encryption", "name": "@wrnexus/encryption",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/encryption — part of the WrNexus framework.", "description": "Authenticated encryption, key rotation, hashing, and optional application-layer encrypted HTTP envelopes.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/encryption"
},
"homepage": "https://wrnexusjs.dev/packages/encryption",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"encryption"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -20,7 +36,11 @@
"import": "./dist/index.js" "import": "./dist/index.js"
} }
}, },
"dependencies": {
"@wrnexus/core": "^0.8.4"
},
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+20 -3
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/helpers", "name": "@wrnexus/helpers",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.", "description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/helpers"
},
"homepage": "https://wrnexusjs.dev/packages/helpers",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"helpers"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -21,9 +37,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.3.6" "@wrnexus/core": "^0.8.4"
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+60 -134
View File
@@ -1,167 +1,93 @@
# @wrnexus/i18n # @wrnexus/i18n
> Per-request translations plus locale-aware number, date, and currency formatting for WrNexus apps. Recursive locale loading, fallback resolution, SSR/browser translations, locale formatting, and language UI blocks for WRNexusJS.
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework. ## Locale files
## Overview Both layouts can be used together:
`@wrnexus/i18n` loads locale files from `app/locales/<lang>.json`, resolves the ```text
active language for each request (cookie → `Accept-Language` → default), and app/locales/en.json
builds a `t(key, params)` translator used both in server code and in `.wrn` app/locales/en/common.json
views. It also ships Intl-based formatting helpers and a tiny client runtime that app/locales/en/auth.json
wires up a language switcher. Translation lookup, language resolution, and HTML app/locales/mr/common.json
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 Namespaced files become keys such as `common.save` and `auth.signIn`.
> (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 ```ts
import { import { loadLocales, makeT, resolveI18n, resolveLang } from "@wrnexus/i18n";
loadLocales,
resolveI18n,
resolveLang,
makeT,
translateHtml,
LANG_COOKIE,
} from "@wrnexus/i18n";
// app/locales/en.json, app/locales/es.json const i18n = resolveI18n(loadLocales("app/locales", { strict: true }), {
const messages = loadLocales("app/locales"); default: "en",
const i18n = resolveI18n(messages, { default: "en", locales: ["en", "es"] }); locales: ["en", "mr", "hi"],
fallbacks: { "mr-IN": ["mr", "en"] },
cookie: { name: "wire-lang", sameSite: "Lax", secure: true },
});
// Per request: const lang = resolveLang(i18n, cookieValue, request.headers.get("accept-language"));
const lang = resolveLang(i18n, req.cookies?.[LANG_COOKIE], req.headers.get("accept-language"));
const t = makeT(i18n, lang); const t = makeT(i18n, lang);
t("common.hello", { name: "Ajay" });
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`: ## Resolution behavior
```json - normalized BCP-47-style locale names
{ - cookie preference
"nav": { "home": "Home" }, - weighted `Accept-Language`
"greeting": "Hello, {name}" - wildcard language ranges
} - regional base fallback
``` - explicit fallback chains
- configured default language
- automatic RTL for Arabic, Hebrew, Persian, Urdu, and related languages
### Views: translation markers Locale JSON is size-limited and rejects prototype-pollution keys. Recursive namespace collisions are resolved safely.
## Views and runtime
```html ```html
<h1 data-t="nav.home">Home</h1> <h1 data-t="dashboard.title">Dashboard</h1>
<input t:placeholder="search.placeholder" /> <input t:placeholder="search.placeholder" />
``` ```
`translateHtml` replaces the element text for `data-t` and the attribute value for Text and translated attributes are resolved during SSR. Active/fallback messages are serialized safely for the language runtime, which rebinds `data-t` markers after client navigation.
any `t:<attr>` (e.g. `t:placeholder`, `t:aria-label`).
### Client: language switcher Enable `i18nPlugin()` to use:
```ts - `<LanguageSwitcher />`
import { renderI18nData, I18N_RUNTIME, I18N_JS_HREF } from "@wrnexus/i18n"; - `<LocaleStatus />`
// In the document <head>: `LanguageSwitcher` renders a native `select[data-wire-lang]`. The packaged runtime validates the
const head = ` selection against the configured locales, writes the configured language cookie, updates the
<script>${renderI18nData(i18n, lang)}</script> document `lang`/`dir` attributes, emits `wrnexus:language-change`, and reloads so the next SSR
<script src="${I18N_JS_HREF}"></script> request uses the same cookie. No application-owned browser script is required.
`;
// Serve I18N_RUNTIME at I18N_JS_HREF; then in markup: ## Formatting
// <button data-wire-lang-set="es">Español</button>
// <select data-wire-lang>…</select>
```
### Formatting - `formatNumber`
- `formatCurrency`
- `formatDate`
- `formatRelativeTime`
- `plural`
- `createLocaleFormatter`
- `translationCoverage`
Localization tooling can extract statically discoverable `t("key")`,
`i18n.t("key")`, and `data-i18n="key"` usage, compare every locale with a
reference, and create layout-stressing pseudo-locales:
```ts ```ts
import { import {
formatNumber, auditLocaleKeys,
formatCurrency, createPseudoLocale,
formatDate, extractTranslationKeysFromFiles,
formatRelativeTime,
plural,
} from "@wrnexus/i18n"; } from "@wrnexus/i18n";
formatNumber(1234.5, lang); // "1,234.5" const used = extractTranslationKeysFromFiles(sourceFiles);
formatCurrency(9.99, "USD", lang); // "$9.99" const coverage = auditLocaleKeys(messages, "en");
formatDate(Date.now(), lang); // "Jul 4, 2026" const enXA = createPseudoLocale(messages.en);
formatRelativeTime(-3, "day", lang); // "3 days ago" const arXB = createPseudoLocale(messages.en, { rtl: true });
plural(2, { one: "# item", other: "# items" }, lang); // "2 items"
``` ```
## Configuration Pseudo-localization preserves interpolation placeholders and markup tags. RTL
pseudo output uses Unicode direction controls, while runtime direction detection
`resolveI18n` accepts an `I18nConfig`: continues to derive `rtl` from Arabic and other RTL language subtags.
- `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.
+37 -5
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/i18n", "name": "@wrnexus/i18n",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/i18n — part of the WrNexus framework.", "description": "Locale loading, fallback resolution, SSR/browser translations, formatters, and WRNexusJS language components.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/i18n"
},
"homepage": "https://wrnexusjs.dev/packages/i18n",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"i18n"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -18,12 +34,28 @@
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
} },
"./plugin": {
"types": "./dist/plugin.d.ts",
"import": "./dist/plugin.js"
},
"./components/*": "./components/*"
}, },
"dependencies": { "dependencies": {
"@wrnexus/core": "^0.3.6" "@wrnexus/core": "^0.8.4",
"@wrnexus/plugin": "^0.8.4",
"@wrnexus/ui": "^0.8.4"
},
"wrnexus": {
"plugin": {
"plugin": "./dist/plugin.js",
"export": "default",
"factory": true
}
}, },
"files": [ "files": [
"dist" "dist",
"README.md",
"components"
] ]
} }
+59
View File
@@ -130,3 +130,62 @@ app.use(jwtAuth({ secret: process.env.JWT_SECRET!, required: false }));
- Integrates with [`@wrnexus/core`](../core) for `Context`, `Middleware`, and - Integrates with [`@wrnexus/core`](../core) for `Context`, `Middleware`, and
`ctx.user`; it complements the framework's cookie/session auth with a `ctx.user`; it complements the framework's cookie/session auth with a
stateless bearer-token flow for API and mobile clients. stateless bearer-token flow for API and mobile clients.
## Access, refresh, scope, and cookie helpers
```ts
import {
createAccessToken,
createRefreshToken,
verifyAccessToken,
verifyRefreshToken,
extractBearerToken,
requireScopes,
jwtCookie,
} from "@wrnexus/jwt";
```
The helpers add explicit `type: "access" | "refresh"` claims, scope checks, refresh-token family metadata, no-store token responses, and secure cookie defaults. `__Host-` cookies are rejected unless they use `Path=/` and `Secure`; `SameSite=None` is rejected without `Secure`.
## 0.8 helper kit
```ts
import {
createTokenPair,
verifyAccessToken,
verifyRefreshToken,
extractBearerToken,
readJwtCookie,
jwtCookie,
clearJwtCookie,
requireScopes,
} from "@wrnexus/jwt";
const pair = await createTokenPair(user.id, {
accessSecret: process.env.JWT_ACCESS_SECRET!,
refreshSecret: process.env.JWT_REFRESH_SECRET!,
scopes: ["profile:read"],
family: sessionFamily,
});
```
The helper kit validates `__Host-` cookie invariants, cookie names and paths, `SameSite=None` security, typed access/refresh token types, scope requirements, and no-store token responses.
In addition to local HS256 secrets/keyrings, the package verifies standards-based
RS256 tokens through bounded remote JWKS caches:
```ts
import { createRemoteJwks, verifyJwtWithJwks } from "@wrnexus/jwt";
const jwks = createRemoteJwks("https://issuer.example/.well-known/jwks.json");
const claims = await verifyJwtWithJwks(token, jwks, {
issuer: "https://issuer.example",
audience: "my-api",
maxAge: 300,
});
```
JWKS URLs must use HTTPS. Responses have key-count/byte limits, accept only
RS256 signing RSA keys, deduplicate concurrent refreshes, cache imported public
keys, and force an immediate refresh for an unknown `kid` so issuer rotation
does not wait for cache expiry. Never use decoded-but-unverified claims for an
authorization decision.
+23 -3
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/jwt", "name": "@wrnexus/jwt",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/jwt — part of the WrNexus framework.", "description": "HS256 JSON Web Tokens, key rotation, access/refresh helpers, scopes, cookies, and auth middleware.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/jwt"
},
"homepage": "https://wrnexusjs.dev/packages/jwt",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"jwt"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -20,7 +36,11 @@
"import": "./dist/index.js" "import": "./dist/index.js"
} }
}, },
"dependencies": {
"@wrnexus/core": "^0.8.4"
},
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+8
View File
@@ -76,6 +76,14 @@ const status = network ? await network.getStatus() : { connected: true, connecti
Unavailable required plugins throw `MobileUnavailableError` with an actionable message. Unavailable required plugins throw `MobileUnavailableError` with an actionable message.
The package also provides portable application-facing primitives:
- `listenDeepLinks` normalizes initial and live links with an allowed-scheme list.
- `PushNotifications` performs permission gating and validates registrations.
- `SecureStorage` namespaces and validates keys over an application-supplied encrypted
Keychain/Keystore adapter; it does not mislabel browser `localStorage` as secure.
- `OfflineQueue` persists bounded sync batches through a pluggable durable store.
## Requirements / Notes ## Requirements / Notes
- Capacitor plugin imports must remain in browser-owned modules. - Capacitor plugin imports must remain in browser-owned modules.
+20 -3
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/mobile", "name": "@wrnexus/mobile",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/mobile — part of the WrNexus framework.", "description": "@wrnexus/mobile — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/mobile"
},
"homepage": "https://wrnexusjs.dev/packages/mobile",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"mobile"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -21,9 +37,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/native": "^0.3.6" "@wrnexus/native": "^0.8.4"
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+3
View File
@@ -79,6 +79,9 @@ const position = await native.run(
Built-ins include `camera`, `clipboard.write`, `share`, `geolocation`, `network`, Built-ins include `camera`, `clipboard.write`, `share`, `geolocation`, `network`,
`haptics`, storage, filesystem, notifications, and device information. `haptics`, storage, filesystem, notifications, and device information.
`defineNativeManifest` declares required capabilities and typed permissions, while
`PermissionManager` normalizes permission query/request flows across platform adapters.
## Requirements / Notes ## Requirements / Notes
Use `supports()` before showing optional controls. Mobile capabilities require their Use `supports()` before showing optional controls. Mobile capabilities require their
+19 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/native", "name": "@wrnexus/native",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/native — part of the WrNexus framework.", "description": "@wrnexus/native — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/native"
},
"homepage": "https://wrnexusjs.dev/packages/native",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"native"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -29,6 +45,7 @@
} }
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+21
View File
@@ -194,3 +194,24 @@ const gitlab = defineProvider({
`verifier` between `startAuth` and `completeAuth` (session or signed cookie). `verifier` between `startAuth` and `completeAuth` (session or signed cookie).
- Pairs with [`@wrnexus/core`](../core) — feed the normalized `OAuthProfile` into - Pairs with [`@wrnexus/core`](../core) — feed the normalized `OAuthProfile` into
`logIn` to establish a session. `logIn` to establish a session.
OIDC integrations can combine strict discovery with the rotating JWKS verifier:
```ts
import { createRemoteJwks } from "@wrnexus/jwt";
import { discoverOidc, verifyOidcIdToken } from "@wrnexus/oauth";
const metadata = await discoverOidc("https://issuer.example");
const jwks = createRemoteJwks(metadata.jwks_uri);
const claims = await verifyOidcIdToken(idToken, {
issuer: metadata.issuer,
clientId: "client-id",
jwks,
nonce: expectedNonce,
accessToken,
});
```
Discovery requires an exact normalized issuer and HTTPS endpoints without URL
credentials/fragments. ID-token verification checks the RS256 signature,
expiry/not-before, issuer, audience, required OIDC claims, nonce, multi-audience
`azp`, optional token age, and optional `at_hash` binding.
+22 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/oauth", "name": "@wrnexus/oauth",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/oauth — part of the WrNexus framework.", "description": "@wrnexus/oauth — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/oauth"
},
"homepage": "https://wrnexusjs.dev/packages/oauth",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"oauth"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -20,7 +36,11 @@
"import": "./dist/index.js" "import": "./dist/index.js"
} }
}, },
"dependencies": {
"@wrnexus/jwt": "^0.8.4"
},
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+53
View File
@@ -1,7 +1,60 @@
# @wrnexus/plugin # @wrnexus/plugin
## Least-privilege package permissions
Package manifests declare every framework capability they register:
```json
{
"wrnexus": {
"permissions": ["routes", "migrations"],
"routes": [{ "kind": "api", "path": "/api/example", "entry": "./route.ts" }]
}
}
```
Applications can enable fail-closed grants:
```ts
export default {
pluginPermissions: {
enforce: true,
grants: { "example-plugin": ["routes"] },
},
};
```
Discovery rejects used-but-undeclared capabilities with
`WRN-PLUGIN-PERMISSION-UNDECLARED` and ungranted capabilities with
`WRN-PLUGIN-PERMISSION-DENIED`. Permissions cover components, browser runtime,
assets, styles, routes, middleware, migrations, config, transforms,
diagnostics/tooling, and server/build hooks.
## Compatibility matrices
Manifests can add `compatibility: { bunMin: "1.3.0", os: ["linux",
"darwin"] }` alongside `runtimes` and `requires`. Use
`testPluginCompatibility(manifest, targets)` in a package test to exercise the
complete support matrix. Runtime discovery enforces the same Bun minimum, OS,
runtime, and capability declarations used by the test kit.
Deterministic WRNexusJS plugin contracts for configuration, AST/code transforms, Deterministic WRNexusJS plugin contracts for configuration, AST/code transforms,
diagnostics, development servers, production builds, and DevToolbar extensions. diagnostics, development servers, production builds, and DevToolbar extensions.
Use `definePlugin()` and declare `enforce`, `before`, or `after` when ordering matters. Use `definePlugin()` and declare `enforce`, `before`, or `after` when ordering matters.
Duplicate names and dependency cycles are rejected. Duplicate names and dependency cycles are rejected.
## Complete lifecycle and contributions
Plugins may implement `setup`, `configure`, `configResolved`, `transformAst`,
`transformCode`, `diagnostics`, `routes`, `configureServer`, `buildStart`,
`buildEnd`, `render`, `deploy`, `shutdown`, and `hmrUpdate`. The runner preserves
resolved plugin order for every hook and executes `setup` exactly once.
In addition to components, routes, middleware, assets, styles, runtimes, and
migrations, plugins can contribute `directives`, `cliCommands`,
`virtualModules`, `deploymentAdapters`, `configSchemas`, `documentation`, and
`typeDefinitions`. Names are collision checked. Configuration schemas run after
configuration resolution, CLI commands are callable as normal `wrnexus`
commands, directives participate in AST transformation, and production builds
materialize virtual modules and invoke matching contributed adapters.
+32 -3
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/plugin", "name": "@wrnexus/plugin",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/plugin — part of the WrNexus framework.", "description": "@wrnexus/plugin — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/plugin"
},
"homepage": "https://wrnexusjs.dev/packages/plugin",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"plugin"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -18,12 +34,25 @@
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "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": { "dependencies": {
"@wrnexus/syntax": "^0.3.6" "@wrnexus/syntax": "^0.8.4"
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+9 -4
View File
@@ -31,13 +31,15 @@ Creates a bus over a driver. Defaults to `memoryDriver()` (in-process).
interface PubSub { interface PubSub {
publish<T = unknown>(topic: string, message: T): Promise<void>; publish<T = unknown>(topic: string, message: T): Promise<void>;
subscribe<T = unknown>(pattern: string, handler: Handler<T>): () => void; subscribe<T = unknown>(pattern: string, handler: Handler<T>): () => void;
close(): Promise<void>;
} }
type Handler<T = unknown> = (message: T, topic: string) => void | Promise<void>; type Handler<T = unknown> = (message: T, topic: string) => void | Promise<void>;
``` ```
- `publish(topic, message)` — resolves once the driver has dispatched the message. - `publish(topic, message)` — resolves once the driver and in-memory async handlers finish.
- `subscribe(pattern, handler)` — returns an unsubscribe function. - `subscribe(pattern, handler)` — returns an unsubscribe function.
- `close()` — idempotently rejects new work, clears local subscriptions, and closes the driver.
### Pattern matching ### Pattern matching
@@ -67,7 +69,7 @@ then `redis://localhost:6379`. The URL may carry a password and a database index
(e.g. `redis://:secret@host:6379/2`). (e.g. `redis://:secret@host:6379/2`).
```ts ```ts
function redisDriver(url?: string): PubSubDriver & { close(): void }; function redisDriver(url?: string, options?: RedisDriverOptions): PubSubDriver & { close(): void };
``` ```
- Exact topics use Redis `SUBSCRIBE`; wildcard patterns (`ns:*`, `*`) use - Exact topics use Redis `SUBSCRIBE`; wildcard patterns (`ns:*`, `*`) use
@@ -75,6 +77,9 @@ function redisDriver(url?: string): PubSubDriver & { close(): void };
- Messages are JSON-stringified on publish and `JSON.parse`d on receipt; a payload - 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. that isn't valid JSON is delivered as the raw string.
- `close()` tears down both the subscriber and publisher connections. - `close()` tears down both the subscriber and publisher connections.
- Lost sockets reconnect with bounded exponential backoff and active subscriptions
are replayed. `maxPending` bounds unavailable-connection writes (default 1000);
`reconnectDelayMs` and `reconnectMaxDelayMs` tune recovery (100ms/5000ms).
### RESP codec (internal) ### RESP codec (internal)
@@ -115,8 +120,8 @@ bus.subscribe("order:*", (msg, topic) => {
await bus.publish("order:created", { id: 7 }); await bus.publish("order:created", { id: 7 });
// on shutdown // on shutdown (also closes the driver)
driver.close(); await bus.close();
``` ```
## Requirements / Notes ## Requirements / Notes
+23 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/pubsub", "name": "@wrnexus/pubsub",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/pubsub — part of the WrNexus framework.", "description": "@wrnexus/pubsub — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/pubsub"
},
"homepage": "https://wrnexusjs.dev/packages/pubsub",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"pubsub"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -19,12 +35,17 @@
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
}, },
"./brokers": {
"types": "./dist/brokers.d.ts",
"import": "./dist/brokers.js"
},
"./redis": { "./redis": {
"types": "./dist/redis.d.ts", "types": "./dist/redis.d.ts",
"import": "./dist/redis.js" "import": "./dist/redis.js"
} }
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+50 -3
View File
@@ -43,6 +43,8 @@ function createQueue(options?: QueueOptions): Queue;
| `backoffMs` | `number` | `1000` | Base retry backoff in ms; doubles per attempt. | | `backoffMs` | `number` | `1000` | Base retry backoff in ms; doubles per attempt. |
| `pollMs` | `number` | `250` | Poll interval used once `start()` is called (ms). | | `pollMs` | `number` | `250` | Poll interval used once `start()` is called (ms). |
| `onFailed` | `(job: Job, error: unknown) => void` | — | Called when a job exhausts its attempts. | | `onFailed` | `(job: Job, error: unknown) => void` | — | Called when a job exhausts its attempts. |
| `concurrency` | `number` | unlimited | Maximum jobs claimed by one `drain()` call. |
| `capacity` | `number` | unlimited | Maximum queued plus active jobs before adds reject. |
| `now` | `() => number` | `Date.now` | Clock injection for deterministic tests. | | `now` | `() => number` | `Date.now` | Clock injection for deterministic tests. |
### `Queue` ### `Queue`
@@ -50,26 +52,36 @@ function createQueue(options?: QueueOptions): Queue;
The object returned by `createQueue`. The object returned by `createQueue`.
| Method | Signature | Description | | 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. | | `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. | | `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. | | `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. | | `start` | `start(): void` | Begin polling every `pollMs`. No-op if already started. |
| `stop` | `stop(): void` | Stop the poll timer. | | `stop` | `stop(): void` | Stop the poll timer. |
| `shutdown` | `shutdown({ force? }): Promise<void>` | Stop accepting jobs and await active work; force aborts it. |
| `size` | `size(): number` | Number of jobs currently queued. | | `size` | `size(): number` | Number of jobs currently queued. |
| `get/list` | `get(id)` / `list(name?)` | Inspect defensive copies of pending jobs. |
| `cancel` | `cancel(id): boolean` | Remove queued work or abort an active handler. |
| `failed` | `failed(): Job[]` | Inspect exhausted jobs in the dead-letter collection. |
| `retry` | `retry(id): Promise<boolean>` | Reset and requeue a dead-lettered job. |
#### `AddOptions` #### `AddOptions`
| Option | Type | Description | | Option | Type | Description |
| ------------- | -------- | -------------------------------------------------------------------------- | | ---------------- | -------- | -------------------------------------------------------------------------- |
| `delayMs` | `number` | Delay before the job becomes runnable (ms). | | `delayMs` | `number` | Delay before the job becomes runnable (ms). |
| `maxAttempts` | `number` | Max attempts before dead-lettering. Defaults to the queue's `maxAttempts`. | | `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). | | `repeat` | `number` | Re-enqueue this job this many ms after each successful run (recurring). |
| `priority` | `number` | Higher values are selected first among due jobs. |
| `idempotencyKey` | `string` | Return the matching pending job instead of enqueueing a duplicate. |
#### `JobHandler<T>` #### `JobHandler<T>`
```ts ```ts
type JobHandler<T = unknown> = (job: Job<T>) => void | Promise<void>; type JobHandler<T = unknown> = (
job: Job<T>,
context: { signal: AbortSignal },
) => void | Promise<void>;
``` ```
#### `Job<T>` #### `Job<T>`
@@ -106,6 +118,18 @@ await queue.add("email", { to: "a@b.com" }, { delayMs: 5000, maxAttempts: 3 });
queue.start(); // begin polling; queue.stop() to halt queue.start(); // begin polling; queue.stop() to halt
``` ```
Use `context.signal` in network/database calls so forced shutdown and active
cancellation finish promptly. For process termination, prefer
`await queue.shutdown()`; use `{ force: true }` only after your grace period.
### Durable queue
`createDurableQueue({ store })` retains jobs until their handler succeeds and
supports atomic leases when a driver implements `QueueStore.claim`. It exposes
the same cancellation/shutdown behavior plus `list`, `failed`, and `retry`.
The included `memoryQueueStore()` is useful for tests; production Redis/SQL
drivers should make `claim()` atomic to prevent two workers executing one job.
### Recurring jobs ### Recurring jobs
Pass `repeat` to re-enqueue a job a fixed interval after each successful run: Pass `repeat` to re-enqueue a job a fixed interval after each successful run:
@@ -145,6 +169,29 @@ clock = 5000;
const ran = await queue.drain(); // => 1 const ran = await queue.drain(); // => 1
``` ```
### Durable workflows and approvals
`createWorkflowEngine(store)` executes dependency-ordered steps and persists every transition,
result, progress update, failure, cancellation, and approval record. Approval steps pause safely
and can resume after a process restart because the snapshot lives in the supplied `WorkflowStore`.
```ts
const workflow = defineDurableWorkflow({
name: "publish-report",
steps: [
{ name: "build", run: buildReport },
{ name: "approve", dependsOn: ["build"], approval: true, run: (report) => report },
{ name: "publish", dependsOn: ["approve"], run: publishReport },
],
});
const run = await engine.start(workflow, input);
await engine.approve(workflow, run.id, "approve", currentUser.id);
```
Use `memoryWorkflowStore()` for tests. Production stores implement the small `get`, `put`, and
`list` contract using the same transactional database or durable service as the application.
## Retry & backoff behavior ## Retry & backoff behavior
- On a thrown handler error, the job is retried while `attempts < maxAttempts`. - On a thrown handler error, the job is retried while `attempts < maxAttempts`.
+22 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/queue", "name": "@wrnexus/queue",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/queue — part of the WrNexus framework.", "description": "@wrnexus/queue — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/queue"
},
"homepage": "https://wrnexusjs.dev/packages/queue",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"queue"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -20,7 +36,11 @@
"import": "./dist/index.js" "import": "./dist/index.js"
} }
}, },
"dependencies": {
"@wrnexus/core": "^0.8.4"
},
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+19 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/reactive", "name": "@wrnexus/reactive",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/reactive — part of the WrNexus framework.", "description": "@wrnexus/reactive — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/reactive"
},
"homepage": "https://wrnexusjs.dev/packages/reactive",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"reactive"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -21,6 +37,7 @@
} }
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+21 -4
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/router", "name": "@wrnexus/router",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/router — part of the WrNexus framework.", "description": "@wrnexus/router — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/router"
},
"homepage": "https://wrnexusjs.dev/packages/router",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"router"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -21,10 +37,11 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/compiler": "^0.3.6", "@wrnexus/compiler": "^0.8.4",
"@wrnexus/core": "^0.3.6" "@wrnexus/core": "^0.8.4"
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+30 -3
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/ssr", "name": "@wrnexus/ssr",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/ssr — part of the WrNexus framework.", "description": "@wrnexus/ssr — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/ssr"
},
"homepage": "https://wrnexusjs.dev/packages/ssr",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"ssr"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -18,12 +34,23 @@
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "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": { "dependencies": {
"@wrnexus/core": "^0.3.6" "@wrnexus/core": "^0.8.4",
"@wrnexus/store": "^0.8.4",
"@wrnexus/security": "^0.8.4"
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+21
View File
@@ -1,5 +1,26 @@
# @wrnexus/styles # @wrnexus/styles
## Reusable layers and presets
Compose local or package foundations in order; later layers override earlier
ones and the application has final base-config precedence:
```ts
export default defineConfig({
extends: ["@workroot/wrnexus-enterprise", "./layers/company"],
profiles: { production: { port: 8080 } },
});
```
A directory layer exports `wrnexus.layer.ts` (JavaScript/MJS are supported).
A package can provide that conventional file or declare
`wrnexus.layer` in its `package.json`. Layers may extend other layers and carry
the complete app configuration, including plugins that contribute layouts,
components, routes, middleware, and migrations. `plugins` and `head` compose;
other arrays intentionally replace earlier values. Cycles and missing/invalid
entries fail with stable `WRN-CONFIG-LAYER-*` diagnostics. `wrnexus config
--explain` lists every resolved layer source.
> Global CSS bundling, the `--wire-*` design-token theme system, and the `wrnexus.config.ts` app-config loader for WrNexus apps. > 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. Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
+22 -5
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/styles", "name": "@wrnexus/styles",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/styles — part of the WrNexus framework.", "description": "@wrnexus/styles — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/styles"
},
"homepage": "https://wrnexusjs.dev/packages/styles",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"styles"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -21,11 +37,12 @@
} }
}, },
"dependencies": { "dependencies": {
"@wrnexus/uploader": "^0.3.6", "@wrnexus/uploader": "^0.8.4",
"@wrnexus/core": "^0.3.6", "@wrnexus/core": "^0.8.4",
"@wrnexus/plugin": "^0.3.6" "@wrnexus/plugin": "^0.8.4"
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+23 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/syntax", "name": "@wrnexus/syntax",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/syntax — part of the WrNexus framework.", "description": "@wrnexus/syntax — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/syntax"
},
"homepage": "https://wrnexusjs.dev/packages/syntax",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"syntax"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -38,9 +54,14 @@
"./spec": { "./spec": {
"types": "./dist/spec.d.ts", "types": "./dist/spec.d.ts",
"import": "./dist/spec.js" "import": "./dist/spec.js"
},
"./formatter": {
"types": "./dist/formatter.d.ts",
"import": "./dist/formatter.js"
} }
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+16
View File
@@ -103,6 +103,22 @@ Remember to `await app.close()` when done.
## Usage ## Usage
The CLI supports focused suites by file or directory convention:
```bash
wrnexus test unit # *.unit.test.ts or test/unit/**
wrnexus test component # *.component.test.ts or test/component/**
wrnexus test api # *.api.test.ts or test/api/**
wrnexus test accessibility # *.a11y.test.ts / *.accessibility.test.ts
wrnexus test performance # *.performance.test.ts / *.benchmark.test.ts
wrnexus test browser # Playwright project when configured
wrnexus test visual # Playwright tests tagged @visual
```
Pass the application directory after the level, for example
`wrnexus test component examples/basic-app`. A focused command fails clearly when no matching
suite exists instead of silently running unrelated tests.
```ts ```ts
import { test, expect, renderComponent, mountHtml, createHarness } from "@wrnexus/test"; import { test, expect, renderComponent, mountHtml, createHarness } from "@wrnexus/test";
+19 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/test", "name": "@wrnexus/test",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/test — part of the WrNexus framework.", "description": "@wrnexus/test — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/test"
},
"homepage": "https://wrnexusjs.dev/packages/test",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"test"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -21,6 +37,7 @@
} }
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+19 -2
View File
@@ -1,9 +1,25 @@
{ {
"name": "@wrnexus/tracking", "name": "@wrnexus/tracking",
"version": "0.3.6", "version": "0.8.4",
"type": "module", "type": "module",
"description": "@wrnexus/tracking — part of the WrNexus framework.", "description": "@wrnexus/tracking — part of the WrNexus framework.",
"license": "MIT", "license": "MIT",
"repository": {
"type": "git",
"url": "https://git.workroot.in/WorkRoot/WRNexusJS.git",
"directory": "packages/tracking"
},
"homepage": "https://wrnexusjs.dev/packages/tracking",
"bugs": {
"url": "https://git.workroot.in/WorkRoot/WRNexusJS/issues"
},
"keywords": [
"wrnexus",
"bun",
"typescript",
"tracking"
],
"sideEffects": false,
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -21,6 +37,7 @@
} }
}, },
"files": [ "files": [
"dist" "dist",
"README.md"
] ]
} }
+408 -195
View File
File diff suppressed because it is too large Load Diff
+10 -8
View File
@@ -15,7 +15,7 @@ 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 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. (CLI build + dev server) can locate the component directory and stylesheet.
The complete PDF-aligned catalog currently contains **891 components**. The The complete PDF-aligned catalog currently contains **85 components**. The
generated `COMPONENTS.md` and `component-reference.json` files document every generated `COMPONENTS.md` and `component-reference.json` files document every
mount name, prop, inferred type, default/required status, slot, event, category, mount name, prop, inferred type, default/required status, slot, event, category,
and source file directly from the packaged `.wrn` source. and source file directly from the packaged `.wrn` source.
@@ -35,10 +35,11 @@ In practice you rarely install this directly: `@wrnexus/cli` and
## Components ## Components
Components live as `.wrn` files under `packages/ui/components/`. The mount name Components live as `.wrn` files under `packages/ui/components/`. The canonical mount
is the **lowercase file basename** (e.g. `button.wrn``data-component="button"`). name comes from the component declaration (for example, `component Button` mounts as
Each accepts a `class` prop (appended to its root element) and most render their `data-component="Button"`). Component lookup is case-insensitive, so existing lowercase
body from either a named prop or the default slot. 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 ### Layout
@@ -91,16 +92,17 @@ equivalent is exported as `@wrnexus/ui/component-reference.json`.
## API ## API
The JS module (`@wrnexus/ui`) exposes four helpers used by the build tooling to The JS module (`@wrnexus/ui`) exposes five helpers used by the build tooling to
locate the component assets. There is no component code to import — the components locate the component assets. There is no component code to import — the components
are `.wrn` files rendered server-side. are `.wrn` files rendered server-side.
| Export | Signature | Returns | | Export | Signature | Returns |
| ------------------ | ---------------- | ------------------------------------------------------------------------------------------ | | ------------------ | -------------------------- | ------------------------------------------------------------------------------------------ |
| `uiComponentsDir` | `() => string` | Absolute path to the `.wrn` component directory (feed to `buildRouter`'s `componentDirs`). | | `uiComponentsDir` | `() => string` | Absolute path to the `.wrn` component directory (feed to `buildRouter`'s `componentDirs`). |
| `uiCssPath` | `() => string` | Absolute path to `ui.css`. | | `uiCssPath` | `() => string` | Absolute path to `ui.css`. |
| `uiCss` | `() => string` | The `ui.css` file contents (all `.wire-*` classes, themed via tokens). | | `uiCss` | `() => string` | The `ui.css` file contents (all `.wire-*` classes, themed via tokens). |
| `uiComponentNames` | `() => string[]` | Sorted list of built-in component names (e.g. for `wrnexus eject` listing). | | `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 ### `./ui.css` asset export
+420 -309
View File
@@ -1,66 +1,46 @@
{ {
"sourceDocuments": ["Screenshot-directed WRNexus UI catalog reset"], "sourceDocuments": ["Screenshot-directed WRNexus UI catalog reset"],
"components": [ "components": [
{
"name": "Container",
"category": "layout",
"purpose": "Theme-aware, responsive container component."
},
{
"name": "Columns",
"category": "layout",
"purpose": "Theme-aware, responsive columns component."
},
{
"name": "Grid",
"category": "layout",
"purpose": "Theme-aware, responsive grid component."
},
{
"name": "LayoutSplitter",
"category": "layout",
"purpose": "Theme-aware, responsive layout splitter component."
},
{
"name": "Typography",
"category": "layout",
"purpose": "Theme-aware, responsive typography component."
},
{
"name": "Image",
"category": "layout",
"purpose": "Theme-aware, responsive image component."
},
{
"name": "Link",
"category": "layout",
"purpose": "Theme-aware, responsive link component."
},
{
"name": "Divider",
"category": "layout",
"purpose": "Theme-aware, responsive divider component."
},
{
"name": "Kbd",
"category": "layout",
"purpose": "Theme-aware, responsive kbd component."
},
{
"name": "CustomScrollbar",
"category": "layout",
"purpose": "Theme-aware, responsive custom scrollbar component."
},
{ {
"name": "Accordion", "name": "Accordion",
"category": "base", "category": "base",
"purpose": "Theme-aware, responsive accordion component." "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", "name": "Alert",
"category": "base", "category": "base",
"purpose": "Theme-aware, responsive alert component." "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", "name": "Avatar",
"category": "base", "category": "base",
@@ -71,6 +51,11 @@
"category": "base", "category": "base",
"purpose": "Theme-aware, responsive avatar group component." "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", "name": "Badge",
"category": "base", "category": "base",
@@ -81,6 +66,11 @@
"category": "base", "category": "base",
"purpose": "Theme-aware, responsive blockquote component." "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", "name": "Button",
"category": "base", "category": "base",
@@ -92,14 +82,14 @@
"purpose": "Theme-aware, responsive button group component." "purpose": "Theme-aware, responsive button group component."
}, },
{ {
"name": "Card", "name": "CTASection",
"category": "base", "category": "marketing",
"purpose": "Theme-aware, responsive card component." "purpose": "Close a page or major section with conversion-focused copy, actions, and optional supporting visual content."
}, },
{ {
"name": "ChatBubble", "name": "Card",
"category": "base", "category": "base",
"purpose": "Theme-aware, responsive chat bubble component." "purpose": "Group related content in a responsive themed surface with title, description, content, and supporting slots."
}, },
{ {
"name": "Carousel", "name": "Carousel",
@@ -107,149 +97,14 @@
"purpose": "Theme-aware, responsive carousel component." "purpose": "Theme-aware, responsive carousel component."
}, },
{ {
"name": "Collapse", "name": "Chart",
"category": "integrations",
"purpose": "Theme-aware, responsive chart component."
},
{
"name": "ChatBubble",
"category": "base", "category": "base",
"purpose": "Theme-aware, responsive collapse component." "purpose": "Theme-aware, responsive chat bubble component."
},
{
"name": "DatePicker",
"category": "base",
"purpose": "Theme-aware, responsive date picker component."
},
{
"name": "DeviceFrame",
"category": "base",
"purpose": "Theme-aware, responsive device frame component."
},
{
"name": "List",
"category": "base",
"purpose": "Theme-aware, responsive list component."
},
{
"name": "ListGroup",
"category": "base",
"purpose": "Theme-aware, responsive list group component."
},
{
"name": "LegendIndicator",
"category": "base",
"purpose": "Theme-aware, responsive legend indicator component."
},
{
"name": "Progress",
"category": "base",
"purpose": "Theme-aware, responsive progress component."
},
{
"name": "FileUploadProgress",
"category": "base",
"purpose": "Theme-aware, responsive file upload progress component."
},
{
"name": "Rating",
"category": "base",
"purpose": "Theme-aware, responsive rating component."
},
{
"name": "Skeleton",
"category": "base",
"purpose": "Theme-aware, responsive skeleton component."
},
{
"name": "Spinner",
"category": "base",
"purpose": "Theme-aware, responsive spinner component."
},
{
"name": "StyledIcon",
"category": "base",
"purpose": "Theme-aware, responsive styled icon component."
},
{
"name": "Toast",
"category": "base",
"purpose": "Theme-aware, responsive toast component."
},
{
"name": "Timeline",
"category": "base",
"purpose": "Theme-aware, responsive timeline component."
},
{
"name": "TreeView",
"category": "base",
"purpose": "Theme-aware, responsive tree view component."
},
{
"name": "Marquee",
"category": "base",
"purpose": "Theme-aware, responsive marquee component."
},
{
"name": "Navbar",
"category": "navigation",
"purpose": "Theme-aware, responsive navbar component."
},
{
"name": "MegaMenu",
"category": "navigation",
"purpose": "Theme-aware, responsive mega menu component."
},
{
"name": "Nav",
"category": "navigation",
"purpose": "Theme-aware, responsive nav component."
},
{
"name": "Tabs",
"category": "navigation",
"purpose": "Theme-aware, responsive tabs component."
},
{
"name": "Sidebar",
"category": "navigation",
"purpose": "Theme-aware, responsive sidebar component."
},
{
"name": "Scrollspy",
"category": "navigation",
"purpose": "Theme-aware, responsive scrollspy component."
},
{
"name": "Breadcrumb",
"category": "navigation",
"purpose": "Theme-aware, responsive breadcrumb component."
},
{
"name": "Pagination",
"category": "navigation",
"purpose": "Theme-aware, responsive pagination component."
},
{
"name": "Stepper",
"category": "navigation",
"purpose": "Theme-aware, responsive stepper component."
},
{
"name": "Input",
"category": "forms",
"purpose": "Theme-aware, responsive input component."
},
{
"name": "InputGroup",
"category": "forms",
"purpose": "Theme-aware, responsive input group component."
},
{
"name": "Textarea",
"category": "forms",
"purpose": "Theme-aware, responsive textarea component."
},
{
"name": "FileInput",
"category": "forms",
"purpose": "Theme-aware, responsive file input component."
}, },
{ {
"name": "Checkbox", "name": "Checkbox",
@@ -257,24 +112,14 @@
"purpose": "Theme-aware, responsive checkbox component." "purpose": "Theme-aware, responsive checkbox component."
}, },
{ {
"name": "Radio", "name": "Clipboard",
"category": "forms", "category": "integrations",
"purpose": "Theme-aware, responsive radio component." "purpose": "Theme-aware, responsive clipboard component."
}, },
{ {
"name": "Switch", "name": "Collapse",
"category": "forms", "category": "base",
"purpose": "Theme-aware, responsive switch component." "purpose": "Theme-aware, responsive collapse component."
},
{
"name": "Select",
"category": "forms",
"purpose": "Theme-aware, responsive select component."
},
{
"name": "RangeSlider",
"category": "forms",
"purpose": "Theme-aware, responsive range slider component."
}, },
{ {
"name": "ColorPicker", "name": "ColorPicker",
@@ -282,47 +127,29 @@
"purpose": "Theme-aware, responsive color picker component." "purpose": "Theme-aware, responsive color picker component."
}, },
{ {
"name": "TimePicker", "name": "Columns",
"category": "forms", "category": "layout",
"purpose": "Theme-aware, responsive time picker component." "purpose": "Create responsive balanced content columns with configurable count, gap, density, and maximum width."
},
{
"name": "AdvancedSelect",
"category": "advanced-forms",
"purpose": "Theme-aware, responsive advanced select component.",
"events": ["search", "select", "change", "clear", "open", "close", "load", "error"]
}, },
{ {
"name": "ComboBox", "name": "ComboBox",
"category": "advanced-forms", "category": "advanced-forms",
"purpose": "Editable autocomplete combobox with local and remote suggestions.", "purpose": "Editable autocomplete combobox with local and remote suggestions."
"events": ["search", "select", "change", "clear", "open", "close", "load", "error"]
}, },
{ {
"name": "SearchBox", "name": "Confetti",
"category": "advanced-forms", "category": "integrations",
"purpose": "Theme-aware, responsive search box component." "purpose": "Theme-aware, responsive confetti component."
}, },
{ {
"name": "InputNumber", "name": "Container",
"category": "advanced-forms", "category": "layout",
"purpose": "Theme-aware, responsive input number component." "purpose": "Constrain and align page content with responsive gutters and compact, wide, or full width options."
}, },
{ {
"name": "StrongPassword", "name": "ContextMenu",
"category": "advanced-forms", "category": "overlays",
"purpose": "Theme-aware, responsive strong password component." "purpose": "Open an accessible keyboard-aware action menu from pointer or keyboard context interactions."
},
{
"name": "TogglePassword",
"category": "advanced-forms",
"purpose": "Accessible password field with optional show and hide controls.",
"events": ["input", "change", "toggle"]
},
{
"name": "ToggleCount",
"category": "advanced-forms",
"purpose": "Theme-aware, responsive toggle count component."
}, },
{ {
"name": "CopyMarkup", "name": "CopyMarkup",
@@ -330,70 +157,9 @@
"purpose": "Theme-aware, responsive copy markup component." "purpose": "Theme-aware, responsive copy markup component."
}, },
{ {
"name": "PinInput", "name": "CustomScrollbar",
"category": "advanced-forms", "category": "layout",
"purpose": "Secure multi-cell PIN and verification-code input with regex and paste support.", "purpose": "Theme-aware, responsive custom scrollbar component."
"events": ["input", "change", "complete", "paste", "clear", "error"]
},
{
"name": "Dropdown",
"category": "overlays",
"purpose": "Theme-aware, responsive dropdown component."
},
{
"name": "ContextMenu",
"category": "overlays",
"purpose": "Theme-aware, responsive context menu component."
},
{
"name": "Modal",
"category": "overlays",
"purpose": "Theme-aware, responsive modal component."
},
{
"name": "Drawer",
"category": "overlays",
"purpose": "Theme-aware, responsive drawer component."
},
{
"name": "Popover",
"category": "overlays",
"purpose": "Theme-aware, responsive popover component."
},
{
"name": "Tooltip",
"category": "overlays",
"purpose": "Theme-aware, responsive tooltip component."
},
{
"name": "Table",
"category": "tables",
"purpose": "Theme-aware, responsive table component."
},
{
"name": "AdvancedRangeSlider",
"category": "integrations",
"purpose": "Theme-aware, responsive advanced range slider component."
},
{
"name": "AdvancedDatePicker",
"category": "integrations",
"purpose": "Theme-aware, responsive advanced date picker component."
},
{
"name": "Chart",
"category": "integrations",
"purpose": "Theme-aware, responsive chart component."
},
{
"name": "Clipboard",
"category": "integrations",
"purpose": "Theme-aware, responsive clipboard component."
},
{
"name": "Confetti",
"category": "integrations",
"purpose": "Theme-aware, responsive confetti component."
}, },
{ {
"name": "DataMap", "name": "DataMap",
@@ -405,26 +171,371 @@
"category": "integrations", "category": "integrations",
"purpose": "Theme-aware, responsive data table component." "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", "name": "DragAndDrop",
"category": "integrations", "category": "integrations",
"purpose": "Theme-aware, responsive drag and drop component." "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", "name": "FileUpload",
"category": "integrations", "category": "integrations",
"purpose": "Theme-aware, responsive file upload component." "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", "name": "Map",
"category": "integrations", "category": "integrations",
"purpose": "Theme-aware, responsive map component." "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", "name": "ToastNotifications",
"category": "integrations", "category": "integrations",
"purpose": "Theme-aware, responsive toast notifications component." "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", "name": "WysiwygEditor",
"category": "integrations", "category": "integrations",
File diff suppressed because it is too large Load Diff
+200 -9
View File
@@ -1,15 +1,206 @@
component Accordion { component Accordion {
props { outputs {
size = "default" 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)
color = "primary" open(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
items = [] close(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
multiple = false
class = ""
} }
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 { view {
<div class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--accordion {class}"> <div
{#each items as item}<details open="{item.open}"><summary>{item.label}</summary><div>{item.content}</div></details>{/each} {...attrs}
<slot /> 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> </div>
} }
} }
+15 -7
View File
@@ -1,12 +1,20 @@
component AdvancedDatePicker { 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 { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
title = "Advanced Date Picker" title: string = "Advanced Date Picker"
description = "" description: string = ""
items = [] items: unknown[] = []
variant = "default" variant: string = "default"
class = "" class: string = ""
} }
view { view {
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--advanced-date-picker wire-next--variant-{variant} {class}"> <section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--advanced-date-picker wire-next--variant-{variant} {class}">
+14 -7
View File
@@ -1,12 +1,19 @@
component AdvancedRangeSlider { 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 { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
title = "Advanced Range Slider" title: string = "Advanced Range Slider"
description = "" description: string = ""
items = [] items: unknown[] = []
variant = "default" variant: string = "default"
class = "" class: string = ""
} }
view { view {
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--advanced-range-slider wire-next--variant-{variant} {class}"> <section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--advanced-range-slider wire-next--variant-{variant} {class}">
+85 -82
View File
@@ -1,82 +1,85 @@
component AdvancedSelect { 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 { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
label = "Advanced Select" label: string = "Advanced Select"
name = "" name: string = ""
value = "" value: string = ""
values = [] values: unknown[] = []
options = [] options: unknown[] = []
groups = [] groups: unknown[] = []
placeholder = "Select an option" placeholder: string = "Select an option"
placeholderIcon = "" placeholderIcon: string = ""
searchPlaceholder = "Search options…" searchPlaceholder: string = "Search options…"
multiple = false multiple: boolean = false
searchable = true searchable: boolean = true
defaultOpen = false defaultOpen: boolean = false
clearable = true clearable: boolean = true
allowEmpty = true allowEmpty: boolean = true
tags = false tags: boolean = false
disabled = false disabled: boolean = false
required = false required: boolean = false
invalid = false invalid: boolean = false
validationMessage = "" validationMessage: string = ""
helpText = "" helpText: string = ""
loading = false loading: boolean = false
loadingLabel = "Loading options…" loadingLabel: string = "Loading options…"
emptyLabel = "No options found" emptyLabel: string = "No options found"
selectedOptionsLabel = "Selected options" selectedOptionsLabel: string = "Selected options"
clearLabel = "Clear selection" clearLabel: string = "Clear selection"
createLabel = "Create" createLabel: string = "Create"
loadMoreLabel = "Load more" loadMoreLabel: string = "Load more"
searchMode = "contains" searchMode: string = "contains"
searchFields = "label,description" searchFields: string = "label,description"
minSearchLength = 0 minSearchLength: number = 0
searchResultLimit = 0 searchResultLimit: number = 0
maxSelections = 0 maxSelections: number = 0
showCounter = false showCounter: boolean = false
counterTemplate = "{selected} selected" counterTemplate: string = "{selected} selected"
optionTemplate = "default" optionTemplate: string = "default"
selectedTemplate = "default" selectedTemplate: string = "default"
closeOnSelect = true closeOnSelect: boolean = true
scrollToSelected = true scrollToSelected: boolean = true
fixed = false fixed: boolean = false
placement = "bottom" placement: string = "bottom"
remote = false remote: boolean = false
remoteUrl = "" remoteUrl: string = ""
remoteQueryParam = "q" remoteQueryParam: string = "q"
remoteDebounce = 250 remoteDebounce: number = 250
remoteAutoLoad = true remoteAutoLoad: boolean = true
infinite = false infinite: boolean = false
hasMore = false hasMore: boolean = false
page = 1 page: number = 1
class = "" class: string = ""
@event search = function
@event select = function
@event change = function
@event clear = function
@event open = function
@event close = function
@event load = function
@event error = function
} }
state open = defaultOpen state open = defaultOpen
state query = "" state query: string = ""
state activeIndex = -1 state activeIndex: number = -1
state selectedValue = value state selectedValue = value
state selectedValues = values state selectedValues = values
functions { functions {
function allOptions() { shared function allOptions() {
return [...groups.flatMap((group) => group.options || []), ...options] return [...groups.flatMap((group) => group.options || []), ...options]
} }
function searchableText(option) { shared function searchableText(option) {
return ((option.label || "") + " " + (option.description || "")).toLowerCase() return ((option.label || "") + " " + (option.description || "")).toLowerCase()
} }
function matches(option) { shared function matches(option) {
if (!query || query.length < Number(minSearchLength || 0)) { if (!query || query.length < Number(minSearchLength || 0)) {
return true return true
} }
@@ -89,22 +92,22 @@ component AdvancedSelect {
return searchableText(option).includes(query.toLowerCase()) return searchableText(option).includes(query.toLowerCase())
} }
function matchingOptions(list) { shared function matchingOptions(list) {
return list.filter((option) => matches(option)) return list.filter((option) => matches(option))
} }
function visibleOptions(list) { shared function visibleOptions(list) {
if (searchResultLimit > 0) { if (searchResultLimit > 0) {
return matchingOptions(list).slice(0, Number(searchResultLimit)) return matchingOptions(list).slice(0, Number(searchResultLimit))
} }
return matchingOptions(list) return matchingOptions(list)
} }
function flatVisibleOptions() { shared function flatVisibleOptions() {
return visibleOptions(allOptions()) return visibleOptions(allOptions())
} }
function isSelected(option) { shared function isSelected(option) {
return ( return (
multiple multiple
? selectedValues.includes(option.value) ? selectedValues.includes(option.value)
@@ -112,15 +115,15 @@ component AdvancedSelect {
) )
} }
function selectedOptions() { shared function selectedOptions() {
return allOptions().filter((option) => isSelected(option)) return allOptions().filter((option) => isSelected(option))
} }
function selectedCount() { shared function selectedCount() {
return multiple ? selectedValues.length : selectedValue ? 1 : 0 return multiple ? selectedValues.length : selectedValue ? 1 : 0
} }
function counterText() { shared function counterText() {
return ( return (
maxSelections maxSelections
? selectedCount() + " / " + maxSelections + " selected" ? selectedCount() + " / " + maxSelections + " selected"
@@ -128,7 +131,7 @@ component AdvancedSelect {
) )
} }
function selectedText() { shared function selectedText() {
return ( return (
multiple multiple
? selectedValues.join(", ") ? selectedValues.join(", ")
@@ -138,11 +141,11 @@ component AdvancedSelect {
) )
} }
function triggerText() { shared function triggerText() {
return selectedCount() ? selectedText() : placeholder return selectedCount() ? selectedText() : placeholder
} }
function canSelect(option) { shared function canSelect(option) {
if (option.disabled) { if (option.disabled) {
return false return false
} }
@@ -155,7 +158,7 @@ component AdvancedSelect {
return selectedCount() < maxSelections return selectedCount() < maxSelections
} }
function chooseSingle(option) { shared function chooseSingle(option) {
if (!canSelect(option)) { if (!canSelect(option)) {
return return
} }
@@ -166,7 +169,7 @@ component AdvancedSelect {
} }
} }
function chooseMultiple(option) { shared function chooseMultiple(option) {
if (!canSelect(option)) { if (!canSelect(option)) {
return return
} }
@@ -178,7 +181,7 @@ component AdvancedSelect {
query = "" query = ""
} }
function chooseOption(option) { shared function chooseOption(option) {
if (multiple) { if (multiple) {
chooseMultiple(option) chooseMultiple(option)
return return
@@ -186,7 +189,7 @@ component AdvancedSelect {
chooseSingle(option) chooseSingle(option)
} }
function clearSelection(event) { client function clearSelection(event) {
event.stopPropagation() event.stopPropagation()
selectedValue = "" selectedValue = ""
selectedValues = [] selectedValues = []
@@ -194,7 +197,7 @@ component AdvancedSelect {
open = false open = false
} }
function toggle() { shared function toggle() {
if (disabled) { if (disabled) {
return; return;
}; };
@@ -202,14 +205,14 @@ component AdvancedSelect {
activeIndex = open && flatVisibleOptions().length ? 0 : -1; activeIndex = open && flatVisibleOptions().length ? 0 : -1;
} }
function moveActive(direction) { shared function moveActive(direction) {
if (!flatVisibleOptions().length) { if (!flatVisibleOptions().length) {
return return
} }
activeIndex = (activeIndex + direction + flatVisibleOptions().length) % flatVisibleOptions().length activeIndex = (activeIndex + direction + flatVisibleOptions().length) % flatVisibleOptions().length
} }
function handleKeydown(event) { client function handleKeydown(event) {
if (disabled) { if (disabled) {
return return
} }
@@ -243,7 +246,7 @@ component AdvancedSelect {
} }
} }
function optionIndex(option) { shared function optionIndex(option) {
return flatVisibleOptions().findIndex((item) => item.value === option.value) return flatVisibleOptions().findIndex((item) => item.value === option.value)
} }
} }
+149 -14
View File
@@ -1,19 +1,154 @@
component AvatarGroup { component AvatarGroup {
props { outputs {
size = "default" overflow(payload: { sourceEvent?: Event; value: string | number | boolean | null | object; [key: string]: string | number | boolean | null | object } | string | number | boolean | null | object[])
color = "primary"
title = "Avatar Group"
description = ""
items = []
variant = "default"
class = ""
} }
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 { view {
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--avatar-group wire-next--variant-{variant} {class}"> <div
{#if title}<strong>{title}</strong>{/if} {...attrs}
{#if description}<p>{description}</p>{/if} class="wire-next wire-next--avatar-group {class}"
{#if items}<div class="wire-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if} data-wrn-avatar-group
<slot /> data-layout="{layout}"
</section> 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>
} }
} }
+60 -12
View File
@@ -1,19 +1,67 @@
component Blockquote { component Blockquote {
props { props {
size = "default" quote: string = "I just wanted to say that I'm very happy with my purchase so far. The documentation is outstanding - clear and detailed."
color = "primary" citation: string = ""
title = "Blockquote" citationTitle: string = ""
description = "" citationUrl: string = ""
items = [] avatarSrc: string = ""
variant = "default" avatarAlt: string = ""
class = "" size: string = "md"
color: string = "primary"
align: string = "left"
variant: string = "default"
quoteMark: boolean = true
italic: boolean = true
class: string = ""
} }
view { view {
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--blockquote wire-next--variant-{variant} {class}"> <figure
{#if title}<strong>{title}</strong>{/if} {...attrs}
{#if description}<p>{description}</p>{/if} class="wire-next wire-next--blockquote wire-next--color-{color} wire-next--size-{size} {class}"
{#if items}<div class="wire-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if} 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 /> <slot />
</section> {/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>
} }
} }
+317 -11
View File
@@ -1,17 +1,323 @@
component Breadcrumb { component Breadcrumb {
props { outputs {
size = "default" 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)
color = "primary"
label = "Breadcrumb"
items = []
active = ""
orientation = "horizontal"
class = ""
} }
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 { view {
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--breadcrumb wire-next--{orientation} {class}" aria-label="{label}"> <nav
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each} {...attrs}
<slot /> 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> </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;
}
}
}
} }
+86 -13
View File
@@ -1,19 +1,92 @@
component ButtonGroup { component ButtonGroup {
props { outputs {
size = "default" click(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
color = "primary" select(payload: { value: string | number | boolean | null | object; previousValue: string | number | boolean | null | object; item: string | number | boolean | null | object; index: number })
title = "Button Group" change(payload: { value: string | number | boolean | null | object; previousValue: string | number | boolean | null | object; item: string | number | boolean | null | object; index: number })
description = ""
items = []
variant = "default"
class = ""
} }
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 { view {
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--button-group wire-next--variant-{variant} {class}"> <div
{#if title}<strong>{title}</strong>{/if} {...attrs}
{#if description}<p>{description}</p>{/if} class="wire-next wire-next--button-group {class}"
{#if items}<div class="wire-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if} 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 /> <slot />
</section> </div>
} }
} }
+456 -11
View File
@@ -1,19 +1,464 @@
// Interactive, hydration-safe content carousel.
component Carousel { component Carousel {
props { outputs {
size = "default" initialize(payload: { index: number; count: number })
color = "primary" change(payload: { index: number; previousIndex: number; item: string | number | boolean | null | object; reason: string | boolean })
title = "Carousel" previous(payload: { index: number; previousIndex: number; item: string | number | boolean | null | object })
description = "" next(payload: { index: number; previousIndex: number; item: string | number | boolean | null | object })
items = [] play(payload: { index: number; interval: number })
variant = "default" pause(payload: { index: number })
class = "" 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 { view {
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--carousel wire-next--variant-{variant} {class}"> <section
{#if title}<strong>{title}</strong>{/if} {...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} {#if description}<p>{description}</p>{/if}
{#if items}<div class="wire-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/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 /> <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> </section>
} }
} }
+13 -7
View File
@@ -1,12 +1,18 @@
component Chart { 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 { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
title = "Chart" title: string = "Chart"
description = "" description: string = ""
items = [] items: unknown[] = []
variant = "default" variant: string = "default"
class = "" class: string = ""
} }
view { view {
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--chart wire-next--variant-{variant} {class}"> <section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--chart wire-next--variant-{variant} {class}">
+142 -11
View File
@@ -1,18 +1,149 @@
component ChatBubble { component ChatBubble {
props { outputs {
size = "default" action(payload: { action: boolean; item: string | number | boolean | null | object; index: number })
color = "primary" messageClick(payload: { item: string | number | boolean | null | object; index: number; direction: string })
title = "Chat Bubble" avatarClick(payload: { item: string | number | boolean | null | object; index: number; direction: string })
description = "" linkClick(payload: { link: string | number | boolean | null | object; item: string | number | boolean | null | object; index: number })
items = []
variant = "default"
class = ""
} }
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 { view {
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--chat-bubble wire-next--variant-{variant} {class}"> <section
{#if title}<strong>{title}</strong>{/if} {...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} {#if description}<p>{description}</p>{/if}
{#if items}<div class="wire-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/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 /> <slot />
</section> </section>
} }
+13 -7
View File
@@ -1,12 +1,18 @@
component Clipboard { 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 { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
title = "Clipboard" title: string = "Clipboard"
description = "" description: string = ""
items = [] items: unknown[] = []
variant = "default" variant: string = "default"
class = "" class: string = ""
} }
view { view {
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--clipboard wire-next--variant-{variant} {class}"> <section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--clipboard wire-next--variant-{variant} {class}">
+92 -9
View File
@@ -1,15 +1,98 @@
component Collapse { component Collapse {
props { outputs {
size = "default" toggle(payload: { index: number; item: string | number | boolean | null | object; open: boolean; openIndexes: number[] })
color = "primary" open(payload: { index: number; item: string | number | boolean | null | object })
items = [] close(payload: { index: number; item: string | number | boolean | null | object })
multiple = false
class = ""
} }
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 { view {
<div class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--collapse {class}"> <section
{#each items as item}<details open="{item.open}"><summary>{item.label}</summary><div>{item.content}</div></details>{/each} {...attrs}
<slot /> 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>
</div>
</article>
{/each}
<slot />
</section>
} }
} }
+35 -14
View File
@@ -1,20 +1,41 @@
component ColorPicker { 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 { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
label = "Color Picker" id: string = ""
name = "" name: string = ""
value = "" label: string = "Color"
placeholder = "" hiddenLabel: boolean = false
type = "color" placeholder: string = ""
min = "" value: string = "#2563eb"
max = "" icon: string = ""
step = "" iconPosition: string = "start"
disabled = false helperText: string = ""
required = false cornerHint: string = ""
class = "" 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 { view {
<label class="wire-next wire-next--field wire-next--color-picker {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> <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>
} }
} }
+29 -7
View File
@@ -1,13 +1,35 @@
component Columns { component Columns {
props { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
columns = 2 columns: number = 2
gap = "md" gap: string = "md"
maxWidth = "xl" maxWidth: string = "xl"
class = "" class: string = ""
} }
view { view {
<div class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--columns wire-next--gap-{gap} wire-next--columns-{columns} wire-next--max-{maxWidth} {class}"><slot /></div> <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>
} }
} }
+76 -73
View File
@@ -1,71 +1,74 @@
component ComboBox { 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 { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
label = "ComboBox" label: string = "ComboBox"
name = "" name: string = ""
value = "" value: string = ""
options = [] options: unknown[] = []
groups = [] groups: unknown[] = []
placeholder = "Search or select an option" placeholder: string = "Search or select an option"
searchPlaceholder = "Start typing…" searchPlaceholder: string = "Start typing…"
clearable = true clearable: boolean = true
allowCustomValue = false allowCustomValue: boolean = false
disabled = false disabled: boolean = false
required = false required: boolean = false
invalid = false invalid: boolean = false
validationMessage = "" validationMessage: string = ""
helpText = "" helpText: string = ""
loading = false loading: boolean = false
loadingLabel = "Loading suggestions…" loadingLabel: string = "Loading suggestions…"
emptyLabel = "No matching options" emptyLabel: string = "No matching options"
clearLabel = "Clear value" clearLabel: string = "Clear value"
toggleLabel = "Toggle suggestions" toggleLabel: string = "Toggle suggestions"
searchMode = "contains" searchMode: string = "contains"
searchFields = "label,description" searchFields: string = "label,description"
minSearchLength = 0 minSearchLength: number = 0
searchResultLimit = 0 searchResultLimit: number = 0
optionTemplate = "default" optionTemplate: string = "default"
defaultOpen = false defaultOpen: boolean = false
closeOnSelect = true closeOnSelect: boolean = true
fixed = false fixed: boolean = false
placement = "bottom" placement: string = "bottom"
autocomplete = "off" autocomplete: string = "off"
remote = false remote: boolean = false
remoteUrl = "" remoteUrl: string = ""
remoteQueryParam = "q" remoteQueryParam: string = "q"
remoteDebounce = 250 remoteDebounce: number = 250
remoteAutoLoad = true remoteAutoLoad: boolean = true
infinite = false infinite: boolean = false
hasMore = false hasMore: boolean = false
page = 1 page: number = 1
loadMoreLabel = "Load more" loadMoreLabel: string = "Load more"
class = "" class: string = ""
@event search = function
@event select = function
@event change = function
@event clear = function
@event open = function
@event close = function
@event load = function
@event error = function
} }
state open = defaultOpen state open = defaultOpen
state query = "" state query: string = ""
state selectedValue = value state selectedValue = value
state activeIndex = -1 state activeIndex: number = -1
functions { functions {
function allOptions() { shared function allOptions() {
return [...groups.flatMap((group) => group.options || []), ...options] return [...groups.flatMap((group) => group.options || []), ...options]
} }
function searchableText(option) { shared function searchableText(option) {
return ((option.label || "") + " " + (option.description || "")).toLowerCase() return ((option.label || "") + " " + (option.description || "")).toLowerCase()
} }
function matches(option) { shared function matches(option) {
if (!query || query.length < minSearchLength) { if (!query || query.length < minSearchLength) {
return true return true
} }
@@ -78,38 +81,38 @@ component ComboBox {
return searchableText(option).includes(query.toLowerCase()) return searchableText(option).includes(query.toLowerCase())
} }
function matchingOptions(list) { shared function matchingOptions(list) {
return list.filter((option) => matches(option)) return list.filter((option) => matches(option))
} }
function visibleOptions(list) { shared function visibleOptions(list) {
if (searchResultLimit > 0) { if (searchResultLimit > 0) {
return matchingOptions(list).slice(0, searchResultLimit) return matchingOptions(list).slice(0, searchResultLimit)
} }
return matchingOptions(list) return matchingOptions(list)
} }
function flatVisibleOptions() { shared function flatVisibleOptions() {
return visibleOptions(allOptions()) return visibleOptions(allOptions())
} }
function isSelected(option) { shared function isSelected(option) {
return selectedValue === option.value return selectedValue === option.value
} }
function selectedOptions() { shared function selectedOptions() {
return allOptions().filter((option) => isSelected(option)) return allOptions().filter((option) => isSelected(option))
} }
function selectedText() { shared function selectedText() {
return selectedOptions().length ? selectedOptions()[0].label : selectedValue return selectedOptions().length ? selectedOptions()[0].label : selectedValue
} }
function inputText() { shared function inputText() {
return query || selectedText() return query || selectedText()
} }
function shouldShowDropdown() { shared function shouldShowDropdown() {
if (!open) { if (!open) {
return false return false
} }
@@ -125,11 +128,11 @@ component ComboBox {
return infinite && hasMore return infinite && hasMore
} }
function canSelect(option) { shared function canSelect(option) {
return !option.disabled return !option.disabled
} }
function chooseOption(option) { shared function chooseOption(option) {
if (!canSelect(option)) { if (!canSelect(option)) {
return return
} }
@@ -141,14 +144,14 @@ component ComboBox {
} }
} }
function updateQuery(event) { client function updateQuery(event) {
query = event.target.value query = event.target.value
selectedValue = allowCustomValue ? event.target.value : "" selectedValue = allowCustomValue ? event.target.value : ""
open = true open = true
activeIndex = flatVisibleOptions().length ? 0 : -1 activeIndex = flatVisibleOptions().length ? 0 : -1
} }
function clearValue(event) { client function clearValue(event) {
if (event) { if (event) {
event.stopPropagation() event.stopPropagation()
} }
@@ -158,18 +161,18 @@ component ComboBox {
open = false open = false
} }
function openDropdown() { shared function openDropdown() {
if (!disabled) { if (!disabled) {
open = true open = true
activeIndex = flatVisibleOptions().length ? 0 : -1 activeIndex = flatVisibleOptions().length ? 0 : -1
} }
} }
function closeDropdown() { shared function closeDropdown() {
open = false open = false
} }
function toggle() { shared function toggle() {
if (open) { if (open) {
closeDropdown() closeDropdown()
} else { } else {
@@ -177,19 +180,19 @@ component ComboBox {
} }
} }
function setValue(nextValue) { shared function setValue(nextValue) {
selectedValue = nextValue selectedValue = nextValue
query = "" query = ""
} }
function moveActive(direction) { shared function moveActive(direction) {
if (!flatVisibleOptions().length) { if (!flatVisibleOptions().length) {
return return
} }
activeIndex = (activeIndex + direction + flatVisibleOptions().length) % flatVisibleOptions().length activeIndex = (activeIndex + direction + flatVisibleOptions().length) % flatVisibleOptions().length
} }
function handleKeydown(event) { client function handleKeydown(event) {
if (disabled) { if (disabled) {
return return
} }
@@ -215,7 +218,7 @@ component ComboBox {
} }
} }
function optionIndex(option) { shared function optionIndex(option) {
return flatVisibleOptions().findIndex((item) => item.value === option.value) return flatVisibleOptions().findIndex((item) => item.value === option.value)
} }
} }
+12 -7
View File
@@ -1,12 +1,17 @@
component Confetti { 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 { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
title = "Confetti" title: string = "Confetti"
description = "" description: string = ""
items = [] items: unknown[] = []
variant = "default" variant: string = "default"
class = "" class: string = ""
} }
view { view {
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--confetti wire-next--variant-{variant} {class}"> <section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--confetti wire-next--variant-{variant} {class}">
+515 -13
View File
@@ -1,19 +1,521 @@
component ContextMenu { component ContextMenu {
props { outputs {
size = "default" open(payload: { x: number; y: number; trigger: string; sourceEvent: Event })
color = "primary" close(payload: { reason: string; sourceEvent: Event })
title = "Context Menu" select(payload: { item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event })
description = "" action(payload: { item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event })
open = false
placement = "bottom"
closeLabel = "Close"
class = ""
} }
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 { view {
<div data-show="{open}" class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--context-menu wire-next--placement-{placement} {class}" role="region" aria-modal="false" aria-label="{title}"> <div
<header><strong>{title}</strong><button type="button" aria-label="{closeLabel}">×</button></header> {...attrs}
{#if description}<p>{description}</p>{/if} data-ui-component="ContextMenu"
<slot /> 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> </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;
}
}
}
} }
+19 -13
View File
@@ -1,18 +1,24 @@
component CopyMarkup { 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 { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
label = "Copy Markup" label: string = "Copy Markup"
name = "" name: string = ""
value = "" value: string = ""
placeholder = "" placeholder: string = ""
type = "text" type: string = "text"
min = "" min: string = ""
max = "" max: string = ""
step = "" step: string = ""
disabled = false disabled: boolean = false
required = false required: boolean = false
class = "" class: string = ""
} }
view { 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> <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>
+10 -6
View File
@@ -1,11 +1,15 @@
component CustomScrollbar { 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 { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
columns = 2 columns: number = 2
gap = "md" gap: string = "md"
maxWidth = "xl" maxWidth: string = "xl"
class = "" class: string = ""
} }
view { view {
<div class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--custom-scrollbar wire-next--gap-{gap} wire-next--columns-{columns} wire-next--max-{maxWidth} {class}"><slot /></div> <div 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>
+12 -7
View File
@@ -1,12 +1,17 @@
component DataMap { 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 { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
title = "Data Map" title: string = "Data Map"
description = "" description: string = ""
items = [] items: unknown[] = []
variant = "default" variant: string = "default"
class = "" class: string = ""
} }
view { view {
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--data-map wire-next--variant-{variant} {class}"> <section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--data-map wire-next--variant-{variant} {class}">
+15 -7
View File
@@ -1,12 +1,20 @@
component DataTable { 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 { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
caption = "Data Table" caption: string = "Data Table"
columns = [] columns: unknown[] = []
rows = [] rows: unknown[] = []
striped = true striped: boolean = true
class = "" class: string = ""
} }
view { 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> <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>
+56 -14
View File
@@ -1,20 +1,62 @@
component DatePicker { 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 { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
label = "Date Picker" label: string = "Date Picker"
name = "" id: string = ""
value = "" name: string = ""
placeholder = "" value: string = ""
type = "date" placeholder: string = ""
min = "" type: string = "date"
max = "" locale: string = "en-US"
step = "" firstDayOfWeek: number = 0
disabled = false 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" }]
required = false 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]
class = "" 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 { view {
<label class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--field wire-next--date-picker {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> <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>
} }
} }
+27 -12
View File
@@ -1,19 +1,34 @@
component DeviceFrame { component DeviceFrame {
outputs {
change(payload: { device: string; orientation: string; sourceEvent: Event })
rotate(payload: { device: string; orientation: string; sourceEvent: Event })
}
props { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
title = "Device Frame" title: string = "Device Frame"
description = "" description: string = ""
items = [] items: unknown[] = []
variant = "default" variant: string = "default"
class = "" 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 { view {
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--device-frame wire-next--variant-{variant} {class}"> <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}">
{#if title}<strong>{title}</strong>{/if} <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>
{#if description}<p>{description}</p>{/if} <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>
{#if items}<div class="wire-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if}
<slot />
</section> </section>
} }
} }
+16 -7
View File
@@ -1,12 +1,21 @@
component DragAndDrop { 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 { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
title = "Drag And Drop" title: string = "Drag And Drop"
description = "" description: string = ""
items = [] items: unknown[] = []
variant = "default" variant: string = "default"
class = "" class: string = ""
} }
view { view {
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--drag-and-drop wire-next--variant-{variant} {class}"> <section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--drag-and-drop wire-next--variant-{variant} {class}">
+447 -13
View File
@@ -1,19 +1,453 @@
component Drawer { component Drawer {
props { outputs {
size = "default" open(payload: { placement: string; sourceEvent: Event })
color = "primary" close(payload: { reason: string; placement: string; sourceEvent: Event })
title = "Drawer" cancel(payload: { placement: string; sourceEvent: Event })
description = ""
open = false
placement = "bottom"
closeLabel = "Close"
class = ""
} }
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 { view {
<div data-show="{open}" class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--drawer wire-next--placement-{placement} {class}" role="dialog" aria-modal="true" aria-label="{title}"> <div
<header><strong>{title}</strong><button type="button" aria-label="{closeLabel}">×</button></header> {...attrs}
{#if description}<p>{description}</p>{/if} data-ui-component="Drawer"
<slot /> 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> </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;
}
}
}
} }
+566 -13
View File
@@ -1,19 +1,572 @@
component Dropdown { component Dropdown {
props { outputs {
size = "default" toggle(payload: { open: boolean; sourceEvent: Event; reason?: object })
color = "primary" open(payload: { sourceEvent: Event })
title = "Dropdown" close(payload: { reason: string; sourceEvent: Event })
description = "" select(payload: { item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event })
open = false action(payload: { item: string | number | boolean | null | object; itemIndex: number; value: string | number | boolean; sourceEvent: Event })
placement = "bottom"
closeLabel = "Close"
class = ""
} }
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 { view {
<div data-show="{open}" class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--dropdown wire-next--placement-{placement} {class}" role="region" aria-modal="false" aria-label="{title}"> <div
<header><strong>{title}</strong><button type="button" aria-label="{closeLabel}">×</button></header> {...attrs}
{#if description}<p>{description}</p>{/if} data-ui-component="Dropdown"
<slot /> 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> </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;
}
}
}
} }
+49 -14
View File
@@ -1,20 +1,55 @@
component FileInput { 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 { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
label = "File Input" id: string = ""
name = "" name: string = ""
value = "" label: string = "File"
placeholder = "" hiddenLabel: boolean = false
type = "file" placeholder: string = "Choose a file"
min = "" value: string = ""
max = "" icon: string = "icon-[lucide--upload]"
step = "" iconPosition: string = "start"
disabled = false accept: string = ""
required = false multiple: boolean = false
class = "" 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 { view {
<label class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--field wire-next--file-input {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> <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>
} }
} }
+17 -7
View File
@@ -1,12 +1,22 @@
component FileUpload { 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 { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
title = "File Upload" title: string = "File Upload"
description = "" description: string = ""
items = [] items: unknown[] = []
variant = "default" variant: string = "default"
class = "" class: string = ""
} }
view { view {
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--file-upload wire-next--variant-{variant} {class}"> <section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--file-upload wire-next--variant-{variant} {class}">
+33 -10
View File
@@ -1,17 +1,40 @@
component FileUploadProgress { 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 { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
label = "Progress" label: string = "Progress"
value = 50 value: number = 50
max = 100 max: number = 100
showValue = true showValue: boolean = true
class = "" 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 { view {
<div class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--file-upload-progress {class}"> <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>{label}</span>{#if showValue}<strong>{value}%</strong>{/if}</div> <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="{value}" max="{max}"></progress> <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> </div>
} }
} }
+40 -12
View File
@@ -1,19 +1,47 @@
component Image { component Image {
props { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
src = "" src: string = ""
alt = "" alt: string = ""
width = "" width: string = ""
height = "" height: string = ""
loading = "lazy" loading: string = "lazy"
rounded = false rounded: boolean = false
class = "" class: string = ""
} }
view { view {
<figure class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--image {rounded ? 'wire-next--rounded' : ''} {class}"> <figure
<img src="{src}" alt="{alt}" width="{width}" height="{height}" loading="{loading}" /> data-ui-component="Image"
<slot /> 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> </figure>
} }
} }
+48 -14
View File
@@ -1,20 +1,54 @@
component InputGroup { 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 { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
label = "Input Group" id: string = ""
name = "" name: string = ""
value = "" label: string = "Input group"
placeholder = "" hiddenLabel: boolean = false
type = "text" value: string = ""
min = "" placeholder: string = ""
max = "" type: string = "text"
step = "" startText: string = ""
disabled = false endText: string = ""
required = false icon: string = ""
class = "" 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 { view {
<label class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--field wire-next--input-group {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> <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>
} }
} }
+621 -15
View File
@@ -1,20 +1,626 @@
component InputNumber { component InputNumber {
props { outputs {
size = "default" 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)
color = "primary" 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)
label = "Input Number" increment(payload: { value: number; previousValue?: number; sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
name = "" decrement(payload: { value: number; previousValue?: number; sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
value = ""
placeholder = ""
type = "number"
min = ""
max = ""
step = ""
disabled = false
required = false
class = ""
} }
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 { view {
<label class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--field wire-next--input-number {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> <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>
} }
} }
+4 -4
View File
@@ -1,9 +1,9 @@
component Kbd { component Kbd {
props { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
label = "⌘ K" label: string = "⌘ K"
class = "" class: string = ""
} }
view { <kbd class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--kbd {class}"><slot>{label}</slot></kbd> } view { <kbd class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--kbd {class}"><slot>{label}</slot></kbd> }
} }
+12 -6
View File
@@ -1,11 +1,17 @@
component LayoutSplitter { 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 { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
columns = 2 columns: number = 2
gap = "md" gap: string = "md"
maxWidth = "xl" maxWidth: string = "xl"
class = "" class: string = ""
} }
view { view {
<div class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--layout-splitter wire-next--gap-{gap} wire-next--columns-{columns} wire-next--max-{maxWidth} {class}"><slot /></div> <div 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>
+12 -7
View File
@@ -1,12 +1,17 @@
component LegendIndicator { 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 { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
title = "Legend Indicator" title: string = "Legend Indicator"
description = "" description: string = ""
items = [] items: unknown[] = []
variant = "default" variant: string = "default"
class = "" class: string = ""
} }
view { view {
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--legend-indicator wire-next--variant-{variant} {class}"> <section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--legend-indicator wire-next--variant-{variant} {class}">
+41 -9
View File
@@ -1,15 +1,47 @@
component Link { component Link {
props { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
label = "Link" label: string = "Link"
href = "#" href: string = "#"
target = "" target: string = ""
rel = "" rel: string = ""
external = false external: boolean = false
class = "" class: string = ""
} }
view { view {
<a {...attrs} href="{href}" target="{target}" rel="{rel}" class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--link {class}"><slot>{label}</slot>{#if external}<span aria-hidden="true">↗</span>{/if}</a> <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>
} }
} }
+100 -12
View File
@@ -1,19 +1,107 @@
component List { component List {
props { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
title = "List" title: string = "List"
description = "" description: string = ""
items = [] items: unknown[] = []
variant = "default" variant: string = "default"
class = "" class: string = ""
} }
view { view {
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--list wire-next--variant-{variant} {class}"> <section
{#if title}<strong>{title}</strong>{/if} data-ui-component="List"
{#if description}<p>{description}</p>{/if} aria-label='{title}'
{#if items}<div class="wire-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if} class='w-full {class}'
<slot /> >
{#if title || description}
<header class="mb-4">
{#if title}
<h3 class="text-lg font-bold text-[var(--wire-color-text)]">{title}</h3>
{/if}
{#if description}
<p class="mt-1 text-sm leading-6 text-[var(--wire-color-text-muted)]">{description}</p>
{/if}
</header>
{/if}
<ul
class="overflow-hidden"
class:divide-y='variant === "default" || variant === "divided"'
class:divide-[var(--wire-color-border)]='variant === "default" || variant === "divided"'
class:rounded-2xl='variant === "card"'
class:border='variant === "card"'
class:border-[var(--wire-color-border)]='variant === "card"'
class:bg-[var(--wire-color-surface-raised)]='variant === "card"'
class:space-y-3='variant === "separated"'
>
{#each items as item, index}
<li
class="group min-w-0"
class:rounded-xl='variant === "separated"'
class:border='variant === "separated"'
class:border-[var(--wire-color-border)]='variant === "separated"'
class:bg-[var(--wire-color-surface-raised)]='variant === "separated"'
>
{#if item.href}
<a
href='{item.href}'
class="flex min-w-0 items-start gap-3 px-4 py-3 outline-none transition hover:bg-[var(--wire-color-surface-soft)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--wire-color-focus)]"
class:px-3='size === "sm"'
class:py-2.5='size === "sm"'
class:px-5='size === "lg"'
class:py-4='size === "lg"'
@click='event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: { item: item, index: index } }))'
>
{#if item.icon}
<span class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-[var(--wire-color-primary-soft)] text-[var(--wire-color-primary)]">
<span class='{item.icon + " size-5"}' aria-hidden="true"></span>
</span>
{/if}
{#if item.imageSrc}
<img src='{item.imageSrc}' alt='{item.imageAlt || ""}' class="size-12 shrink-0 rounded-xl object-cover" loading="lazy" />
{/if}
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-3">
<p class="truncate font-semibold text-[var(--wire-color-text)]">{item.title || item.label}</p>
{#if item.meta}
<span class="shrink-0 text-xs text-[var(--wire-color-text-muted)]">{item.meta}</span>
{/if}
</div>
{#if item.description}
<p class="mt-1 line-clamp-2 text-sm leading-5 text-[var(--wire-color-text-muted)]">{item.description}</p>
{/if}
{#if item.badge}
<span class="mt-2 inline-flex rounded-full bg-[var(--wire-color-primary-soft)] px-2.5 py-1 text-xs font-semibold text-[var(--wire-color-primary)]">{item.badge}</span>
{/if}
</div>
<span class="icon-[lucide--chevron-right] mt-1 size-4 shrink-0 text-[var(--wire-color-text-muted)] transition group-hover:translate-x-0.5 group-hover:text-[var(--wire-color-primary)]" aria-hidden="true"></span>
</a>
{:else}
<div class="flex min-w-0 items-start gap-3 px-4 py-3">
{#if item.icon}
<span class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-[var(--wire-color-primary-soft)] text-[var(--wire-color-primary)]">
<span class='{item.icon + " size-5"}' aria-hidden="true"></span>
</span>
{/if}
<div class="min-w-0 flex-1">
<p class="font-semibold text-[var(--wire-color-text)]">{item.title || item.label}</p>
{#if item.description}
<p class="mt-1 text-sm leading-5 text-[var(--wire-color-text-muted)]">{item.description}</p>
{/if}
</div>
</div>
{/if}
</li>
{:empty}
<li class="rounded-xl border border-dashed border-[var(--wire-color-border)] p-6 text-center text-sm text-[var(--wire-color-text-muted)]">
No items available.
</li>
{/each}
</ul>
<slot></slot>
</section> </section>
} }
} }
+12 -7
View File
@@ -1,12 +1,17 @@
component ListGroup { component ListGroup {
outputs {
select(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
change(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
}
props { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
title = "List Group" title: string = "List Group"
description = "" description: string = ""
items = [] items: unknown[] = []
variant = "default" variant: string = "default"
class = "" class: string = ""
} }
view { view {
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--list-group wire-next--variant-{variant} {class}"> <section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--list-group wire-next--variant-{variant} {class}">
+78 -12
View File
@@ -1,19 +1,85 @@
component Map { component Map {
props { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
title = "Map" title: string = "Map"
description = "" description: string = ""
items = [] items: unknown[] = []
variant = "default" variant: string = "default"
class = "" class: string = ""
} }
view { view {
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--map wire-next--variant-{variant} {class}"> <section
{#if title}<strong>{title}</strong>{/if} data-ui-component="Map"
{#if description}<p>{description}</p>{/if} aria-label='{title}'
{#if items}<div class="wire-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if} class='overflow-hidden rounded-2xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-raised)] shadow-sm {class}'
<slot /> >
{#if title || description}
<header class="flex items-start justify-between gap-4 border-b border-[var(--wire-color-border)] p-5">
<div>
{#if title}
<h3 class="text-lg font-bold text-[var(--wire-color-text)]">{title}</h3>
{/if}
{#if description}
<p class="mt-1 text-sm leading-6 text-[var(--wire-color-text-muted)]">{description}</p>
{/if}
</div>
<span class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-[var(--wire-color-primary-soft)] text-[var(--wire-color-primary)]">
<span class="icon-[lucide--map] size-5" aria-hidden="true"></span>
</span>
</header>
{/if}
<div
class="relative isolate overflow-hidden bg-[var(--wire-color-surface-soft)]"
class:h-64='size === "sm"'
class:h-80='size === "default" || size === "md"'
class:h-[28rem]='size === "lg"'
>
<div
class="pointer-events-none absolute inset-0 opacity-50"
style="background-image: linear-gradient(var(--wire-color-border) 1px, transparent 1px), linear-gradient(90deg, var(--wire-color-border) 1px, transparent 1px); background-size: 32px 32px;"
aria-hidden="true"
></div>
<div class="absolute inset-0 flex items-center justify-center p-6">
<slot></slot>
</div>
{#if items.length > 0}
{#each items as item, index}
<button
type="button"
aria-label='{item.label || item.title || "Map marker"}'
class="absolute inline-flex size-10 items-center justify-center rounded-full border-4 border-[var(--wire-color-surface-raised)] bg-[var(--wire-color-primary)] text-[var(--wire-color-on-primary)] shadow-lg transition hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]"
style='left: {item.x || (20 + index * 12)}%; top: {item.y || (30 + (index % 3) * 18)}%;'
@click='event.currentTarget.dispatchEvent(new CustomEvent("markerClick", { bubbles: true, detail: item })); event.currentTarget.dispatchEvent(new CustomEvent("select", { bubbles: true, detail: item }))'
>
<span class='{item.icon || "icon-[lucide--map-pin]"}' aria-hidden="true"></span>
</button>
{/each}
{/if}
<div class="absolute bottom-4 right-4 flex flex-col gap-2">
<button
type="button"
aria-label="Zoom in"
class="inline-flex size-10 items-center justify-center rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-raised)] text-[var(--wire-color-text)] shadow-sm transition hover:bg-[var(--wire-color-surface-soft)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]"
@click='event.currentTarget.dispatchEvent(new CustomEvent("zoom", { bubbles: true, detail: { direction: "in" } }))'
>
<span class="icon-[lucide--plus] size-4" aria-hidden="true"></span>
</button>
<button
type="button"
aria-label="Zoom out"
class="inline-flex size-10 items-center justify-center rounded-xl border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-raised)] text-[var(--wire-color-text)] shadow-sm transition hover:bg-[var(--wire-color-surface-soft)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--wire-color-focus)]"
@click='event.currentTarget.dispatchEvent(new CustomEvent("zoom", { bubbles: true, detail: { direction: "out" } }))'
>
<span class="icon-[lucide--minus] size-4" aria-hidden="true"></span>
</button>
</div>
</div>
</section> </section>
} }
} }
+104 -12
View File
@@ -1,19 +1,111 @@
component Marquee { component Marquee {
props { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
title = "Marquee" title: string = "Marquee"
description = "" description: string = ""
items = [] items: unknown[] = []
variant = "default" variant: string = "default"
class = "" class: string = ""
} }
state paused: boolean = false
view { view {
<section class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--marquee wire-next--variant-{variant} {class}"> <section
{#if title}<strong>{title}</strong>{/if} data-ui-component="Marquee"
{#if description}<p>{description}</p>{/if} aria-label='{title}'
{#if items}<div class="wire-next__items">{#each items as item}<span>{item.label}</span>{/each}</div>{/if} class='overflow-hidden border-y border-[var(--wire-color-border)] bg-[var(--wire-color-surface-raised)] {class}'
<slot /> >
<div class="flex items-stretch">
{#if title}
<div class="relative z-10 flex shrink-0 items-center gap-2 border-r border-[var(--wire-color-border)] bg-[var(--wire-color-primary)] px-4 py-3 text-[var(--wire-color-on-primary)] sm:px-5">
<span class="icon-[lucide--megaphone] size-4" aria-hidden="true"></span>
<span class="text-sm font-bold">{title}</span>
</div>
{/if}
<div
class="group relative min-w-0 flex-1 overflow-hidden"
@mouseenter='paused = true; event.currentTarget.dispatchEvent(new CustomEvent("pause", { bubbles: true }))'
@mouseleave='paused = false; event.currentTarget.dispatchEvent(new CustomEvent("resume", { bubbles: true }))'
@focusin='paused = true'
@focusout='paused = false'
>
<div
class="wire-marquee-track flex w-max min-w-full items-center"
class:wire-marquee-paused='paused'
>
{#each items as item}
<a
href='{item.href || "#"}'
class="flex shrink-0 items-center gap-3 px-5 py-3 text-sm font-medium text-[var(--wire-color-text)] transition hover:bg-[var(--wire-color-primary-soft)] hover:text-[var(--wire-color-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--wire-color-focus)]"
>
<span
class="size-2 shrink-0 rounded-full bg-[var(--wire-color-primary)]"
class:bg-[var(--wire-color-danger)]='item.color === "danger"'
class:bg-[var(--wire-color-warning)]='item.color === "warning"'
class:bg-[var(--wire-color-success)]='item.color === "success"'
aria-hidden="true"
></span>
<span>{item.label || item.title}</span>
{#if item.meta}
<span class="text-xs text-[var(--wire-color-text-muted)]">{item.meta}</span>
{/if}
</a>
{:empty}
<p class="px-5 py-3 text-sm text-[var(--wire-color-text-muted)]">{description || "No announcements available."}</p>
{/each}
{#if items.length > 0}
{#each items as item}
<a
href='{item.href || "#"}'
aria-hidden="true"
tabindex="-1"
class="flex shrink-0 items-center gap-3 px-5 py-3 text-sm font-medium text-[var(--wire-color-text)]"
>
<span class="size-2 shrink-0 rounded-full bg-[var(--wire-color-primary)]" aria-hidden="true"></span>
<span>{item.label || item.title}</span>
</a>
{/each}
{/if}
</div>
</div>
<button
type="button"
aria-label='{paused ? "Resume announcements" : "Pause announcements"}'
class="flex shrink-0 items-center justify-center border-l border-[var(--wire-color-border)] px-4 text-[var(--wire-color-text-muted)] transition hover:bg-[var(--wire-color-surface-soft)] hover:text-[var(--wire-color-text)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--wire-color-focus)]"
@click='paused = !paused; event.currentTarget.dispatchEvent(new CustomEvent(paused ? "pause" : "resume", { bubbles: true }))'
>
<span class="icon-[lucide--pause] size-4" data-show='!paused' aria-hidden="true"></span>
<span class="icon-[lucide--play] size-4" data-show='paused' aria-hidden="true"></span>
</button>
</div>
<slot></slot>
</section> </section>
} }
style {
.wire-marquee-track {
animation: wire-marquee 32s linear infinite;
}
.wire-marquee-paused {
animation-play-state: paused;
}
@keyframes wire-marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
.wire-marquee-track {
animation: none;
}
}
}
} }
+13 -7
View File
@@ -1,12 +1,18 @@
component MegaMenu { component MegaMenu {
outputs {
open(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
close(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
select(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
}
props { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
label = "Mega Menu" label: string = "Mega Menu"
items = [] items: unknown[] = []
active = "" active: string = ""
orientation = "horizontal" orientation: string = "horizontal"
class = "" class: string = ""
} }
view { view {
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--mega-menu wire-next--{orientation} {class}" aria-label="{label}"> <nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--mega-menu wire-next--{orientation} {class}" aria-label="{label}">
+602 -13
View File
@@ -1,19 +1,608 @@
component Modal { component Modal {
props { outputs {
size = "default" open(payload: { sourceEvent: Event })
color = "primary" close(payload: { reason: string; sourceEvent: Event })
title = "Modal" cancel(payload: { sourceEvent: Event })
description = "" confirm(payload: { sourceEvent: Event })
open = false
placement = "bottom"
closeLabel = "Close"
class = ""
} }
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 = ""
}
state visible = defaultOpen
functions {
shared function isOpen() {
return open || visible
}
client function showModal(sourceEvent) {
visible = true
output.open({ sourceEvent: sourceEvent })
}
client function hideModal(reason, sourceEvent) {
visible = false
output.close({
reason: reason,
sourceEvent: sourceEvent
})
}
client function cancelModal(sourceEvent) {
output.cancel({ sourceEvent: sourceEvent })
if (closeOnCancel) {
hideModal("cancel", sourceEvent)
}
}
client function confirmModal(sourceEvent) {
if (confirmDisabled || confirmLoading) {
return
}
output.confirm({ sourceEvent: sourceEvent })
if (closeOnConfirm) {
hideModal("confirm", sourceEvent)
}
}
client function handleKeydown(sourceEvent) {
if (closeOnEscape && sourceEvent.key === "Escape") {
sourceEvent.preventDefault()
cancelModal(sourceEvent)
}
}
}
view { view {
<div data-show="{open}" class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--modal wire-next--placement-{placement} {class}" role="dialog" aria-modal="true" aria-label="{title}"> <div
<header><strong>{title}</strong><button type="button" aria-label="{closeLabel}">×</button></header> {...attrs}
{#if description}<p>{description}</p>{/if} data-ui-component="Modal"
<slot /> data-open='{open || visible ? "true" : "false"}'
data-size='{size}'
data-placement='{placement}'
data-color='{color}'
data-variant='{variant}'
data-scrollable='{scrollable ? "true" : "false"}'
data-destructive='{destructive ? "true" : "false"}'
class='wire-modal {class}'
>
{#if triggerLabel}
<button
type="button"
class="wire-modal__trigger"
aria-haspopup="dialog"
aria-expanded='{open || visible ? "true" : "false"}'
@click='showModal(event)'
>
{#if triggerIcon}
<span
class='{triggerIcon}'
aria-hidden="true"
>
</span>
{/if}
<span>{triggerLabel}</span>
</button>
{/if}
<span
class="wire-modal__trigger-slot"
@click='showModal(event)'
>
<slot
name="trigger"
>
</slot>
</span>
<div
class="wire-modal__layer"
data-show='{open || visible}'
role="presentation"
@keydown='handleKeydown(event)'
>
<button
type="button"
class="wire-modal__backdrop"
aria-label='{closeLabel}'
@click='if (closeOnBackdrop) { cancelModal(event) }'
>
</button>
<section
class="wire-modal__panel"
role="dialog"
aria-modal="true"
aria-label='{label || title}'
tabindex="-1"
>
<header
class="wire-modal__header"
>
<div
class="wire-modal__heading"
>
{#if icon}
<span
class='wire-modal__icon {icon}'
aria-hidden="true"
>
</span>
{/if}
<div
class="wire-modal__heading-copy"
>
<slot
name="header"
>
</slot>
{#if title}
<h2>{title}</h2>
{/if}
{#if description}
<p>{description}</p>
{/if}
</div>
</div>
{#if showClose}
<button
type="button"
class="wire-modal__close"
aria-label='{closeLabel}'
@click='hideModal("close-button", event)'
>
<span
class="icon-[lucide--x]"
aria-hidden="true"
>
</span>
</button>
{/if}
</header>
<div
class="wire-modal__body"
>
<slot></slot>
</div>
{#if showFooter}
<footer
class="wire-modal__footer"
>
<div
class="wire-modal__custom-footer"
>
<slot
name="footer"
>
</slot>
</div>
<div
class="wire-modal__actions"
>
{#if cancelLabel}
<button
type="button"
class="wire-modal__button wire-modal__button--secondary"
@click='cancelModal(event)'
>
{#if cancelIcon}
<span
class='{cancelIcon}'
aria-hidden="true"
>
</span>
{/if}
<span>{cancelLabel}</span>
</button>
{/if}
{#if confirmLabel}
<button
type="button"
class="wire-modal__button wire-modal__button--primary"
disabled='{confirmDisabled || confirmLoading}'
@click='confirmModal(event)'
>
{#if confirmLoading}
<span
class="wire-modal__spinner"
aria-hidden="true"
>
</span>
{:else if confirmIcon}
<span
class='{confirmIcon}'
aria-hidden="true"
>
</span>
{/if}
<span>{confirmLabel}</span>
</button>
{/if}
</div>
</footer>
{/if}
</section>
</div>
</div> </div>
} }
style {
.wire-modal {
--modal-accent: var(--wire-color-primary);
--modal-soft: var(--wire-color-primary-soft);
--modal-contrast: var(--wire-color-primary-contrast);
position: relative;
display: inline-flex;
}
.wire-modal[data-color="secondary"] {
--modal-accent: var(--wire-color-secondary);
--modal-soft: var(--wire-color-secondary-soft);
--modal-contrast: var(--wire-color-secondary-contrast);
}
.wire-modal[data-color="info"] {
--modal-accent: var(--wire-color-info);
--modal-soft: var(--wire-color-info-soft);
--modal-contrast: var(--wire-color-info-contrast);
}
.wire-modal[data-color="success"] {
--modal-accent: var(--wire-color-success);
--modal-soft: var(--wire-color-success-soft);
--modal-contrast: var(--wire-color-success-contrast);
}
.wire-modal[data-color="warning"] {
--modal-accent: var(--wire-color-warning);
--modal-soft: var(--wire-color-warning-soft);
--modal-contrast: var(--wire-color-warning-text);
}
.wire-modal[data-color="danger"],
.wire-modal[data-destructive="true"] {
--modal-accent: var(--wire-color-danger);
--modal-soft: var(--wire-color-danger-soft);
--modal-contrast: var(--wire-color-on-danger);
}
.wire-modal__trigger,
.wire-modal__trigger-slot {
display: inline-flex;
align-items: center;
gap: 0.55rem;
}
.wire-modal__trigger {
appearance: none;
min-height: 2.55rem;
padding: 0.65rem 1rem;
color: var(--modal-contrast);
background: var(--modal-accent);
border: 0;
border-radius: 0.8rem;
font: inherit;
font-size: 0.85rem;
font-weight: 650;
cursor: pointer;
}
.wire-modal__layer {
position: fixed;
inset: 0;
z-index: 1250;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.wire-modal[data-placement="top"] .wire-modal__layer {
align-items: flex-start;
padding-top: clamp(1rem, 8vh, 5rem);
}
.wire-modal__backdrop {
position: absolute;
inset: 0;
z-index: 0;
appearance: none;
padding: 0;
background: color-mix(in srgb, black 62%, transparent);
border: 0;
backdrop-filter: blur(9px);
}
.wire-modal__panel {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
width: min(32rem, calc(100vw - 2rem));
max-height: min(88vh, 52rem);
color: var(--wire-color-text);
background:
radial-gradient(
circle at 100% 0%,
color-mix(in srgb, var(--modal-accent) 9%, transparent),
transparent 34%
),
var(--wire-color-surface-raised);
border: 1px solid color-mix(in srgb, var(--modal-accent) 18%, var(--wire-color-border));
border-radius: 1.35rem;
box-shadow:
0 1px 0 color-mix(in srgb, white 5%, transparent) inset,
0 40px 110px color-mix(in srgb, black 38%, transparent);
overflow: hidden;
}
.wire-modal[data-size="sm"] .wire-modal__panel {
width: min(25rem, calc(100vw - 2rem));
}
.wire-modal[data-size="lg"] .wire-modal__panel {
width: min(44rem, calc(100vw - 2rem));
}
.wire-modal[data-size="xl"] .wire-modal__panel {
width: min(64rem, calc(100vw - 2rem));
}
.wire-modal[data-size="full"] .wire-modal__panel {
width: calc(100vw - 2rem);
height: calc(100vh - 2rem);
max-height: none;
}
.wire-modal[data-variant="soft"] .wire-modal__panel {
background:
linear-gradient(145deg, var(--modal-soft), transparent 68%),
var(--wire-color-surface-raised);
}
.wire-modal[data-variant="outline"] .wire-modal__panel {
background: var(--wire-color-surface-raised);
box-shadow: 0 24px 72px color-mix(in srgb, black 24%, transparent);
}
.wire-modal[data-variant="solid"] .wire-modal__panel {
color: var(--modal-contrast);
background: var(--modal-accent);
border-color: color-mix(in srgb, white 20%, transparent);
}
.wire-modal__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1.4rem 1.4rem 1.15rem;
border-bottom: 1px solid var(--wire-color-border);
}
.wire-modal__heading {
display: flex;
align-items: flex-start;
gap: 0.9rem;
min-width: 0;
}
.wire-modal__icon {
flex: 0 0 auto;
width: 1.3rem;
height: 1.3rem;
margin-top: 0.15rem;
color: var(--modal-accent);
}
.wire-modal[data-variant="solid"] .wire-modal__icon {
color: currentColor;
}
.wire-modal__heading-copy {
display: grid;
gap: 0.3rem;
min-width: 0;
}
.wire-modal__heading-copy h2,
.wire-modal__heading-copy p {
margin: 0;
}
.wire-modal__heading-copy h2 {
font-size: 1.08rem;
font-weight: 650;
line-height: 1.3;
}
.wire-modal__heading-copy p {
color: var(--wire-color-text-muted);
font-size: 0.8rem;
line-height: 1.55;
}
.wire-modal[data-variant="solid"] .wire-modal__heading-copy p {
color: color-mix(in srgb, currentColor 76%, transparent);
}
.wire-modal__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-modal__close:hover,
.wire-modal__close:focus-visible {
color: var(--modal-accent);
border-color: color-mix(in srgb, var(--modal-accent) 34%, var(--wire-color-border));
outline: none;
}
.wire-modal__body {
flex: 1 1 auto;
min-height: 0;
padding: 1.4rem;
}
.wire-modal[data-scrollable="true"] .wire-modal__body {
overflow: auto;
overscroll-behavior: contain;
}
.wire-modal__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.4rem 1.4rem;
border-top: 1px solid var(--wire-color-border);
}
.wire-modal__custom-footer:empty {
display: none;
}
.wire-modal__actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.65rem;
margin-left: auto;
}
.wire-modal__button {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 2.55rem;
padding: 0.65rem 1rem;
border-radius: 0.78rem;
font: inherit;
font-size: 0.83rem;
font-weight: 650;
cursor: pointer;
transition: transform 150ms ease, opacity 150ms ease, border-color 150ms ease;
}
.wire-modal__button:active {
transform: scale(0.985);
}
.wire-modal__button--secondary {
color: var(--wire-color-text);
background: transparent;
border: 1px solid var(--wire-color-border);
}
.wire-modal__button--primary {
color: var(--modal-contrast);
background: var(--modal-accent);
border: 1px solid transparent;
}
.wire-modal__button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.wire-modal__spinner {
width: 0.95rem;
height: 0.95rem;
border: 2px solid color-mix(in srgb, currentColor 35%, transparent);
border-top-color: currentColor;
border-radius: 50%;
animation: wire-modal-spin 750ms linear infinite;
}
@keyframes wire-modal-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 639px) {
.wire-modal__layer {
align-items: flex-end;
padding: 0;
}
.wire-modal__panel,
.wire-modal[data-size="sm"] .wire-modal__panel,
.wire-modal[data-size="lg"] .wire-modal__panel,
.wire-modal[data-size="xl"] .wire-modal__panel {
width: 100%;
max-height: 92vh;
border-radius: 1.25rem 1.25rem 0 0;
}
.wire-modal__footer {
align-items: stretch;
flex-direction: column;
}
.wire-modal__actions {
width: 100%;
}
.wire-modal__button {
flex: 1 1 0;
}
}
@media (prefers-reduced-motion: reduce) {
.wire-modal__button,
.wire-modal__spinner {
animation: none;
transition: none;
}
}
}
} }
+12 -7
View File
@@ -1,12 +1,17 @@
component Nav { component Nav {
outputs {
select(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
change(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
}
props { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
label = "Nav" label: string = "Nav"
items = [] items: unknown[] = []
active = "" active: string = ""
orientation = "horizontal" orientation: string = "horizontal"
class = "" class: string = ""
} }
view { view {
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--nav wire-next--{orientation} {class}" aria-label="{label}"> <nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--nav wire-next--{orientation} {class}" aria-label="{label}">
+137 -11
View File
@@ -1,17 +1,143 @@
component Navbar { component Navbar {
props { outputs {
size = "default" toggle(payload: { open: boolean })
color = "primary" open(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
label = "Navbar" close(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
items = [] select(payload: { item: string | number | boolean | null | object; value: string | number | boolean; level: string })
active = "" action(payload: { item: string | number | boolean | null | object; value: string | number | boolean })
orientation = "horizontal"
class = ""
} }
props {
size: string = "default"
color: string = "primary"
label: string = "Primary navigation"
topbarLabel: string = "Utility navigation"
brand: Record<string, unknown> = {}
items: unknown[] = []
actions: unknown[] = []
active: string = ""
sticky: boolean = false
openOnHover: boolean = false
maxWidth: string = "full"
mobileLabel: string = "Toggle navigation"
class: string = ""
}
state mobileOpen: boolean = false
functions {
client function toggleNavigation() {
mobileOpen = !mobileOpen
output.toggle({ open: mobileOpen })
output[mobileOpen ? "open" : "close"]({ source: "mobile" })
}
client function selectItem(item, level) {
mobileOpen = false
output.select({ item: item, value: item.value || "", level: level })
}
client function selectAction(item) {
mobileOpen = false
output.action({ item: item, value: item.value || "" })
}
shared function isItemActive(item) {
if (!item) return false
if (item.value && item.value === active) return true
if (!item.children || !item.children.length) return false
return item.children.some(function (child) {
return isItemActive(child)
})
}
client function toggleDropdown(event, item) {
output[event.currentTarget.open ? "open" : "close"]({
source: "menu",
item: item
})
}
}
view { view {
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--navbar wire-next--{orientation} {class}" aria-label="{label}"> <header class="wire-navbar wire-navbar--width-{maxWidth} wire-next--color-{color} wire-next--size-{size} {sticky ? 'wire-navbar--sticky' : ''} {class}" data-wrn-navbar data-open-on-hover="{openOnHover}">
{#each items as item}<a href="{item.href}" aria-current="{item.value === active ? 'page' : ''}">{item.label}</a>{/each} <div class="wire-navbar__topbar" aria-label="{topbarLabel}">
<slot /> <slot name="topbar" />
</div>
<div class="wire-navbar__main">
<a class="wire-navbar__brand" href="{brand.href || '/'}" aria-label="{brand.ariaLabel || brand.label || label}">
{#if brand.logo}
<img class="wire-navbar__brand-logo" src="{brand.logo}" alt="{brand.logoAlt || brand.label || ''}" width="{brand.logoWidth || ''}" height="{brand.logoHeight || ''}" />
{/if}
{#if brand.icon}
<span class="wire-navbar__brand-icon {brand.icon}" aria-hidden="true"></span>
{/if}
{#if brand.label || brand.description}
<span class="wire-navbar__brand-copy">
{#if brand.label}<strong>{brand.label}</strong>{/if}
{#if brand.description}<small>{brand.description}</small>{/if}
</span>
{/if}
</a>
<button type="button" class="wire-navbar__toggle" aria-label="{mobileLabel}" aria-expanded="{mobileOpen}" @click="toggleNavigation()">
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
</button>
<div class="wire-navbar__collapse {mobileOpen ? 'is-open' : ''}">
<nav class="wire-navbar__menus" aria-label="{label}">
{#each items as item}
{#if item.children && item.children.length}
<details class="wire-navbar__dropdown wire-navbar__dropdown--{item.type || 'dropdown'}" name="wire-navbar-menu" @toggle="toggleDropdown(event, item)">
<summary aria-current="{isItemActive(item) ? 'page' : ''}">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span>{item.label}</span>
<span class="wire-navbar__chevron" aria-hidden="true"></span>
</summary>
<div class="wire-navbar__panel wire-navbar__panel--columns-{item.columns || 1}">
{#if item.description}<p class="wire-navbar__panel-intro">{item.description}</p>{/if}
{#each item.children as child}
<div class="wire-navbar__group">
{#if child.children && child.children.length}
{#if child.label}<strong class="wire-navbar__group-title">{child.label}</strong>{/if}
{#if child.description}<small>{child.description}</small>{/if}
{#each child.children as nested}
<a href="{nested.href || '#'}" target="{nested.target || ''}" rel="{nested.rel || ''}" aria-current="{nested.value === active ? 'page' : ''}" @click="selectItem(nested, 3)">
{#if nested.icon}<span class="{nested.icon}" aria-hidden="true"></span>{/if}
<span><strong>{nested.label}</strong>{#if nested.description}<small>{nested.description}</small>{/if}</span>
</a>
{/each}
{:else}
<a href="{child.href || '#'}" target="{child.target || ''}" rel="{child.rel || ''}" aria-current="{child.value === active ? 'page' : ''}" @click="selectItem(child, 2)">
{#if child.icon}<span class="{child.icon}" aria-hidden="true"></span>{/if}
<span><strong>{child.label}</strong>{#if child.description}<small>{child.description}</small>{/if}</span>
</a>
{/if}
</div>
{/each}
</div>
</details>
{:else}
<a class="wire-navbar__menu-link" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" aria-current="{item.value === active ? 'page' : ''}" @click="selectItem(item, 1)">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span>{item.label}</span>
</a>
{/if}
{/each}
</nav> </nav>
<div class="wire-navbar__actions">
{#each actions as item}
<a class="wire-navbar__action wire-navbar__action--{item.variant || 'link'}" href="{item.href || '#'}" target="{item.target || ''}" rel="{item.rel || ''}" @click="selectAction(item)">
{#if item.icon}<span class="{item.icon}" aria-hidden="true"></span>{/if}
<span>{item.label}</span>
</a>
{/each}
<slot name="actions" />
</div>
</div>
</div>
</header>
} }
} }
+13 -7
View File
@@ -1,12 +1,18 @@
component Pagination { component Pagination {
outputs {
change(payload: { value?: string | number | boolean | null; values?: Array<string | number | boolean | null | object>; sourceEvent?: Event; [key: string]: string | number | boolean | null | object } | string | number | boolean | null)
previous(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
next(payload: { sourceEvent?: Event; [key: string]: string | number | boolean | null | object })
}
props { props {
size = "default" size: string = "default"
color = "primary" color: string = "primary"
label = "Pagination" label: string = "Pagination"
items = [] items: unknown[] = []
active = "" active: string = ""
orientation = "horizontal" orientation: string = "horizontal"
class = "" class: string = ""
} }
view { view {
<nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--pagination wire-next--{orientation} {class}" aria-label="{label}"> <nav class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--pagination wire-next--{orientation} {class}" aria-label="{label}">

Some files were not shown because too many files have changed in this diff Show More