frontend: shadow-DOM upgrade for hive-dialog/hive-toast, drop global modal.css
Follow-up to the light-DOM custom-elements pilot (mara: 'that one landed and works. i dont really like the css still being shared - id like that to be split by component, with common stuff via @include'). <hive-dialog> and <hive-toast> now attach a shadow root and adopt a component-scoped CSSStyleSheet built from a template-string constant in modal.js (DIALOG_CSS / TOAST_CSS), plus a new shared/src/component- styles.js sheet (currently just .btn) adopted alongside it via adoptedStyleSheets -- the native equivalent of a Sass @include, no preprocessor added. Theme vars keep resolving through the shadow boundary since CSS custom properties inherit across it; only plain class rules needed the explicit move. Deleted the global shared/src/modal.css entirely and dropped its @import from both dashboard/common.css and agent/agent.css -- nothing outside modal.js renders the old .tc-* classes any more. The <hive- dialog> element is now the backdrop itself (:host carries the fixed- position/centering rules that used to be .tc-backdrop on a light-DOM div); box/title/message/content/actions all render inside its shadow root. <hive-toast> similarly styles :host directly instead of a light- DOM div, with the message text placed straight into the shadow root (no <slot> needed since there's no external light-DOM content to project). Public API (openDialog/themedConfirm/themedPrompt/themedToast) unchanged -- no call-site changes needed anywhere in dashboard/agent. Verified with a full frontend build; nix fmt clean.
This commit is contained in:
parent
be27a62fb1
commit
d79df3883d
6 changed files with 246 additions and 170 deletions
|
|
@ -6,10 +6,9 @@
|
||||||
agent SUB-pages (stats, screen) the same back-link nav the dashboard's
|
agent SUB-pages (stats, screen) the same back-link nav the dashboard's
|
||||||
standalone pages use. The live terminal page keeps its own header. */
|
standalone pages use. The live terminal page keeps its own header. */
|
||||||
@import "@hive/shared/chrome.css";
|
@import "@hive/shared/chrome.css";
|
||||||
/* Themed dialog/toast styles for the shared bindAsyncForms handler + any
|
/* Themed dialog/toast component CSS (modal.js) now lives in each
|
||||||
direct themedConfirm/themedToast calls, so the per-agent UI's
|
component's own shadow root (adoptedStyleSheets), not a global
|
||||||
confirmations and errors render in-theme instead of unstyled. */
|
stylesheet — nothing to @import here any more. */
|
||||||
@import "@hive/shared/modal.css";
|
|
||||||
|
|
||||||
/* ─── full-screen layout overrides ─────────────────────────────────
|
/* ─── full-screen layout overrides ─────────────────────────────────
|
||||||
The agent page mounts a full-viewport terminal under a fixed
|
The agent page mounts a full-viewport terminal under a fixed
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
@import "@hive/shared/terminal.css";
|
@import "@hive/shared/terminal.css";
|
||||||
@import "@hive/shared/tabs.css";
|
@import "@hive/shared/tabs.css";
|
||||||
@import "@hive/shared/chrome.css";
|
@import "@hive/shared/chrome.css";
|
||||||
@import "@hive/shared/modal.css";
|
|
||||||
|
|
||||||
/* ─── global typography ─────────────────────────────────────────────
|
/* ─── global typography ─────────────────────────────────────────────
|
||||||
Element-level rules shared across all three pages (index, flow,
|
Element-level rules shared across all three pages (index, flow,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
"./forms.js": "./src/forms.js",
|
"./forms.js": "./src/forms.js",
|
||||||
"./dom.js": "./src/dom.js",
|
"./dom.js": "./src/dom.js",
|
||||||
"./modal.js": "./src/modal.js",
|
"./modal.js": "./src/modal.js",
|
||||||
"./modal.css": "./src/modal.css"
|
"./component-styles.js": "./src/component-styles.js"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"src/"
|
"src/"
|
||||||
|
|
|
||||||
58
frontend/packages/shared/src/component-styles.js
Normal file
58
frontend/packages/shared/src/component-styles.js
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
// Shared, adopted-alongside-component-specific stylesheets for shadow-DOM
|
||||||
|
// custom elements — the native equivalent of a Sass `@include`: a
|
||||||
|
// `CSSStyleSheet` is parsed once and adopted by reference
|
||||||
|
// (`shadowRoot.adoptedStyleSheets = [sharedButtonStyleSheet(), ownSheet]`)
|
||||||
|
// into as many shadow roots as want it, rather than each component
|
||||||
|
// duplicating the rule text or re-fetching an external stylesheet.
|
||||||
|
//
|
||||||
|
// Start small: `.btn` is the one rule shadow-DOM components have needed so
|
||||||
|
// far (the dialog buttons in modal.js). Add more shared rules here as more
|
||||||
|
// components need them — don't grow this into a full reset/utility layer
|
||||||
|
// preemptively.
|
||||||
|
//
|
||||||
|
// NOTE: this `.btn` is a near-duplicate of the app-wide `.btn` base rule in
|
||||||
|
// dashboard/src/common.css and agent/src/agent.css, which already differ
|
||||||
|
// slightly from each other (agent's is a simpler subset). Shadow DOM can't
|
||||||
|
// see either — a shadow component that wants the same button chrome needs
|
||||||
|
// its own copy, so this is now a third copy. Picked dashboard's version
|
||||||
|
// (the fuller one) as canonical for shadow components going forward.
|
||||||
|
// Unifying all three into one source of truth is a separate follow-up, not
|
||||||
|
// bundled into whichever component conversion first needed a button.
|
||||||
|
|
||||||
|
let sheet = null;
|
||||||
|
|
||||||
|
export function sharedComponentStyleSheet() {
|
||||||
|
if (!sheet) {
|
||||||
|
sheet = new CSSStyleSheet();
|
||||||
|
sheet.replaceSync(`
|
||||||
|
.btn {
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
background: transparent;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid;
|
||||||
|
padding: 0.25em 0.8em;
|
||||||
|
cursor: pointer;
|
||||||
|
text-shadow: 0 0 4px currentColor;
|
||||||
|
box-shadow: 0 0 0 0 currentColor;
|
||||||
|
transition: box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--fg) 6%, transparent);
|
||||||
|
text-shadow: 0 0 10px currentColor;
|
||||||
|
box-shadow: 0 0 10px -2px currentColor;
|
||||||
|
}
|
||||||
|
.btn:disabled,
|
||||||
|
.btn[disabled] {
|
||||||
|
opacity: 0.32;
|
||||||
|
cursor: not-allowed;
|
||||||
|
text-shadow: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
return sheet;
|
||||||
|
}
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
/* modal.css — themed dialog component styles.
|
|
||||||
Pairs with modal.js (themedConfirm / themedPrompt / themedToast),
|
|
||||||
whose `<hive-dialog>` / `<hive-toast>` custom elements are styled
|
|
||||||
entirely by class selectors here (light DOM, no shadow root — see the
|
|
||||||
design note atop modal.js). The dialogs are raised from the shared
|
|
||||||
`bindAsyncForms` data-async / data-confirm handler (forms.js), used by
|
|
||||||
both the dashboard and the per-agent UI, so both packages' base
|
|
||||||
stylesheets (common.css / agent.css) @import this to keep the styles
|
|
||||||
present wherever a dialog can fire.
|
|
||||||
(Previously these rules lived in dashboard.css, so dialogs rendered
|
|
||||||
unstyled on standalone dashboard pages such as /core, and the per-agent
|
|
||||||
UI never had them at all — a themed-dialogs consistency fix moved them
|
|
||||||
here and wired both packages' base stylesheets to import this file.) */
|
|
||||||
|
|
||||||
.tc-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 1em;
|
|
||||||
background: color-mix(in srgb, var(--crust) 60%, transparent);
|
|
||||||
-webkit-backdrop-filter: blur(2px);
|
|
||||||
backdrop-filter: blur(2px);
|
|
||||||
}
|
|
||||||
.tc-box {
|
|
||||||
background: var(--bg-elev);
|
|
||||||
border: 1px solid var(--purple-dim);
|
|
||||||
box-shadow: 0 8px 40px -8px var(--crust);
|
|
||||||
padding: 1.2em 1.4em;
|
|
||||||
max-width: min(32em, 92vw);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.9em;
|
|
||||||
}
|
|
||||||
.tc-title {
|
|
||||||
font-weight: bold;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--subtext0);
|
|
||||||
}
|
|
||||||
.tc-message { color: var(--fg); line-height: 1.45; }
|
|
||||||
.tc-checks { display: flex; flex-direction: column; gap: 0.4em; }
|
|
||||||
.tc-checkrow {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.5em;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--subtext0);
|
|
||||||
font-size: 0.92em;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
.tc-checkrow .tc-check { margin-top: 0.2em; flex: 0 0 auto; }
|
|
||||||
.tc-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.6em;
|
|
||||||
margin-top: 0.2em;
|
|
||||||
}
|
|
||||||
.tc-cancel { color: var(--subtext0); }
|
|
||||||
.tc-confirm { color: var(--green); }
|
|
||||||
.tc-confirm.tc-danger { color: var(--red); }
|
|
||||||
|
|
||||||
/* themedPrompt input field */
|
|
||||||
.tc-promptfield { display: flex; flex-direction: column; gap: 0.4em; }
|
|
||||||
.tc-promptlabel { color: var(--subtext0); font-size: 0.9em; }
|
|
||||||
.tc-input {
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 1em;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: var(--crust);
|
|
||||||
color: var(--fg);
|
|
||||||
border: 1px solid var(--purple-dim);
|
|
||||||
padding: 0.4em 0.6em;
|
|
||||||
}
|
|
||||||
.tc-input:focus { outline: 1px solid var(--green); }
|
|
||||||
/* themedPrompt's field is always a resizable textarea (Enter submits,
|
|
||||||
Shift+Enter inserts a newline) — sensible min-height, wrapped output. */
|
|
||||||
.tc-textarea {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 4.5em;
|
|
||||||
line-height: 1.4;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* themedToast — non-blocking transient notifications (errors/info/ok) */
|
|
||||||
.tc-toasts {
|
|
||||||
position: fixed;
|
|
||||||
top: 1em;
|
|
||||||
right: 1em;
|
|
||||||
z-index: 1100;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5em;
|
|
||||||
max-width: min(28em, 92vw);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.tc-toast {
|
|
||||||
pointer-events: auto;
|
|
||||||
cursor: pointer;
|
|
||||||
background: var(--bg-elev);
|
|
||||||
border: 1px solid var(--purple-dim);
|
|
||||||
border-left-width: 3px;
|
|
||||||
box-shadow: 0 4px 20px -6px var(--crust);
|
|
||||||
padding: 0.6em 0.9em;
|
|
||||||
font-size: 0.9em;
|
|
||||||
color: var(--fg);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
opacity: 1;
|
|
||||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
||||||
}
|
|
||||||
.tc-toast-out { opacity: 0; transform: translateX(0.5em); }
|
|
||||||
.tc-toast-error { border-left-color: var(--red); }
|
|
||||||
.tc-toast-info { border-left-color: var(--purple-dim); }
|
|
||||||
.tc-toast-ok { border-left-color: var(--green); }
|
|
||||||
|
|
@ -4,27 +4,110 @@
|
||||||
// actions and prompts match each page's chrome instead of a jarring OS
|
// actions and prompts match each page's chrome instead of a jarring OS
|
||||||
// dialog.
|
// dialog.
|
||||||
//
|
//
|
||||||
// Implemented as two light-DOM custom elements (`<hive-dialog>`,
|
// Implemented as two shadow-DOM custom elements (`<hive-dialog>`,
|
||||||
// `<hive-toast>`) — no shadow root, so styling stays exactly where it
|
// `<hive-toast>`). Each attaches its own shadow root and adopts a
|
||||||
// already lived: the `.tc-*` classes in `modal.css`, imported globally by
|
// component-scoped `CSSStyleSheet` alongside the shared one in
|
||||||
// both packages' base stylesheets. Light DOM was the deliberate call for
|
// `component-styles.js` (the native equivalent of a Sass `@include`) —
|
||||||
// this first custom-element pilot (see the frontend components-split
|
// real style encapsulation, no global `.tc-*` class namespace needed any
|
||||||
// discussion on the forge issue tracker): it buys lifecycle
|
// more, and no stylesheet to import from every page. Theme vars (`--bg`,
|
||||||
// encapsulation (`connectedCallback`/`disconnectedCallback` own the
|
// `--purple`, …) keep resolving inside the shadow tree automatically:
|
||||||
// keydown listener / auto-dismiss timer instead of hand-rolled
|
// CSS custom properties inherit through shadow boundaries even though
|
||||||
// add/removeEventListener bookkeeping in a closure) without paying the
|
// plain class rules don't. This started as a light-DOM pilot (see the
|
||||||
// shadow-DOM cost of re-importing every shared stylesheet per instance.
|
// frontend components-split discussion on the forge issue tracker) —
|
||||||
// If a later pilot wants real style encapsulation, shadow DOM is a
|
// light DOM validated the lifecycle-encapsulation win
|
||||||
// drop-in upgrade to these same two classes — the public functional API
|
// (`connectedCallback`/`disconnectedCallback` owning the keydown
|
||||||
// below (`openDialog`/`themedConfirm`/`themedPrompt`/`themedToast`)
|
// listener / auto-dismiss timer) cheaply; this is the shadow-DOM
|
||||||
// wouldn't need to change either way. (Pilot for the frontend
|
// follow-up once real per-component style scoping was worth the cost.
|
||||||
// components-split proposal — see the forge issue tracker for context.)
|
|
||||||
//
|
//
|
||||||
// `openDialog` is the general primitive (any title/message/content + a row of
|
// `openDialog` is the general primitive (any title/message/content + a row of
|
||||||
// buttons); `themedConfirm` is a thin cancel/confirm wrapper with optional
|
// buttons); `themedConfirm` is a thin cancel/confirm wrapper with optional
|
||||||
// checkboxes built on top of it.
|
// checkboxes built on top of it.
|
||||||
|
|
||||||
import { el } from './dom.js';
|
import { el } from './dom.js';
|
||||||
|
import { sharedComponentStyleSheet } from './component-styles.js';
|
||||||
|
|
||||||
|
// Attach an open shadow root to `host`, adopt the shared component
|
||||||
|
// stylesheet plus `ownCss` (a scoped stylesheet built fresh per call —
|
||||||
|
// only the shared one is cached/reused across instances), and return the
|
||||||
|
// shadow root for the caller to populate.
|
||||||
|
function attachShadow(host, ownCss) {
|
||||||
|
const root = host.attachShadow({ mode: 'open' });
|
||||||
|
const own = new CSSStyleSheet();
|
||||||
|
own.replaceSync(ownCss);
|
||||||
|
root.adoptedStyleSheets = [sharedComponentStyleSheet(), own];
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIALOG_CSS = `
|
||||||
|
:host {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1em;
|
||||||
|
background: color-mix(in srgb, var(--crust) 60%, transparent);
|
||||||
|
-webkit-backdrop-filter: blur(2px);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
.box {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--purple-dim);
|
||||||
|
box-shadow: 0 8px 40px -8px var(--crust);
|
||||||
|
padding: 1.2em 1.4em;
|
||||||
|
max-width: min(32em, 92vw);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.9em;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--subtext0);
|
||||||
|
}
|
||||||
|
.message { color: var(--fg); line-height: 1.45; }
|
||||||
|
.checks { display: flex; flex-direction: column; gap: 0.4em; }
|
||||||
|
.checkrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5em;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--subtext0);
|
||||||
|
font-size: 0.92em;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.checkrow .check { margin-top: 0.2em; flex: 0 0 auto; }
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.6em;
|
||||||
|
margin-top: 0.2em;
|
||||||
|
}
|
||||||
|
.cancel { color: var(--subtext0); }
|
||||||
|
.confirm { color: var(--green); }
|
||||||
|
.confirm.danger { color: var(--red); }
|
||||||
|
.promptfield { display: flex; flex-direction: column; gap: 0.4em; }
|
||||||
|
.promptlabel { color: var(--subtext0); font-size: 0.9em; }
|
||||||
|
.input {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1em;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--crust);
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid var(--purple-dim);
|
||||||
|
padding: 0.4em 0.6em;
|
||||||
|
}
|
||||||
|
.input:focus { outline: 1px solid var(--green); }
|
||||||
|
.textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 4.5em;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
// <hive-dialog> — the backdrop + box custom element behind `openDialog`.
|
// <hive-dialog> — the backdrop + box custom element behind `openDialog`.
|
||||||
// Not exported; constructed and configured by `openDialog` only. Callers
|
// Not exported; constructed and configured by `openDialog` only. Callers
|
||||||
|
|
@ -34,7 +117,9 @@ import { el } from './dom.js';
|
||||||
// via a `hive-dialog-close` CustomEvent (`detail` = the resolved value)
|
// via a `hive-dialog-close` CustomEvent (`detail` = the resolved value)
|
||||||
// rather than exposing a resolve/reject pair directly — that keeps the
|
// rather than exposing a resolve/reject pair directly — that keeps the
|
||||||
// element a normal DOM node with a normal event contract instead of a
|
// element a normal DOM node with a normal event contract instead of a
|
||||||
// bespoke Promise-ish object.
|
// bespoke Promise-ish object. The element itself *is* the backdrop
|
||||||
|
// (`:host` carries the fixed-position/centering rules); the box, title,
|
||||||
|
// message, content, and buttons all render inside its shadow root.
|
||||||
class HiveDialog extends HTMLElement {
|
class HiveDialog extends HTMLElement {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
const {
|
const {
|
||||||
|
|
@ -43,7 +128,8 @@ class HiveDialog extends HTMLElement {
|
||||||
danger = false, dismissable = true,
|
danger = false, dismissable = true,
|
||||||
} = this._opts || {};
|
} = this._opts || {};
|
||||||
|
|
||||||
this.className = 'tc-backdrop';
|
const root = attachShadow(this, DIALOG_CSS);
|
||||||
|
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const done = (value) => {
|
const done = (value) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
|
|
@ -64,7 +150,7 @@ class HiveDialog extends HTMLElement {
|
||||||
const btnEls = buttons.map((b) => {
|
const btnEls = buttons.map((b) => {
|
||||||
const btn = el('button', {
|
const btn = el('button', {
|
||||||
type: 'button',
|
type: 'button',
|
||||||
class: 'btn tc-btn' + (b.class ? ' ' + b.class : '') + (b.danger ? ' tc-danger' : ''),
|
class: 'btn' + (b.class ? ' ' + b.class : '') + (b.danger ? ' danger' : ''),
|
||||||
}, b.label);
|
}, b.label);
|
||||||
btn.addEventListener('click', () => done(b.value));
|
btn.addEventListener('click', () => done(b.value));
|
||||||
return { spec: b, btn };
|
return { spec: b, btn };
|
||||||
|
|
@ -72,20 +158,23 @@ class HiveDialog extends HTMLElement {
|
||||||
|
|
||||||
// Give the dialog an accessible name: label it by its title if present,
|
// Give the dialog an accessible name: label it by its title if present,
|
||||||
// else by its message, via `aria-labelledby` (a11y — role=dialog needs a
|
// else by its message, via `aria-labelledby` (a11y — role=dialog needs a
|
||||||
// name). Only the labelling element carries the id.
|
// name). Only the labelling element carries the id. IDs are scoped to
|
||||||
const labelId = 'tc-dlg-' + Math.random().toString(36).slice(2, 9);
|
// this shadow root, so no cross-instance collision risk even without
|
||||||
const titleEl = title ? el('div', { class: 'tc-title', id: labelId }, title) : null;
|
// the random suffix — kept anyway since it costs nothing and guards
|
||||||
|
// against a future shared-DOM edge case (e.g. `::part()` piercing).
|
||||||
|
const labelId = 'dlg-' + Math.random().toString(36).slice(2, 9);
|
||||||
|
const titleEl = title ? el('div', { class: 'title', id: labelId }, title) : null;
|
||||||
const messageEl = message
|
const messageEl = message
|
||||||
? el('div', title ? { class: 'tc-message' } : { class: 'tc-message', id: labelId }, message)
|
? el('div', title ? { class: 'message' } : { class: 'message', id: labelId }, message)
|
||||||
: null;
|
: null;
|
||||||
const boxAttrs = { class: 'tc-box', role: 'dialog', 'aria-modal': 'true' };
|
const boxAttrs = { class: 'box', role: 'dialog', 'aria-modal': 'true' };
|
||||||
if (titleEl || messageEl) boxAttrs['aria-labelledby'] = labelId;
|
if (titleEl || messageEl) boxAttrs['aria-labelledby'] = labelId;
|
||||||
const box = el('div', boxAttrs,
|
const box = el('div', boxAttrs,
|
||||||
titleEl,
|
titleEl,
|
||||||
messageEl,
|
messageEl,
|
||||||
content || null,
|
content || null,
|
||||||
el('div', { class: 'tc-actions' }, ...btnEls.map((b) => b.btn)));
|
el('div', { class: 'actions' }, ...btnEls.map((b) => b.btn)));
|
||||||
this.append(box);
|
root.append(box);
|
||||||
|
|
||||||
this.addEventListener('click', (e) => {
|
this.addEventListener('click', (e) => {
|
||||||
if (dismissable && e.target === this) done(null);
|
if (dismissable && e.target === this) done(null);
|
||||||
|
|
@ -104,11 +193,14 @@ customElements.define('hive-dialog', HiveDialog);
|
||||||
// → Promise resolving to the clicked button's `value`, or `null` when the
|
// → Promise resolving to the clicked button's `value`, or `null` when the
|
||||||
// dialog is dismissed (Escape, backdrop click, or a button whose value is
|
// dialog is dismissed (Escape, backdrop click, or a button whose value is
|
||||||
// null). `content` is an optional DOM node rendered between the message
|
// null). `content` is an optional DOM node rendered between the message
|
||||||
// and the buttons (checkboxes, custom fields, …). `buttons` is
|
// and the buttons (checkboxes, custom fields, …) — built by the caller
|
||||||
// `[{ label, value, danger?, class?, autofocus? }]`, rendered
|
// with `el()` and appended into the dialog's shadow root once mounted,
|
||||||
// right-aligned. Initial focus: the `autofocus` button if any, else — for
|
// same as any other node (a JS-created element isn't bound to a
|
||||||
// a `danger` dialog — the first non-destructive button (so a stray Enter
|
// document/shadow-root until it's actually appended somewhere).
|
||||||
// can't fire the destructive path), else the last button.
|
// `buttons` is `[{ label, value, danger?, class?, autofocus? }]`,
|
||||||
|
// rendered right-aligned. Initial focus: the `autofocus` button if any,
|
||||||
|
// else — for a `danger` dialog — the first non-destructive button (so a
|
||||||
|
// stray Enter can't fire the destructive path), else the last button.
|
||||||
export function openDialog(opts = {}) {
|
export function openDialog(opts = {}) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const dlg = document.createElement('hive-dialog');
|
const dlg = document.createElement('hive-dialog');
|
||||||
|
|
@ -133,14 +225,14 @@ export function themedConfirm(opts = {}) {
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
const boxes = checkboxes.map((cb) => {
|
const boxes = checkboxes.map((cb) => {
|
||||||
const input = el('input', { type: 'checkbox', class: 'tc-check', name: cb.name });
|
const input = el('input', { type: 'checkbox', class: 'check', name: cb.name });
|
||||||
if (cb.checked) input.checked = true;
|
if (cb.checked) input.checked = true;
|
||||||
const row = el('label', { class: 'tc-checkrow' },
|
const row = el('label', { class: 'checkrow' },
|
||||||
input, el('span', { class: 'tc-check-label' }, cb.label || cb.name));
|
input, el('span', {}, cb.label || cb.name));
|
||||||
return { input, row };
|
return { input, row };
|
||||||
});
|
});
|
||||||
const content = boxes.length
|
const content = boxes.length
|
||||||
? el('div', { class: 'tc-checks' }, ...boxes.map((b) => b.row))
|
? el('div', { class: 'checks' }, ...boxes.map((b) => b.row))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return openDialog({
|
return openDialog({
|
||||||
|
|
@ -149,8 +241,8 @@ export function themedConfirm(opts = {}) {
|
||||||
content,
|
content,
|
||||||
danger,
|
danger,
|
||||||
buttons: [
|
buttons: [
|
||||||
{ label: cancelLabel, value: null, class: 'tc-cancel', autofocus: danger },
|
{ label: cancelLabel, value: null, class: 'cancel', autofocus: danger },
|
||||||
{ label: confirmLabel, value: 'confirm', danger, class: 'tc-confirm', autofocus: !danger },
|
{ label: confirmLabel, value: 'confirm', danger, class: 'confirm', autofocus: !danger },
|
||||||
],
|
],
|
||||||
}).then((v) => {
|
}).then((v) => {
|
||||||
if (v !== 'confirm') return null;
|
if (v !== 'confirm') return null;
|
||||||
|
|
@ -172,26 +264,29 @@ export function themedPrompt(opts = {}) {
|
||||||
title = '', message = '', label = '', placeholder = '', value = '',
|
title = '', message = '', label = '', placeholder = '', value = '',
|
||||||
confirmLabel = 'ok', cancelLabel = 'cancel',
|
confirmLabel = 'ok', cancelLabel = 'cancel',
|
||||||
} = opts;
|
} = opts;
|
||||||
const input = el('textarea', { class: 'tc-input tc-textarea', rows: '3', placeholder });
|
const input = el('textarea', { class: 'input textarea', rows: '3', placeholder });
|
||||||
if (value) input.value = value;
|
if (value) input.value = value;
|
||||||
// Enter submits (clicks the confirm button mounted by openDialog);
|
// Enter submits (clicks the confirm button mounted by openDialog);
|
||||||
// Shift+Enter falls through to the textarea's default newline insert.
|
// Shift+Enter falls through to the textarea's default newline insert.
|
||||||
|
// `closest()`/`querySelector()` stay within the shadow tree the input
|
||||||
|
// lives in, so this resolves to the dialog's own `.box`/`.confirm`
|
||||||
|
// without leaking across instances.
|
||||||
input.addEventListener('keydown', (e) => {
|
input.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
input.closest('.tc-box')?.querySelector('.tc-confirm')?.click();
|
input.closest('.box')?.querySelector('.confirm')?.click();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const content = el('div', { class: 'tc-promptfield' },
|
const content = el('div', { class: 'promptfield' },
|
||||||
label ? el('label', { class: 'tc-promptlabel' }, label) : null,
|
label ? el('label', { class: 'promptlabel' }, label) : null,
|
||||||
input);
|
input);
|
||||||
const result = openDialog({
|
const result = openDialog({
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
content,
|
content,
|
||||||
buttons: [
|
buttons: [
|
||||||
{ label: cancelLabel, value: '__cancel__', class: 'tc-cancel' },
|
{ label: cancelLabel, value: '__cancel__', class: 'cancel' },
|
||||||
{ label: confirmLabel, value: '__ok__', class: 'tc-confirm', autofocus: true },
|
{ label: confirmLabel, value: '__ok__', class: 'confirm', autofocus: true },
|
||||||
],
|
],
|
||||||
}).then((v) => (v === '__ok__' ? input.value : null));
|
}).then((v) => (v === '__ok__' ? input.value : null));
|
||||||
// Prefer focusing the field over the OK button once the dialog has mounted.
|
// Prefer focusing the field over the OK button once the dialog has mounted.
|
||||||
|
|
@ -199,24 +294,51 @@ export function themedPrompt(opts = {}) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TOAST_CSS = `
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--purple-dim);
|
||||||
|
border-left-width: 3px;
|
||||||
|
box-shadow: 0 4px 20px -6px var(--crust);
|
||||||
|
padding: 0.6em 0.9em;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--fg);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
:host(.out) { opacity: 0; transform: translateX(0.5em); }
|
||||||
|
:host(.error) { border-left-color: var(--red); }
|
||||||
|
:host(.info) { border-left-color: var(--purple-dim); }
|
||||||
|
:host(.ok) { border-left-color: var(--green); }
|
||||||
|
`;
|
||||||
|
|
||||||
// <hive-toast> — one transient notification entry. Owns its own
|
// <hive-toast> — one transient notification entry. Owns its own
|
||||||
// auto-dismiss timer via connectedCallback/disconnectedCallback (cleared
|
// auto-dismiss timer via connectedCallback/disconnectedCallback (cleared
|
||||||
// on removal so a toast dismissed early by click doesn't leave a stray
|
// on removal so a toast dismissed early by click doesn't leave a stray
|
||||||
// timer), rather than the closure-captured timer handle the pre-custom-
|
// timer), rather than the closure-captured timer handle the pre-custom-
|
||||||
// element version used. Callers set `._message`/`._opts` before append —
|
// element version used. Callers set `._message`/`._opts` before append —
|
||||||
// same reason as `<hive-dialog>` above.
|
// same reason as `<hive-dialog>` above. The element itself is the visible
|
||||||
|
// toast box (`:host` carries the styling, `:host(.error)` etc. switch on
|
||||||
|
// a plain class the host carries) — the message text renders directly in
|
||||||
|
// the shadow root rather than via a `<slot>`, since there's no external
|
||||||
|
// light-DOM content to project.
|
||||||
class HiveToast extends HTMLElement {
|
class HiveToast extends HTMLElement {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
const { type = 'info', duration } = this._opts || {};
|
const { type = 'info', duration } = this._opts || {};
|
||||||
const ms = duration != null ? duration : (type === 'error' ? 8000 : 4000);
|
const ms = duration != null ? duration : (type === 'error' ? 8000 : 4000);
|
||||||
this.className = 'tc-toast tc-toast-' + type;
|
const root = attachShadow(this, TOAST_CSS);
|
||||||
|
this.classList.add(type);
|
||||||
this.setAttribute('role', type === 'error' ? 'alert' : 'status');
|
this.setAttribute('role', type === 'error' ? 'alert' : 'status');
|
||||||
this.textContent = this._message || '';
|
root.textContent = this._message || '';
|
||||||
let removed = false;
|
let removed = false;
|
||||||
this._remove = () => {
|
this._remove = () => {
|
||||||
if (removed) return;
|
if (removed) return;
|
||||||
removed = true;
|
removed = true;
|
||||||
this.classList.add('tc-toast-out');
|
this.classList.add('out');
|
||||||
setTimeout(() => this.remove(), 200);
|
setTimeout(() => this.remove(), 200);
|
||||||
};
|
};
|
||||||
this.addEventListener('click', this._remove);
|
this.addEventListener('click', this._remove);
|
||||||
|
|
@ -233,10 +355,25 @@ customElements.define('hive-toast', HiveToast);
|
||||||
// decision (errors, validation, status). Stacks in a fixed top-right
|
// decision (errors, validation, status). Stacks in a fixed top-right
|
||||||
// container, auto-dismisses after `duration` ms (errors linger longer), and
|
// container, auto-dismisses after `duration` ms (errors linger longer), and
|
||||||
// can be clicked to dismiss early. `type` ∈ {'info','error','ok'}.
|
// can be clicked to dismiss early. `type` ∈ {'info','error','ok'}.
|
||||||
|
// The stack container is a plain positioning wrapper (not itself a
|
||||||
|
// component — no theming, no encapsulation need), so it's styled with a
|
||||||
|
// one-off inline style rather than a stylesheet.
|
||||||
export function themedToast(message, opts = {}) {
|
export function themedToast(message, opts = {}) {
|
||||||
let container = document.getElementById('tc-toasts');
|
let container = document.getElementById('tc-toasts');
|
||||||
if (!container) {
|
if (!container) {
|
||||||
container = el('div', { id: 'tc-toasts', class: 'tc-toasts' });
|
container = document.createElement('div');
|
||||||
|
container.id = 'tc-toasts';
|
||||||
|
Object.assign(container.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
top: '1em',
|
||||||
|
right: '1em',
|
||||||
|
zIndex: '1100',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5em',
|
||||||
|
maxWidth: 'min(28em, 92vw)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
document.body.append(container);
|
document.body.append(container);
|
||||||
}
|
}
|
||||||
const toast = document.createElement('hive-toast');
|
const toast = document.createElement('hive-toast');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue