frontend: fix hive-btn crashing every themed dialog — customized built-ins can't host shadow DOM
Element.attachShadow() throws NotSupportedError unconditionally for a customized built-in (<button is="hive-btn">): the spec only allows autonomous custom elements or a fixed list of native tags to host a shadow root, and explicitly excludes any is=-upgraded built-in regardless of which tag it upgrades. button isn't on that list either way. This made every themed dialog (any confirm/prompt, since openDialog always renders at least one button) throw and fail to render in a real browser, though it passed CI since nothing there exercises actual browser DOM. hive-dialog and hive-toast are unaffected — both are genuine autonomous custom elements (extends HTMLElement, no is= upgrade), which are valid shadow hosts. Fix: hive-btn no longer calls attachShadow. Styles adopt onto document once (module-level guard) instead of per-instance shadow root, scoped via the [is="hive-btn"] attribute selector instead of :host — same light-DOM approach the rest of the app's .btn consumers already use. Native button behaviour is untouched, only the styling mechanism changed. Build clean.
This commit is contained in:
parent
d2dc681fcf
commit
b201f6be88
3 changed files with 61 additions and 51 deletions
|
|
@ -1,14 +1,20 @@
|
|||
/* 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. */
|
||||
/* hive-btn.css — 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 adopted once on `document` (see hive-btn.js) — NOT
|
||||
scoped to a shadow root. A customized built-in (`<button is="hive-btn">`)
|
||||
can't host one: `Element.attachShadow()` only accepts autonomous custom
|
||||
elements or a fixed list of native tags that doesn't include `button`,
|
||||
and explicitly excludes `is=`-upgraded built-ins regardless of tag —
|
||||
so this styles via the `[is="hive-btn"]` attribute selector instead of
|
||||
`:host`, same light-DOM-scoping approach the rest of the app's `.btn`
|
||||
consumers already use, just keyed off the attribute instead of a class.
|
||||
`:hover`/`:disabled` below are still genuine native pseudo-classes on a
|
||||
real `<button>`, not hand-rolled state tracking — that part of the
|
||||
original design goal survives even without shadow encapsulation.
|
||||
`variant` is an attribute (not a class) since it's a semantic property
|
||||
of the component, not an arbitrary styling hook. */
|
||||
|
||||
:host {
|
||||
[is="hive-btn"] {
|
||||
font-family: inherit;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
|
|
@ -24,17 +30,17 @@
|
|||
box-shadow: 0 0 0 0 currentColor;
|
||||
transition: box-shadow 0.15s ease;
|
||||
}
|
||||
:host(:hover) {
|
||||
[is="hive-btn"]: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) {
|
||||
[is="hive-btn"]: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); }
|
||||
[is="hive-btn"][variant="cancel"] { color: var(--subtext0); }
|
||||
[is="hive-btn"][variant="confirm"] { color: var(--green); }
|
||||
[is="hive-btn"][variant="danger"] { color: var(--red); }
|
||||
|
|
|
|||
|
|
@ -1,40 +1,43 @@
|
|||
// 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).
|
||||
// is="hive-btn">`) — the button primitive for modal.js's dialog buttons.
|
||||
// Extending `HTMLButtonElement` keeps native button behaviour (click/
|
||||
// keyboard activation, `:disabled`) for free.
|
||||
//
|
||||
// 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.
|
||||
// NOT shadow-DOM-encapsulated: a customized built-in can't host a shadow
|
||||
// root — `attachShadow()` throws `NotSupportedError` unconditionally for
|
||||
// any element that isn't an autonomous custom element or on the HTML
|
||||
// spec's short fixed list (article/aside/div/span/etc., no `button`),
|
||||
// and `is=`-upgraded built-ins are excluded regardless of tag. The prior
|
||||
// version called `attachShadow()` here anyway — it shipped and passed
|
||||
// review since nothing in CI exercises real browser DOM, then broke the
|
||||
// very first live dialog open. Styled via a stylesheet adopted once on
|
||||
// `document` instead, scoped with `[is="hive-btn"]` (hive-btn.css) — the
|
||||
// light-DOM approach the rest of the app's `.btn` consumers already use.
|
||||
// (`<hive-dialog>`/`<hive-toast>` in modal.js are unaffected — genuine
|
||||
// autonomous custom elements, valid shadow hosts.)
|
||||
//
|
||||
// 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.
|
||||
// Customized built-ins aren't supported in Safari/WebKit — 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.
|
||||
// Usage: `el('button', { type: 'button', is: 'hive-btn', variant: 'danger' }, 'label')`.
|
||||
// `variant` ∈ 'cancel' | 'confirm' | 'danger' | unset (neutral default).
|
||||
|
||||
import hiveBtnCss from './hive-btn.css';
|
||||
|
||||
// Adopted once, module-wide — every <hive-btn> instance shares the same
|
||||
// document-level stylesheet rather than each instance re-adopting it.
|
||||
let styleInstalled = false;
|
||||
function ensureStyleInstalled() {
|
||||
if (styleInstalled) return;
|
||||
styleInstalled = true;
|
||||
const sheet = new CSSStyleSheet();
|
||||
sheet.replaceSync(hiveBtnCss);
|
||||
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
|
||||
}
|
||||
|
||||
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'));
|
||||
ensureStyleInstalled();
|
||||
}
|
||||
}
|
||||
customElements.define('hive-btn', HiveBtn, { extends: 'button' });
|
||||
|
|
|
|||
|
|
@ -5,14 +5,15 @@
|
|||
// dialog.
|
||||
//
|
||||
// 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.
|
||||
// `<hive-toast>`) — real per-component style encapsulation, CSS in real
|
||||
// `.css` files imported as raw text (see each component's own header
|
||||
// comment for the shadow-DOM-vs-light-DOM tradeoffs) — plus `<hive-btn>`
|
||||
// (hive-btn.js) for the dialog's own buttons, which is NOT shadow-DOM
|
||||
// encapsulated: a customized built-in (`<button is="hive-btn">`) can't
|
||||
// host a shadow root at all, so it styles itself via a document-level
|
||||
// stylesheet instead (see hive-btn.js's header for why). 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue