import { rocket, startPeeking, stopPeeking } from 'datastar' // Host getters must not subscribe callers (e.g. data-bind's sync effect) to // the internal signal, or that effect writes the stale bound value back. const peek = (fn) => { startPeeking() try { return fn() } finally { stopPeeking() } } // One ElementInternals per element: attachInternals() works once, and setup // runs again when the element is re-attached. Its custom states // (:state(pending)) are styleable from the page and morph-proof. const internals = new WeakMap() const internalsOf = (host) => internals.get(host) ?? internals.set(host, host.attachInternals()).get(host) // Pixel corners: notches every corner by p (2px times --sb-notch; at 0 the // border-radius takes over). const notch = (p) => `polygon(${p} 0, calc(100% - ${p}) 0, calc(100% - ${p}) ${p}, 100% ${p}, 100% calc(100% - ${p}), calc(100% - ${p}) calc(100% - ${p}), calc(100% - ${p}) 100%, ${p} 100%, ${p} calc(100% - ${p}), 0 calc(100% - ${p}), 0 ${p}, ${p} ${p})` // Choices come as strings or {value, label?, description?, disabled?}, from the // options prop or from children. A choice needs a value: "" is the // group's "nothing chosen". const normalize = (list) => (Array.isArray(list) ? list : []) .map((o) => (typeof o === 'object' && o !== null ? o : { value: String(o) })) // a string is its value and label .map((o) => ({ value: String(o.value ?? o.label ?? ''), label: String(o.label ?? o.value ?? ''), description: o.description ? String(o.description) : '', disabled: !!o.disabled })) .filter((o) => o.value) // A choice that can be picked (and take the focus). const live = (o) => !!o && !o.disabled // :state(disabled) comes from the decoded prop, so disabled="false" is not // disabled. Forced colours paint every background Canvas: the checked dot is // filled with Highlight there. const styles = /* css */ ` :host { --_bg: var(--sb-control-bg, #0B1224); --_border: var(--sb-control-border, #283552); --_border-hover: var(--sb-control-border-hover, #3A4868); --_text: var(--sb-control-text, #F3F4FA); --_label: var(--sb-text-2, #AEBBDD); --_muted: var(--sb-text-muted, #7785A8); --_brand: var(--sb-brand, #8C6BFF); --_brand-light: var(--sb-brand-light, #B09AFF); --_brand-subtle: var(--sb-brand-subtle, rgb(140 107 255 / 0.14)); --_hover: var(--sb-surface-hover, #1A2540); --_radius: var(--sb-control-radius, 6px); --_notch: var(--sb-notch, 1); --_n: calc(2px * var(--_notch)); display: block; } :host([hidden]) { display: none; } :host(:state(disabled)) { opacity: 0.5; pointer-events: none; } .group { display: grid; gap: 0.5rem; } .label { color: var(--_label); font-size: 0.8125rem; font-weight: 600; } /* flex-start: an item is as wide as its own text, so the checked tint and the hover don't stretch a whole column. */ .items { display: flex; flex-direction: column; align-items: flex-start; gap: 0.25rem; } .items.row { flex-direction: row; flex-wrap: wrap; column-gap: 0.75rem; } .item { display: flex; align-items: flex-start; gap: 0.6rem; padding: 0.4rem 0.6rem; color: var(--_text); font-size: 0.875rem; cursor: pointer; clip-path: ${notch('var(--_n)')}; border-radius: calc(var(--_radius) * (1 - var(--_notch))); transition: background 120ms; } .item.hot { background: var(--_hover); } .item.checked { background: var(--_brand-subtle); } .item.off { opacity: 0.45; cursor: default; } /* The outline would be clipped away by the notch, so the notch steps aside. */ .item:focus-visible { outline: 2px solid var(--_brand-light); outline-offset: 2px; clip-path: none; } .dot { position: relative; flex: none; box-sizing: border-box; width: 16px; height: 16px; margin-block-start: 0.1rem; border: 2px solid var(--_border); background: var(--_bg); clip-path: ${notch('var(--_n)')}; border-radius: calc(8px * (1 - var(--_notch))); transition: border-color 120ms; } .item.hot .dot { border-color: var(--_border-hover); } .item.checked .dot { border-color: var(--_brand-light); } .item.checked .dot::after { content: ""; position: absolute; inset: 2px; background: var(--_brand); clip-path: ${notch('calc(1px * var(--_notch))')}; border-radius: calc(4px * (1 - var(--_notch))); } .text { display: grid; gap: 0.1rem; } .desc { color: var(--_muted); font-size: 0.75rem; } @media (prefers-reduced-motion: reduce) { .item, .dot { transition: none; } } @media (forced-colors: active) { .item.checked .dot::after { forced-color-adjust: none; background: Highlight; } } ` rocket('sb-radio-group', { props: ({ bool, json, oneOf, string }) => ({ value: string.docs({ description: 'The selected value. A new value from the server replaces it; the live value is the value property.' }), options: json.default(() => []).docs({ description: 'Choices from the server: ["A", "B"] or [{value, label, description?, disabled?}]. children win over it.' }), label: string.trim.docs({ description: 'Visible label, and the accessible name of the group.' }), orientation: oneOf('vertical', 'horizontal').default('vertical').docs({ description: 'Stack the choices or lay them out in a row.' }), disabled: bool.docs({ description: 'Disable the whole group.' }), confirm: bool.docs({ description: 'Server-confirmed value: :state(pending) while the local value differs from the server\'s value attribute (see revert()).' }), name: string.trim.docs({ description: 'Name reported in sb-change (e.g. the field of a command), and the field it submits in a form.' }), }), manifest: { slots: [{ name: '', description: 'Label items: markup the group reads as its choices, like