Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a588ed42ce | ||
|
|
bce666a9b2 | ||
|
|
d79df3883d |
12 changed files with 297 additions and 174 deletions
|
|
@ -41,6 +41,10 @@ await build({
|
|||
target: ['es2022'],
|
||||
sourcemap: true,
|
||||
logLevel: 'info',
|
||||
// `@hive/shared/modal.js` imports its shadow-DOM component CSS as raw
|
||||
// text (see dashboard/build.mjs's matching comment) — same reasoning
|
||||
// applies here.
|
||||
loader: { '.css': 'text' },
|
||||
});
|
||||
|
||||
// Bundle the CSS. `colors.css` re-exports the standalone base16 palette
|
||||
|
|
|
|||
|
|
@ -6,10 +6,9 @@
|
|||
agent SUB-pages (stats, screen) the same back-link nav the dashboard's
|
||||
standalone pages use. The live terminal page keeps its own header. */
|
||||
@import "@hive/shared/chrome.css";
|
||||
/* Themed dialog/toast styles for the shared bindAsyncForms handler + any
|
||||
direct themedConfirm/themedToast calls, so the per-agent UI's
|
||||
confirmations and errors render in-theme instead of unstyled. */
|
||||
@import "@hive/shared/modal.css";
|
||||
/* Themed dialog/toast component CSS (modal.js) now lives in each
|
||||
component's own shadow root (adoptedStyleSheets), not a global
|
||||
stylesheet — nothing to @import here any more. */
|
||||
|
||||
/* ─── full-screen layout overrides ─────────────────────────────────
|
||||
The agent page mounts a full-viewport terminal under a fixed
|
||||
|
|
|
|||
|
|
@ -74,6 +74,15 @@ await build({
|
|||
target: ['es2022'],
|
||||
sourcemap: true,
|
||||
logLevel: 'info',
|
||||
// `@hive/shared/modal.js` and `hive-btn.js` import their shadow-DOM
|
||||
// component CSS (hive-dialog.css, hive-toast.css, hive-btn.css) as raw
|
||||
// text via a plain `import css from './foo.css'` — the `text` loader turns
|
||||
// that into a string constant at bundle time instead of erroring on an
|
||||
// unrecognised extension. None of these JS entries import a `.css`
|
||||
// file any other way, so this doesn't collide with the separate
|
||||
// page-stylesheet bundling below (`loader: { '.css': 'css' }`), which
|
||||
// runs as its own esbuild invocation over different entry points.
|
||||
loader: { '.css': 'text' },
|
||||
});
|
||||
|
||||
// Stream-worker entry (#448). Lives in a separate bundle: SharedWorker
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
@import "@hive/shared/terminal.css";
|
||||
@import "@hive/shared/tabs.css";
|
||||
@import "@hive/shared/chrome.css";
|
||||
@import "@hive/shared/modal.css";
|
||||
|
||||
/* ─── global typography ─────────────────────────────────────────────
|
||||
Element-level rules shared across all three pages (index, flow,
|
||||
|
|
|
|||
|
|
@ -17,8 +17,7 @@
|
|||
"./chrome.css": "./src/chrome.css",
|
||||
"./forms.js": "./src/forms.js",
|
||||
"./dom.js": "./src/dom.js",
|
||||
"./modal.js": "./src/modal.js",
|
||||
"./modal.css": "./src/modal.css"
|
||||
"./modal.js": "./src/modal.js"
|
||||
},
|
||||
"files": [
|
||||
"src/"
|
||||
|
|
|
|||
|
|
@ -5,12 +5,18 @@
|
|||
// used).
|
||||
//
|
||||
// `el(tag, attrs, ...children)` creates an element, applying `attrs` as
|
||||
// either the `class`/`html` special cases or plain attributes, and
|
||||
// either the `class`/`html`/`is` special cases or plain attributes, and
|
||||
// appending `children` (strings become text nodes, `null`/`undefined`
|
||||
// entries are skipped so callers can inline conditional children).
|
||||
// `is: 'custom-name'` creates a customized built-in element (e.g.
|
||||
// `el('button', { is: 'hive-btn' }, 'label')` → `<button is="hive-btn">`)
|
||||
// — passed to `document.createElement` itself, since a customized
|
||||
// built-in has to be created with its `is` option up front, not upgraded
|
||||
// after the fact via `setAttribute`.
|
||||
export const el = (tag, attrs = {}, ...children) => {
|
||||
const e = document.createElement(tag);
|
||||
const e = attrs.is ? document.createElement(tag, { is: attrs.is }) : document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === 'is') continue;
|
||||
if (k === 'class') e.className = v;
|
||||
else if (k === 'html') e.innerHTML = v;
|
||||
else e.setAttribute(k, v);
|
||||
|
|
|
|||
40
frontend/packages/shared/src/hive-btn.css
Normal file
40
frontend/packages/shared/src/hive-btn.css
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/* hive-btn.css — scoped stylesheet for the <hive-btn> customized built-in
|
||||
button element (hive-btn.js). Loaded as raw text at build time
|
||||
(esbuild's `text` loader) and turned into a `CSSStyleSheet` adopted by
|
||||
the element's own shadow root. `:host` styles the button itself (the
|
||||
element *is* a real `<button is="hive-btn">`, so `:host(:hover)` /
|
||||
`:host(:disabled)` select genuine native pseudo-classes, not
|
||||
hand-rolled state tracking) — this is the base look every `<hive-btn>`
|
||||
gets; `variant` is an attribute (not a class) since it's a semantic
|
||||
property of the component, not an arbitrary styling hook. */
|
||||
|
||||
:host {
|
||||
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;
|
||||
color: inherit;
|
||||
text-shadow: 0 0 4px currentColor;
|
||||
box-shadow: 0 0 0 0 currentColor;
|
||||
transition: box-shadow 0.15s ease;
|
||||
}
|
||||
:host(:hover) {
|
||||
background: color-mix(in srgb, var(--fg) 6%, transparent);
|
||||
text-shadow: 0 0 10px currentColor;
|
||||
box-shadow: 0 0 10px -2px currentColor;
|
||||
}
|
||||
:host(:disabled) {
|
||||
opacity: 0.32;
|
||||
cursor: not-allowed;
|
||||
text-shadow: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
:host([variant="cancel"]) { color: var(--subtext0); }
|
||||
:host([variant="confirm"]) { color: var(--green); }
|
||||
:host([variant="danger"]) { color: var(--red); }
|
||||
40
frontend/packages/shared/src/hive-btn.js
Normal file
40
frontend/packages/shared/src/hive-btn.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// hive-btn.js — <hive-btn>, a customized built-in `<button>` (`<button
|
||||
// is="hive-btn">`) with a shadow root for style encapsulation, replacing
|
||||
// the light-DOM `.btn` class as the button primitive for shadow-DOM
|
||||
// consumers (starting with modal.js's dialog buttons — see the frontend
|
||||
// components-split discussion on the forge issue tracker).
|
||||
//
|
||||
// Extending `HTMLButtonElement` (a "customized built-in element", not an
|
||||
// autonomous one) keeps every native `<button>` behaviour for free —
|
||||
// click/keyboard activation, `:disabled`, form participation/submission —
|
||||
// instead of re-implementing them on a generic wrapper. The shadow root
|
||||
// holds only a `<style>`-equivalent (adopted stylesheet) plus a `<slot>`
|
||||
// so the button's light-DOM content (its label, or asyncBtn's swapped-in
|
||||
// spinner span) renders through unchanged — slotted content stays styled
|
||||
// by the light-DOM cascade (global `.spinner` etc. still apply), only the
|
||||
// host element's own box/text styling is shadow-scoped.
|
||||
//
|
||||
// Customized built-ins aren't supported in Safari/WebKit (a deliberate
|
||||
// WebKit-team stance, unlikely to change) — fine here since the project
|
||||
// targets recent Firefox only.
|
||||
//
|
||||
// Usage: `el('button', { type: 'button', is: 'hive-btn', variant: 'danger' }, 'label')`
|
||||
// (dom.js's `el()` passes `is` to `document.createElement` so custom-
|
||||
// element upgrade happens at creation, not after). `variant` is one of
|
||||
// 'cancel' | 'confirm' | 'danger' | (unset, for the neutral/default look)
|
||||
// — a plain attribute, not a class, since it's a semantic prop of the
|
||||
// component rather than an arbitrary styling hook.
|
||||
|
||||
import hiveBtnCss from './hive-btn.css';
|
||||
|
||||
class HiveBtn extends HTMLButtonElement {
|
||||
connectedCallback() {
|
||||
if (this.shadowRoot) return; // guard: connectedCallback can re-fire (e.g. re-parenting)
|
||||
const root = this.attachShadow({ mode: 'open' });
|
||||
const sheet = new CSSStyleSheet();
|
||||
sheet.replaceSync(hiveBtnCss);
|
||||
root.adoptedStyleSheets = [sheet];
|
||||
root.append(document.createElement('slot'));
|
||||
}
|
||||
}
|
||||
customElements.define('hive-btn', HiveBtn, { extends: 'button' });
|
||||
73
frontend/packages/shared/src/hive-dialog.css
Normal file
73
frontend/packages/shared/src/hive-dialog.css
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/* hive-dialog.css — scoped stylesheet for the <hive-dialog> shadow-DOM
|
||||
custom element (modal.js). Loaded as raw text at build time (esbuild's
|
||||
`text` loader) and adopted via `adoptedStyleSheets`. `:host` styles the
|
||||
element itself, which *is* the fixed-position backdrop — no separate
|
||||
light-DOM backdrop div. Button styling (base look + cancel/confirm/
|
||||
danger variants) lives in hive-btn.css, not here — the dialog's
|
||||
buttons are <hive-btn> elements with their own shadow root. */
|
||||
|
||||
: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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
25
frontend/packages/shared/src/hive-toast.css
Normal file
25
frontend/packages/shared/src/hive-toast.css
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/* hive-toast.css — scoped stylesheet for the <hive-toast> shadow-DOM
|
||||
custom element (modal.js). Loaded as raw text at build time (esbuild's
|
||||
`text` loader) and adopted via `adoptedStyleSheets`. `:host` styles the
|
||||
element itself, which *is* the visible toast box; `:host(.error)` etc.
|
||||
switch on a plain class the host element carries (set in JS). */
|
||||
|
||||
: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); }
|
||||
|
|
@ -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,36 @@
|
|||
// actions and prompts match each page's chrome instead of a jarring OS
|
||||
// dialog.
|
||||
//
|
||||
// Implemented as two light-DOM custom elements (`<hive-dialog>`,
|
||||
// `<hive-toast>`) — no shadow root, so styling stays exactly where it
|
||||
// already lived: the `.tc-*` classes in `modal.css`, imported globally by
|
||||
// both packages' base stylesheets. Light DOM was the deliberate call for
|
||||
// this first custom-element pilot (see the frontend components-split
|
||||
// discussion on the forge issue tracker): it buys lifecycle
|
||||
// encapsulation (`connectedCallback`/`disconnectedCallback` own the
|
||||
// keydown listener / auto-dismiss timer instead of hand-rolled
|
||||
// add/removeEventListener bookkeeping in a closure) without paying the
|
||||
// shadow-DOM cost of re-importing every shared stylesheet per instance.
|
||||
// If a later pilot wants real style encapsulation, shadow DOM is a
|
||||
// drop-in upgrade to these same two classes — the public functional API
|
||||
// below (`openDialog`/`themedConfirm`/`themedPrompt`/`themedToast`)
|
||||
// wouldn't need to change either way. (Pilot for the frontend
|
||||
// components-split proposal — see the forge issue tracker for context.)
|
||||
// Implemented as two shadow-DOM custom elements (`<hive-dialog>`,
|
||||
// `<hive-toast>`), plus `<hive-btn>` (hive-btn.js) for the dialog's own
|
||||
// buttons — real per-component style encapsulation, CSS in real `.css`
|
||||
// files imported as raw text (see each component's own header comment
|
||||
// for the design rationale: shadow-DOM-vs-light-DOM tradeoffs live atop
|
||||
// the `<hive-dialog>`/`<hive-toast>` classes below, the customized-
|
||||
// built-in choice lives in hive-btn.js). Other `.btn` consumers across
|
||||
// the app stay on the light-DOM `.btn` class for now — migrating them
|
||||
// is a separate follow-up.
|
||||
//
|
||||
// `openDialog` is the general primitive (any title/message/content + a row of
|
||||
// buttons); `themedConfirm` is a thin cancel/confirm wrapper with optional
|
||||
// checkboxes built on top of it.
|
||||
|
||||
import { el } from './dom.js';
|
||||
import './hive-btn.js'; // registers <hive-btn> — side-effect import, no named export needed
|
||||
import dialogCss from './hive-dialog.css';
|
||||
import toastCss from './hive-toast.css';
|
||||
|
||||
// Attach an open shadow root to `host`, adopt `ownCssText` (a scoped
|
||||
// stylesheet built fresh per call from a real .css file's contents,
|
||||
// imported as raw text), and return the shadow root for the caller to
|
||||
// populate.
|
||||
function attachShadow(host, ownCssText) {
|
||||
const root = host.attachShadow({ mode: 'open' });
|
||||
const sheet = new CSSStyleSheet();
|
||||
sheet.replaceSync(ownCssText);
|
||||
root.adoptedStyleSheets = [sheet];
|
||||
return root;
|
||||
}
|
||||
|
||||
// <hive-dialog> — the backdrop + box custom element behind `openDialog`.
|
||||
// Not exported; constructed and configured by `openDialog` only. Callers
|
||||
|
|
@ -34,7 +43,9 @@ import { el } from './dom.js';
|
|||
// via a `hive-dialog-close` CustomEvent (`detail` = the resolved value)
|
||||
// rather than exposing a resolve/reject pair directly — that keeps the
|
||||
// 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 {
|
||||
connectedCallback() {
|
||||
const {
|
||||
|
|
@ -43,7 +54,8 @@ class HiveDialog extends HTMLElement {
|
|||
danger = false, dismissable = true,
|
||||
} = this._opts || {};
|
||||
|
||||
this.className = 'tc-backdrop';
|
||||
const root = attachShadow(this, dialogCss);
|
||||
|
||||
let settled = false;
|
||||
const done = (value) => {
|
||||
if (settled) return;
|
||||
|
|
@ -62,9 +74,13 @@ class HiveDialog extends HTMLElement {
|
|||
this._done = done; // exposed for close-on-escape-elsewhere callers, if ever needed
|
||||
|
||||
const btnEls = buttons.map((b) => {
|
||||
// `b.class` names the variant ('cancel' | 'confirm'); `b.danger`
|
||||
// overrides it to the 'danger' look regardless (a destructive
|
||||
// confirm button reads as danger, not as a plain confirm).
|
||||
const variant = b.danger ? 'danger' : b.class;
|
||||
const btn = el('button', {
|
||||
type: 'button',
|
||||
class: 'btn tc-btn' + (b.class ? ' ' + b.class : '') + (b.danger ? ' tc-danger' : ''),
|
||||
type: 'button', is: 'hive-btn',
|
||||
...(variant ? { variant } : {}),
|
||||
}, b.label);
|
||||
btn.addEventListener('click', () => done(b.value));
|
||||
return { spec: b, btn };
|
||||
|
|
@ -72,20 +88,23 @@ class HiveDialog extends HTMLElement {
|
|||
|
||||
// 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
|
||||
// name). Only the labelling element carries the id.
|
||||
const labelId = 'tc-dlg-' + Math.random().toString(36).slice(2, 9);
|
||||
const titleEl = title ? el('div', { class: 'tc-title', id: labelId }, title) : null;
|
||||
// name). Only the labelling element carries the id. IDs are scoped to
|
||||
// this shadow root, so no cross-instance collision risk even without
|
||||
// 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
|
||||
? el('div', title ? { class: 'tc-message' } : { class: 'tc-message', id: labelId }, message)
|
||||
? el('div', title ? { class: 'message' } : { class: 'message', id: labelId }, message)
|
||||
: 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;
|
||||
const box = el('div', boxAttrs,
|
||||
titleEl,
|
||||
messageEl,
|
||||
content || null,
|
||||
el('div', { class: 'tc-actions' }, ...btnEls.map((b) => b.btn)));
|
||||
this.append(box);
|
||||
el('div', { class: 'actions' }, ...btnEls.map((b) => b.btn)));
|
||||
root.append(box);
|
||||
|
||||
this.addEventListener('click', (e) => {
|
||||
if (dismissable && e.target === this) done(null);
|
||||
|
|
@ -104,11 +123,14 @@ customElements.define('hive-dialog', HiveDialog);
|
|||
// → Promise resolving to the clicked button's `value`, or `null` when the
|
||||
// dialog is dismissed (Escape, backdrop click, or a button whose value is
|
||||
// null). `content` is an optional DOM node rendered between the message
|
||||
// and the buttons (checkboxes, custom fields, …). `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.
|
||||
// and the buttons (checkboxes, custom fields, …) — built by the caller
|
||||
// with `el()` and appended into the dialog's shadow root once mounted,
|
||||
// same as any other node (a JS-created element isn't bound to a
|
||||
// document/shadow-root until it's actually appended somewhere).
|
||||
// `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 = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const dlg = document.createElement('hive-dialog');
|
||||
|
|
@ -133,14 +155,14 @@ export function themedConfirm(opts = {}) {
|
|||
} = opts;
|
||||
|
||||
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;
|
||||
const row = el('label', { class: 'tc-checkrow' },
|
||||
input, el('span', { class: 'tc-check-label' }, cb.label || cb.name));
|
||||
const row = el('label', { class: 'checkrow' },
|
||||
input, el('span', {}, cb.label || cb.name));
|
||||
return { input, row };
|
||||
});
|
||||
const content = boxes.length
|
||||
? el('div', { class: 'tc-checks' }, ...boxes.map((b) => b.row))
|
||||
? el('div', { class: 'checks' }, ...boxes.map((b) => b.row))
|
||||
: null;
|
||||
|
||||
return openDialog({
|
||||
|
|
@ -149,8 +171,8 @@ export function themedConfirm(opts = {}) {
|
|||
content,
|
||||
danger,
|
||||
buttons: [
|
||||
{ label: cancelLabel, value: null, class: 'tc-cancel', autofocus: danger },
|
||||
{ label: confirmLabel, value: 'confirm', danger, class: 'tc-confirm', autofocus: !danger },
|
||||
{ label: cancelLabel, value: null, class: 'cancel', autofocus: danger },
|
||||
{ label: confirmLabel, value: 'confirm', danger, class: 'confirm', autofocus: !danger },
|
||||
],
|
||||
}).then((v) => {
|
||||
if (v !== 'confirm') return null;
|
||||
|
|
@ -172,26 +194,30 @@ export function themedPrompt(opts = {}) {
|
|||
title = '', message = '', label = '', placeholder = '', value = '',
|
||||
confirmLabel = 'ok', cancelLabel = 'cancel',
|
||||
} = 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;
|
||||
// Enter submits (clicks the confirm button mounted by openDialog);
|
||||
// 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 button
|
||||
// without leaking across instances. The confirm button is selected by
|
||||
// its `variant` attribute now (hive-btn.js), not a CSS class.
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
input.closest('.tc-box')?.querySelector('.tc-confirm')?.click();
|
||||
input.closest('.box')?.querySelector('[variant="confirm"]')?.click();
|
||||
}
|
||||
});
|
||||
const content = el('div', { class: 'tc-promptfield' },
|
||||
label ? el('label', { class: 'tc-promptlabel' }, label) : null,
|
||||
const content = el('div', { class: 'promptfield' },
|
||||
label ? el('label', { class: 'promptlabel' }, label) : null,
|
||||
input);
|
||||
const result = openDialog({
|
||||
title,
|
||||
message,
|
||||
content,
|
||||
buttons: [
|
||||
{ label: cancelLabel, value: '__cancel__', class: 'tc-cancel' },
|
||||
{ label: confirmLabel, value: '__ok__', class: 'tc-confirm', autofocus: true },
|
||||
{ label: cancelLabel, value: '__cancel__', class: 'cancel' },
|
||||
{ label: confirmLabel, value: '__ok__', class: 'confirm', autofocus: true },
|
||||
],
|
||||
}).then((v) => (v === '__ok__' ? input.value : null));
|
||||
// Prefer focusing the field over the OK button once the dialog has mounted.
|
||||
|
|
@ -204,19 +230,24 @@ export function themedPrompt(opts = {}) {
|
|||
// 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-
|
||||
// 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 {
|
||||
connectedCallback() {
|
||||
const { type = 'info', duration } = this._opts || {};
|
||||
const ms = duration != null ? duration : (type === 'error' ? 8000 : 4000);
|
||||
this.className = 'tc-toast tc-toast-' + type;
|
||||
const root = attachShadow(this, toastCss);
|
||||
this.classList.add(type);
|
||||
this.setAttribute('role', type === 'error' ? 'alert' : 'status');
|
||||
this.textContent = this._message || '';
|
||||
root.textContent = this._message || '';
|
||||
let removed = false;
|
||||
this._remove = () => {
|
||||
if (removed) return;
|
||||
removed = true;
|
||||
this.classList.add('tc-toast-out');
|
||||
this.classList.add('out');
|
||||
setTimeout(() => this.remove(), 200);
|
||||
};
|
||||
this.addEventListener('click', this._remove);
|
||||
|
|
@ -233,10 +264,25 @@ customElements.define('hive-toast', HiveToast);
|
|||
// decision (errors, validation, status). Stacks in a fixed top-right
|
||||
// container, auto-dismisses after `duration` ms (errors linger longer), and
|
||||
// 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 = {}) {
|
||||
let container = document.getElementById('tc-toasts');
|
||||
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);
|
||||
}
|
||||
const toast = document.createElement('hive-toast');
|
||||
|
|
|
|||
Loading…
Reference in a new issue