frontend: hive-btn — drop the is= customized-built-in, use an autonomous element instead
Per review: don't use is=, it reads as a hack (and it is one — is=-upgraded
built-ins can never host a shadow root, which is what caused the crash this PR
fixes in the first place). <hive-btn> is now a normal autonomous custom element
wrapping a real <button> inside its own shadow root, so it gets its shadow
encapsulation back (matching hive-dialog/hive-toast) instead of the document-
level stylesheet workaround from the previous commit.
delegatesFocus: true on the shadow root means .focus() on the host (what
modal.js calls for autofocus) reaches the inner button directly. The inner
button's native click is a composed event, so host-level click listeners
(what modal.js/themedPrompt already use) keep working unchanged.
modal.js: el('button', { is: 'hive-btn', ... }) -> el('hive-btn', { ... }) at
the one call site. dom.js: removed the is= special case from el() entirely —
it existed only to support this one now-gone usage. Build clean.
This commit is contained in:
parent
b201f6be88
commit
e5b307df1b
4 changed files with 84 additions and 75 deletions
|
|
@ -5,18 +5,15 @@
|
||||||
// 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`/`is` special cases or plain attributes, and
|
// either the `class`/`html` 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.
|
// `tag` can be any custom element's tag name too (e.g. `el('hive-btn',
|
||||||
// `el('button', { is: 'hive-btn' }, 'label')` → `<button is="hive-btn">`)
|
// { variant: 'danger' }, 'label')`) — `document.createElement` upgrades
|
||||||
// — passed to `document.createElement` itself, since a customized
|
// it automatically if the tag is already registered.
|
||||||
// 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 = attrs.is ? document.createElement(tag, { is: attrs.is }) : document.createElement(tag);
|
const e = 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);
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,24 @@
|
||||||
/* hive-btn.css — stylesheet for the <hive-btn> customized built-in button
|
/* hive-btn.css — shadow-DOM stylesheet for the <hive-btn> autonomous
|
||||||
element (hive-btn.js). Loaded as raw text at build time (esbuild's
|
custom element (hive-btn.js). Loaded as raw text at build time
|
||||||
`text` loader) and adopted once on `document` (see hive-btn.js) — NOT
|
(esbuild's `text` loader) and adopted into each instance's own shadow
|
||||||
scoped to a shadow root. A customized built-in (`<button is="hive-btn">`)
|
root. `:host` is an invisible wrapper (`display: contents`) — the
|
||||||
can't host one: `Element.attachShadow()` only accepts autonomous custom
|
real box is the plain `button` selector below, targeting the actual
|
||||||
elements or a fixed list of native tags that doesn't include `button`,
|
`<button>` the shadow root wraps (scope-isolated by the shadow
|
||||||
and explicitly excludes `is=`-upgraded built-ins regardless of tag —
|
boundary already, no clash risk with anything outside). `variant` is
|
||||||
so this styles via the `[is="hive-btn"]` attribute selector instead of
|
an attribute on the HOST (how callers set it, and where hive-dialog's
|
||||||
`:host`, same light-DOM-scoping approach the rest of the app's `.btn`
|
own selectors read it back); `:host([variant="…"])` sets `color`,
|
||||||
consumers already use, just keyed off the attribute instead of a class.
|
which then inherits down into the shadow tree the normal way. */
|
||||||
`: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. */
|
|
||||||
|
|
||||||
[is="hive-btn"] {
|
:host {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
:host([variant="cancel"]) { color: var(--subtext0); }
|
||||||
|
:host([variant="confirm"]) { color: var(--green); }
|
||||||
|
:host([variant="danger"]) { color: var(--red); }
|
||||||
|
|
||||||
|
button {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
|
|
@ -30,17 +33,14 @@
|
||||||
box-shadow: 0 0 0 0 currentColor;
|
box-shadow: 0 0 0 0 currentColor;
|
||||||
transition: box-shadow 0.15s ease;
|
transition: box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
[is="hive-btn"]:hover {
|
button:hover {
|
||||||
background: color-mix(in srgb, var(--fg) 6%, transparent);
|
background: color-mix(in srgb, var(--fg) 6%, transparent);
|
||||||
text-shadow: 0 0 10px currentColor;
|
text-shadow: 0 0 10px currentColor;
|
||||||
box-shadow: 0 0 10px -2px currentColor;
|
box-shadow: 0 0 10px -2px currentColor;
|
||||||
}
|
}
|
||||||
[is="hive-btn"]:disabled {
|
button:disabled {
|
||||||
opacity: 0.32;
|
opacity: 0.32;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
[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,43 +1,58 @@
|
||||||
// hive-btn.js — <hive-btn>, a customized built-in `<button>` (`<button
|
// hive-btn.js — <hive-btn>, the button primitive for modal.js's dialog
|
||||||
// is="hive-btn">`) — the button primitive for modal.js's dialog buttons.
|
// buttons. Autonomous custom element (previously a customized built-in,
|
||||||
// Extending `HTMLButtonElement` keeps native button behaviour (click/
|
// `<button is="hive-btn">` — dropped: `is=` upgrades can't host a shadow
|
||||||
// keyboard activation, `:disabled`) for free.
|
// root at all, which crashed every themed dialog, and reads as a hack
|
||||||
|
// regardless once you know that). A real `<button>` lives inside the
|
||||||
|
// shadow root instead, so native click/keyboard activation and
|
||||||
|
// `:disabled` still come for free — just one level down from the host.
|
||||||
//
|
//
|
||||||
// NOT shadow-DOM-encapsulated: a customized built-in can't host a shadow
|
// `delegatesFocus: true` on the shadow root means `.focus()` on the host
|
||||||
// root — `attachShadow()` throws `NotSupportedError` unconditionally for
|
// (what modal.js calls) focuses the inner button directly, and a click
|
||||||
// any element that isn't an autonomous custom element or on the HTML
|
// anywhere on the host focuses it too, matching native `<button>` feel.
|
||||||
// spec's short fixed list (article/aside/div/span/etc., no `button`),
|
// The inner button's native `click` is a composed event, so it bubbles
|
||||||
// and `is=`-upgraded built-ins are excluded regardless of tag. The prior
|
// out through the shadow boundary — callers add `click` listeners on the
|
||||||
// version called `attachShadow()` here anyway — it shipped and passed
|
// `<hive-btn>` host exactly as they would on a plain `<button>`.
|
||||||
// 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 — fine here
|
// Usage: `el('hive-btn', { type: 'button', variant: 'danger' }, 'label')`.
|
||||||
// since the project targets recent Firefox only.
|
// `variant` ∈ 'cancel' | 'confirm' | 'danger' | unset (neutral default) —
|
||||||
//
|
// reflected straight through to the inner button so hive-btn.css's
|
||||||
// Usage: `el('button', { type: 'button', is: 'hive-btn', variant: 'danger' }, 'label')`.
|
// `:host([variant="…"])` selectors keep working unchanged.
|
||||||
// `variant` ∈ 'cancel' | 'confirm' | 'danger' | unset (neutral default).
|
// `disabled`/`type` are likewise plain attributes on the host, mirrored
|
||||||
|
// onto the inner button on connect and on every attribute change.
|
||||||
|
|
||||||
import hiveBtnCss from './hive-btn.css';
|
import hiveBtnCss from './hive-btn.css';
|
||||||
|
|
||||||
// Adopted once, module-wide — every <hive-btn> instance shares the same
|
const OBSERVED = ['disabled', 'type'];
|
||||||
// document-level stylesheet rather than each instance re-adopting it.
|
|
||||||
let styleInstalled = false;
|
class HiveBtn extends HTMLElement {
|
||||||
function ensureStyleInstalled() {
|
static get observedAttributes() {
|
||||||
if (styleInstalled) return;
|
return OBSERVED;
|
||||||
styleInstalled = true;
|
}
|
||||||
const sheet = new CSSStyleSheet();
|
|
||||||
sheet.replaceSync(hiveBtnCss);
|
|
||||||
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
|
|
||||||
}
|
|
||||||
|
|
||||||
class HiveBtn extends HTMLButtonElement {
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
ensureStyleInstalled();
|
if (this._btn) {
|
||||||
|
this._sync();
|
||||||
|
return; // already built (e.g. re-parenting re-fires connectedCallback)
|
||||||
|
}
|
||||||
|
const root = this.attachShadow({ mode: 'open', delegatesFocus: true });
|
||||||
|
const sheet = new CSSStyleSheet();
|
||||||
|
sheet.replaceSync(hiveBtnCss);
|
||||||
|
root.adoptedStyleSheets = [sheet];
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.append(document.createElement('slot'));
|
||||||
|
root.append(btn);
|
||||||
|
this._btn = btn;
|
||||||
|
this._sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback() {
|
||||||
|
this._sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
_sync() {
|
||||||
|
if (!this._btn) return;
|
||||||
|
this._btn.disabled = this.hasAttribute('disabled');
|
||||||
|
this._btn.type = this.getAttribute('type') || 'button';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
customElements.define('hive-btn', HiveBtn, { extends: 'button' });
|
customElements.define('hive-btn', HiveBtn);
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,13 @@
|
||||||
// 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 shadow-DOM custom elements (`<hive-dialog>`,
|
// Implemented as three shadow-DOM custom elements (`<hive-dialog>`,
|
||||||
// `<hive-toast>`) — real per-component style encapsulation, CSS in real
|
// `<hive-toast>`, and `<hive-btn>` for the dialog's own buttons) — real
|
||||||
// `.css` files imported as raw text (see each component's own header
|
// per-component style encapsulation, CSS in real `.css` files imported
|
||||||
// comment for the shadow-DOM-vs-light-DOM tradeoffs) — plus `<hive-btn>`
|
// as raw text (see each component's own header comment for the design
|
||||||
// (hive-btn.js) for the dialog's own buttons, which is NOT shadow-DOM
|
// rationale). Other `.btn` consumers across the app stay on the
|
||||||
// encapsulated: a customized built-in (`<button is="hive-btn">`) can't
|
// light-DOM `.btn` class for now — migrating them is a separate
|
||||||
// host a shadow root at all, so it styles itself via a document-level
|
// follow-up.
|
||||||
// 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
|
// `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
|
||||||
|
|
@ -79,8 +76,8 @@ class HiveDialog extends HTMLElement {
|
||||||
// overrides it to the 'danger' look regardless (a destructive
|
// overrides it to the 'danger' look regardless (a destructive
|
||||||
// confirm button reads as danger, not as a plain confirm).
|
// confirm button reads as danger, not as a plain confirm).
|
||||||
const variant = b.danger ? 'danger' : b.class;
|
const variant = b.danger ? 'danger' : b.class;
|
||||||
const btn = el('button', {
|
const btn = el('hive-btn', {
|
||||||
type: 'button', is: 'hive-btn',
|
type: 'button',
|
||||||
...(variant ? { variant } : {}),
|
...(variant ? { variant } : {}),
|
||||||
}, b.label);
|
}, b.label);
|
||||||
btn.addEventListener('click', () => done(b.value));
|
btn.addEventListener('click', () => done(b.value));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue