frontend: add <hive-btn> component, use it for dialog buttons
mara: 'wait the common styles is literally just the button stuff? pls make a button component now as part of this pr and replace the usage in the modal. replacing all usages and finally removing the btn styles from common css is a follow up then.' <hive-btn> (hive-btn.js) is a customized built-in <button is="hive-btn"> with its own shadow root -- extending HTMLButtonElement keeps every native button behaviour (click/keyboard activation, :disabled, form participation) instead of re-implementing it on a generic wrapper. Shadow root holds only an adopted stylesheet + a <slot>, so the button's light-DOM content (label, or asyncBtn's swapped-in spinner span) renders through unchanged -- slotted content stays styled by the light-DOM cascade, so the global .spinner class still applies. Variants (cancel/confirm/danger) are a 'variant' attribute, not a CSS class, since they're a semantic prop of the component. Customized built-ins aren't supported in Safari/WebKit -- fine here, the project targets recent Firefox only (same reasoning as the original custom-elements pilot). Wired into modal.js: HiveDialog's buttons now render as <button is="hive-btn" variant="...">, replacing the old component-common.css .btn copy -- deleted that file + component- styles.js entirely (their sole purpose was giving dialog buttons a .btn look, which hive-btn now owns properly). dom.js's el() gained support so it can create customized built-ins the same way it creates everything else. hive-dialog.css dropped the now-dead .cancel/.confirm/.confirm.danger rules. Per mara's scoping: NOT touching the other .btn consumers across the app (dashboard/agent submit buttons, form() helper, etc.) or removing .btn from dashboard/common.css / agent/agent.css in this PR -- that migration + cleanup is an explicit follow-up. Verified with a full frontend build (grepped bundled JS for hive-btn/ variant to confirm it inlines); nix fmt clean.
This commit is contained in:
parent
bce666a9b2
commit
a588ed42ce
9 changed files with 123 additions and 122 deletions
|
|
@ -74,9 +74,9 @@ await build({
|
||||||
target: ['es2022'],
|
target: ['es2022'],
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
// `@hive/shared/modal.js` imports its shadow-DOM component CSS
|
// `@hive/shared/modal.js` and `hive-btn.js` import their shadow-DOM
|
||||||
// (hive-dialog.css, hive-toast.css, component-common.css) as raw text
|
// component CSS (hive-dialog.css, hive-toast.css, hive-btn.css) as raw
|
||||||
// via a plain `import css from './foo.css'` — the `text` loader turns
|
// 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
|
// that into a string constant at bundle time instead of erroring on an
|
||||||
// unrecognised extension. None of these JS entries import a `.css`
|
// unrecognised extension. None of these JS entries import a `.css`
|
||||||
// file any other way, so this doesn't collide with the separate
|
// file any other way, so this doesn't collide with the separate
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,7 @@
|
||||||
"./chrome.css": "./src/chrome.css",
|
"./chrome.css": "./src/chrome.css",
|
||||||
"./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"
|
||||||
"./component-styles.js": "./src/component-styles.js"
|
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"src/"
|
"src/"
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
/* component-common.css — the shared stylesheet adopted alongside every
|
|
||||||
shadow-DOM custom element's own scoped CSS (`adoptedStyleSheets`), the
|
|
||||||
native equivalent of a Sass `@include`. Loaded as raw text at build time
|
|
||||||
(esbuild's `text` loader — see build.mjs) and turned into a
|
|
||||||
`CSSStyleSheet` via `replaceSync()` in component-styles.js; a real .css
|
|
||||||
file, not a JS template string, so it gets normal editor tooling and
|
|
||||||
stays syntactically obvious as CSS.
|
|
||||||
|
|
||||||
NOTE: `.btn` here 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. 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 (see
|
|
||||||
hyperhive's forge issue tracker: the `<hive-btn>` component proposal),
|
|
||||||
not bundled into whichever component conversion first needed a
|
|
||||||
button. */
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
// 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 = [sharedComponentStyleSheet(), ownSheet]`)
|
|
||||||
// into as many shadow roots as want it, rather than each component
|
|
||||||
// duplicating the rule text or re-fetching an external stylesheet.
|
|
||||||
//
|
|
||||||
// The CSS itself lives in `component-common.css` — a real stylesheet, not
|
|
||||||
// a JS template string — imported here as raw text via esbuild's `text`
|
|
||||||
// loader (see both packages' `build.mjs`) and turned into a
|
|
||||||
// `CSSStyleSheet` at module-init time. Component-scoped stylesheets
|
|
||||||
// (`hive-dialog.css`, `hive-toast.css` in modal.js) follow the same
|
|
||||||
// pattern.
|
|
||||||
//
|
|
||||||
// Start small: `.btn` is the one rule shadow-DOM components have needed so
|
|
||||||
// far (the dialog buttons in modal.js). Add more shared rules to
|
|
||||||
// component-common.css as more components need them — don't grow this
|
|
||||||
// into a full reset/utility layer preemptively.
|
|
||||||
|
|
||||||
import componentCommonCss from './component-common.css';
|
|
||||||
|
|
||||||
let sheet = null;
|
|
||||||
|
|
||||||
export function sharedComponentStyleSheet() {
|
|
||||||
if (!sheet) {
|
|
||||||
sheet = new CSSStyleSheet();
|
|
||||||
sheet.replaceSync(componentCommonCss);
|
|
||||||
}
|
|
||||||
return sheet;
|
|
||||||
}
|
|
||||||
|
|
@ -5,12 +5,18 @@
|
||||||
// used).
|
// used).
|
||||||
//
|
//
|
||||||
// `el(tag, attrs, ...children)` creates an element, applying `attrs` as
|
// `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`
|
// appending `children` (strings become text nodes, `null`/`undefined`
|
||||||
// entries are skipped so callers can inline conditional children).
|
// 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) => {
|
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)) {
|
for (const [k, v] of Object.entries(attrs)) {
|
||||||
|
if (k === 'is') continue;
|
||||||
if (k === 'class') e.className = v;
|
if (k === 'class') e.className = v;
|
||||||
else if (k === 'html') e.innerHTML = v;
|
else if (k === 'html') e.innerHTML = v;
|
||||||
else e.setAttribute(k, 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' });
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
/* hive-dialog.css — scoped stylesheet for the <hive-dialog> shadow-DOM
|
/* hive-dialog.css — scoped stylesheet for the <hive-dialog> shadow-DOM
|
||||||
custom element (modal.js). Loaded as raw text at build time (esbuild's
|
custom element (modal.js). Loaded as raw text at build time (esbuild's
|
||||||
`text` loader) and adopted via `adoptedStyleSheets` alongside
|
`text` loader) and adopted via `adoptedStyleSheets`. `:host` styles the
|
||||||
component-common.css. `:host` styles the element itself, which *is*
|
element itself, which *is* the fixed-position backdrop — no separate
|
||||||
the fixed-position backdrop — no separate light-DOM backdrop div. */
|
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 {
|
:host {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
@ -50,9 +52,6 @@
|
||||||
gap: 0.6em;
|
gap: 0.6em;
|
||||||
margin-top: 0.2em;
|
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; }
|
.promptfield { display: flex; flex-direction: column; gap: 0.4em; }
|
||||||
.promptlabel { color: var(--subtext0); font-size: 0.9em; }
|
.promptlabel { color: var(--subtext0); font-size: 0.9em; }
|
||||||
.input {
|
.input {
|
||||||
|
|
|
||||||
|
|
@ -5,45 +5,33 @@
|
||||||
// dialog.
|
// dialog.
|
||||||
//
|
//
|
||||||
// Implemented as two shadow-DOM custom elements (`<hive-dialog>`,
|
// Implemented as two shadow-DOM custom elements (`<hive-dialog>`,
|
||||||
// `<hive-toast>`). Each attaches its own shadow root and adopts a
|
// `<hive-toast>`), plus `<hive-btn>` (hive-btn.js) for the dialog's own
|
||||||
// component-scoped `CSSStyleSheet` alongside the shared one in
|
// buttons — real per-component style encapsulation, CSS in real `.css`
|
||||||
// `component-styles.js` (the native equivalent of a Sass `@include`) —
|
// files imported as raw text (see each component's own header comment
|
||||||
// real style encapsulation, no global `.tc-*` class namespace needed any
|
// for the design rationale: shadow-DOM-vs-light-DOM tradeoffs live atop
|
||||||
// more, and no stylesheet to import from every page. The CSS itself
|
// the `<hive-dialog>`/`<hive-toast>` classes below, the customized-
|
||||||
// lives in real `.css` files (`hive-dialog.css`, `hive-toast.css`,
|
// built-in choice lives in hive-btn.js). Other `.btn` consumers across
|
||||||
// `component-common.css`), imported here as raw text via esbuild's
|
// the app stay on the light-DOM `.btn` class for now — migrating them
|
||||||
// `text` loader and turned into `CSSStyleSheet`s at runtime — not JS
|
// is a separate follow-up.
|
||||||
// template strings, so it stays normal, editor-tooled CSS. Theme vars
|
|
||||||
// (`--bg`, `--purple`, …) keep resolving inside the shadow tree
|
|
||||||
// automatically: CSS custom properties inherit through shadow
|
|
||||||
// boundaries even though plain class rules don't. This started as a
|
|
||||||
// light-DOM pilot (see the frontend components-split discussion on the
|
|
||||||
// forge issue tracker) — light DOM validated the lifecycle-
|
|
||||||
// encapsulation win (`connectedCallback`/`disconnectedCallback` owning
|
|
||||||
// the keydown listener / auto-dismiss timer) cheaply; this is the
|
|
||||||
// shadow-DOM follow-up once real per-component style scoping was worth
|
|
||||||
// the cost.
|
|
||||||
//
|
//
|
||||||
// `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';
|
import './hive-btn.js'; // registers <hive-btn> — side-effect import, no named export needed
|
||||||
import dialogCss from './hive-dialog.css';
|
import dialogCss from './hive-dialog.css';
|
||||||
import toastCss from './hive-toast.css';
|
import toastCss from './hive-toast.css';
|
||||||
|
|
||||||
// Attach an open shadow root to `host`, adopt the shared component
|
// Attach an open shadow root to `host`, adopt `ownCssText` (a scoped
|
||||||
// stylesheet plus `ownCssText` (a scoped stylesheet built fresh per call
|
// stylesheet built fresh per call from a real .css file's contents,
|
||||||
// from a real .css file's contents, imported as raw text — see
|
// imported as raw text), and return the shadow root for the caller to
|
||||||
// component-styles.js's header comment; only the shared one is
|
// populate.
|
||||||
// cached/reused across instances), and return the shadow root for the
|
|
||||||
// caller to populate.
|
|
||||||
function attachShadow(host, ownCssText) {
|
function attachShadow(host, ownCssText) {
|
||||||
const root = host.attachShadow({ mode: 'open' });
|
const root = host.attachShadow({ mode: 'open' });
|
||||||
const own = new CSSStyleSheet();
|
const sheet = new CSSStyleSheet();
|
||||||
own.replaceSync(ownCssText);
|
sheet.replaceSync(ownCssText);
|
||||||
root.adoptedStyleSheets = [sharedComponentStyleSheet(), own];
|
root.adoptedStyleSheets = [sheet];
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,9 +74,13 @@ class HiveDialog extends HTMLElement {
|
||||||
this._done = done; // exposed for close-on-escape-elsewhere callers, if ever needed
|
this._done = done; // exposed for close-on-escape-elsewhere callers, if ever needed
|
||||||
|
|
||||||
const btnEls = buttons.map((b) => {
|
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', {
|
const btn = el('button', {
|
||||||
type: 'button',
|
type: 'button', is: 'hive-btn',
|
||||||
class: 'btn' + (b.class ? ' ' + b.class : '') + (b.danger ? ' danger' : ''),
|
...(variant ? { variant } : {}),
|
||||||
}, b.label);
|
}, b.label);
|
||||||
btn.addEventListener('click', () => done(b.value));
|
btn.addEventListener('click', () => done(b.value));
|
||||||
return { spec: b, btn };
|
return { spec: b, btn };
|
||||||
|
|
@ -207,12 +199,13 @@ export function themedPrompt(opts = {}) {
|
||||||
// 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
|
// `closest()`/`querySelector()` stay within the shadow tree the input
|
||||||
// lives in, so this resolves to the dialog's own `.box`/`.confirm`
|
// lives in, so this resolves to the dialog's own `.box`/confirm button
|
||||||
// without leaking across instances.
|
// 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) => {
|
input.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
input.closest('.box')?.querySelector('.confirm')?.click();
|
input.closest('.box')?.querySelector('[variant="confirm"]')?.click();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const content = el('div', { class: 'promptfield' },
|
const content = el('div', { class: 'promptfield' },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue