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})` // The check mark in 2px pixels, cut out of the box's 12px inside. const check = 'path("M10 1h2v2h-2zM8 3h4v2H8zM0 5h2v2H0zM6 5h4v2H6zM0 7h8v2H0zM2 9h4v2H2z")' // Choices come as strings or {value, label?, description?, disabled?}, from the // options prop or from children. A choice needs a value. 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) // The value: a JSON array (or an array), each value once. const list = (v) => { try { v = typeof v === 'string' ? JSON.parse(v) : v } catch {} return Array.isArray(v) ? [...new Set(v.map(String))] : [] } // Order doesn't matter when comparing values. const same = (a, b) => JSON.stringify([...a].sort()) === JSON.stringify([...b].sort()) // A choice that can be checked (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: checked boxes and // their marks take the system's Highlight colours 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); --_mark: var(--sb-text-on-brand, #F3F4FA); --_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 hover doesn'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; } @media (hover: hover) { .item:not(.off):hover { background: var(--_hover); } } .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; } .box { 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(4px * (1 - var(--_notch))); transition: border-color 120ms, background 120ms; } .item:not(.off):hover .box { border-color: var(--_border-hover); } .on .box, .mixed .box { border-color: var(--_brand-light); background: var(--_brand); } .on .box::after, .mixed .box::after { content: ""; position: absolute; inset: 0; background: var(--_mark); clip-path: ${check}; } .mixed .box::after { clip-path: inset(5px 2px); } .text { display: grid; gap: 0.1rem; } .desc { color: var(--_muted); font-size: 0.75rem; } @media (prefers-reduced-motion: reduce) { .item, .box { transition: none; } } @media (forced-colors: active) { .box { forced-color-adjust: none; border-color: CanvasText; background: Canvas; } .on .box, .mixed .box { border-color: Highlight; background: Highlight; } .box::after { forced-color-adjust: none; background: HighlightText; } } ` rocket('sb-checkbox-group', { props: ({ bool, json, oneOf, string }) => ({ value: json.default(() => []).docs({ description: 'The checked values, as a JSON array. 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.' }), selectAll: string.trim.docs({ description: 'Show a leading checkbox that checks or clears every enabled choice, labelled with this text ("All" when empty).' }), 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 each checked choice submits in a form.' }), }), manifest: { slots: [{ name: '', description: 'Label items: markup the group reads as its choices, like