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:
iris 2026-07-29 22:51:24 +02:00
commit b201f6be88
3 changed files with 61 additions and 51 deletions

View file

@ -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' });