Changeset 0.37.2 (#214)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -488,7 +488,7 @@ longer generates any UI DOM.
|
||||
|
||||
```
|
||||
0.37.1 Permission audit + enforcement fixes + GET /profile/permissions ✅
|
||||
0.37.2 Layer 0 — UI primitives (Menu, Dialog, Toast, Drawer, etc.)
|
||||
0.37.2 Layer 0 — UI primitives + Layer 1 Shell layout ✅
|
||||
0.37.3 Layer 1 — SDK (namespaced API client, auth, RBAC, events, pipe)
|
||||
0.37.4 Layer 2 — Shell (no auth gate, temp bypass for visual validation)
|
||||
0.37.5 Login surface + Settings surface
|
||||
|
||||
131
docs/ROADMAP.md
131
docs/ROADMAP.md
@@ -26,30 +26,35 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅
|
||||
│
|
||||
v0.28.8 ICD Green Board ✅
|
||||
│
|
||||
┌───────────────┴───────────────┐
|
||||
│ │
|
||||
Extension Track Operations Track
|
||||
│ │
|
||||
v0.29.0 Starlark Sandbox ✅ v0.32.0 Multi-Replica HA ✅
|
||||
v0.29.1 API Extensions ✅ v0.33.0 Observability ✅
|
||||
v0.29.2 DB Extensions ✅ v0.34.0 Data Portability ✅
|
||||
v0.29.3 Workflow Forms ✅ │
|
||||
v0.30.0 Package Lifecycle ✅ │
|
||||
v0.30.1 SDK Adoption ✅ │
|
||||
v0.30.2 Workflow Packages ✅ │
|
||||
v0.31.0 Editor + SDK ✅ │
|
||||
v0.31.1 SDK Exercise ✅ │
|
||||
v0.31.2 Team Workflows ✅ │
|
||||
│ │
|
||||
│ v0.35.0 Workflow Product
|
||||
│ │
|
||||
│ v0.36.0 Full OpenAPI Spec ✅
|
||||
│ │
|
||||
══════╪═══════════════════════════════╪══════
|
||||
│ MVP v0.50.0 │
|
||||
│ (v0.31.2 + v0.35.0 │
|
||||
│ + mobile + deployment docs) │
|
||||
══════╪═══════════════════════════════╪══════
|
||||
┌───────────────┼───────────────┐
|
||||
│ │ │
|
||||
Extension Track UI Rewrite Operations Track
|
||||
│ Track │
|
||||
v0.29.0 ✅ │ v0.32.0 ✅
|
||||
v0.29.1 ✅ │ v0.33.0 ✅
|
||||
v0.29.2 ✅ │ v0.34.0 ✅
|
||||
v0.29.3 ✅ │ │
|
||||
v0.30.0 ✅ │ │
|
||||
v0.30.1 ✅ │ │
|
||||
v0.30.2 ✅ │ │
|
||||
v0.31.0 ✅ │ │
|
||||
v0.31.1 ✅ │ │
|
||||
v0.31.2 ✅ │ │
|
||||
│ │ v0.35.0 Workflow Product
|
||||
│ │ │
|
||||
│ │ v0.36.0 Full OpenAPI ✅
|
||||
│ │ │
|
||||
│ v0.37.1 Perm Audit ✅ │
|
||||
│ v0.37.2 Primitives ✅ │
|
||||
│ v0.37.3 SDK │
|
||||
│ v0.37.4 Shell │
|
||||
│ v0.37.5–13 Surfaces │
|
||||
│ │ │
|
||||
══════╪════════════╪═════════════════╪══════
|
||||
│ MVP v0.50.0 │
|
||||
│ (all three tracks complete │
|
||||
│ + mobile + deploy docs) │
|
||||
══════╪════════════╪═════════════════╪══════
|
||||
│
|
||||
v0.50.0+ Rich Media + Beyond
|
||||
(image gen, code sandbox,
|
||||
@@ -528,6 +533,84 @@ Depends on: v0.33.0 (Swagger UI + initial spec).
|
||||
|
||||
---
|
||||
|
||||
## UI Rewrite Track
|
||||
|
||||
Scorched earth rebuild of the frontend on Preact+htm. Three-layer
|
||||
architecture (Primitives → SDK → Shell) replacing the imperative
|
||||
DOM manipulation codebase. See `UI redesign.md` for full design doc.
|
||||
|
||||
Depends on: v0.36.0 (OpenAPI spec), v0.31.2 (SDK composability).
|
||||
|
||||
### v0.37.1 — Permission Audit & Enforcement ✅
|
||||
|
||||
Backend prerequisite for frontend RBAC. See `CHANGESET.md`.
|
||||
|
||||
- [x] `RequirePermission` middleware on 8 unprotected routes
|
||||
- [x] `GET /profile/permissions` — self-service resolved permission set
|
||||
- [x] 12 permission enforcement integration tests (deny → grant → allow)
|
||||
- [x] Mirror route fixes in integration test harness
|
||||
|
||||
### v0.37.2 — Layer 0: UI Primitives + Shell Layout ✅
|
||||
|
||||
Preact+htm component library and application frame.
|
||||
|
||||
- [x] Vendor: preact.module.js, hooks.module.js, htm.module.js (~5KB total)
|
||||
- [x] 14 primitive components: Button, Spinner, Avatar, FormField,
|
||||
Tooltip, Banner, Toast, Dialog, Confirm, Prompt, Menu, Drawer,
|
||||
Tabs, Dropdown
|
||||
- [x] AppShell layout: fixed top/bottom banners (single config),
|
||||
dismissible message bar, footer, surface viewport
|
||||
- [x] `sw-primitives.css` + `sw-shell.css` — `sw-` prefixed, no
|
||||
conflicts with existing styles
|
||||
- [x] `dev.html` — interactive primitives gallery + shell controls
|
||||
- [x] ES module loading with `window.preact/hooks/html` globals
|
||||
|
||||
### v0.37.3 — Layer 1: SDK
|
||||
|
||||
Namespaced REST client, auth module, RBAC gate, event bus, pipe/filter
|
||||
pipeline, theme control.
|
||||
|
||||
Depends on: v0.37.2.
|
||||
|
||||
- [ ] `sw.api.{domain}.*` — namespaced methods for all 18 ICD domains
|
||||
- [ ] `sw.auth.*` — login, logout, refresh, session lifecycle
|
||||
- [ ] `sw.can(perm)` — synchronous RBAC gate from cached permission set
|
||||
- [ ] `sw.on/off/emit` — event bus with wildcard patterns
|
||||
- [ ] `sw.pipe.*` — carry forward pre-send/stream/render pipeline
|
||||
- [ ] `sw.theme.*` — theme control with FOUC prevention
|
||||
|
||||
### v0.37.4 — Layer 2: Shell
|
||||
|
||||
Application frame with user menu, surface viewport, banners, toast/dialog
|
||||
stacks. Temp auth bypass for visual validation.
|
||||
|
||||
Depends on: v0.37.3.
|
||||
|
||||
- [ ] Root `<App>` component tree
|
||||
- [ ] `UserMenu` — avatar + flyout (Settings, Admin, Sign Out)
|
||||
- [ ] `SurfaceViewport` — mount point for active surface
|
||||
- [ ] `ToastContainer` + `DialogStack` — global overlay management
|
||||
- [ ] Simplified `base.html` Go template (single `#app` mount)
|
||||
|
||||
### v0.37.5–v0.37.13 — Surface Rebuilds
|
||||
|
||||
Each surface rebuilt as Preact component tree. Old JS deleted per surface.
|
||||
|
||||
| Version | Surface | Notes |
|
||||
|---------|---------|-------|
|
||||
| v0.37.5 | Login + Settings | Self-contained, no real-time |
|
||||
| v0.37.6 | Admin | Largest section count, no real-time |
|
||||
| v0.37.7 | Team Admin | Inherits from Admin patterns |
|
||||
| v0.37.8 | ChatPane | Reusable component, NOT a surface |
|
||||
| v0.37.9 | NotesPane | Reusable component, NOT a surface |
|
||||
| v0.37.10 | Chat surface | Composes ChatPane + sidebar + panels |
|
||||
| v0.37.11 | Notes surface | Composes NotesPane + graph |
|
||||
| v0.37.12 | Extension surface container | `.pkg` mount point |
|
||||
| v0.37.13 | Workflow surfaces | Form renderer, review views |
|
||||
| v0.37.# | Tag | Functionality restored, old code deleted |
|
||||
|
||||
---
|
||||
|
||||
## MVP v0.50.0
|
||||
|
||||
**Gate:** deploy for 5–10 teams, ~50 users, 100+ anonymous visitors
|
||||
|
||||
285
src/css/sw-primitives.css
Normal file
285
src/css/sw-primitives.css
Normal file
@@ -0,0 +1,285 @@
|
||||
/* ── Layer 0: New Primitives CSS (v0.37.2) ───────────
|
||||
Preact component styles. Uses variables.css tokens.
|
||||
Prefixed sw- to avoid clash with old primitives.css.
|
||||
──────────────────────────────────────────────────── */
|
||||
|
||||
/* ── Button ────────────────────────────────── */
|
||||
|
||||
.sw-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
gap: 0.5em; border: 1px solid transparent; border-radius: var(--radius);
|
||||
font-family: var(--font); font-weight: 500; cursor: pointer;
|
||||
transition: background var(--transition), border-color var(--transition), opacity var(--transition);
|
||||
white-space: nowrap; position: relative;
|
||||
}
|
||||
.sw-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.sw-btn--sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; }
|
||||
.sw-btn--md { padding: 0.45rem 0.9rem; font-size: 0.875rem; }
|
||||
.sw-btn--lg { padding: 0.6rem 1.2rem; font-size: 1rem; }
|
||||
|
||||
.sw-btn--primary { background: var(--accent); color: var(--text-on-color); }
|
||||
.sw-btn--primary:hover:not(:disabled) { background: var(--accent-hover); }
|
||||
.sw-btn--secondary { background: var(--bg-raised); color: var(--text); border-color: var(--border); }
|
||||
.sw-btn--secondary:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--border-light); }
|
||||
.sw-btn--danger { background: var(--danger); color: var(--text-on-color); }
|
||||
.sw-btn--danger:hover:not(:disabled) { opacity: 0.9; }
|
||||
.sw-btn--ghost { background: transparent; color: var(--text-2); }
|
||||
.sw-btn--ghost:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
|
||||
|
||||
.sw-btn--loading .sw-btn__content--hidden { visibility: hidden; }
|
||||
.sw-btn__spinner {
|
||||
position: absolute; width: 1em; height: 1em;
|
||||
border: 2px solid transparent; border-top-color: currentColor;
|
||||
border-radius: 50%; animation: sw-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
/* ── Spinner ───────────────────────────────── */
|
||||
|
||||
.sw-spinner {
|
||||
display: inline-block; border: 2px solid var(--border);
|
||||
border-top-color: var(--accent); border-radius: 50%;
|
||||
animation: sw-spin 0.6s linear infinite;
|
||||
}
|
||||
.sw-spinner--sm { width: 16px; height: 16px; }
|
||||
.sw-spinner--md { width: 24px; height: 24px; }
|
||||
.sw-spinner--lg { width: 40px; height: 40px; }
|
||||
|
||||
@keyframes sw-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Avatar ────────────────────────────────── */
|
||||
|
||||
.sw-avatar {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border-radius: 50%; object-fit: cover; flex-shrink: 0;
|
||||
font-weight: 600; color: var(--text-on-color); background: var(--accent);
|
||||
}
|
||||
.sw-avatar--sm { width: 24px; height: 24px; font-size: 0.65rem; }
|
||||
.sw-avatar--md { width: 32px; height: 32px; font-size: 0.75rem; }
|
||||
.sw-avatar--lg { width: 48px; height: 48px; font-size: 1rem; }
|
||||
.sw-avatar--fallback { user-select: none; }
|
||||
|
||||
/* ── FormField ─────────────────────────────── */
|
||||
|
||||
.sw-field { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.sw-field__label { font-size: 0.8rem; font-weight: 500; color: var(--text-2); }
|
||||
.sw-field__required { color: var(--danger); margin-left: 2px; }
|
||||
.sw-field__error { font-size: 0.75rem; color: var(--danger); }
|
||||
.sw-field__hint { font-size: 0.75rem; color: var(--text-3); }
|
||||
.sw-field--error .sw-input { border-color: var(--danger); }
|
||||
|
||||
/* ── Shared input ──────────────────────────── */
|
||||
|
||||
.sw-input {
|
||||
width: 100%; padding: 0.45rem 0.7rem; background: var(--input-bg);
|
||||
color: var(--text); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
font-family: var(--font); font-size: 0.875rem; outline: none;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
.sw-input:focus { border-color: var(--accent); }
|
||||
.sw-input::placeholder { color: var(--text-3); }
|
||||
|
||||
/* ── Tooltip ───────────────────────────────── */
|
||||
|
||||
.sw-tooltip-wrap { position: relative; display: inline-flex; }
|
||||
.sw-tooltip {
|
||||
position: absolute; z-index: 1000; padding: 0.3rem 0.6rem;
|
||||
background: var(--bg-elevated); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); font-size: 0.75rem; white-space: nowrap;
|
||||
pointer-events: none; animation: sw-fade-in 0.15s ease;
|
||||
}
|
||||
.sw-tooltip--top { bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%); }
|
||||
.sw-tooltip--bottom { top: calc(100% + 6px); left: 50%; transform: translateX(-50%); }
|
||||
.sw-tooltip--left { right: calc(100% + 6px); top: 50%; transform: translateY(-50%); }
|
||||
.sw-tooltip--right { left: calc(100% + 6px); top: 50%; transform: translateY(-50%); }
|
||||
|
||||
@keyframes sw-fade-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
/* ── Banner ────────────────────────────────── */
|
||||
|
||||
.sw-banner {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
gap: 0.5rem; padding: 0.5rem 1rem; font-size: 0.85rem; font-weight: 500;
|
||||
width: 100%; flex-shrink: 0;
|
||||
}
|
||||
.sw-banner--info { background: var(--accent-dim); color: var(--accent-light); }
|
||||
.sw-banner--warn { background: var(--warning-dim); color: var(--warning-light); }
|
||||
.sw-banner--error { background: var(--danger-dim); color: var(--danger-light); }
|
||||
.sw-banner--success { background: var(--success-dim); color: var(--success-light); }
|
||||
.sw-banner__close {
|
||||
background: none; border: none; color: inherit; cursor: pointer;
|
||||
font-size: 1.1rem; padding: 0 0.3rem; opacity: 0.7;
|
||||
}
|
||||
.sw-banner__close:hover { opacity: 1; }
|
||||
|
||||
/* ── Toast ─────────────────────────────────── */
|
||||
|
||||
.sw-toast-container {
|
||||
position: fixed; bottom: 1rem; right: 1rem; z-index: 10000;
|
||||
display: flex; flex-direction: column-reverse; gap: 0.5rem;
|
||||
max-width: 380px; pointer-events: none;
|
||||
}
|
||||
.sw-toast {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
padding: 0.75rem 1rem; border-radius: var(--radius-lg);
|
||||
background: var(--bg-raised); border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-lg); pointer-events: auto;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.sw-toast--enter { animation: sw-slide-in 0.25s ease; }
|
||||
.sw-toast--exit { animation: sw-fade-out 0.2s ease forwards; }
|
||||
.sw-toast--success { border-left: 3px solid var(--success); }
|
||||
.sw-toast--error { border-left: 3px solid var(--danger); }
|
||||
.sw-toast--warn { border-left: 3px solid var(--warning); }
|
||||
.sw-toast--info { border-left: 3px solid var(--accent); }
|
||||
.sw-toast__msg { flex: 1; color: var(--text); }
|
||||
.sw-toast__close {
|
||||
background: none; border: none; color: var(--text-3); cursor: pointer;
|
||||
font-size: 1.1rem; padding: 0; line-height: 1;
|
||||
}
|
||||
.sw-toast__close:hover { color: var(--text); }
|
||||
|
||||
@keyframes sw-slide-in { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
@keyframes sw-fade-out { to { opacity: 0; transform: translateX(50%); } }
|
||||
|
||||
/* ── Dialog ────────────────────────────────── */
|
||||
|
||||
.sw-dialog-overlay {
|
||||
position: fixed; inset: 0; z-index: 5000;
|
||||
background: var(--overlay); display: flex; align-items: center; justify-content: center;
|
||||
animation: sw-fade-in 0.15s ease;
|
||||
}
|
||||
.sw-dialog {
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); box-shadow: var(--shadow-lg);
|
||||
min-width: 360px; max-width: min(520px, 90vw); max-height: 85vh;
|
||||
display: flex; flex-direction: column; animation: sw-scale-in 0.2s ease;
|
||||
}
|
||||
.sw-dialog__header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 1rem 1.25rem 0.75rem; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sw-dialog__title { font-size: 1rem; font-weight: 600; color: var(--text); margin: 0; }
|
||||
.sw-dialog__close {
|
||||
background: none; border: none; color: var(--text-3); cursor: pointer;
|
||||
font-size: 1.3rem; padding: 0; line-height: 1;
|
||||
}
|
||||
.sw-dialog__close:hover { color: var(--text); }
|
||||
.sw-dialog__body { padding: 1rem 1.25rem; overflow-y: auto; color: var(--text); font-size: 0.9rem; line-height: 1.5; }
|
||||
.sw-dialog__actions {
|
||||
display: flex; gap: 0.5rem; justify-content: flex-end;
|
||||
padding: 0.75rem 1.25rem; border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@keyframes sw-scale-in { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
||||
|
||||
/* ── Menu ──────────────────────────────────── */
|
||||
|
||||
.sw-menu {
|
||||
z-index: 6000; min-width: 160px; max-width: 300px;
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); box-shadow: var(--shadow-lg);
|
||||
padding: 4px 0; outline: none; animation: sw-fade-in 0.1s ease;
|
||||
}
|
||||
.sw-menu__item {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.4rem 0.75rem; cursor: pointer; font-size: 0.85rem;
|
||||
color: var(--text); transition: background var(--transition);
|
||||
}
|
||||
.sw-menu__item:hover, .sw-menu__item--focused { background: var(--bg-hover); }
|
||||
.sw-menu__item--disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.sw-menu__item--disabled:hover { background: transparent; }
|
||||
.sw-menu__icon { flex-shrink: 0; width: 1.1em; text-align: center; color: var(--text-2); }
|
||||
.sw-menu__label { flex: 1; }
|
||||
.sw-menu__arrow { font-size: 0.6em; color: var(--text-3); }
|
||||
.sw-menu__divider { height: 1px; background: var(--border); margin: 4px 0; }
|
||||
|
||||
/* ── Drawer ────────────────────────────────── */
|
||||
|
||||
.sw-drawer-overlay {
|
||||
position: fixed; inset: 0; z-index: 4000;
|
||||
background: transparent; transition: background 0.25s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sw-drawer-overlay--open { background: var(--overlay); pointer-events: auto; }
|
||||
.sw-drawer {
|
||||
position: fixed; top: 0; bottom: 0; z-index: 4001;
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
transition: transform 0.25s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sw-drawer--right { right: 0; transform: translateX(100%); }
|
||||
.sw-drawer--right.sw-drawer--open { transform: translateX(0); }
|
||||
.sw-drawer--left { left: 0; transform: translateX(-100%); }
|
||||
.sw-drawer--left.sw-drawer--open { transform: translateX(0); }
|
||||
.sw-drawer__header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); flex-shrink: 0;
|
||||
}
|
||||
.sw-drawer__title { font-size: 1rem; font-weight: 600; color: var(--text); margin: 0; }
|
||||
.sw-drawer__close {
|
||||
background: none; border: none; color: var(--text-3); cursor: pointer;
|
||||
font-size: 1.3rem; padding: 0; line-height: 1;
|
||||
}
|
||||
.sw-drawer__close:hover { color: var(--text); }
|
||||
.sw-drawer__body { flex: 1; overflow-y: auto; padding: 1rem 1.25rem; }
|
||||
|
||||
/* ── Tabs ──────────────────────────────────── */
|
||||
|
||||
.sw-tabs {
|
||||
display: flex; align-items: center; gap: 0;
|
||||
border-bottom: 1px solid var(--border); position: relative;
|
||||
}
|
||||
.sw-tabs__scroll {
|
||||
display: flex; overflow-x: auto; scrollbar-width: none;
|
||||
-ms-overflow-style: none; flex: 1;
|
||||
}
|
||||
.sw-tabs__scroll::-webkit-scrollbar { display: none; }
|
||||
.sw-tabs__tab {
|
||||
padding: 0.5rem 0.9rem; background: none; border: none;
|
||||
border-bottom: 2px solid transparent; color: var(--text-2);
|
||||
font-family: var(--font); font-size: 0.85rem; font-weight: 500;
|
||||
cursor: pointer; white-space: nowrap;
|
||||
transition: color var(--transition), border-color var(--transition);
|
||||
}
|
||||
.sw-tabs__tab:hover { color: var(--text); }
|
||||
.sw-tabs__tab--active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.sw-tabs__tab--disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.sw-tabs__icon { margin-right: 0.35rem; }
|
||||
.sw-tabs__arrow {
|
||||
background: none; border: none; color: var(--text-3);
|
||||
cursor: pointer; padding: 0.3rem 0.5rem; font-size: 1.2rem; flex-shrink: 0;
|
||||
}
|
||||
.sw-tabs__arrow:hover { color: var(--text); }
|
||||
|
||||
/* ── Dropdown ──────────────────────────────── */
|
||||
|
||||
.sw-dropdown { position: relative; display: inline-block; }
|
||||
.sw-dropdown__trigger {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 0.5rem; width: 100%; padding: 0.45rem 0.7rem;
|
||||
background: var(--input-bg); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
font-family: var(--font); font-size: 0.875rem; cursor: pointer;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
.sw-dropdown--open .sw-dropdown__trigger { border-color: var(--accent); }
|
||||
.sw-dropdown__value { flex: 1; text-align: left; }
|
||||
.sw-dropdown__chevron { color: var(--text-3); font-size: 0.7em; }
|
||||
.sw-dropdown__list {
|
||||
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
|
||||
z-index: 6000; max-height: 240px; overflow-y: auto;
|
||||
background: var(--bg-surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); box-shadow: var(--shadow-lg);
|
||||
animation: sw-fade-in 0.1s ease;
|
||||
}
|
||||
.sw-dropdown__search { margin: 4px; width: calc(100% - 8px); }
|
||||
.sw-dropdown__option {
|
||||
padding: 0.4rem 0.75rem; cursor: pointer; font-size: 0.85rem;
|
||||
color: var(--text); transition: background var(--transition);
|
||||
}
|
||||
.sw-dropdown__option:hover, .sw-dropdown__option--focused { background: var(--bg-hover); }
|
||||
.sw-dropdown__option--selected { color: var(--accent); font-weight: 500; }
|
||||
.sw-dropdown__option--disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.sw-dropdown__option--disabled:hover { background: transparent; }
|
||||
.sw-dropdown__empty { padding: 0.75rem; text-align: center; color: var(--text-3); font-size: 0.85rem; }
|
||||
109
src/css/sw-shell.css
Normal file
109
src/css/sw-shell.css
Normal file
@@ -0,0 +1,109 @@
|
||||
/* ── Layer 1: AppShell layout ─────────────────
|
||||
Full-viewport column layout with optional
|
||||
fixed banners, announcement, surface, and footer.
|
||||
──────────────────────────────────────────── */
|
||||
|
||||
.sw-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* ── Banner (fixed top / bottom — identical component) ── */
|
||||
|
||||
.sw-shell__banner {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 1rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sw-shell__banner--top { top: 0; }
|
||||
.sw-shell__banner--bottom { bottom: 0; }
|
||||
|
||||
.sw-shell__banner--info { background: var(--accent-dim); color: var(--accent-light); }
|
||||
.sw-shell__banner--warn { background: var(--warning-dim); color: var(--warning-light); }
|
||||
.sw-shell__banner--error { background: var(--danger-dim); color: var(--danger-light); }
|
||||
.sw-shell__banner--success { background: var(--success-dim); color: var(--success-light); }
|
||||
|
||||
.sw-shell__banner-text {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sw-shell__banner-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
padding: 0 0.3rem;
|
||||
opacity: 0.7;
|
||||
line-height: 1;
|
||||
}
|
||||
.sw-shell__banner-close:hover { opacity: 1; }
|
||||
|
||||
/* ── Shell body — insets for fixed banners ─── */
|
||||
|
||||
.sw-shell__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding-top: var(--banner-top-height, 0px);
|
||||
padding-bottom: var(--banner-bottom-height, 0px);
|
||||
}
|
||||
|
||||
/* ── Announcement bar ─────────────────────── */
|
||||
|
||||
.sw-shell__announcement {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sw-shell__announcement-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 1.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.sw-shell__announcement--info { background: var(--bg-surface); color: var(--text-2); }
|
||||
.sw-shell__announcement--warn { background: var(--warning-dim); color: var(--warning-light); }
|
||||
.sw-shell__announcement--error { background: var(--danger-dim); color: var(--danger-light); }
|
||||
.sw-shell__announcement--success { background: var(--success-dim); color: var(--success-light); }
|
||||
|
||||
.sw-shell__announcement-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Surface (main content area) ──────────── */
|
||||
|
||||
.sw-shell__surface {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Footer ───────────────────────────────── */
|
||||
|
||||
.sw-shell__footer {
|
||||
flex-shrink: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
359
src/dev.html
Normal file
359
src/dev.html
Normal file
@@ -0,0 +1,359 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Chat Switchboard — Dev Gallery</title>
|
||||
<link rel="stylesheet" href="css/variables.css">
|
||||
<link rel="stylesheet" href="css/sw-primitives.css">
|
||||
<link rel="stylesheet" href="css/sw-shell.css">
|
||||
<style>
|
||||
body { overflow: auto; padding: 0; }
|
||||
.dev-root { height: 100vh; display: flex; flex-direction: column; }
|
||||
.dev-nav { display: flex; gap: 0; border-bottom: 1px solid var(--border); background: var(--bg-surface); flex-shrink: 0; padding: 0 1rem; }
|
||||
.dev-nav button {
|
||||
padding: 0.6rem 1.2rem; background: none; border: none; border-bottom: 2px solid transparent;
|
||||
color: var(--text-2); font-family: var(--font); font-size: 0.85rem; font-weight: 500; cursor: pointer;
|
||||
}
|
||||
.dev-nav button:hover { color: var(--text); }
|
||||
.dev-nav button.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.dev-page { flex: 1; overflow: auto; }
|
||||
.gallery { max-width: 800px; margin: 0 auto; padding: 2rem; }
|
||||
.gallery h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
|
||||
.gallery .subtitle { color: var(--text-2); margin-bottom: 2rem; font-size: 0.9rem; }
|
||||
.section { margin-bottom: 2.5rem; }
|
||||
.section h2 {
|
||||
font-size: 0.85rem; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.5px; color: var(--text-3); margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.row { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; margin-bottom: 0.75rem; }
|
||||
.col { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.spacer { height: 0.5rem; }
|
||||
.shell-demo { height: 100%; }
|
||||
.shell-demo .sw-shell { height: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module">
|
||||
(async () => {
|
||||
try {
|
||||
const { h, render } = await import('./js/sw/vendor/preact.module.js');
|
||||
const hooksModule = await import('./js/sw/vendor/hooks.module.js?v=2');
|
||||
const { default: htm } = await import('./js/sw/vendor/htm.module.js');
|
||||
const html = htm.bind(h);
|
||||
|
||||
const { useState, useEffect, useRef, useMemo, useCallback } = hooksModule;
|
||||
window.preact = { h, render };
|
||||
window.hooks = hooksModule;
|
||||
window.html = html;
|
||||
|
||||
const { Button } = await import('./js/sw/primitives/button.js');
|
||||
const { Spinner } = await import('./js/sw/primitives/spinner.js');
|
||||
const { Avatar } = await import('./js/sw/primitives/avatar.js');
|
||||
const { FormField } = await import('./js/sw/primitives/form-field.js');
|
||||
const { Tooltip } = await import('./js/sw/primitives/tooltip.js');
|
||||
const { Banner } = await import('./js/sw/primitives/banner.js');
|
||||
const { Dialog } = await import('./js/sw/primitives/dialog.js');
|
||||
const { toast, ToastContainer } = await import('./js/sw/primitives/toast.js');
|
||||
const { Menu } = await import('./js/sw/primitives/menu.js');
|
||||
const { Drawer } = await import('./js/sw/primitives/drawer.js');
|
||||
const { Tabs } = await import('./js/sw/primitives/tabs.js');
|
||||
const { Dropdown } = await import('./js/sw/primitives/dropdown.js');
|
||||
const { AppShell } = await import('./js/sw/shell/app-shell.js');
|
||||
|
||||
function PrimitivesPage() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('tab1');
|
||||
const [dropVal, setDropVal] = useState('opt2');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const menuAnchor = useRef(null);
|
||||
|
||||
return html`
|
||||
<div class="gallery">
|
||||
<h1>Primitives Gallery</h1>
|
||||
<p class="subtitle">Layer 0 — Preact + htm components (v0.37.2)</p>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="section">
|
||||
<h2>Button</h2>
|
||||
<div class="row">
|
||||
<${Button} variant="primary">Primary<//>
|
||||
<${Button} variant="secondary">Secondary<//>
|
||||
<${Button} variant="danger">Danger<//>
|
||||
<${Button} variant="ghost">Ghost<//>
|
||||
</div>
|
||||
<div class="row">
|
||||
<${Button} size="sm">Small<//>
|
||||
<${Button} size="md">Medium<//>
|
||||
<${Button} size="lg">Large<//>
|
||||
</div>
|
||||
<div class="row">
|
||||
<${Button} disabled>Disabled<//>
|
||||
<${Button} loading=${true}>Loading<//>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spinner -->
|
||||
<div class="section">
|
||||
<h2>Spinner</h2>
|
||||
<div class="row">
|
||||
<${Spinner} size="sm" />
|
||||
<${Spinner} size="md" />
|
||||
<${Spinner} size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="section">
|
||||
<h2>Avatar</h2>
|
||||
<div class="row">
|
||||
<${Avatar} name="Jane Doe" size="sm" />
|
||||
<${Avatar} name="Jane Doe" size="md" />
|
||||
<${Avatar} name="Jane Doe" size="lg" />
|
||||
<${Avatar} name="X" size="md" />
|
||||
<${Avatar} src="https://i.pravatar.cc/48" name="Photo" size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FormField -->
|
||||
<div class="section">
|
||||
<h2>FormField</h2>
|
||||
<div class="col" style="max-width:320px">
|
||||
<${FormField} label="Username" required>
|
||||
<input class="sw-input" placeholder="Enter username" />
|
||||
<//>
|
||||
<${FormField} label="Email" error="Invalid email address">
|
||||
<input class="sw-input" value="bad@" />
|
||||
<//>
|
||||
<${FormField} label="Bio" hint="Optional, max 200 chars">
|
||||
<textarea class="sw-input" rows="2" placeholder="Tell us about yourself" />
|
||||
<//>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tooltip -->
|
||||
<div class="section">
|
||||
<h2>Tooltip</h2>
|
||||
<div class="row">
|
||||
<${Tooltip} content="Top tooltip">
|
||||
<${Button} variant="secondary">Hover me (top)<//>
|
||||
<//>
|
||||
<${Tooltip} content="Bottom tooltip" position="bottom">
|
||||
<${Button} variant="secondary">Bottom<//>
|
||||
<//>
|
||||
<${Tooltip} content="Right tooltip" position="right">
|
||||
<${Button} variant="secondary">Right<//>
|
||||
<//>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Banner -->
|
||||
<div class="section">
|
||||
<h2>Banner</h2>
|
||||
<div class="col">
|
||||
<${Banner} text="Informational banner message" variant="info" />
|
||||
<${Banner} text="Warning: maintenance scheduled" variant="warn" dismissible />
|
||||
<${Banner} text="Error: service unavailable" variant="error" />
|
||||
<${Banner} text="Successfully deployed v0.37.2" variant="success" dismissible />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="section">
|
||||
<h2>Tabs</h2>
|
||||
<${Tabs}
|
||||
tabs=${[
|
||||
{ label: 'General', value: 'tab1' },
|
||||
{ label: 'Appearance', value: 'tab2' },
|
||||
{ label: 'Notifications', value: 'tab3' },
|
||||
{ label: 'Disabled', value: 'tab4', disabled: true },
|
||||
]}
|
||||
active=${activeTab}
|
||||
onChange=${setActiveTab}
|
||||
/>
|
||||
<div style="padding: 1rem 0; color: var(--text-2); font-size: 0.9rem">
|
||||
Active tab: ${activeTab}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown -->
|
||||
<div class="section">
|
||||
<h2>Dropdown</h2>
|
||||
<div class="row">
|
||||
<div style="width:220px">
|
||||
<${Dropdown}
|
||||
options=${[
|
||||
{ label: 'Claude Sonnet', value: 'opt1' },
|
||||
{ label: 'Claude Opus', value: 'opt2' },
|
||||
{ label: 'Claude Haiku', value: 'opt3' },
|
||||
{ label: 'GPT-4o', value: 'opt4' },
|
||||
{ label: 'Disabled', value: 'opt5', disabled: true },
|
||||
]}
|
||||
value=${dropVal}
|
||||
onChange=${setDropVal}
|
||||
searchable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menu -->
|
||||
<div class="section">
|
||||
<h2>Menu</h2>
|
||||
<div class="row">
|
||||
<${Button} ref=${menuAnchor} variant="secondary"
|
||||
onClick=${() => setMenuOpen(!menuOpen)}>
|
||||
Open Menu
|
||||
<//>
|
||||
<${Menu}
|
||||
open=${menuOpen}
|
||||
anchor=${menuAnchor.current}
|
||||
items=${[
|
||||
{ label: 'Settings', action: 'settings', icon: '\u2699' },
|
||||
{ label: 'Profile', action: 'profile', icon: '\ud83d\udc64' },
|
||||
{ divider: true },
|
||||
{ label: 'Submenu', children: [
|
||||
{ label: 'Option A', action: 'a' },
|
||||
{ label: 'Option B', action: 'b' },
|
||||
]},
|
||||
{ divider: true },
|
||||
{ label: 'Disabled', action: 'x', disabled: true },
|
||||
{ label: 'Sign Out', action: 'logout' },
|
||||
]}
|
||||
onSelect=${(action) => { toast.info('Selected: ' + action); }}
|
||||
onClose=${() => setMenuOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog -->
|
||||
<div class="section">
|
||||
<h2>Dialog</h2>
|
||||
<div class="row">
|
||||
<${Button} onClick=${() => setDialogOpen(true)}>Open Dialog<//>
|
||||
</div>
|
||||
<${Dialog}
|
||||
open=${dialogOpen}
|
||||
title="Example Dialog"
|
||||
onClose=${() => setDialogOpen(false)}
|
||||
actions=${[
|
||||
{ label: 'Cancel', variant: 'secondary', onClick: () => setDialogOpen(false) },
|
||||
{ label: 'Confirm', variant: 'primary', onClick: () => { setDialogOpen(false); toast.success('Confirmed!'); } },
|
||||
]}
|
||||
>
|
||||
<p>This is a modal dialog with focus trapping, Escape to close, and backdrop click to dismiss.</p>
|
||||
<//>
|
||||
</div>
|
||||
|
||||
<!-- Drawer -->
|
||||
<div class="section">
|
||||
<h2>Drawer</h2>
|
||||
<div class="row">
|
||||
<${Button} variant="secondary" onClick=${() => setDrawerOpen(true)}>Open Drawer<//>
|
||||
</div>
|
||||
<${Drawer}
|
||||
open=${drawerOpen}
|
||||
title="Settings"
|
||||
onClose=${() => setDrawerOpen(false)}
|
||||
>
|
||||
<p style="color: var(--text-2)">Slide-in panel for settings, admin sections, etc.</p>
|
||||
<div class="spacer" />
|
||||
<${FormField} label="Display Name">
|
||||
<input class="sw-input" value="Jane Doe" />
|
||||
<//>
|
||||
<//>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="section">
|
||||
<h2>Toast</h2>
|
||||
<div class="row">
|
||||
<${Button} variant="secondary" onClick=${() => toast.success('Operation successful')}>Success<//>
|
||||
<${Button} variant="secondary" onClick=${() => toast.error('Something went wrong')}>Error<//>
|
||||
<${Button} variant="secondary" onClick=${() => toast.warn('Check your settings')}>Warning<//>
|
||||
<${Button} variant="secondary" onClick=${() => toast.info('New message received')}>Info<//>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<${ToastContainer} />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const VARIANTS = ['info', 'warn', 'error', 'success'];
|
||||
|
||||
function ShellControls({ bannerOn, setBannerOn, bannerVariant, setBannerVariant, bannerText, setBannerText,
|
||||
msgOn, setMsgOn, msgVariant, setMsgVariant, msgText, setMsgText,
|
||||
footerOn, setFooterOn }) {
|
||||
return html`
|
||||
<div style="display:flex; align-items:center; gap:0.75rem; padding:0.4rem 0; flex-wrap:wrap;">
|
||||
<${Button} variant=${bannerOn ? 'primary' : 'secondary'} size="sm"
|
||||
onClick=${() => setBannerOn(!bannerOn)}>Banner<//>
|
||||
${bannerOn && html`
|
||||
<${Dropdown} options=${VARIANTS.map(v => ({ label: v, value: v }))}
|
||||
value=${bannerVariant} onChange=${setBannerVariant} />
|
||||
<input class="sw-input" style="width:140px" placeholder="text..."
|
||||
value=${bannerText} onInput=${e => setBannerText(e.target.value)} />
|
||||
`}
|
||||
<${Button} variant=${msgOn ? 'primary' : 'secondary'} size="sm"
|
||||
onClick=${() => setMsgOn(!msgOn)}>Message<//>
|
||||
${msgOn && html`
|
||||
<${Dropdown} options=${VARIANTS.map(v => ({ label: v, value: v }))}
|
||||
value=${msgVariant} onChange=${setMsgVariant} />
|
||||
<input class="sw-input" style="width:140px" placeholder="text..."
|
||||
value=${msgText} onInput=${e => setMsgText(e.target.value)} />
|
||||
`}
|
||||
<${Button} variant=${footerOn ? 'primary' : 'secondary'} size="sm"
|
||||
onClick=${() => setFooterOn(!footerOn)}>Footer<//>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function DevRoot() {
|
||||
const [page, setPage] = useState('primitives');
|
||||
const [bannerOn, setBannerOn] = useState(false);
|
||||
const [msgOn, setMsgOn] = useState(false);
|
||||
const [footerOn, setFooterOn] = useState(false);
|
||||
const [bannerVariant, setBannerVariant] = useState('info');
|
||||
const [msgVariant, setMsgVariant] = useState('info');
|
||||
const [bannerText, setBannerText] = useState('');
|
||||
const [msgText, setMsgText] = useState('');
|
||||
|
||||
const banner = bannerOn ? { text: bannerText || 'Banner', variant: bannerVariant } : null;
|
||||
const msg = msgOn ? { text: msgText || 'Message', variant: msgVariant } : null;
|
||||
|
||||
return html`
|
||||
<${AppShell}
|
||||
banner=${banner}
|
||||
message=${msg}
|
||||
footer=${footerOn ? html`Chat Switchboard · Layer 1 Shell` : null}
|
||||
>
|
||||
<div class="dev-root">
|
||||
<nav class="dev-nav">
|
||||
<button class=${page === 'primitives' ? 'active' : ''} onClick=${() => setPage('primitives')}>Primitives</button>
|
||||
<button class=${page === 'shell' ? 'active' : ''} onClick=${() => setPage('shell')}>Shell</button>
|
||||
<div style="flex:1" />
|
||||
<${ShellControls} ...${{
|
||||
bannerOn, setBannerOn, bannerVariant, setBannerVariant, bannerText, setBannerText,
|
||||
msgOn, setMsgOn, msgVariant, setMsgVariant, msgText, setMsgText,
|
||||
footerOn, setFooterOn
|
||||
}} />
|
||||
</nav>
|
||||
<div class="dev-page">
|
||||
${page === 'primitives' && html`<${PrimitivesPage} />`}
|
||||
</div>
|
||||
</div>
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
render(html`<${DevRoot} />`, document.getElementById('app'));
|
||||
} catch(e) { console.error('DEV:', e); }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
35
src/js/sw/primitives/avatar.js
Normal file
35
src/js/sw/primitives/avatar.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Avatar primitive — circle avatar with letter fallback
|
||||
* Sizes: sm (24px), md (32px), lg (48px)
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState } = hooks;
|
||||
|
||||
function initials(name) {
|
||||
if (!name) return '?';
|
||||
const parts = name.trim().split(/\s+/);
|
||||
return parts.length > 1
|
||||
? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
||||
: name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function Avatar({ src, name = '', size = 'md' }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (src && !failed) {
|
||||
return html`
|
||||
<img
|
||||
class="sw-avatar sw-avatar--${size}"
|
||||
src=${src}
|
||||
alt=${name}
|
||||
onError=${() => setFailed(true)}
|
||||
/>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<span class="sw-avatar sw-avatar--${size} sw-avatar--fallback"
|
||||
aria-label=${name || 'Avatar'}>
|
||||
${initials(name)}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
26
src/js/sw/primitives/banner.js
Normal file
26
src/js/sw/primitives/banner.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Banner primitive — persistent top/bottom bar
|
||||
* Variants: info, warn, error, success
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState } = hooks;
|
||||
|
||||
export function Banner({ text, variant = 'info', dismissible = false, position = 'top', onDismiss }) {
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
if (dismissed || !text) return null;
|
||||
|
||||
const handleDismiss = () => {
|
||||
setDismissed(true);
|
||||
if (onDismiss) onDismiss();
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="sw-banner sw-banner--${variant} sw-banner--${position}" role="status">
|
||||
<span class="sw-banner__text">${text}</span>
|
||||
${dismissible && html`
|
||||
<button class="sw-banner__close" onClick=${handleDismiss} aria-label="Dismiss">\u00d7</button>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
22
src/js/sw/primitives/button.js
Normal file
22
src/js/sw/primitives/button.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Button primitive — variants: primary, secondary, danger, ghost
|
||||
* Sizes: sm, md (default), lg
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState } = hooks;
|
||||
|
||||
export function Button({ variant = 'primary', size = 'md', disabled, loading, onClick, children, type = 'button', className = '' }) {
|
||||
const cls = `sw-btn sw-btn--${variant} sw-btn--${size} ${loading ? 'sw-btn--loading' : ''} ${className}`.trim();
|
||||
|
||||
return html`
|
||||
<button
|
||||
type=${type}
|
||||
class=${cls}
|
||||
disabled=${disabled || loading}
|
||||
onClick=${onClick}
|
||||
>
|
||||
${loading && html`<span class="sw-btn__spinner" />`}
|
||||
<span class=${loading ? 'sw-btn__content--hidden' : ''}>${children}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
44
src/js/sw/primitives/confirm.js
Normal file
44
src/js/sw/primitives/confirm.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Confirm primitive — specialized Dialog that resolves a Promise<boolean>
|
||||
* Usage: const ok = await confirm('Delete this?', { destructive: true })
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState } = hooks;
|
||||
const { h, render } = preact;
|
||||
|
||||
import { Dialog } from './dialog.js';
|
||||
|
||||
let _setConfirm = null;
|
||||
let _resolve = null;
|
||||
|
||||
export function confirm(message, opts = {}) {
|
||||
return new Promise((resolve) => {
|
||||
_resolve = resolve;
|
||||
if (_setConfirm) _setConfirm({ message, ...opts });
|
||||
});
|
||||
}
|
||||
|
||||
function handleClose(result) {
|
||||
if (_resolve) _resolve(result);
|
||||
_resolve = null;
|
||||
if (_setConfirm) _setConfirm(null);
|
||||
}
|
||||
|
||||
export function ConfirmHost() {
|
||||
const [state, setState] = useState(null);
|
||||
_setConfirm = setState;
|
||||
|
||||
if (!state) return null;
|
||||
|
||||
const { message, destructive, confirmLabel = 'Confirm', cancelLabel = 'Cancel' } = state;
|
||||
const actions = [
|
||||
{ label: cancelLabel, variant: 'secondary', onClick: () => handleClose(false) },
|
||||
{ label: confirmLabel, variant: destructive ? 'danger' : 'primary', onClick: () => handleClose(true) },
|
||||
];
|
||||
|
||||
return html`
|
||||
<${Dialog} open title="Confirm" onClose=${() => handleClose(false)} actions=${actions}>
|
||||
<p>${message}</p>
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
93
src/js/sw/primitives/dialog.js
Normal file
93
src/js/sw/primitives/dialog.js
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Dialog primitive — modal with focus trapping, Escape close, backdrop click
|
||||
* Actions: [{label, onClick, variant}]
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useEffect, useRef, useCallback } = hooks;
|
||||
|
||||
export function Dialog({ open, title, children, onClose, actions = [], persistent = false, className = '' }) {
|
||||
const dialogRef = useRef(null);
|
||||
const prevFocus = useRef(null);
|
||||
|
||||
// Focus trap + restore
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
prevFocus.current = document.activeElement;
|
||||
const el = dialogRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// Focus first focusable element
|
||||
requestAnimationFrame(() => {
|
||||
const focusable = el.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
if (focusable.length) focusable[0].focus();
|
||||
});
|
||||
|
||||
// Lock body scroll
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
if (prevFocus.current) prevFocus.current.focus();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Escape key
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => {
|
||||
if (e.key === 'Escape' && onClose) onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Focus trap: Tab cycles within dialog
|
||||
const onKeyDown = useCallback((e) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
const el = dialogRef.current;
|
||||
if (!el) return;
|
||||
const focusable = el.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
if (!focusable.length) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const backdropClick = persistent ? undefined : onClose;
|
||||
|
||||
return html`
|
||||
<div class="sw-dialog-overlay" onClick=${backdropClick}>
|
||||
<div class="sw-dialog ${className}" ref=${dialogRef}
|
||||
role="dialog" aria-modal="true" aria-label=${title || 'Dialog'}
|
||||
onClick=${(e) => e.stopPropagation()}
|
||||
onKeyDown=${onKeyDown}>
|
||||
${title && html`
|
||||
<div class="sw-dialog__header">
|
||||
<h2 class="sw-dialog__title">${title}</h2>
|
||||
${onClose && html`
|
||||
<button class="sw-dialog__close" onClick=${onClose} aria-label="Close">\u00d7</button>
|
||||
`}
|
||||
</div>
|
||||
`}
|
||||
<div class="sw-dialog__body">${children}</div>
|
||||
${actions.length > 0 && html`
|
||||
<div class="sw-dialog__actions">
|
||||
${actions.map(a => html`
|
||||
<button class="sw-btn sw-btn--${a.variant || 'secondary'} sw-btn--sm"
|
||||
onClick=${a.onClick}>
|
||||
${a.label}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
47
src/js/sw/primitives/drawer.js
Normal file
47
src/js/sw/primitives/drawer.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Drawer primitive — slide-in panel (left/right)
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useEffect, useRef } = hooks;
|
||||
|
||||
export function Drawer({ open, side = 'right', width = '360px', title, children, onClose }) {
|
||||
const drawerRef = useRef(null);
|
||||
|
||||
// Escape to close
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => {
|
||||
if (e.key === 'Escape' && onClose) onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Lock body scroll
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [open]);
|
||||
|
||||
return html`
|
||||
<div class="sw-drawer-overlay ${open ? 'sw-drawer-overlay--open' : ''}"
|
||||
onClick=${onClose}>
|
||||
<div class="sw-drawer sw-drawer--${side} ${open ? 'sw-drawer--open' : ''}"
|
||||
ref=${drawerRef}
|
||||
style="width:${width}"
|
||||
onClick=${(e) => e.stopPropagation()}
|
||||
role="dialog" aria-modal="true" aria-label=${title || 'Panel'}>
|
||||
${title && html`
|
||||
<div class="sw-drawer__header">
|
||||
<h2 class="sw-drawer__title">${title}</h2>
|
||||
${onClose && html`
|
||||
<button class="sw-drawer__close" onClick=${onClose} aria-label="Close">\u00d7</button>
|
||||
`}
|
||||
</div>
|
||||
`}
|
||||
<div class="sw-drawer__body">${children}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
106
src/js/sw/primitives/dropdown.js
Normal file
106
src/js/sw/primitives/dropdown.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Dropdown primitive — select replacement with search/filter
|
||||
* options: [{ label, value, disabled? }]
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useRef, useEffect, useCallback, useMemo } = hooks;
|
||||
|
||||
export function Dropdown({ options = [], value, onChange, placeholder = 'Select...', searchable = false }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [focusIdx, setFocusIdx] = useState(-1);
|
||||
const wrapRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return options;
|
||||
const q = search.toLowerCase();
|
||||
return options.filter(o => o.label.toLowerCase().includes(q));
|
||||
}, [options, search]);
|
||||
|
||||
const selected = options.find(o => o.value === value);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
// Focus search on open
|
||||
useEffect(() => {
|
||||
if (open && searchable && inputRef.current) inputRef.current.focus();
|
||||
if (open) setFocusIdx(-1);
|
||||
if (!open) setSearch('');
|
||||
}, [open, searchable]);
|
||||
|
||||
const select = useCallback((opt) => {
|
||||
if (opt.disabled) return;
|
||||
if (onChange) onChange(opt.value);
|
||||
setOpen(false);
|
||||
}, [onChange]);
|
||||
|
||||
const onKeyDown = useCallback((e) => {
|
||||
if (!open) {
|
||||
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const count = filtered.length;
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setFocusIdx(i => Math.min(i + 1, count - 1));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setFocusIdx(i => Math.max(i - 1, 0));
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (focusIdx >= 0 && focusIdx < count) select(filtered[focusIdx]);
|
||||
break;
|
||||
case 'Escape':
|
||||
setOpen(false);
|
||||
break;
|
||||
}
|
||||
}, [open, filtered, focusIdx, select]);
|
||||
|
||||
return html`
|
||||
<div class="sw-dropdown ${open ? 'sw-dropdown--open' : ''}" ref=${wrapRef} onKeyDown=${onKeyDown}>
|
||||
<button class="sw-dropdown__trigger" type="button"
|
||||
onClick=${() => setOpen(!open)}
|
||||
aria-haspopup="listbox" aria-expanded=${open}>
|
||||
<span class="sw-dropdown__value">${selected ? selected.label : placeholder}</span>
|
||||
<span class="sw-dropdown__chevron">\u25be</span>
|
||||
</button>
|
||||
${open && html`
|
||||
<div class="sw-dropdown__list" role="listbox">
|
||||
${searchable && html`
|
||||
<input class="sw-dropdown__search sw-input" ref=${inputRef}
|
||||
placeholder="Search..."
|
||||
value=${search}
|
||||
onInput=${(e) => { setSearch(e.target.value); setFocusIdx(0); }} />
|
||||
`}
|
||||
${filtered.map((opt, i) => html`
|
||||
<div class="sw-dropdown__option ${opt.value === value ? 'sw-dropdown__option--selected' : ''} ${opt.disabled ? 'sw-dropdown__option--disabled' : ''} ${i === focusIdx ? 'sw-dropdown__option--focused' : ''}"
|
||||
role="option"
|
||||
aria-selected=${opt.value === value}
|
||||
onMouseEnter=${() => setFocusIdx(i)}
|
||||
onClick=${() => select(opt)}>
|
||||
${opt.label}
|
||||
</div>
|
||||
`)}
|
||||
${filtered.length === 0 && html`
|
||||
<div class="sw-dropdown__empty">No results</div>
|
||||
`}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
19
src/js/sw/primitives/form-field.js
Normal file
19
src/js/sw/primitives/form-field.js
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* FormField primitive — wrapper for form inputs with label + validation
|
||||
*/
|
||||
const { html } = window;
|
||||
|
||||
export function FormField({ label, error, hint, required, children }) {
|
||||
return html`
|
||||
<div class="sw-field ${error ? 'sw-field--error' : ''}">
|
||||
${label && html`
|
||||
<label class="sw-field__label">
|
||||
${label}${required && html`<span class="sw-field__required">*</span>`}
|
||||
</label>
|
||||
`}
|
||||
<div class="sw-field__control">${children}</div>
|
||||
${error && html`<p class="sw-field__error">${error}</p>`}
|
||||
${hint && !error && html`<p class="sw-field__hint">${hint}</p>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
18
src/js/sw/primitives/index.js
Normal file
18
src/js/sw/primitives/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Layer 0 — Primitives re-export
|
||||
* All UI primitives available via: import { Button, Dialog, ... } from './primitives/index.js'
|
||||
*/
|
||||
export { Button } from './button.js';
|
||||
export { Spinner } from './spinner.js';
|
||||
export { Avatar } from './avatar.js';
|
||||
export { FormField } from './form-field.js';
|
||||
export { Tooltip } from './tooltip.js';
|
||||
export { Banner } from './banner.js';
|
||||
export { Dialog } from './dialog.js';
|
||||
export { confirm, ConfirmHost } from './confirm.js';
|
||||
export { prompt, PromptHost } from './prompt.js';
|
||||
export { toast, ToastContainer } from './toast.js';
|
||||
export { Menu } from './menu.js';
|
||||
export { Drawer } from './drawer.js';
|
||||
export { Tabs } from './tabs.js';
|
||||
export { Dropdown } from './dropdown.js';
|
||||
159
src/js/sw/primitives/menu.js
Normal file
159
src/js/sw/primitives/menu.js
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Menu primitive — flyout menu with viewport clamping, keyboard nav, submenus
|
||||
*
|
||||
* Items: [{ label, action, icon?, disabled?, divider?, children? }]
|
||||
* Directions: down-right (default), down-left, up-right, up-left
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useRef, useCallback } = hooks;
|
||||
|
||||
export function Menu({ items = [], anchor, open, direction = 'down-right', onSelect, onClose }) {
|
||||
const menuRef = useRef(null);
|
||||
const [pos, setPos] = useState(null);
|
||||
const [focusIdx, setFocusIdx] = useState(-1);
|
||||
const [submenu, setSubmenu] = useState(null);
|
||||
|
||||
const selectableItems = items.filter(i => !i.divider && !i.disabled);
|
||||
|
||||
// Position relative to anchor
|
||||
useEffect(() => {
|
||||
if (!open || !anchor) return;
|
||||
const rect = typeof anchor.getBoundingClientRect === 'function'
|
||||
? anchor.getBoundingClientRect()
|
||||
: anchor;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
let top, left;
|
||||
const [vDir, hDir] = direction.split('-');
|
||||
|
||||
if (vDir === 'down') top = rect.bottom + 4;
|
||||
else top = rect.top - 4;
|
||||
|
||||
if (hDir === 'right') left = rect.left;
|
||||
else left = rect.right;
|
||||
|
||||
// Defer measurement to get menu size
|
||||
requestAnimationFrame(() => {
|
||||
const el = menuRef.current;
|
||||
if (!el) return;
|
||||
const mw = el.offsetWidth;
|
||||
const mh = el.offsetHeight;
|
||||
|
||||
// Viewport clamp
|
||||
if (vDir === 'down' && top + mh > vh) top = Math.max(4, rect.top - mh - 4);
|
||||
if (vDir === 'up') { top = top - mh; if (top < 4) top = rect.bottom + 4; }
|
||||
if (hDir === 'right' && left + mw > vw) left = vw - mw - 4;
|
||||
if (hDir === 'left') { left = left - mw; if (left < 4) left = 4; }
|
||||
|
||||
setPos({ top, left });
|
||||
});
|
||||
setPos({ top: -9999, left: -9999 }); // initial offscreen for measurement
|
||||
setFocusIdx(-1);
|
||||
}, [open, anchor, direction]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target)) {
|
||||
if (onClose) onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Keyboard navigation
|
||||
const onKeyDown = useCallback((e) => {
|
||||
const count = selectableItems.length;
|
||||
if (!count) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setFocusIdx(i => (i + 1) % count);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setFocusIdx(i => (i - 1 + count) % count);
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
setFocusIdx(0);
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
setFocusIdx(count - 1);
|
||||
break;
|
||||
case 'Enter': {
|
||||
e.preventDefault();
|
||||
if (focusIdx >= 0 && focusIdx < count) {
|
||||
const item = selectableItems[focusIdx];
|
||||
if (item.children) setSubmenu(item);
|
||||
else if (onSelect) { onSelect(item.action || item.label); if (onClose) onClose(); }
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'ArrowRight': {
|
||||
if (focusIdx >= 0 && selectableItems[focusIdx]?.children) {
|
||||
setSubmenu(selectableItems[focusIdx]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'ArrowLeft':
|
||||
if (submenu) setSubmenu(null);
|
||||
break;
|
||||
case 'Escape':
|
||||
if (submenu) setSubmenu(null);
|
||||
else if (onClose) onClose();
|
||||
break;
|
||||
}
|
||||
}, [selectableItems, focusIdx, submenu, onSelect, onClose]);
|
||||
|
||||
// Focus menu on open
|
||||
useEffect(() => {
|
||||
if (open && menuRef.current) menuRef.current.focus();
|
||||
}, [open, pos]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
let selIdx = 0;
|
||||
return html`
|
||||
<div class="sw-menu" ref=${menuRef} role="menu" tabindex="-1"
|
||||
style=${pos ? `position:fixed;top:${pos.top}px;left:${pos.left}px` : 'position:fixed;top:-9999px;left:-9999px'}
|
||||
onKeyDown=${onKeyDown}>
|
||||
${items.map((item) => {
|
||||
if (item.divider) return html`<div class="sw-menu__divider" role="separator" />`;
|
||||
const mySelIdx = selIdx++;
|
||||
const focused = mySelIdx === focusIdx;
|
||||
return html`
|
||||
<div class="sw-menu__item ${item.disabled ? 'sw-menu__item--disabled' : ''} ${focused ? 'sw-menu__item--focused' : ''}"
|
||||
role="menuitem"
|
||||
aria-disabled=${item.disabled || false}
|
||||
onMouseEnter=${() => { setFocusIdx(mySelIdx); if (item.children) setSubmenu(item); }}
|
||||
onClick=${() => {
|
||||
if (item.disabled) return;
|
||||
if (item.children) { setSubmenu(item); return; }
|
||||
if (onSelect) onSelect(item.action || item.label);
|
||||
if (onClose) onClose();
|
||||
}}>
|
||||
${item.icon && html`<span class="sw-menu__icon">${item.icon}</span>`}
|
||||
<span class="sw-menu__label">${item.label}</span>
|
||||
${item.children && html`<span class="sw-menu__arrow">\u25b6</span>`}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
${submenu && html`
|
||||
<${Menu}
|
||||
items=${submenu.children}
|
||||
open
|
||||
anchor=${menuRef.current}
|
||||
direction="down-right"
|
||||
onSelect=${(action) => { if (onSelect) onSelect(action); if (onClose) onClose(); }}
|
||||
onClose=${() => setSubmenu(null)}
|
||||
/>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
60
src/js/sw/primitives/prompt.js
Normal file
60
src/js/sw/primitives/prompt.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Prompt primitive — specialized Dialog that resolves Promise<string|null>
|
||||
* Usage: const val = await prompt('Enter name:', { defaultValue: 'foo' })
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useRef, useEffect } = hooks;
|
||||
|
||||
import { Dialog } from './dialog.js';
|
||||
|
||||
let _setPrompt = null;
|
||||
let _resolve = null;
|
||||
|
||||
export function prompt(message, opts = {}) {
|
||||
return new Promise((resolve) => {
|
||||
_resolve = resolve;
|
||||
if (_setPrompt) _setPrompt({ message, ...opts });
|
||||
});
|
||||
}
|
||||
|
||||
function handleClose(value) {
|
||||
if (_resolve) _resolve(value);
|
||||
_resolve = null;
|
||||
if (_setPrompt) _setPrompt(null);
|
||||
}
|
||||
|
||||
export function PromptHost() {
|
||||
const [state, setState] = useState(null);
|
||||
const [value, setValue] = useState('');
|
||||
const inputRef = useRef(null);
|
||||
_setPrompt = (s) => {
|
||||
setState(s);
|
||||
if (s) setValue(s.defaultValue || '');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (state && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
if (!state) return null;
|
||||
|
||||
const { message, placeholder = '', submitLabel = 'OK', cancelLabel = 'Cancel' } = state;
|
||||
const actions = [
|
||||
{ label: cancelLabel, variant: 'secondary', onClick: () => handleClose(null) },
|
||||
{ label: submitLabel, variant: 'primary', onClick: () => handleClose(value) },
|
||||
];
|
||||
|
||||
return html`
|
||||
<${Dialog} open title="Prompt" onClose=${() => handleClose(null)} actions=${actions}>
|
||||
<p style="margin-bottom: 0.75rem">${message}</p>
|
||||
<input class="sw-input" ref=${inputRef}
|
||||
value=${value}
|
||||
placeholder=${placeholder}
|
||||
onInput=${(e) => setValue(e.target.value)}
|
||||
onKeyDown=${(e) => { if (e.key === 'Enter') handleClose(value); }} />
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
9
src/js/sw/primitives/spinner.js
Normal file
9
src/js/sw/primitives/spinner.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Spinner primitive — loading indicator
|
||||
* Sizes: sm (16px), md (24px), lg (40px)
|
||||
*/
|
||||
const { html } = window;
|
||||
|
||||
export function Spinner({ size = 'md' }) {
|
||||
return html`<span class="sw-spinner sw-spinner--${size}" role="status" aria-label="Loading" />`;
|
||||
}
|
||||
60
src/js/sw/primitives/tabs.js
Normal file
60
src/js/sw/primitives/tabs.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Tabs primitive — horizontal tab bar with overflow scroll arrows
|
||||
* tabs: [{ label, value, icon?, disabled? }]
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useRef, useEffect, useState, useCallback } = hooks;
|
||||
|
||||
export function Tabs({ tabs = [], active, onChange }) {
|
||||
const scrollRef = useRef(null);
|
||||
const [overflow, setOverflow] = useState({ left: false, right: false });
|
||||
|
||||
const checkOverflow = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
setOverflow({
|
||||
left: el.scrollLeft > 0,
|
||||
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkOverflow();
|
||||
const el = scrollRef.current;
|
||||
if (el) el.addEventListener('scroll', checkOverflow, { passive: true });
|
||||
window.addEventListener('resize', checkOverflow);
|
||||
return () => {
|
||||
if (el) el.removeEventListener('scroll', checkOverflow);
|
||||
window.removeEventListener('resize', checkOverflow);
|
||||
};
|
||||
}, [checkOverflow, tabs]);
|
||||
|
||||
const scroll = (dir) => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollBy({ left: dir * 150, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="sw-tabs">
|
||||
${overflow.left && html`
|
||||
<button class="sw-tabs__arrow sw-tabs__arrow--left" onClick=${() => scroll(-1)} aria-label="Scroll left">\u2039</button>
|
||||
`}
|
||||
<div class="sw-tabs__scroll" ref=${scrollRef}>
|
||||
${tabs.map(tab => html`
|
||||
<button
|
||||
class="sw-tabs__tab ${tab.value === active ? 'sw-tabs__tab--active' : ''} ${tab.disabled ? 'sw-tabs__tab--disabled' : ''}"
|
||||
disabled=${tab.disabled}
|
||||
onClick=${() => !tab.disabled && onChange && onChange(tab.value)}
|
||||
role="tab"
|
||||
aria-selected=${tab.value === active}>
|
||||
${tab.icon && html`<span class="sw-tabs__icon">${tab.icon}</span>`}
|
||||
${tab.label}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
${overflow.right && html`
|
||||
<button class="sw-tabs__arrow sw-tabs__arrow--right" onClick=${() => scroll(1)} aria-label="Scroll right">\u203a</button>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
94
src/js/sw/primitives/toast.js
Normal file
94
src/js/sw/primitives/toast.js
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Toast primitive + ToastContainer
|
||||
* Variants: success, error, info, warn
|
||||
* Auto-dismiss, hover-pause, max 5 visible, stacks bottom-right
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useRef, useCallback } = hooks;
|
||||
const { h, render } = preact;
|
||||
|
||||
// ── Singleton state ──────────────────────────
|
||||
|
||||
let _toasts = [];
|
||||
let _id = 0;
|
||||
let _setToasts = null;
|
||||
|
||||
export function toast(message, variant = 'info', duration) {
|
||||
if (!duration) duration = variant === 'error' ? 5000 : 3000;
|
||||
const id = ++_id;
|
||||
const t = { id, message, variant, duration };
|
||||
_toasts = [..._toasts, t];
|
||||
if (_toasts.length > 5) _toasts = _toasts.slice(-5);
|
||||
if (_setToasts) _setToasts([..._toasts]);
|
||||
return id;
|
||||
}
|
||||
|
||||
toast.success = (msg, dur) => toast(msg, 'success', dur);
|
||||
toast.error = (msg, dur) => toast(msg, 'error', dur);
|
||||
toast.info = (msg, dur) => toast(msg, 'info', dur);
|
||||
toast.warn = (msg, dur) => toast(msg, 'warn', dur);
|
||||
|
||||
function dismiss(id) {
|
||||
_toasts = _toasts.filter(t => t.id !== id);
|
||||
if (_setToasts) _setToasts([..._toasts]);
|
||||
}
|
||||
|
||||
// ── Single Toast ─────────────────────────────
|
||||
|
||||
function ToastItem({ id, message, variant, duration }) {
|
||||
const [exiting, setExiting] = useState(false);
|
||||
const timer = useRef(null);
|
||||
const hovered = useRef(false);
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => {
|
||||
setExiting(true);
|
||||
setTimeout(() => dismiss(id), 200);
|
||||
}, duration);
|
||||
}, [id, duration]);
|
||||
|
||||
useEffect(() => {
|
||||
startTimer();
|
||||
return () => clearTimeout(timer.current);
|
||||
}, [startTimer]);
|
||||
|
||||
const onEnter = useCallback(() => {
|
||||
hovered.current = true;
|
||||
clearTimeout(timer.current);
|
||||
}, []);
|
||||
|
||||
const onLeave = useCallback(() => {
|
||||
hovered.current = false;
|
||||
startTimer();
|
||||
}, [startTimer]);
|
||||
|
||||
const cls = `sw-toast sw-toast--${variant} ${exiting ? 'sw-toast--exit' : 'sw-toast--enter'}`;
|
||||
return html`
|
||||
<div class=${cls} role="alert"
|
||||
onMouseEnter=${onEnter}
|
||||
onMouseLeave=${onLeave}>
|
||||
<span class="sw-toast__msg">${message}</span>
|
||||
<button class="sw-toast__close" onClick=${() => {
|
||||
setExiting(true);
|
||||
setTimeout(() => dismiss(id), 200);
|
||||
}} aria-label="Dismiss">\u00d7</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Container ────────────────────────────────
|
||||
|
||||
export function ToastContainer() {
|
||||
const [toasts, setToasts] = useState(_toasts);
|
||||
_setToasts = setToasts;
|
||||
|
||||
useEffect(() => () => { _setToasts = null; }, []);
|
||||
|
||||
if (!toasts.length) return null;
|
||||
return html`
|
||||
<div class="sw-toast-container" aria-live="polite">
|
||||
${toasts.map(t => html`<${ToastItem} key=${t.id} ...${t} />`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
36
src/js/sw/primitives/tooltip.js
Normal file
36
src/js/sw/primitives/tooltip.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Tooltip primitive — hover tooltip
|
||||
* Positions: top (default), bottom, left, right
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useRef, useCallback } = hooks;
|
||||
|
||||
export function Tooltip({ content, position = 'top', children }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const timeout = useRef(null);
|
||||
|
||||
const show = useCallback(() => {
|
||||
clearTimeout(timeout.current);
|
||||
timeout.current = setTimeout(() => setVisible(true), 200);
|
||||
}, []);
|
||||
|
||||
const hide = useCallback(() => {
|
||||
clearTimeout(timeout.current);
|
||||
setVisible(false);
|
||||
}, []);
|
||||
|
||||
return html`
|
||||
<span class="sw-tooltip-wrap"
|
||||
onMouseEnter=${show}
|
||||
onMouseLeave=${hide}
|
||||
onFocus=${show}
|
||||
onBlur=${hide}>
|
||||
${children}
|
||||
${visible && html`
|
||||
<span class="sw-tooltip sw-tooltip--${position}" role="tooltip">
|
||||
${content}
|
||||
</span>
|
||||
`}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
81
src/js/sw/shell/app-shell.js
Normal file
81
src/js/sw/shell/app-shell.js
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* AppShell — Layer 1 layout scaffold
|
||||
*
|
||||
* ┌─────────────────────────────────┐ ← fixed banner (top, optional)
|
||||
* ├─────────────────────────────────┤
|
||||
* │ message (optional, dismissible)│
|
||||
* ├─────────────────────────────────┤
|
||||
* │ │
|
||||
* │ surface (fills remaining) │
|
||||
* │ │
|
||||
* ├─────────────────────────────────┤
|
||||
* │ footer (optional) │
|
||||
* ├─────────────────────────────────┤
|
||||
* └─────────────────────────────────┘ ← fixed banner (bottom, same as top)
|
||||
*
|
||||
* Props:
|
||||
* banner — { text, variant } or null (one config → top AND bottom)
|
||||
* message — { text, variant } or null (dismissible)
|
||||
* footer — VNode children or null
|
||||
* children — Surface content
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useRef } = hooks;
|
||||
|
||||
function ShellBanner({ position, text, variant = 'info' }) {
|
||||
const ref = useRef(null);
|
||||
const prop = position === 'top' ? '--banner-top-height' : '--banner-bottom-height';
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
const h = ref.current.offsetHeight + 'px';
|
||||
document.documentElement.style.setProperty(prop, h);
|
||||
}
|
||||
return () => document.documentElement.style.setProperty(prop, '0px');
|
||||
}, [text, prop]);
|
||||
|
||||
return html`
|
||||
<div class="sw-shell__banner sw-shell__banner--${position} sw-shell__banner--${variant}" ref=${ref}>
|
||||
<span class="sw-shell__banner-text">${text}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function AppShell({ banner, message, footer, children }) {
|
||||
const [msgDismissed, setMsgDismissed] = useState(false);
|
||||
|
||||
const showMsg = message && !msgDismissed;
|
||||
|
||||
return html`
|
||||
<div class="sw-shell">
|
||||
${banner && html`
|
||||
<${ShellBanner} position="top" text=${banner.text} variant=${banner.variant} />
|
||||
`}
|
||||
|
||||
<div class="sw-shell__body">
|
||||
${showMsg && html`
|
||||
<div class="sw-shell__announcement">
|
||||
<div class="sw-shell__announcement-inner sw-shell__announcement--${message.variant || 'info'}">
|
||||
<span class="sw-shell__announcement-text">${message.text}</span>
|
||||
<button class="sw-shell__banner-close" onClick=${() => setMsgDismissed(true)} aria-label="Dismiss">×</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
<main class="sw-shell__surface">
|
||||
${children}
|
||||
</main>
|
||||
|
||||
${footer && html`
|
||||
<footer class="sw-shell__footer">
|
||||
${footer}
|
||||
</footer>
|
||||
`}
|
||||
</div>
|
||||
|
||||
${banner && html`
|
||||
<${ShellBanner} position="bottom" text=${banner.text} variant=${banner.variant} />
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
2
src/js/sw/vendor/hooks.module.js
vendored
Normal file
2
src/js/sw/vendor/hooks.module.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
import{options as n}from"./preact.module.js";var t,r,u,i,o=0,f=[],c=n,e=c.__b,a=c.__r,v=c.diffed,l=c.__c,m=c.unmount,s=c.__;function p(n,t){c.__h&&c.__h(r,n,o||t),o=0;var u=r.__H||(r.__H={__:[],__h:[]});return n>=u.__.length&&u.__.push({}),u.__[n]}function d(n){return o=1,h(D,n)}function h(n,u,i){var o=p(t++,2);if(o.t=n,!o.__c&&(o.__=[i?i(u):D(void 0,u),function(n){var t=o.__N?o.__N[0]:o.__[0],r=o.t(t,n);t!==r&&(o.__N=[r,o.__[1]],o.__c.setState({}))}],o.__c=r,!r.__f)){var f=function(n,t,r){if(!o.__c.__H)return!0;var u=o.__c.__H.__.filter(function(n){return n.__c});if(u.every(function(n){return!n.__N}))return!c||c.call(this,n,t,r);var i=o.__c.props!==n;return u.some(function(n){if(n.__N){var t=n.__[0];n.__=n.__N,n.__N=void 0,t!==n.__[0]&&(i=!0)}}),c&&c.call(this,n,t,r)||i};r.__f=!0;var c=r.shouldComponentUpdate,e=r.componentWillUpdate;r.componentWillUpdate=function(n,t,r){if(this.__e){var u=c;c=void 0,f(n,t,r),c=u}e&&e.call(this,n,t,r)},r.shouldComponentUpdate=f}return o.__N||o.__}function y(n,u){var i=p(t++,3);!c.__s&&C(i.__H,u)&&(i.__=n,i.u=u,r.__H.__h.push(i))}function _(n,u){var i=p(t++,4);!c.__s&&C(i.__H,u)&&(i.__=n,i.u=u,r.__h.push(i))}function A(n){return o=5,T(function(){return{current:n}},[])}function F(n,t,r){o=6,_(function(){if("function"==typeof n){var r=n(t());return function(){n(null),r&&"function"==typeof r&&r()}}if(n)return n.current=t(),function(){return n.current=null}},null==r?r:r.concat(n))}function T(n,r){var u=p(t++,7);return C(u.__H,r)&&(u.__=n(),u.__H=r,u.__h=n),u.__}function q(n,t){return o=8,T(function(){return n},t)}function x(n){var u=r.context[n.__c],i=p(t++,9);return i.c=n,u?(null==i.__&&(i.__=!0,u.sub(r)),u.props.value):n.__}function P(n,t){c.useDebugValue&&c.useDebugValue(t?t(n):n)}function b(n){var u=p(t++,10),i=d();return u.__=n,r.componentDidCatch||(r.componentDidCatch=function(n,t){u.__&&u.__(n,t),i[1](n)}),[i[0],function(){i[1](void 0)}]}function g(){var n=p(t++,11);if(!n.__){for(var u=r.__v;null!==u&&!u.__m&&null!==u.__;)u=u.__;var i=u.__m||(u.__m=[0,0]);n.__="P"+i[0]+"-"+i[1]++}return n.__}function j(){for(var n;n=f.shift();){var t=n.__H;if(n.__P&&t)try{t.__h.some(z),t.__h.some(B),t.__h=[]}catch(r){t.__h=[],c.__e(r,n.__v)}}}c.__b=function(n){r=null,e&&e(n)},c.__=function(n,t){n&&t.__k&&t.__k.__m&&(n.__m=t.__k.__m),s&&s(n,t)},c.__r=function(n){a&&a(n),t=0;var i=(r=n.__c).__H;i&&(u===r?(i.__h=[],r.__h=[],i.__.some(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(i.__h.some(z),i.__h.some(B),i.__h=[],t=0)),u=r},c.diffed=function(n){v&&v(n);var t=n.__c;t&&t.__H&&(t.__H.__h.length&&(1!==f.push(t)&&i===c.requestAnimationFrame||((i=c.requestAnimationFrame)||w)(j)),t.__H.__.some(function(n){n.u&&(n.__H=n.u),n.u=void 0})),u=r=null},c.__c=function(n,t){t.some(function(n){try{n.__h.some(z),n.__h=n.__h.filter(function(n){return!n.__||B(n)})}catch(r){t.some(function(n){n.__h&&(n.__h=[])}),t=[],c.__e(r,n.__v)}}),l&&l(n,t)},c.unmount=function(n){m&&m(n);var t,r=n.__c;r&&r.__H&&(r.__H.__.some(function(n){try{z(n)}catch(n){t=n}}),r.__H=void 0,t&&c.__e(t,r.__v))};var k="function"==typeof requestAnimationFrame;function w(n){var t,r=function(){clearTimeout(u),k&&cancelAnimationFrame(t),setTimeout(n)},u=setTimeout(r,35);k&&(t=requestAnimationFrame(r))}function z(n){var t=r,u=n.__c;"function"==typeof u&&(n.__c=void 0,u()),r=t}function B(n){var t=r;n.__c=n.__(),r=t}function C(n,t){return!n||n.length!==t.length||t.some(function(t,r){return t!==n[r]})}function D(n,t){return"function"==typeof t?t(n):t}export{q as useCallback,x as useContext,P as useDebugValue,y as useEffect,b as useErrorBoundary,g as useId,F as useImperativeHandle,_ as useLayoutEffect,T as useMemo,h as useReducer,A as useRef,d as useState};
|
||||
//# sourceMappingURL=hooks.module.js.map
|
||||
1
src/js/sw/vendor/htm.module.js
vendored
Normal file
1
src/js/sw/vendor/htm.module.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
var n=function(t,s,r,e){var u;s[0]=0;for(var h=1;h<s.length;h++){var p=s[h++],a=s[h]?(s[0]|=p?1:2,r[s[h++]]):s[++h];3===p?e[0]=a:4===p?e[1]=Object.assign(e[1]||{},a):5===p?(e[1]=e[1]||{})[s[++h]]=a:6===p?e[1][s[++h]]+=a+"":p?(u=t.apply(a,n(t,a,r,["",null])),e.push(u),a[0]?s[0]|=2:(s[h-2]=0,s[h]=u)):e.push(a)}return e},t=new Map;export default function(s){var r=t.get(this);return r||(r=new Map,t.set(this,r)),(r=n(this,r.get(s)||(r.set(s,r=function(n){for(var t,s,r=1,e="",u="",h=[0],p=function(n){1===r&&(n||(e=e.replace(/^\s*\n\s*|\s*\n\s*$/g,"")))?h.push(0,n,e):3===r&&(n||e)?(h.push(3,n,e),r=2):2===r&&"..."===e&&n?h.push(4,n,0):2===r&&e&&!n?h.push(5,0,!0,e):r>=5&&((e||!n&&5===r)&&(h.push(r,0,e,s),r=6),n&&(h.push(r,n,0,s),r=6)),e=""},a=0;a<n.length;a++){a&&(1===r&&p(),p(a));for(var l=0;l<n[a].length;l++)t=n[a][l],1===r?"<"===t?(p(),h=[h],r=3):e+=t:4===r?"--"===e&&">"===t?(r=1,e=""):e=t+e[0]:u?t===u?u="":e+=t:'"'===t||"'"===t?u=t:">"===t?(p(),r=1):r&&("="===t?(r=5,s=e,e=""):"/"===t&&(r<5||">"===n[a][l+1])?(p(),3===r&&(h=h[0]),r=h,(h=h[0]).push(2,0,r),r=0):" "===t||"\t"===t||"\n"===t||"\r"===t?(p(),r=2):e+=t),3===r&&"!--"===e&&(r=4,h=h[0])}return p(),h}(s)),r),arguments,[])).length>1?r:r[0]}
|
||||
2
src/js/sw/vendor/preact.module.js
vendored
Normal file
2
src/js/sw/vendor/preact.module.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user