Compare commits
28 Commits
8585993602
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0af46954d7 | |||
| 8df14ebbe3 | |||
| 39862fab3b | |||
| 8f2f310fe2 | |||
| 19d6a2883c | |||
| 75c57092f8 | |||
| f61705afb8 | |||
| 140a57a029 | |||
| f9445ed5ae | |||
| 044adb3a18 | |||
| 842190d225 | |||
| a807944b50 | |||
| 1b7e57d9f5 | |||
| 083a918729 | |||
| de30ff13e6 | |||
| 32f7b49f98 | |||
| 80011e6de5 | |||
| d651cfbe2c | |||
| bdf3ad085f | |||
| 5caaf38e9a | |||
| 7630da8abd | |||
| 45b968b77d | |||
| ca2d6d225e | |||
| 8b54efef61 | |||
| e8c1197613 | |||
| b84d71dd7a | |||
| d08007cdd7 | |||
| fdcadad23b |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"env": { "browser": true, "node": true, "es2021": true },
|
||||
"parserOptions": { "ecmaVersion": 2021, "sourceType": "script" },
|
||||
"rules": {
|
||||
"no-unused-vars": "warn",
|
||||
"no-undef": "warn",
|
||||
"no-empty": "warn",
|
||||
"semi": ["error", "always"],
|
||||
"eqeqeq": "warn"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
js-lint:
|
||||
name: JS (eslint)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install ESLint
|
||||
run: npm install --save-dev eslint@8
|
||||
|
||||
- name: Run ESLint
|
||||
run: npx eslint --ext .js base.js node/
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.env
|
||||
@@ -0,0 +1,495 @@
|
||||
# Authelia Portal — LotusGuild Terminal Design System Integration
|
||||
|
||||
This document covers everything that had to be **written from scratch** to theme the
|
||||
Authelia authentication portal using the LotusGuild Terminal Design System v1.2.
|
||||
It is a companion to `base.css` / `base.js` for future integrations with third-party
|
||||
applications that use their own component frameworks.
|
||||
|
||||
---
|
||||
|
||||
## Why a separate file at all?
|
||||
|
||||
`base.css` targets exclusively `.lt-*` class names — the design system's own prefix.
|
||||
Authelia's frontend is a React single-page application built on
|
||||
**Material UI (MUI) v5**, which generates its own stable class names like
|
||||
`.MuiCard-root`, `.MuiButton-containedPrimary`, etc.
|
||||
Bridging the two requires a translation layer: the same design tokens and visual
|
||||
patterns from `base.css`, re-expressed against MUI's class structure.
|
||||
|
||||
Additionally, Authelia v4.x (latest: 4.39.x) does **not** support native CSS
|
||||
injection — `server.asset_path` only handles `logo.png` and `favicon.ico`.
|
||||
The CSS is injected via **nginx `sub_filter`** in Nginx Proxy Manager, which rewrites
|
||||
Authelia's HTML on the fly to include a `<link>` tag before `</head>`.
|
||||
|
||||
---
|
||||
|
||||
## Deployment architecture
|
||||
|
||||
```
|
||||
Browser
|
||||
└─► https://auth.lotusguild.org (NPM — LXC 139)
|
||||
├─ GET /custom.css ──────── served from /data/custom_assets/authelia-custom.css
|
||||
│ (nginx alias, no upstream request)
|
||||
└─ GET /* ──────────────── proxied to Authelia (LXC 167 :9091)
|
||||
HTML response is rewritten by sub_filter:
|
||||
</head> → <link rel="stylesheet" href="/custom.css"></head>
|
||||
```
|
||||
|
||||
**NPM config changed:** `/data/nginx/proxy_host/29.conf` (auth.lotusguild.org proxy host)
|
||||
|
||||
```nginx
|
||||
# Serve LotusGuild Terminal custom CSS
|
||||
location = /custom.css {
|
||||
alias /data/custom_assets/authelia-custom.css;
|
||||
add_header Content-Type "text/css";
|
||||
add_header Cache-Control "public, max-age=3600";
|
||||
}
|
||||
|
||||
location / {
|
||||
...existing headers...
|
||||
|
||||
# Prevent upstream gzip so sub_filter can inspect the body
|
||||
proxy_set_header Accept-Encoding "";
|
||||
|
||||
# Inject CSS link before </head>
|
||||
sub_filter '</head>' '<link rel="stylesheet" href="/custom.css"></head>';
|
||||
sub_filter_once on;
|
||||
|
||||
include /etc/nginx/conf.d/include/proxy.conf;
|
||||
}
|
||||
```
|
||||
|
||||
> **Warning:** NPM regenerates proxy_host conf files when you edit a proxy host in
|
||||
> the UI. If host 29 is ever modified through NPM, re-apply the `sub_filter` block
|
||||
> and the `/custom.css` location manually, or set the config via NPM's
|
||||
> "Advanced" tab before saving.
|
||||
|
||||
**Authelia config changed:** `/etc/authelia/configuration.yml` — added `asset_path`
|
||||
under `server:` so Authelia serves the resized logo and favicon:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
address: tcp://0.0.0.0:9091
|
||||
asset_path: /etc/authelia/assets
|
||||
```
|
||||
|
||||
Assets in `/etc/authelia/assets/`:
|
||||
- `logo.png` — 256 × 256 PNG resized from Tinker Tickets `assets/images/favicon.png`
|
||||
- `favicon.ico` — 32 × 32 ICO from the same source
|
||||
|
||||
---
|
||||
|
||||
## What is NOT in `base.css` and had to be written
|
||||
|
||||
The sections below are ordered as they appear in `authelia-custom.css`.
|
||||
|
||||
---
|
||||
|
||||
### 1. Re-declaring design tokens with `!important`
|
||||
|
||||
**In `base.css`:** `:root { ... }` is declared once, without `!important`.
|
||||
|
||||
**What had to be added:** The entire `:root` block is repeated in `custom.css` because
|
||||
Authelia's HTML is a self-contained SPA — `base.css` is never loaded. More critically,
|
||||
MUI injects its own inline styles and CSS-in-JS rules at very high specificity.
|
||||
Every property that needs to override MUI requires `!important`. `base.css` never
|
||||
uses `!important` because it owns its namespace; here we are guests in MUI's DOM.
|
||||
|
||||
```css
|
||||
/* base.css does NOT use !important anywhere */
|
||||
body { background-color: var(--bg-primary); }
|
||||
|
||||
/* custom.css must fight MUI's inline styles */
|
||||
body { background-color: var(--bg-primary) !important; }
|
||||
```
|
||||
|
||||
The token values themselves are identical to `base.css` — only the override
|
||||
mechanism is new.
|
||||
|
||||
---
|
||||
|
||||
### 2. Universal box-sizing reset with `!important`
|
||||
|
||||
**In `base.css`:** `*, *::before, *::after { box-sizing: border-box; }` — no flag needed
|
||||
because `base.css` loads first.
|
||||
|
||||
**What had to be added:**
|
||||
|
||||
```css
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
```
|
||||
|
||||
MUI components set `box-sizing` inline on some elements. Without the flag the
|
||||
clip-path geometry breaks on inputs and buttons.
|
||||
|
||||
---
|
||||
|
||||
### 3. MUI typography selectors
|
||||
|
||||
**In `base.css`:** Typography rules target `h1–h6`, `a`, `p`, `body` — standard HTML
|
||||
elements. No MUI class names exist in the design system.
|
||||
|
||||
**What had to be added:** Authelia renders text almost exclusively through MUI's
|
||||
`<Typography>` component, which produces elements with `.MuiTypography-root` and
|
||||
variant classes. Without targeting these, all text inherits MUI's Roboto font and
|
||||
light grey colour instead of JetBrains Mono and `--text-primary`.
|
||||
|
||||
```css
|
||||
.MuiTypography-root,
|
||||
.MuiInputBase-input,
|
||||
.MuiFormLabel-root,
|
||||
.MuiFormHelperText-root,
|
||||
label, p, span, div {
|
||||
font-family: var(--font-mono) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6,
|
||||
.MuiTypography-h5,
|
||||
.MuiTypography-h6 {
|
||||
color: var(--accent-orange) !important;
|
||||
text-shadow: var(--glow-orange) !important;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. `.MuiCard-root` / `.MuiPaper-root` — the login card
|
||||
|
||||
**In `base.css`:** `.lt-card` implements the terminal card with `clip-path`, border,
|
||||
background, and the `::before` corner triangle accent.
|
||||
|
||||
**What had to be added:** Authelia wraps its login form in a MUI `<Card>` (which
|
||||
extends `<Paper>`). Neither selector exists in `base.css`. The visual result is
|
||||
identical to `.lt-card` — the clip-path polygon, cyan border, box-glow, and
|
||||
corner triangle are all copied — but they target different class names and require
|
||||
`!important` throughout to override MUI's elevation shadows and `border-radius: 4px`.
|
||||
|
||||
```css
|
||||
.MuiCard-root,
|
||||
.MuiPaper-root {
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border-color) !important;
|
||||
border-radius: 0 !important; /* ← overrides MUI default */
|
||||
clip-path: polygon(...) !important; /* same geometry as .lt-card */
|
||||
...
|
||||
}
|
||||
|
||||
.MuiCard-root::before,
|
||||
.MuiPaper-root::before {
|
||||
/* same corner triangle as .lt-card::before */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Logo element — `img[alt="Authelia"]`
|
||||
|
||||
**In `base.css`:** No equivalent. The design system has no logo slot component.
|
||||
|
||||
**What had to be added:** Authelia renders the portal logo as
|
||||
`<img alt="Authelia" src="./static/media/logo.png">`. This selector targets it
|
||||
specifically to apply the cyan drop-shadow and orange hover glow. Without it the
|
||||
logo renders without any terminal aesthetic treatment.
|
||||
|
||||
```css
|
||||
img[alt="Authelia"] {
|
||||
filter: drop-shadow(0 0 12px rgba(0,212,255,0.4)) !important;
|
||||
transition: filter 0.25s ease !important;
|
||||
}
|
||||
img[alt="Authelia"]:hover {
|
||||
filter: drop-shadow(0 0 18px rgba(255,107,0,0.5)) !important;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. MUI outlined input family
|
||||
|
||||
**In `base.css`:** `.lt-input`, `.lt-select`, `.lt-textarea` — custom elements with
|
||||
`clip-path`, terminal background, and cyan focus ring.
|
||||
|
||||
**What had to be added:** MUI's outlined text field is a composition of three
|
||||
separate elements that each need individual targeting:
|
||||
|
||||
| MUI class | What it controls | Equivalent in `base.css` |
|
||||
|-----------|-----------------|--------------------------|
|
||||
| `.MuiOutlinedInput-root` | Outer wrapper — background, clip-path | `.lt-input` outer |
|
||||
| `.MuiOutlinedInput-notchedOutline` | The SVG border element | `.lt-input` border |
|
||||
| `.MuiInputBase-input` | The actual `<input>` inside | `.lt-input` inner text |
|
||||
| `.MuiInputLabel-root` | Floating label | `.lt-label` |
|
||||
| `.MuiFormHelperText-root` | Error / hint text below field | `.lt-form-hint` |
|
||||
|
||||
MUI splits the border rendering into a separate SVG `<fieldset>` element
|
||||
(`.MuiOutlinedInput-notchedOutline`), which requires its own border-color override.
|
||||
`base.css` has no equivalent splitting since `.lt-input` is a single element with a
|
||||
CSS border.
|
||||
|
||||
The `.Mui-focused` and `.Mui-error` state classes also need explicit overrides because
|
||||
MUI applies its own colour through CSS-in-JS with high specificity.
|
||||
|
||||
```css
|
||||
.MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline {
|
||||
border-color: var(--accent-cyan) !important;
|
||||
box-shadow: var(--box-glow-cyan) !important;
|
||||
}
|
||||
.MuiFormHelperText-root.Mui-error {
|
||||
color: var(--accent-red) !important;
|
||||
text-shadow: var(--glow-red) !important;
|
||||
}
|
||||
```
|
||||
|
||||
Additionally, `caret-color` is set explicitly on the input because MUI does not
|
||||
expose this through its theme — the blinking text cursor would otherwise be white.
|
||||
|
||||
---
|
||||
|
||||
### 7. MUI button family
|
||||
|
||||
**In `base.css`:** `.lt-btn`, `.lt-btn-primary`, `.lt-btn-danger`, `.lt-btn-ghost` —
|
||||
all using `clip-path` hexagon cuts, transparent backgrounds, and border+glow styling.
|
||||
|
||||
**What had to be added:** MUI uses four separate button variant classes. The primary
|
||||
(sign-in) button is `.MuiButton-containedPrimary` which by default renders as a
|
||||
solid filled rectangle. The entire button appearance — transparent background,
|
||||
border, clip-path, text-transform, letter-spacing — has to be explicitly overridden
|
||||
because MUI's `contained` variant applies an opaque `background-color` inline.
|
||||
|
||||
```css
|
||||
/* MUI default: background-color: #1976d2; border-radius: 4px; box-shadow: ... */
|
||||
.MuiButton-containedPrimary {
|
||||
background: transparent !important; /* fight the inline fill */
|
||||
border: 1px solid var(--accent-orange-border) !important;
|
||||
border-radius: 0 !important;
|
||||
clip-path: polygon(...) !important;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Text/link buttons (`.MuiButton-text`) are separate from contained buttons in MUI but
|
||||
map to the same `.lt-btn-ghost` pattern in `base.css`.
|
||||
|
||||
---
|
||||
|
||||
### 8. MUI checkbox and switch
|
||||
|
||||
**In `base.css`:** `.lt-checkbox`, `.lt-switch` — fully custom-drawn using CSS only.
|
||||
|
||||
**What had to be added:** Authelia uses MUI's `<Checkbox>` (remember-me) and
|
||||
`<Switch>` (settings toggles). These are SVG-based components. Because MUI controls
|
||||
their colour through injected CSS variables, the only reliable override is to target
|
||||
`.Mui-checked` state classes and use `!important`. There is no equivalent split
|
||||
between `track` and `thumb` in `base.css`.
|
||||
|
||||
```css
|
||||
.MuiSwitch-switchBase.Mui-checked { color: var(--accent-cyan) !important; }
|
||||
.MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track {
|
||||
background-color: var(--accent-cyan) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. MUI alert / notification banners
|
||||
|
||||
**In `base.css`:** `.lt-inline-*` for inline messages (`.lt-inline-success`,
|
||||
`.lt-inline-error`, etc.) and `.lt-toast-*` for toasts. These use a left border
|
||||
accent pattern.
|
||||
|
||||
**What had to be added:** Authelia uses MUI `<Alert>` for login errors, success
|
||||
messages, and 2FA prompts. MUI Alert has its own severity system with four variant
|
||||
classes (`.MuiAlert-standardError`, etc.). Each severity needs explicit colour,
|
||||
background, and border-color overrides. The `border-left: 3px solid currentColor`
|
||||
pattern is adapted from `base.css`'s inline message style but applied to MUI's
|
||||
class names with `clip-path: none !important` to cancel MUI's rounded corners.
|
||||
|
||||
---
|
||||
|
||||
### 10. MUI select, menu, and popover
|
||||
|
||||
**In `base.css`:** `.lt-select` for the select element itself; no dropdown overlay
|
||||
component exists since the design system uses native `<select>`.
|
||||
|
||||
**What had to be added:** MUI's `<Select>` renders its dropdown as a `<Paper>`
|
||||
element portalled to `<body>` with class `.MuiMenu-paper`. Without targeting this
|
||||
separately, the dropdown popup inherits the browser default white background and
|
||||
Roboto font, appearing completely unstyled. `.MuiMenuItem-root` hover and selected
|
||||
states also need individual rules.
|
||||
|
||||
---
|
||||
|
||||
### 11. OTP / 2FA digit inputs — `input[type="tel"]`, `input[type="number"]`
|
||||
|
||||
**In `base.css`:** No equivalent. The design system has no numeric code input.
|
||||
|
||||
**What had to be added:** Authelia's one-time-password entry uses `<input type="tel">`
|
||||
elements arranged in a row. These need oversized, centered, cyan-glowing characters
|
||||
with wide letter-spacing to render like a retro code display.
|
||||
|
||||
```css
|
||||
input[type="tel"],
|
||||
input[type="number"] {
|
||||
font-size: 1.2rem !important;
|
||||
font-weight: 700 !important;
|
||||
color: var(--accent-cyan) !important;
|
||||
text-shadow: var(--glow-cyan) !important;
|
||||
letter-spacing: 0.15em !important;
|
||||
text-align: center !important;
|
||||
caret-color: var(--accent-orange) !important;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 12. MUI stepper — 2FA flow breadcrumb
|
||||
|
||||
**In `base.css`:** `.lt-stepper`, `.lt-step`, `.lt-step-num` — a custom CSS stepper
|
||||
for multi-step wizard flows.
|
||||
|
||||
**What had to be added:** Authelia renders a MUI `<Stepper>` at the top of the 2FA
|
||||
flow showing steps like "Username → Password → TOTP". MUI Stepper uses distinct
|
||||
classes for label, icon, and connector that don't map 1-to-1 to `.lt-step-*`.
|
||||
The step icon is an SVG circle rendered by React so it can only be tinted via
|
||||
`color` and `filter: drop-shadow(...)`. The connector line between steps is a
|
||||
separate `<hr>` with class `.MuiStepConnector-line`.
|
||||
|
||||
```css
|
||||
.MuiStepIcon-root.Mui-active {
|
||||
color: var(--accent-orange) !important;
|
||||
filter: drop-shadow(0 0 6px rgba(255,107,0,0.6)) !important;
|
||||
}
|
||||
.MuiStepIcon-root.Mui-completed {
|
||||
color: var(--accent-green) !important;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 13. MUI linear and circular progress
|
||||
|
||||
**In `base.css`:** `.lt-progress` / `.lt-progress-bar` — a standard CSS progress bar
|
||||
using `width` transitions. `.lt-spinner` — a rotating pseudo-element spinner.
|
||||
|
||||
**What had to be added:** Authelia uses MUI `<LinearProgress>` during API calls and
|
||||
`<CircularProgress>` as the main loading spinner. MUI Linear Progress animates its
|
||||
own internal elements (`.MuiLinearProgress-bar`) with keyframe animations; it cannot
|
||||
be replaced with the `.lt-progress` pattern. The cyan-to-orange gradient and
|
||||
`box-shadow` glow are new here — `base.css`'s progress bar uses a solid
|
||||
`--accent-orange` fill.
|
||||
|
||||
```css
|
||||
.MuiLinearProgress-bar {
|
||||
background: linear-gradient(90deg, var(--accent-cyan), var(--accent-orange)) !important;
|
||||
box-shadow: 0 0 8px rgba(0,212,255,0.5) !important;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 14. MUI tooltip
|
||||
|
||||
**In `base.css`:** `.lt-tooltip` — a CSS-only tooltip shown via `:hover` on a
|
||||
`[data-tooltip]` attribute.
|
||||
|
||||
**What had to be added:** MUI `<Tooltip>` portals its bubble to `<body>` with class
|
||||
`.MuiTooltip-tooltip`. It is a separate DOM node, not a pseudo-element, so
|
||||
`.lt-tooltip` styles cannot reach it. A standalone rule sets the terminal background,
|
||||
monospace font, and squared border.
|
||||
|
||||
---
|
||||
|
||||
### 15. `*:focus-visible` global ring
|
||||
|
||||
**In `base.css`:** Focus rings are defined individually per component
|
||||
(`.lt-btn:focus-visible`, `.lt-input:focus-visible`, etc.).
|
||||
|
||||
**What had to be added:** A single global rule is more practical here because
|
||||
Authelia contains many interactive MUI elements not known at design time, and
|
||||
adding per-component rules for every possible focusable element would be brittle.
|
||||
|
||||
```css
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--accent-cyan) !important;
|
||||
outline-offset: 2px !important;
|
||||
box-shadow: var(--box-glow-cyan) !important;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 16. Branding footer watermark — `#root::after`
|
||||
|
||||
**In `base.css`:** No equivalent. The design system does not add page-level
|
||||
watermarks.
|
||||
|
||||
**What had to be added:** Authelia has no footer. The `#root::after` pseudo-element
|
||||
attaches a fixed-position text watermark reading
|
||||
`LOTUSGUILD SECURE PORTAL // AUTH.LOTUSGUILD.ORG` at the bottom of the viewport.
|
||||
This uses `--text-muted`, `--font-mono`, uppercase, and wide letter-spacing to match
|
||||
the terminal aesthetic without adding any HTML.
|
||||
|
||||
```css
|
||||
#root::after {
|
||||
content: 'LOTUSGUILD SECURE PORTAL // AUTH.LOTUSGUILD.ORG';
|
||||
position: fixed;
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.55rem;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Patterns shared with `base.css` (not new)
|
||||
|
||||
The following are **not** documented above because they are direct re-uses of
|
||||
existing `base.css` patterns, only re-targeted to new selectors:
|
||||
|
||||
| Pattern | `base.css` source | Re-used in `custom.css` for |
|
||||
|---------|------------------|-----------------------------|
|
||||
| Dot-grid background | `html { background-image: radial-gradient(...) }` | `body` |
|
||||
| Scanlines overlay | `body::before` | `body::before` (same rule, `!important` added) |
|
||||
| Corner vignette | `body::after` | `body::after` (same rule, `!important` added) |
|
||||
| Card clip-path polygon | `.lt-card { clip-path: polygon(...) }` | `.MuiCard-root` |
|
||||
| Card corner triangle | `.lt-card::before` | `.MuiCard-root::before` |
|
||||
| Button clip-path hexagon | `.lt-btn { clip-path: polygon(...) }` | `.MuiButton-containedPrimary` |
|
||||
| Input clip-path cut | `.lt-input { clip-path: polygon(...) }` | `.MuiOutlinedInput-root` |
|
||||
| Scrollbar | `::-webkit-scrollbar-*` section 45 | identical, `border-radius: 0` added |
|
||||
| Link colours | `a { color: var(--accent-cyan) }` | `a` (same, `!important` added) |
|
||||
| Divider | `.lt-divider` border-color | `.MuiDivider-root` |
|
||||
|
||||
---
|
||||
|
||||
## Notes for future third-party integrations
|
||||
|
||||
1. **Always check for `!important` requirements.** CSS-in-JS frameworks (MUI, Emotion,
|
||||
styled-components) inject styles at runtime with high specificity. Without
|
||||
`!important`, most design token overrides will lose to injected inline styles.
|
||||
|
||||
2. **The split border problem.** MUI (and many component libraries) separate the
|
||||
border from the element it visually belongs to. Always inspect the actual DOM
|
||||
before writing selectors — what looks like one element is often three.
|
||||
|
||||
3. **Portalled overlays.** Dropdowns, tooltips, and modals in MUI are rendered into
|
||||
a separate DOM tree (`<body>` portal). Scoped selectors inside a card or form
|
||||
won't reach them. Target their root classes (`.MuiMenu-paper`, `.MuiTooltip-tooltip`)
|
||||
directly.
|
||||
|
||||
4. **State classes.** MUI uses `.Mui-focused`, `.Mui-checked`, `.Mui-error`,
|
||||
`.Mui-active`, `.Mui-completed`, `.Mui-disabled` as state modifiers. These are the
|
||||
equivalent of `:focus`, `:checked`, etc. but applied by JavaScript — pseudo-class
|
||||
selectors alone will sometimes not fire.
|
||||
|
||||
5. **SVG-based components.** Checkboxes, radio buttons, step icons, and spinners are
|
||||
SVGs injected by React. They cannot be styled with `background`, `border`, or
|
||||
`clip-path`. Use `color` (SVG `currentColor` inheritance) and `filter:
|
||||
drop-shadow()` instead.
|
||||
@@ -73,7 +73,7 @@
|
||||
function showToast(message, type, duration) {
|
||||
type = type || 'info';
|
||||
duration = duration || 3500;
|
||||
if (_toastActive) { _toastQueue.push({ message, type, duration }); return; }
|
||||
if (_toastActive) { if (_toastQueue.length < 12) _toastQueue.push({ message, type, duration }); return; }
|
||||
_displayToast(message, type, duration);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
msgEl.textContent = message;
|
||||
|
||||
const closeEl = document.createElement('button');
|
||||
closeEl.type = 'button';
|
||||
closeEl.className = 'lt-toast-close';
|
||||
closeEl.textContent = '✕';
|
||||
closeEl.setAttribute('aria-label', 'Dismiss');
|
||||
@@ -214,7 +215,7 @@
|
||||
_modalTriggers.set(el, document.activeElement);
|
||||
}
|
||||
el.classList.add('is-open');
|
||||
el.setAttribute('aria-hidden', 'false');
|
||||
el.removeAttribute('aria-hidden'); /* removing is correct; setting 'false' is an anti-pattern */
|
||||
_lockScroll();
|
||||
// Focus first focusable element
|
||||
const first = el.querySelector(_FOCUSABLE);
|
||||
@@ -232,9 +233,14 @@
|
||||
_unlockScroll();
|
||||
// Remove trap handler
|
||||
if (el._ltTrapHandler) { el.removeEventListener('keydown', el._ltTrapHandler); delete el._ltTrapHandler; }
|
||||
// Return focus to trigger
|
||||
// Return focus to trigger (only if no other modal remains open)
|
||||
const trigger = _modalTriggers.get(el);
|
||||
if (trigger) { trigger.focus(); _modalTriggers.delete(el); }
|
||||
if (trigger) {
|
||||
_modalTriggers.delete(el);
|
||||
if (!document.querySelector('.lt-modal-overlay.is-open') && document.contains(trigger)) {
|
||||
trigger.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeAllModals() {
|
||||
@@ -263,22 +269,38 @@
|
||||
lt.tabs.switch('panel-id')
|
||||
---------------------------------------------------------------- */
|
||||
function switchTab(panelId) {
|
||||
document.querySelectorAll('.lt-tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.lt-tab[data-tab]').forEach(t => {
|
||||
t.classList.remove('active');
|
||||
t.setAttribute('aria-selected', 'false');
|
||||
});
|
||||
document.querySelectorAll('.lt-tab-panel').forEach(p => p.classList.remove('active'));
|
||||
const btn = document.querySelector('.lt-tab[data-tab="' + panelId + '"]');
|
||||
const panel = document.getElementById(panelId);
|
||||
if (btn) btn.classList.add('active');
|
||||
if (btn) { btn.classList.add('active'); btn.setAttribute('aria-selected', 'true'); }
|
||||
if (panel) panel.classList.add('active');
|
||||
try { localStorage.setItem('lt_activeTab_' + location.pathname, panelId); } catch (_) {}
|
||||
}
|
||||
|
||||
let _tabsInitialized = false;
|
||||
function initTabs() {
|
||||
if (_tabsInitialized) return; _tabsInitialized = true;
|
||||
try {
|
||||
const saved = localStorage.getItem('lt_activeTab_' + location.pathname);
|
||||
if (saved && document.getElementById(saved)) { switchTab(saved); }
|
||||
} catch (_) {}
|
||||
document.querySelectorAll('.lt-tab[data-tab]').forEach(btn => {
|
||||
document.querySelectorAll('[role="tablist"]').forEach(tablist => {
|
||||
const btns = Array.from(tablist.querySelectorAll('.lt-tab[data-tab]'));
|
||||
btns.forEach((btn, i) => {
|
||||
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
|
||||
btn.addEventListener('keydown', e => {
|
||||
let idx = -1;
|
||||
if (e.key === 'ArrowRight') idx = (i + 1) % btns.length;
|
||||
else if (e.key === 'ArrowLeft') idx = (i - 1 + btns.length) % btns.length;
|
||||
else if (e.key === 'Home') idx = 0;
|
||||
else if (e.key === 'End') idx = btns.length - 1;
|
||||
if (idx >= 0) { e.preventDefault(); btns[idx].focus(); switchTab(btns[idx].dataset.tab); }
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -290,9 +312,13 @@
|
||||
lt.boot.run('APP NAME')
|
||||
lt.boot.run('APP NAME', true) // force replay
|
||||
---------------------------------------------------------------- */
|
||||
let _bootFired = false; // in-memory guard: survives within a JS context, resets on true page reload
|
||||
function runBoot(appName, force) {
|
||||
if (!force && _bootFired) return; // Fastest guard — blocks any same-page double-call
|
||||
const storageKey = 'lt_booted_' + (appName || 'app');
|
||||
if (!force && sessionStorage.getItem(storageKey)) return;
|
||||
_bootFired = true;
|
||||
sessionStorage.setItem(storageKey, '1');
|
||||
const overlay = document.getElementById('lt-boot');
|
||||
const pre = document.getElementById('lt-boot-text');
|
||||
if (!overlay || !pre) return;
|
||||
@@ -338,7 +364,6 @@
|
||||
overlay.style.opacity = '0';
|
||||
setTimeout(() => { overlay.style.display = 'none'; overlay.style.opacity = ''; overlay.style.transition = ''; }, 520);
|
||||
}, 500);
|
||||
sessionStorage.setItem(storageKey, '1');
|
||||
}
|
||||
}, 65);
|
||||
}
|
||||
@@ -392,7 +417,9 @@
|
||||
----------------------------------------------------------------
|
||||
lt.sidebar.init()
|
||||
---------------------------------------------------------------- */
|
||||
let _sidebarInitialized = false;
|
||||
function initSidebar() {
|
||||
if (_sidebarInitialized) return; _sidebarInitialized = true;
|
||||
document.querySelectorAll('[data-sidebar-toggle]').forEach(btn => {
|
||||
const sidebar = document.getElementById(btn.dataset.sidebarToggle);
|
||||
if (!sidebar) return;
|
||||
@@ -429,8 +456,11 @@
|
||||
lt.api.put / patch / delete
|
||||
---------------------------------------------------------------- */
|
||||
async function apiFetch(method, url, body) {
|
||||
const opts = { method, headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()) };
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
const hasBody = body !== undefined;
|
||||
const headers = Object.assign({}, csrfHeaders());
|
||||
if (hasBody) headers['Content-Type'] = 'application/json'; // Only set on requests with a body
|
||||
const opts = { method, headers };
|
||||
if (hasBody) opts.body = JSON.stringify(body);
|
||||
let resp;
|
||||
try { resp = await fetch(url, opts); } catch (err) { throw new Error('Network error: ' + err.message); }
|
||||
let data;
|
||||
@@ -536,9 +566,12 @@
|
||||
const ths = Array.from(table.querySelectorAll('th[data-sort-key]'));
|
||||
ths.forEach((th, colIdx) => {
|
||||
let dir = 'asc';
|
||||
th.addEventListener('click', () => {
|
||||
ths.forEach(h => h.removeAttribute('data-sort'));
|
||||
th.setAttribute('aria-sort', 'none');
|
||||
th.setAttribute('tabindex', '0');
|
||||
const _sort = () => {
|
||||
ths.forEach(h => { h.removeAttribute('data-sort'); h.setAttribute('aria-sort', 'none'); });
|
||||
th.setAttribute('data-sort', dir);
|
||||
th.setAttribute('aria-sort', dir === 'asc' ? 'ascending' : 'descending');
|
||||
const tbody = table.querySelector('tbody');
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
rows.sort((a, b) => {
|
||||
@@ -550,7 +583,9 @@
|
||||
});
|
||||
rows.forEach(r => tbody.appendChild(r));
|
||||
dir = dir === 'asc' ? 'desc' : 'asc';
|
||||
});
|
||||
};
|
||||
th.addEventListener('click', _sort);
|
||||
th.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _sort(); } });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -563,14 +598,16 @@
|
||||
---------------------------------------------------------------- */
|
||||
function initStatsFilter() {
|
||||
document.querySelectorAll('.lt-stat-card[data-filter-key]').forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
const _activate = () => {
|
||||
const key = card.dataset.filterKey, val = card.dataset.filterVal;
|
||||
const wasActive = card.classList.contains('active');
|
||||
document.querySelectorAll('.lt-stat-card').forEach(c => c.classList.remove('active'));
|
||||
if (!wasActive) card.classList.add('active');
|
||||
if (typeof global.lt_onStatFilter === 'function')
|
||||
global.lt_onStatFilter(wasActive ? null : key, wasActive ? null : val);
|
||||
});
|
||||
};
|
||||
card.addEventListener('click', _activate);
|
||||
card.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _activate(); } });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -625,7 +662,9 @@
|
||||
});
|
||||
}
|
||||
|
||||
let _accordionInitialized = false;
|
||||
function initAccordion() {
|
||||
if (_accordionInitialized) return; _accordionInitialized = true;
|
||||
// Support both data-accordion attribute (HTML) and .lt-accordion-trigger class
|
||||
document.querySelectorAll('[data-accordion], .lt-accordion-trigger').forEach(trigger => {
|
||||
if (trigger.getAttribute('aria-expanded') === 'true') {
|
||||
@@ -680,7 +719,11 @@
|
||||
case 'right': top = r.top + sy + r.height / 2 - tr.height / 2; left = r.right + sx + 8; break;
|
||||
default: top = r.top + sy - tr.height - 8; left = r.left + sx + r.width / 2 - tr.width / 2;
|
||||
}
|
||||
tip.style.cssText = 'position:absolute;top:' + Math.max(4, top) + 'px;left:' + Math.max(4, left) + 'px;z-index:9000';
|
||||
const maxLeft = (global.scrollX || 0) + global.innerWidth - tr.width - 4;
|
||||
const maxTop = (global.scrollY || 0) + global.innerHeight - tr.height - 4;
|
||||
left = Math.max(4 + (global.scrollX || 0), Math.min(maxLeft, left));
|
||||
top = Math.max(4 + (global.scrollY || 0), Math.min(maxTop, top));
|
||||
tip.style.cssText = 'position:absolute;top:' + top + 'px;left:' + left + 'px';
|
||||
requestAnimationFrame(() => tip.classList.add('is-visible'));
|
||||
}
|
||||
|
||||
@@ -688,7 +731,10 @@
|
||||
if (_tooltipEl) { _tooltipEl.remove(); _tooltipEl = null; }
|
||||
}
|
||||
|
||||
let _tooltipInitialized = false;
|
||||
function initTooltips() {
|
||||
if (_tooltipInitialized) return;
|
||||
_tooltipInitialized = true;
|
||||
document.addEventListener('mouseover', e => { const a = e.target.closest('[data-tooltip]'); if (a) _tooltipShow(a); });
|
||||
document.addEventListener('mouseout', e => { if (!e.target.closest('[data-tooltip]')) return; if (!e.relatedTarget || !e.relatedTarget.closest('[data-tooltip]')) _tooltipHide(); });
|
||||
document.addEventListener('focusin', e => { const a = e.target.closest('[data-tooltip]'); if (a) _tooltipShow(a); });
|
||||
@@ -718,7 +764,9 @@
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
|
||||
let _copyInitialized = false;
|
||||
function initCopyButtons() {
|
||||
if (_copyInitialized) return; _copyInitialized = true;
|
||||
document.addEventListener('click', async function (e) {
|
||||
const btn = e.target.closest('[data-copy]'); if (!btn) return;
|
||||
const orig = btn.textContent;
|
||||
@@ -726,7 +774,7 @@
|
||||
if (ok) {
|
||||
btn.textContent = 'COPIED ✓'; btn.disabled = true;
|
||||
if (btn.hasAttribute('data-copy-toast')) toast.success('Copied to clipboard');
|
||||
setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 1500);
|
||||
setTimeout(() => { if (document.contains(btn)) { btn.textContent = orig; btn.disabled = false; } }, 1500);
|
||||
} else { toast.error('Copy failed'); }
|
||||
});
|
||||
}
|
||||
@@ -750,9 +798,11 @@
|
||||
}));
|
||||
}
|
||||
|
||||
let _alertsInitialized = false;
|
||||
function initAlerts() {
|
||||
if (_alertsInitialized) return; _alertsInitialized = true;
|
||||
document.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('.lt-alert-dismiss'); if (!btn) return;
|
||||
const btn = e.target.closest('.lt-alert-close, .lt-alert-dismiss'); if (!btn) return;
|
||||
const al = btn.closest('.lt-alert'); if (al) dismissAlert(al);
|
||||
});
|
||||
document.querySelectorAll('.lt-alert[data-alert-auto-dismiss]').forEach(el => {
|
||||
@@ -805,12 +855,13 @@
|
||||
|
||||
Command: { id, label, icon?, description?, kbd?, group?, tags?, action }
|
||||
---------------------------------------------------------------- */
|
||||
let _cpCommands = [], _cpSelected = 0;
|
||||
let _cpCommands = [], _cpSelected = 0, _cpTrigger = null;
|
||||
const _cpRecentKey = 'lt_cmd_recent';
|
||||
|
||||
function _cmdPaletteOpen() {
|
||||
const ov = document.getElementById('lt-cmd-overlay'); if (!ov) return;
|
||||
if (_mnOpen) _mnSetOpen(false);
|
||||
_cpTrigger = (document.activeElement && document.activeElement !== document.body) ? document.activeElement : null;
|
||||
ov.classList.add('is-open');
|
||||
_lockScroll();
|
||||
const palette = document.getElementById('lt-cmd-palette');
|
||||
@@ -823,6 +874,9 @@
|
||||
const ov = document.getElementById('lt-cmd-overlay'); if (!ov) return;
|
||||
ov.classList.remove('is-open');
|
||||
_unlockScroll();
|
||||
const inp = document.querySelector('#lt-cmd-palette .lt-cmd-input');
|
||||
if (inp) inp.removeAttribute('aria-activedescendant');
|
||||
if (_cpTrigger) { if (document.contains(_cpTrigger)) _cpTrigger.focus(); _cpTrigger = null; }
|
||||
}
|
||||
|
||||
function _cpHighlight(text, q) {
|
||||
@@ -865,7 +919,7 @@
|
||||
if (!groups[g] || !groups[g].length) return;
|
||||
html += '<div class="lt-cmd-section-label">' + escHtml(g) + '</div>';
|
||||
groups[g].forEach(cmd => {
|
||||
html += '<div class="lt-cmd-item' + (idx === 0 ? ' is-selected' : '') + '" data-cmd-id="' + escHtml(cmd.id) + '">' +
|
||||
html += '<div class="lt-cmd-item' + (idx === 0 ? ' is-selected' : '') + '" id="lt-cmd-item-' + idx + '" data-cmd-id="' + escHtml(cmd.id) + '">' +
|
||||
'<span class="lt-cmd-item-icon">' + escHtml(cmd.icon || '◦') + '</span>' +
|
||||
'<span class="lt-cmd-item-label">' + _cpHighlight(cmd.label, query) + '</span>' +
|
||||
(cmd.kbd ? '<span class="lt-cmd-item-kbd">' + escHtml(cmd.kbd) + '</span>' : '') +
|
||||
@@ -875,10 +929,15 @@
|
||||
});
|
||||
|
||||
results.innerHTML = html;
|
||||
results.querySelectorAll('.lt-cmd-item').forEach((item, i) => {
|
||||
const pal = document.getElementById('lt-cmd-palette');
|
||||
const inp = pal && pal.querySelector('.lt-cmd-input');
|
||||
const allItems = Array.from(results.querySelectorAll('.lt-cmd-item'));
|
||||
if (inp && allItems[0]) inp.setAttribute('aria-activedescendant', allItems[0].id);
|
||||
allItems.forEach((item, i) => {
|
||||
item.addEventListener('mouseenter', () => {
|
||||
results.querySelectorAll('.lt-cmd-item').forEach(x => x.classList.remove('is-selected'));
|
||||
allItems.forEach(x => x.classList.remove('is-selected'));
|
||||
item.classList.add('is-selected'); _cpSelected = i;
|
||||
if (inp) inp.setAttribute('aria-activedescendant', item.id);
|
||||
});
|
||||
item.addEventListener('click', () => _cpExec(item.dataset.cmdId));
|
||||
});
|
||||
@@ -903,6 +962,8 @@
|
||||
_cpSelected = (_cpSelected + dir + items.length) % items.length;
|
||||
items[_cpSelected] && items[_cpSelected].classList.add('is-selected');
|
||||
items[_cpSelected] && items[_cpSelected].scrollIntoView({ block: 'nearest' });
|
||||
const inp = ov.querySelector('.lt-cmd-input');
|
||||
if (inp && items[_cpSelected]) inp.setAttribute('aria-activedescendant', items[_cpSelected].id);
|
||||
}
|
||||
|
||||
function initCmdPalette(commands) {
|
||||
@@ -943,6 +1004,8 @@
|
||||
|
||||
function _validateField(el) {
|
||||
const val = el.value || '', type = (el.type || '').toLowerCase();
|
||||
if ((type === 'checkbox' || type === 'radio') && el.required && !el.checked) return { valid: false, message: 'This field is required' };
|
||||
if (el.tagName === 'SELECT' && el.multiple && el.required && el.selectedOptions.length === 0) return { valid: false, message: 'Please select at least one option' };
|
||||
if (el.required && !val.trim()) return { valid: false, message: 'This field is required' };
|
||||
if (el.minLength > 0 && val.length < el.minLength) return { valid: false, message: 'Minimum ' + el.minLength + ' characters' };
|
||||
if (el.maxLength > 0 && val.length > el.maxLength) return { valid: false, message: 'Maximum ' + el.maxLength + ' characters' };
|
||||
@@ -960,12 +1023,22 @@
|
||||
function _showError(el, msg) {
|
||||
el.classList.add('is-invalid'); el.classList.remove('is-valid');
|
||||
let err = el.parentElement && el.parentElement.querySelector('.lt-field-error');
|
||||
if (!err) { err = document.createElement('span'); err.className = 'lt-field-error'; if (el.parentElement) el.parentElement.appendChild(err); }
|
||||
if (!err) {
|
||||
err = document.createElement('span');
|
||||
err.className = 'lt-field-error';
|
||||
err.id = (el.id || ('lt-field-' + Math.random().toString(36).slice(2))) + '-err';
|
||||
if (el.parentElement) el.parentElement.appendChild(err);
|
||||
}
|
||||
err.textContent = msg;
|
||||
err.setAttribute('role', 'alert');
|
||||
el.setAttribute('aria-describedby', err.id);
|
||||
el.setAttribute('aria-invalid', 'true');
|
||||
}
|
||||
|
||||
function _clearError(el) {
|
||||
el.classList.remove('is-invalid'); el.classList.add('is-valid');
|
||||
el.removeAttribute('aria-invalid');
|
||||
el.removeAttribute('aria-describedby');
|
||||
const err = el.parentElement && el.parentElement.querySelector('.lt-field-error');
|
||||
if (err) err.remove();
|
||||
}
|
||||
@@ -990,7 +1063,7 @@
|
||||
e.preventDefault();
|
||||
const r = _validateForm(formEl);
|
||||
if (r.valid && typeof onSubmit === 'function') onSubmit(formEl, e);
|
||||
else if (!r.valid) r.errors[0].el.focus();
|
||||
else if (!r.valid && r.errors.length) r.errors[0].el.focus();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1343,7 +1416,7 @@
|
||||
Auto-inits on [id="lt-menu-btn"] + [id="lt-nav-drawer"]
|
||||
Swipe right from left edge (≤ 20px) opens; swipe left closes.
|
||||
================================================================ */
|
||||
let _mnOpen = false;
|
||||
let _mnOpen = false, _mnTrigger = null;
|
||||
|
||||
function _mnSetOpen(open) {
|
||||
_mnOpen = open;
|
||||
@@ -1353,20 +1426,28 @@
|
||||
if (!drawer) return;
|
||||
|
||||
if (open) {
|
||||
_mnTrigger = (document.activeElement && document.activeElement !== document.body) ? document.activeElement : null;
|
||||
drawer.classList.add('open');
|
||||
drawer.setAttribute('aria-hidden', 'false');
|
||||
drawer.removeAttribute('aria-hidden');
|
||||
if (overlay) overlay.classList.add('open');
|
||||
if (btn) { btn.classList.add('open'); btn.setAttribute('aria-expanded', 'true'); }
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Trap focus inside drawer
|
||||
if (!drawer._mnTrapHandler) {
|
||||
drawer._mnTrapHandler = e => { if (e.key === 'Tab') _trapFocus(drawer, e); };
|
||||
drawer.addEventListener('keydown', drawer._mnTrapHandler);
|
||||
}
|
||||
const first = drawer.querySelector('button, a, [tabindex]');
|
||||
if (first) setTimeout(() => first.focus(), 50);
|
||||
if (first) setTimeout(() => { if (document.contains(first)) first.focus(); }, 50);
|
||||
} else {
|
||||
drawer.classList.remove('open');
|
||||
drawer.setAttribute('aria-hidden', 'true');
|
||||
if (overlay) overlay.classList.remove('open');
|
||||
if (btn) { btn.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); }
|
||||
document.body.style.overflow = '';
|
||||
if (drawer._mnTrapHandler) { drawer.removeEventListener('keydown', drawer._mnTrapHandler); delete drawer._mnTrapHandler; }
|
||||
if (_mnTrigger && document.contains(_mnTrigger)) { _mnTrigger.focus(); }
|
||||
_mnTrigger = null;
|
||||
}
|
||||
bus.emit('mobileNav:' + (open ? 'open' : 'close'));
|
||||
}
|
||||
@@ -1544,15 +1625,17 @@
|
||||
const ov = document.getElementById(ovId);
|
||||
if (_mnOpen) _mnSetOpen(false);
|
||||
drawer.classList.add('is-open');
|
||||
drawer.setAttribute('aria-hidden', 'false');
|
||||
drawer.removeAttribute('aria-hidden');
|
||||
if (ov) ov.classList.add('is-open');
|
||||
_lockScroll();
|
||||
if (triggerEl) _modalTriggers.set(drawer, triggerEl);
|
||||
const first = drawer.querySelector(_FOCUSABLE);
|
||||
if (first) setTimeout(() => first.focus(), 50);
|
||||
// ESC to close
|
||||
// ESC to close + Tab trap
|
||||
drawer._rdKeyHandler = e => { if (e.key === 'Escape') _rdClose(drawer); };
|
||||
document.addEventListener('keydown', drawer._rdKeyHandler);
|
||||
drawer._rdTrapHandler = e => { if (e.key === 'Tab') _trapFocus(drawer, e); };
|
||||
drawer.addEventListener('keydown', drawer._rdTrapHandler);
|
||||
// Overlay click
|
||||
if (ov) ov._rdClick = () => _rdClose(drawer);
|
||||
if (ov) ov.addEventListener('click', ov._rdClick);
|
||||
@@ -1570,8 +1653,12 @@
|
||||
drawer.classList.remove('is-open');
|
||||
drawer.setAttribute('aria-hidden', 'true');
|
||||
if (ov) { ov.classList.remove('is-open'); if (ov._rdClick) { ov.removeEventListener('click', ov._rdClick); delete ov._rdClick; } }
|
||||
drawer.querySelectorAll('[data-drawer-close]').forEach(btn => {
|
||||
if (btn._rdHandler) { btn.removeEventListener('click', btn._rdHandler); delete btn._rdHandler; }
|
||||
});
|
||||
_unlockScroll();
|
||||
if (drawer._rdKeyHandler) { document.removeEventListener('keydown', drawer._rdKeyHandler); delete drawer._rdKeyHandler; }
|
||||
if (drawer._rdTrapHandler) { drawer.removeEventListener('keydown', drawer._rdTrapHandler); delete drawer._rdTrapHandler; }
|
||||
const trigger = _modalTriggers.get(drawer);
|
||||
if (trigger) { trigger.focus(); _modalTriggers.delete(drawer); }
|
||||
}
|
||||
@@ -1594,9 +1681,10 @@
|
||||
lt.contextMenu.register(selector, items)
|
||||
items = [{ label, icon, kbd, danger, divider, action }]
|
||||
================================================================ */
|
||||
let _ctxMenu = null;
|
||||
let _ctxMenu = null, _ctxTrigger = null;
|
||||
const _ctxItems = {};
|
||||
function _ctxShow(x, y, items) {
|
||||
function _ctxShow(x, y, items, trigger) {
|
||||
_ctxTrigger = trigger || (document.activeElement !== document.body ? document.activeElement : null);
|
||||
if (!_ctxMenu) {
|
||||
_ctxMenu = document.createElement('div');
|
||||
_ctxMenu.className = 'lt-context-menu';
|
||||
@@ -1613,14 +1701,22 @@
|
||||
el.setAttribute('tabindex', '0');
|
||||
el.innerHTML = `${item.icon ? `<span class="icon">${escHtml(item.icon)}</span>` : '<span class="icon"></span>'}<span>${escHtml(item.label || '')}</span>${item.kbd ? `<kbd>${escHtml(item.kbd)}</kbd>` : ''}`;
|
||||
el.addEventListener('click', () => { _ctxHide(); if (item.action) item.action(); });
|
||||
el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); el.click(); } });
|
||||
el.addEventListener('keydown', e => {
|
||||
const items = Array.from(_ctxMenu.querySelectorAll('[role="menuitem"]'));
|
||||
const idx = items.indexOf(e.currentTarget);
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); el.click(); }
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); (items[idx + 1] || items[0]).focus(); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); (items[idx - 1] || items[items.length - 1]).focus(); }
|
||||
else if (e.key === 'Home') { e.preventDefault(); items[0].focus(); }
|
||||
else if (e.key === 'End') { e.preventDefault(); items[items.length - 1].focus(); }
|
||||
});
|
||||
_ctxMenu.appendChild(el);
|
||||
});
|
||||
_ctxMenu.classList.add('is-open');
|
||||
// Position — keep on screen
|
||||
const vw = window.innerWidth, vh = window.innerHeight;
|
||||
const mw = _ctxMenu.offsetWidth || 180, mh = _ctxMenu.offsetHeight || 200;
|
||||
_ctxMenu.style.left = Math.min(x, vw - mw - 8) + 'px';
|
||||
_ctxMenu.style.left = Math.max(8, Math.min(x, vw - mw - 8)) + 'px';
|
||||
_ctxMenu.style.top = Math.min(y, vh - mh - 8) + 'px';
|
||||
// Focus first item
|
||||
const first = _ctxMenu.querySelector('[role="menuitem"]');
|
||||
@@ -1628,6 +1724,8 @@
|
||||
}
|
||||
function _ctxHide() {
|
||||
if (_ctxMenu) _ctxMenu.classList.remove('is-open');
|
||||
if (_ctxTrigger && document.contains(_ctxTrigger)) { _ctxTrigger.focus(); }
|
||||
_ctxTrigger = null;
|
||||
}
|
||||
document.addEventListener('click', () => _ctxHide());
|
||||
document.addEventListener('contextmenu', e => {
|
||||
@@ -1636,7 +1734,7 @@
|
||||
e.preventDefault();
|
||||
const menuId = target.dataset.contextMenu;
|
||||
const items = _ctxItems[menuId];
|
||||
if (items) _ctxShow(e.clientX, e.clientY, items);
|
||||
if (items) _ctxShow(e.clientX, e.clientY, items, target);
|
||||
});
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') _ctxHide(); });
|
||||
const contextMenu = {
|
||||
@@ -1791,6 +1889,15 @@
|
||||
let focusedIdx = -1;
|
||||
let filtered = [...options];
|
||||
|
||||
// ARIA combobox wiring
|
||||
const dropId = dropdown.id || ('lt-cb-drop-' + Math.random().toString(36).slice(2));
|
||||
dropdown.id = dropId;
|
||||
dropdown.setAttribute('role', 'listbox');
|
||||
inputEl.setAttribute('role', 'combobox');
|
||||
inputEl.setAttribute('aria-expanded', 'false');
|
||||
inputEl.setAttribute('aria-controls', dropId);
|
||||
inputEl.setAttribute('aria-autocomplete', 'list');
|
||||
|
||||
function _renderTags() {
|
||||
wrap.querySelectorAll('.lt-combobox-tag').forEach(t => t.remove());
|
||||
selected.forEach(v => {
|
||||
@@ -1798,7 +1905,7 @@
|
||||
if (!opt) return;
|
||||
const tag = document.createElement('span');
|
||||
tag.className = 'lt-combobox-tag';
|
||||
tag.innerHTML = `${escHtml(opt.label)}<button class="lt-combobox-tag-remove" data-value="${escHtml(v)}" aria-label="Remove ${escHtml(opt.label)}">✕</button>`;
|
||||
tag.innerHTML = `${escHtml(opt.label)}<button type="button" class="lt-combobox-tag-remove" data-value="${escHtml(v)}" aria-label="Remove ${escHtml(opt.label)}">✕</button>`;
|
||||
inputWrap.insertBefore(tag, inputEl);
|
||||
});
|
||||
}
|
||||
@@ -1813,10 +1920,12 @@
|
||||
}
|
||||
filtered.forEach((opt, i) => {
|
||||
const el = document.createElement('div');
|
||||
el.id = dropId + '-opt-' + i;
|
||||
el.className = 'lt-combobox-option' + (selected.includes(opt.value) ? ' is-selected' : '');
|
||||
el.setAttribute('role', 'option');
|
||||
el.setAttribute('data-value', opt.value);
|
||||
const hl = q ? opt.label.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>') : escHtml(opt.label);
|
||||
const safeLabel = escHtml(opt.label);
|
||||
const hl = q ? safeLabel.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>') : safeLabel;
|
||||
el.innerHTML = `${opt.icon ? `<span class="icon">${escHtml(opt.icon)}</span>` : ''}<span>${hl}</span>`;
|
||||
el.addEventListener('mousedown', e => { e.preventDefault(); _toggle(opt.value); });
|
||||
dropdown.appendChild(el);
|
||||
@@ -1836,20 +1945,26 @@
|
||||
}
|
||||
|
||||
function _moveFocus(dir) {
|
||||
const items = dropdown.querySelectorAll('.lt-combobox-option');
|
||||
const items = Array.from(dropdown.querySelectorAll('.lt-combobox-option'));
|
||||
if (!items.length) return;
|
||||
focusedIdx = Math.max(0, Math.min(focusedIdx + dir, items.length - 1));
|
||||
items.forEach((el, i) => el.classList.toggle('is-focused', i === focusedIdx));
|
||||
items[focusedIdx].scrollIntoView({ block: 'nearest' });
|
||||
inputEl.setAttribute('aria-activedescendant', items[focusedIdx].id);
|
||||
}
|
||||
|
||||
inputEl.addEventListener('input', () => { dropdown.classList.add('is-open'); _renderDropdown(inputEl.value); });
|
||||
inputEl.addEventListener('focus', () => { dropdown.classList.add('is-open'); _renderDropdown(inputEl.value); });
|
||||
function _setOpen(open) {
|
||||
dropdown.classList.toggle('is-open', open);
|
||||
inputEl.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
if (!open) { inputEl.removeAttribute('aria-activedescendant'); focusedIdx = -1; }
|
||||
}
|
||||
inputEl.addEventListener('input', () => { _setOpen(true); _renderDropdown(inputEl.value); });
|
||||
inputEl.addEventListener('focus', () => { _setOpen(true); _renderDropdown(inputEl.value); });
|
||||
inputEl.addEventListener('keydown', e => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); _moveFocus(1); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); _moveFocus(-1); }
|
||||
if (e.key === 'Enter') { e.preventDefault(); if (focusedIdx >= 0 && filtered[focusedIdx]) _toggle(filtered[focusedIdx].value); }
|
||||
if (e.key === 'Escape') { dropdown.classList.remove('is-open'); }
|
||||
if (e.key === 'Escape') { _setOpen(false); }
|
||||
if (e.key === 'Backspace' && !inputEl.value && selected.length) { _toggle(selected[selected.length - 1]); }
|
||||
});
|
||||
inputWrap.addEventListener('mousedown', e => {
|
||||
@@ -1857,7 +1972,7 @@
|
||||
if (rmBtn) { e.preventDefault(); _toggle(rmBtn.dataset.value); return; }
|
||||
inputEl.focus();
|
||||
});
|
||||
document.addEventListener('click', e => { if (!wrap.contains(e.target)) dropdown.classList.remove('is-open'); });
|
||||
document.addEventListener('click', e => { if (!wrap.contains(e.target)) _setOpen(false); });
|
||||
|
||||
_renderTags();
|
||||
_renderDropdown('');
|
||||
@@ -1897,9 +2012,11 @@
|
||||
const q = query.toLowerCase();
|
||||
_items.forEach((item, i) => {
|
||||
const el = document.createElement('div');
|
||||
el.id = (inputEl.id || 'lt-ta') + '-item-' + i;
|
||||
el.className = 'lt-typeahead-item';
|
||||
el.setAttribute('role', 'option');
|
||||
const hl = item.label.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>');
|
||||
const safeItemLabel = escHtml(item.label);
|
||||
const hl = safeItemLabel.replace(new RegExp(`(${q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')})`, 'gi'), '<mark>$1</mark>');
|
||||
el.innerHTML = `${item.icon ? `<span class="icon">${escHtml(item.icon)}</span>` : ''}<span>${hl}</span>${item.meta ? `<span style="margin-left:auto;color:var(--text-muted);font-size:0.68rem">${escHtml(item.meta)}</span>` : ''}`;
|
||||
el.addEventListener('mousedown', e => { e.preventDefault(); _select(item); });
|
||||
dropdown.appendChild(el);
|
||||
@@ -1910,27 +2027,32 @@
|
||||
async function _search(query) {
|
||||
dropdown.innerHTML = '<div class="lt-typeahead-loading">Searching…</div>';
|
||||
dropdown.classList.add('is-open');
|
||||
inputEl.setAttribute('aria-busy', 'true');
|
||||
try {
|
||||
const results = typeof source === 'function' ? await source(query) : source.filter(i => i.label.toLowerCase().includes(query.toLowerCase()));
|
||||
_render(results, query);
|
||||
} catch(e) {
|
||||
dropdown.innerHTML = '<div class="lt-typeahead-empty">Error loading results</div>';
|
||||
} finally {
|
||||
inputEl.setAttribute('aria-busy', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
function _select(item) {
|
||||
inputEl.value = item.label;
|
||||
inputEl.removeAttribute('aria-activedescendant');
|
||||
dropdown.classList.remove('is-open');
|
||||
if (onSelect) onSelect(item);
|
||||
bus.emit('typeahead:select', { item });
|
||||
}
|
||||
|
||||
function _moveFocus(dir) {
|
||||
const els = dropdown.querySelectorAll('.lt-typeahead-item');
|
||||
const els = Array.from(dropdown.querySelectorAll('.lt-typeahead-item'));
|
||||
if (!els.length) return;
|
||||
_focusedIdx = Math.max(0, Math.min(_focusedIdx + dir, els.length - 1));
|
||||
els.forEach((el, i) => el.classList.toggle('is-focused', i === _focusedIdx));
|
||||
els[_focusedIdx].scrollIntoView({ block: 'nearest' });
|
||||
inputEl.setAttribute('aria-activedescendant', els[_focusedIdx].id);
|
||||
}
|
||||
|
||||
inputEl.addEventListener('input', () => {
|
||||
@@ -2035,6 +2157,25 @@
|
||||
});
|
||||
divider.addEventListener('pointerup', () => { dragging = false; divider.classList.remove('is-dragging'); });
|
||||
|
||||
// Keyboard resize support
|
||||
divider.setAttribute('tabindex', '0');
|
||||
divider.setAttribute('role', 'separator');
|
||||
divider.setAttribute('aria-label', 'Resize panes');
|
||||
divider.addEventListener('keydown', e => {
|
||||
const step = 0.05;
|
||||
const total = vertical ? container.clientHeight : container.clientWidth;
|
||||
const divSize = vertical ? divider.offsetHeight : divider.offsetWidth;
|
||||
const available = total - divSize;
|
||||
const currentSize = vertical ? panes[0].offsetHeight : panes[0].offsetWidth;
|
||||
const currentRatio = currentSize / available;
|
||||
if ((e.key === 'ArrowRight' && !vertical) || (e.key === 'ArrowDown' && vertical)) {
|
||||
e.preventDefault(); _setRatio(Math.min(1, currentRatio + step));
|
||||
} else if ((e.key === 'ArrowLeft' && !vertical) || (e.key === 'ArrowUp' && vertical)) {
|
||||
e.preventDefault(); _setRatio(Math.max(0, currentRatio - step));
|
||||
} else if (e.key === 'Home') { e.preventDefault(); _setRatio(0); }
|
||||
else if (e.key === 'End') { e.preventDefault(); _setRatio(1); }
|
||||
});
|
||||
|
||||
_setRatio(initial);
|
||||
return { setRatio: _setRatio };
|
||||
},
|
||||
@@ -2052,10 +2193,21 @@
|
||||
group._sbInit = true;
|
||||
const label = group.querySelector('.lt-sidebar-group-label');
|
||||
if (!label) return;
|
||||
label.addEventListener('click', () => group.classList.toggle('is-open'));
|
||||
label.setAttribute('tabindex', '0');
|
||||
label.setAttribute('role', 'button');
|
||||
const chevron = label.querySelector('.chevron, .lt-sidebar-chevron');
|
||||
if (chevron) chevron.setAttribute('aria-hidden', 'true');
|
||||
const _toggle = () => {
|
||||
group.classList.toggle('is-open');
|
||||
label.setAttribute('aria-expanded', group.classList.contains('is-open') ? 'true' : 'false');
|
||||
};
|
||||
label.setAttribute('aria-expanded', group.classList.contains('is-open') ? 'true' : 'false');
|
||||
label.addEventListener('click', _toggle);
|
||||
label.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _toggle(); } });
|
||||
// Open group if it contains the active link
|
||||
if (group.querySelector('.lt-sidebar-sub-link.active, .lt-sidebar-sub-link[aria-current="page"]')) {
|
||||
group.classList.add('is-open');
|
||||
label.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2119,8 +2271,9 @@
|
||||
const dist = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
if (dist < threshold) _load();
|
||||
}
|
||||
scrollRoot.addEventListener('scroll', throttle(_onScroll, 150), { passive: true });
|
||||
return { reset() { _done = false; _loading = false; }, stop() { scrollRoot.removeEventListener('scroll', _onScroll); } };
|
||||
const _onScrollThrottled = throttle(_onScroll, 150);
|
||||
scrollRoot.addEventListener('scroll', _onScrollThrottled, { passive: true });
|
||||
return { reset() { _done = false; _loading = false; }, stop() { scrollRoot.removeEventListener('scroll', _onScrollThrottled); } };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -2151,7 +2304,7 @@
|
||||
function _show(idx) {
|
||||
steps.forEach((s, i) => {
|
||||
s.classList.toggle('is-active', i === idx);
|
||||
s.setAttribute('aria-hidden', i !== idx ? 'true' : 'false');
|
||||
if (i !== idx) s.setAttribute('aria-hidden', 'true'); else s.removeAttribute('aria-hidden');
|
||||
});
|
||||
// Update step indicators
|
||||
container.querySelectorAll('[data-wizard-indicator]').forEach((ind, i) => {
|
||||
@@ -2175,7 +2328,11 @@
|
||||
if (first) setTimeout(() => first.focus(), 60);
|
||||
}
|
||||
|
||||
let _wizBusy = false;
|
||||
async function _next() {
|
||||
if (_wizBusy) return;
|
||||
_wizBusy = true;
|
||||
try {
|
||||
if (validate) {
|
||||
const ok = await validate(current + 1, _getStepData(current));
|
||||
if (!ok) {
|
||||
@@ -2185,6 +2342,9 @@
|
||||
}
|
||||
Object.assign(formData, _getStepData(current));
|
||||
if (current < total - 1) { current++; _show(current); }
|
||||
} finally {
|
||||
_wizBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function _prev() {
|
||||
@@ -2367,7 +2527,7 @@
|
||||
const lightbox = {
|
||||
init(selector, opts = {}) {
|
||||
const { caption = 'alt', loop = true } = opts;
|
||||
let _images = [], _current = 0, _overlay = null;
|
||||
let _images = [], _current = 0, _overlay = null, _lbKeyBound = null, _lbTrigger = null;
|
||||
|
||||
function _getCaption(img) {
|
||||
if (typeof caption === 'function') return caption(img);
|
||||
@@ -2382,9 +2542,9 @@
|
||||
_overlay.setAttribute('aria-modal', 'true');
|
||||
_overlay.setAttribute('aria-label', 'Image viewer');
|
||||
_overlay.innerHTML = `
|
||||
<button class="lt-lightbox-close" aria-label="Close">×</button>
|
||||
<button class="lt-lightbox-prev" aria-label="Previous">‹</button>
|
||||
<button class="lt-lightbox-next" aria-label="Next">›</button>
|
||||
<button type="button" class="lt-lightbox-close" aria-label="Close">×</button>
|
||||
<button type="button" class="lt-lightbox-prev" aria-label="Previous">‹</button>
|
||||
<button type="button" class="lt-lightbox-next" aria-label="Next">›</button>
|
||||
<div class="lt-lightbox-img-wrap">
|
||||
<img class="lt-lightbox-img" src="" alt="">
|
||||
</div>
|
||||
@@ -2397,7 +2557,8 @@
|
||||
_overlay.querySelector('.lt-lightbox-prev').addEventListener('click', () => lightbox.prev());
|
||||
_overlay.querySelector('.lt-lightbox-next').addEventListener('click', () => lightbox.next());
|
||||
_overlay.addEventListener('click', e => { if (e.target === _overlay) lightbox.close(); });
|
||||
document.addEventListener('keydown', _lbKey);
|
||||
_lbKeyBound = _lbKey.bind(null);
|
||||
document.addEventListener('keydown', _lbKeyBound);
|
||||
}
|
||||
|
||||
function _lbKey(e) {
|
||||
@@ -2409,6 +2570,9 @@
|
||||
|
||||
function _show(idx) {
|
||||
if (!_overlay) _buildOverlay();
|
||||
if (!_overlay.classList.contains('is-open')) {
|
||||
_lbTrigger = (document.activeElement && document.activeElement !== document.body) ? document.activeElement : null;
|
||||
}
|
||||
_current = idx;
|
||||
const img = _images[idx];
|
||||
const el = _overlay.querySelector('.lt-lightbox-img');
|
||||
@@ -2420,7 +2584,7 @@
|
||||
_overlay.querySelector('.lt-lightbox-next').style.display = (loop || idx < _images.length - 1) && _images.length > 1 ? '' : 'none';
|
||||
_overlay.classList.add('is-open');
|
||||
_lockScroll();
|
||||
setTimeout(() => el.focus?.(), 50);
|
||||
setTimeout(() => { if (document.contains(el)) el.focus?.(); }, 50);
|
||||
}
|
||||
|
||||
function _collect() {
|
||||
@@ -2444,6 +2608,9 @@
|
||||
if (!_overlay) return;
|
||||
_overlay.classList.remove('is-open');
|
||||
_unlockScroll();
|
||||
if (_lbKeyBound) { document.removeEventListener('keydown', _lbKeyBound); _lbKeyBound = null; }
|
||||
if (_lbTrigger && document.contains(_lbTrigger)) { _lbTrigger.focus(); }
|
||||
_lbTrigger = null;
|
||||
},
|
||||
prev() { _show(loop ? (_current - 1 + _images.length) % _images.length : Math.max(0, _current - 1)); },
|
||||
next() { _show(loop ? (_current + 1) % _images.length : Math.min(_images.length - 1, _current + 1)); },
|
||||
@@ -2574,10 +2741,16 @@
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
||||
.replace(/_(.+?)_/g, '<em>$1</em>')
|
||||
// Links
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
// Images
|
||||
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%">')
|
||||
// Links — block javascript: and data: URIs
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => {
|
||||
const safeUrl = /^(https?:\/\/|\/|#|\.\.?\/)/i.test(url) ? url : '#';
|
||||
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${escHtml(text)}</a>`;
|
||||
})
|
||||
// Images — block javascript: and data: URIs
|
||||
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => {
|
||||
const safeSrc = /^(https?:\/\/|\/|\.\.?\/)/i.test(src) ? src : '';
|
||||
return `<img src="${safeSrc}" alt="${escHtml(alt)}" style="max-width:100%">`;
|
||||
})
|
||||
// Blockquote
|
||||
.replace(/^>\s(.+)$/gm, '<blockquote>$1</blockquote>')
|
||||
// Horizontal rule
|
||||
@@ -2622,7 +2795,7 @@
|
||||
const pages = _pages();
|
||||
let html = '';
|
||||
// Prev
|
||||
html += `<button class="lt-page-btn" ${page <= 1 ? 'disabled' : ''} data-page="${page - 1}">«</button>`;
|
||||
html += `<button type="button" class="lt-page-btn" ${page <= 1 ? 'disabled' : ''} data-page="${page - 1}" aria-label="Previous page">«</button>`;
|
||||
// Page buttons with ellipsis
|
||||
const half = Math.floor((maxBtns - 2) / 2);
|
||||
let start = Math.max(2, page - half);
|
||||
@@ -2631,15 +2804,17 @@
|
||||
if (start === 2) end = Math.min(pages - 1, start + maxBtns - 3);
|
||||
else start = Math.max(2, end - maxBtns + 3);
|
||||
}
|
||||
html += `<button class="lt-page-btn${page === 1 ? ' active' : ''}" data-page="1">1</button>`;
|
||||
if (start > 2) html += `<button class="lt-page-btn" disabled>…</button>`;
|
||||
html += `<button type="button" class="lt-page-btn${page === 1 ? ' active' : ''}" data-page="1"${page === 1 ? ' aria-current="page"' : ''} aria-label="Page 1">1</button>`;
|
||||
if (start > 2) html += `<button type="button" class="lt-page-btn" disabled aria-hidden="true">…</button>`;
|
||||
for (let i = start; i <= end; i++) {
|
||||
html += `<button class="lt-page-btn${page === i ? ' active' : ''}" data-page="${i}">${i}</button>`;
|
||||
html += `<button type="button" class="lt-page-btn${page === i ? ' active' : ''}" data-page="${i}"${page === i ? ' aria-current="page"' : ''} aria-label="Page ${i}">${i}</button>`;
|
||||
}
|
||||
if (end < pages - 1) html += `<button class="lt-page-btn" disabled>…</button>`;
|
||||
if (pages > 1) html += `<button class="lt-page-btn${page === pages ? ' active' : ''}" data-page="${pages}">${pages}</button>`;
|
||||
if (end < pages - 1) html += `<button type="button" class="lt-page-btn" disabled aria-hidden="true">…</button>`;
|
||||
if (pages > 1) html += `<button type="button" class="lt-page-btn${page === pages ? ' active' : ''}" data-page="${pages}"${page === pages ? ' aria-current="page"' : ''} aria-label="Page ${pages}">${pages}</button>`;
|
||||
// Next
|
||||
html += `<button class="lt-page-btn" ${page >= pages ? 'disabled' : ''} data-page="${page + 1}">»</button>`;
|
||||
html += `<button type="button" class="lt-page-btn" ${page >= pages ? 'disabled' : ''} data-page="${page + 1}" aria-label="Next page">»</button>`;
|
||||
if (!nav.getAttribute('role')) nav.setAttribute('role', 'navigation');
|
||||
if (!nav.getAttribute('aria-label')) nav.setAttribute('aria-label', 'Pagination');
|
||||
nav.innerHTML = html;
|
||||
}
|
||||
|
||||
@@ -2671,7 +2846,10 @@
|
||||
alerts: bool, clipboard: bool, sidebar: bool, submenus: bool }
|
||||
Individual modules can still be called manually.
|
||||
================================================================ */
|
||||
let _ltInitialized = false;
|
||||
function ltInit(opts) {
|
||||
if (_ltInitialized) return; // Guard: safe to call multiple times
|
||||
_ltInitialized = true;
|
||||
const o = Object.assign({
|
||||
boot: true,
|
||||
bootName: null,
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
<%–
|
||||
LOTUSGUILD TERMINAL DESIGN SYSTEM — Node.js / Express EJS Base Layout
|
||||
Extend this in every page template via res.render('page', { ... }).
|
||||
|
||||
Required Express setup (server.js / app.js):
|
||||
const { requireAuth, cspNonce, injectLocals } = require('./middleware');
|
||||
app.use(cspNonce);
|
||||
app.use(requireAuth);
|
||||
app.use(injectLocals);
|
||||
app.set('view engine', 'ejs');
|
||||
|
||||
Locals injected automatically by middleware.js:
|
||||
user { username, name, email, groups, isAdmin }
|
||||
nonce CSP nonce string
|
||||
appName process.env.APP_NAME
|
||||
appSubtitle process.env.APP_SUBTITLE
|
||||
|
||||
Locals to set per-route (or via a second res.locals middleware):
|
||||
pageTitle string — page <title> suffix
|
||||
activeNav string — must match a navLinks[].key
|
||||
navLinks array — navigation items:
|
||||
[{ href: '/path', key: 'mykey', label: 'My Page' }, ...]
|
||||
Dropdown:
|
||||
{ label: 'Admin', key: 'admin', adminOnly: true, children: [
|
||||
{ href: '/admin/users', label: 'Users' }
|
||||
]}
|
||||
pageStyles array — optional extra CSS hrefs
|
||||
pageScripts array — optional extra <script src> paths
|
||||
–%>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#030508">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title><%= pageTitle ? pageTitle + ' — ' : '' %><%= appName || 'LotusGuild' %></title>
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=VT323&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Design system -->
|
||||
<link rel="stylesheet" href="/web_template/base.css">
|
||||
<!-- App-specific CSS (extends base, never overrides variables without good reason) -->
|
||||
<link rel="stylesheet" href="/assets/app.css">
|
||||
|
||||
<% if (typeof pageStyles !== 'undefined') { pageStyles.forEach(href => { %>
|
||||
<link rel="stylesheet" href="<%- href %>">
|
||||
<% }); } %>
|
||||
|
||||
<link rel="icon" href="/assets/favicon.png" type="image/png">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Boot overlay -->
|
||||
<div id="lt-boot" class="lt-boot-overlay"
|
||||
data-app-name="<%= (appName || 'APP').toUpperCase() %>"
|
||||
style="display:none">
|
||||
<pre id="lt-boot-text" class="lt-boot-text"></pre>
|
||||
</div>
|
||||
|
||||
<!-- =========================================================
|
||||
HEADER
|
||||
========================================================= -->
|
||||
<header class="lt-header">
|
||||
<div class="lt-header-left">
|
||||
|
||||
<div class="lt-brand">
|
||||
<a href="/" class="lt-brand-title" style="text-decoration:none">
|
||||
<%= (appName || 'APP').toUpperCase() %>
|
||||
</a>
|
||||
<span class="lt-brand-subtitle"><%= appSubtitle || 'LotusGuild Infrastructure' %></span>
|
||||
</div>
|
||||
|
||||
<nav class="lt-nav" aria-label="Main navigation">
|
||||
<% (navLinks || []).forEach(link => {
|
||||
if (link.adminOnly && !user.isAdmin) return;
|
||||
if (link.children) { %>
|
||||
<div class="lt-nav-dropdown">
|
||||
<a href="#" class="lt-nav-link <%= (activeNav || '').startsWith(link.key) ? 'active' : '' %>">
|
||||
<%= link.label %> ▾
|
||||
</a>
|
||||
<ul class="lt-nav-dropdown-menu">
|
||||
<% link.children.forEach(child => { %>
|
||||
<li><a href="<%= child.href %>"><%= child.label %></a></li>
|
||||
<% }); %>
|
||||
</ul>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<a href="<%= link.href %>"
|
||||
class="lt-nav-link <%= activeNav === link.key ? 'active' : '' %>">
|
||||
<%= link.label %>
|
||||
</a>
|
||||
<% } }); %>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="lt-header-right">
|
||||
<% if (user && (user.name || user.username)) { %>
|
||||
<span class="lt-header-user"><%= user.name || user.username %></span>
|
||||
<% } %>
|
||||
<% if (user && user.isAdmin) { %>
|
||||
<span class="lt-badge-admin">admin</span>
|
||||
<% } %>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- =========================================================
|
||||
MAIN CONTENT — provided by the including view via <%- body %>
|
||||
or by structuring routes to render with this as a wrapper.
|
||||
========================================================= -->
|
||||
<main class="lt-main lt-container">
|
||||
<%- body %>
|
||||
</main>
|
||||
|
||||
<!-- =========================================================
|
||||
SCRIPTS — all tags carry the CSP nonce
|
||||
========================================================= -->
|
||||
|
||||
<!-- Runtime globals -->
|
||||
<script nonce="<%= nonce %>">
|
||||
window.CSRF_TOKEN = <%= JSON.stringify(csrfToken || '') %>;
|
||||
window.CURRENT_USER = {
|
||||
username: <%= JSON.stringify(user.username || '') %>,
|
||||
name: <%= JSON.stringify(user.name || '') %>,
|
||||
groups: <%= JSON.stringify(user.groups || []) %>,
|
||||
isAdmin: <%= user.isAdmin ? 'true' : 'false' %>,
|
||||
};
|
||||
// App-specific config: set window.APP_CONFIG in your route's inline script,
|
||||
// not here. This file is shared across all apps.
|
||||
</script>
|
||||
|
||||
<!-- Design system -->
|
||||
<script nonce="<%= nonce %>" src="/web_template/base.js"></script>
|
||||
|
||||
<!-- App JS -->
|
||||
<script nonce="<%= nonce %>" src="/assets/app.js"></script>
|
||||
|
||||
<% if (typeof pageScripts !== 'undefined') { pageScripts.forEach(src => { %>
|
||||
<script nonce="<%= nonce %>" src="<%- src %>"></script>
|
||||
<% }); } %>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1140
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"eslint": "^8.57.1"
|
||||
}
|
||||
}
|
||||
+30
-15
@@ -17,8 +17,14 @@
|
||||
* $nonce string CSP nonce from SecurityHeadersMiddleware::getNonce()
|
||||
* $currentUser array ['username', 'name', 'is_admin', 'groups']
|
||||
* $pageTitle string Page <title> suffix
|
||||
* $activeNav string Which nav link is active ('dashboard','tickets',etc.)
|
||||
* $activeNav string Which nav key is active — must match a $navLinks entry
|
||||
* $config array From config/config.php
|
||||
* $navLinks array Navigation items:
|
||||
* [['href' => '/path', 'key' => 'mykey', 'label' => 'My Page'], ...]
|
||||
* Nested (dropdown):
|
||||
* ['label' => 'Admin', 'key' => 'admin', 'adminOnly' => true, 'children' => [
|
||||
* ['href' => '/admin/users', 'label' => 'Users'],
|
||||
* ]]
|
||||
*/
|
||||
|
||||
// Defensive defaults
|
||||
@@ -27,6 +33,7 @@ $currentUser = $currentUser ?? [];
|
||||
$pageTitle = $pageTitle ?? 'Dashboard';
|
||||
$activeNav = $activeNav ?? '';
|
||||
$config = $config ?? [];
|
||||
$navLinks = $navLinks ?? [];
|
||||
$isAdmin = $currentUser['is_admin'] ?? false;
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
@@ -40,7 +47,7 @@ $isAdmin = $currentUser['is_admin'] ?? false;
|
||||
<!-- Unified design system CSS -->
|
||||
<link rel="stylesheet" href="/web_template/base.css">
|
||||
<!-- App-specific CSS (extends base, never overrides variables without good reason) -->
|
||||
<link rel="stylesheet" href="/assets/css/app.css?v=<?php echo $config['CSS_VERSION'] ?? '20260314'; ?>">
|
||||
<link rel="stylesheet" href="/assets/css/app.css?v=<?php echo $config['CSS_VERSION'] ?? '1'; ?>">
|
||||
|
||||
<link rel="icon" href="/assets/images/favicon.png" type="image/png">
|
||||
</head>
|
||||
@@ -67,25 +74,31 @@ $isAdmin = $currentUser['is_admin'] ?? false;
|
||||
</div>
|
||||
|
||||
<nav class="lt-nav" aria-label="Main navigation">
|
||||
<a href="/" class="lt-nav-link <?php echo $activeNav === 'dashboard' ? 'active' : ''; ?>">Dashboard</a>
|
||||
<a href="/tickets" class="lt-nav-link <?php echo $activeNav === 'tickets' ? 'active' : ''; ?>">Tickets</a>
|
||||
<?php foreach ($navLinks as $link): ?>
|
||||
<?php
|
||||
$skipAdminOnly = !empty($link['adminOnly']) && !$isAdmin;
|
||||
if ($skipAdminOnly) continue;
|
||||
?>
|
||||
|
||||
<?php if ($isAdmin): ?>
|
||||
<?php if (!empty($link['children'])): ?>
|
||||
<?php $parentActive = str_starts_with($activeNav, $link['key']); ?>
|
||||
<div class="lt-nav-dropdown">
|
||||
<a href="#" class="lt-nav-link <?php echo str_starts_with($activeNav, 'admin') ? 'active' : ''; ?>">
|
||||
Admin ▾
|
||||
<a href="#" class="lt-nav-link <?php echo $parentActive ? 'active' : ''; ?>">
|
||||
<?php echo htmlspecialchars($link['label']); ?> ▾
|
||||
</a>
|
||||
<ul class="lt-nav-dropdown-menu">
|
||||
<li><a href="/admin/templates">Templates</a></li>
|
||||
<li><a href="/admin/workflow">Workflow</a></li>
|
||||
<li><a href="/admin/recurring-tickets">Recurring</a></li>
|
||||
<li><a href="/admin/custom-fields">Custom Fields</a></li>
|
||||
<li><a href="/admin/user-activity">User Activity</a></li>
|
||||
<li><a href="/admin/audit-log">Audit Log</a></li>
|
||||
<li><a href="/admin/api-keys">API Keys</a></li>
|
||||
<?php foreach ($link['children'] as $child): ?>
|
||||
<li><a href="<?php echo htmlspecialchars($child['href']); ?>"><?php echo htmlspecialchars($child['label']); ?></a></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<a href="<?php echo htmlspecialchars($link['href']); ?>"
|
||||
class="lt-nav-link <?php echo $activeNav === $link['key'] ? 'active' : ''; ?>">
|
||||
<?php echo htmlspecialchars($link['label']); ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -130,6 +143,8 @@ $isAdmin = $currentUser['is_admin'] ?? false;
|
||||
username: <?php echo json_encode($currentUser['username'] ?? ''); ?>,
|
||||
isAdmin: <?php echo $isAdmin ? 'true' : 'false'; ?>,
|
||||
};
|
||||
// App-specific config: set window.APP_CONFIG in your app's own <script> block,
|
||||
// not here. This file is shared across all apps.
|
||||
</script>
|
||||
|
||||
<!-- Unified design system JS -->
|
||||
@@ -137,7 +152,7 @@ $isAdmin = $currentUser['is_admin'] ?? false;
|
||||
|
||||
<!-- App-specific JS (cache-busted) -->
|
||||
<script nonce="<?php echo $nonce; ?>"
|
||||
src="/assets/js/app.js?v=<?php echo $config['JS_VERSION'] ?? '20260314'; ?>">
|
||||
src="/assets/js/app.js?v=<?php echo $config['JS_VERSION'] ?? '1'; ?>">
|
||||
</script>
|
||||
|
||||
<!-- Per-page inline JS goes here in the including view, e.g.: -->
|
||||
|
||||
+35
-21
@@ -17,7 +17,8 @@
|
||||
Required Flask setup (app.py):
|
||||
- Pass `nonce` into every render_template() call via a context processor
|
||||
- Pass `user` dict from _get_user() helper
|
||||
- Pass `config` dict with APP_NAME, etc.
|
||||
- Pass `config` dict with APP_NAME, APP_SUBTITLE, etc.
|
||||
- Pass `nav_links` list of dicts defining navigation
|
||||
|
||||
Context processor example:
|
||||
@app.context_processor
|
||||
@@ -25,6 +26,16 @@
|
||||
import base64, os
|
||||
nonce = base64.b64encode(os.urandom(16)).decode()
|
||||
return dict(nonce=nonce, user=_get_user(), config=_config())
|
||||
|
||||
nav_links format (pass from route or context processor):
|
||||
nav_links = [
|
||||
{'href': url_for('index'), 'key': 'dashboard', 'label': 'Dashboard'},
|
||||
{'href': url_for('settings'), 'key': 'settings', 'label': 'Settings'},
|
||||
# Admin-only dropdown:
|
||||
{'label': 'Admin', 'key': 'admin', 'admin_only': True, 'children': [
|
||||
{'href': url_for('admin_users'), 'label': 'Users'},
|
||||
]},
|
||||
]
|
||||
#}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -64,24 +75,28 @@
|
||||
</div>
|
||||
|
||||
<nav class="lt-nav" aria-label="Main navigation">
|
||||
{# Each page sets {% block active_nav %}pagename{% endblock %} #}
|
||||
{% set active = self.active_nav() | default('') %}
|
||||
<a href="{{ url_for('index') }}"
|
||||
class="lt-nav-link {% if active == 'dashboard' %}active{% endif %}">
|
||||
Dashboard
|
||||
{% for link in nav_links | default([]) %}
|
||||
{% if not link.get('admin_only') or 'admin' in user.groups %}
|
||||
{% if link.get('children') %}
|
||||
<div class="lt-nav-dropdown">
|
||||
<a href="#" class="lt-nav-link {% if active.startswith(link.key) %}active{% endif %}">
|
||||
{{ link.label }} ▾
|
||||
</a>
|
||||
<a href="{{ url_for('links_page') }}"
|
||||
class="lt-nav-link {% if active == 'links' %}active{% endif %}">
|
||||
Link Debug
|
||||
</a>
|
||||
<a href="{{ url_for('inspector') }}"
|
||||
class="lt-nav-link {% if active == 'inspector' %}active{% endif %}">
|
||||
Inspector
|
||||
</a>
|
||||
<a href="{{ url_for('suppressions_page') }}"
|
||||
class="lt-nav-link {% if active == 'suppressions' %}active{% endif %}">
|
||||
Suppressions
|
||||
<ul class="lt-nav-dropdown-menu">
|
||||
{% for child in link.children %}
|
||||
<li><a href="{{ child.href }}">{{ child.label }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="{{ link.href }}"
|
||||
class="lt-nav-link {% if active == link.key %}active{% endif %}">
|
||||
{{ link.label }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -107,17 +122,16 @@
|
||||
All <script> tags MUST carry the nonce attribute for CSP.
|
||||
========================================================= -->
|
||||
|
||||
<!-- Runtime config (no CSRF needed for Gandalf — SameSite=Strict) -->
|
||||
<!-- Runtime config injected by the server -->
|
||||
<script nonce="{{ nonce }}">
|
||||
window.APP_CONFIG = {
|
||||
ticketWebUrl: {{ config.get('ticket_api', {}).get('web_url', 'https://t.lotusguild.org/ticket/') | tojson }},
|
||||
};
|
||||
window.CURRENT_USER = {
|
||||
username: {{ user.username | tojson }},
|
||||
name: {{ (user.name or user.username) | tojson }},
|
||||
groups: {{ user.groups | tojson }},
|
||||
isAdmin: {{ ('admin' in user.groups) | lower }},
|
||||
};
|
||||
// App-specific config: set window.APP_CONFIG in your app's own template block,
|
||||
// not here. This file is shared across all apps.
|
||||
</script>
|
||||
|
||||
<!-- Unified design system JS -->
|
||||
@@ -136,6 +150,6 @@
|
||||
|
||||
{% block active_nav %}dashboard{% endblock %}
|
||||
|
||||
Values: dashboard | links | inspector | suppressions
|
||||
Value must match a 'key' in your nav_links list.
|
||||
--------------------------------------------------------------- #}
|
||||
{% block active_nav %}{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user