@obvi/blueprint 1.0.9 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -433
- package/THIRD_PARTY_NOTICES.md +27 -0
- package/dist/blueprint-choices.js +567 -0
- package/dist/blueprint.css +2626 -1121
- package/dist/blueprint.js +2464 -36
- package/dist/code-highlighting/blueprint-code.css +12 -7
- package/package.json +5 -3
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
// blueprint-choices.js — interactive choice / preflight Web Components.
|
|
2
|
+
//
|
|
3
|
+
// Authoring shrinks to a small set of elements; each component expands to
|
|
4
|
+
// light-DOM markup styled by blueprint.css. Interaction is CSS-only (radio /
|
|
5
|
+
// checkbox + :has() + native form reset). Per-instance scoped rules are
|
|
6
|
+
// injected only where value-specific selectors are required (tab panels,
|
|
7
|
+
// verdict labels, preflight gate).
|
|
8
|
+
//
|
|
9
|
+
// <bp-choice layout="tabs" verdict="Adopted">
|
|
10
|
+
// <bp-choice-option value="a" tab="Draft A" title="Phased rollout">
|
|
11
|
+
// <p>…</p>
|
|
12
|
+
// <bp-rationale><p><strong>Trade-off.</strong> …</p></bp-rationale>
|
|
13
|
+
// </bp-choice-option>
|
|
14
|
+
// </bp-choice>
|
|
15
|
+
//
|
|
16
|
+
// layout: tabs | stack | gallery
|
|
17
|
+
// resolved: option value of an already-made decision — renders statically
|
|
18
|
+
// (no inputs, no reset); the named option carries the verdict
|
|
19
|
+
// verdict: kicker on the committed banner (default "Chosen")
|
|
20
|
+
// adopt: tabs-only commit button label (default "Adopt this direction")
|
|
21
|
+
// hint: footer hint; reconsider: reset button label (default "Reconsider")
|
|
22
|
+
// compare: gallery-only <details> summary (omit to skip compare block)
|
|
23
|
+
|
|
24
|
+
/** @typedef {'tabs' | 'stack' | 'gallery'} ChoiceLayout */
|
|
25
|
+
|
|
26
|
+
let choiceSeq = 0
|
|
27
|
+
|
|
28
|
+
/** @param {string} prefix */
|
|
29
|
+
function nextId(prefix) {
|
|
30
|
+
choiceSeq += 1
|
|
31
|
+
return `${prefix}-${choiceSeq}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @param {ParentNode} root */
|
|
35
|
+
function extractRationale(root) {
|
|
36
|
+
const node = root.querySelector(':scope > bp-rationale')
|
|
37
|
+
if (!node) return null
|
|
38
|
+
const wrap = node.ownerDocument.createElement('div')
|
|
39
|
+
wrap.className = 'bp-choice-rationale'
|
|
40
|
+
wrap.append(...node.childNodes)
|
|
41
|
+
return wrap
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @param {Element} root */
|
|
45
|
+
function bodyWithoutRationale(root) {
|
|
46
|
+
const frag = root.ownerDocument.createDocumentFragment()
|
|
47
|
+
for (const child of root.childNodes) {
|
|
48
|
+
if (child instanceof Element && child.tagName === 'BP-RATIONALE') continue
|
|
49
|
+
if (child instanceof Element && child.getAttribute('slot') === 'frame') continue
|
|
50
|
+
frag.append(child.cloneNode(true))
|
|
51
|
+
}
|
|
52
|
+
return frag
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @param {Element} root */
|
|
56
|
+
function frameContent(root) {
|
|
57
|
+
const slotted = root.querySelector(':scope > [slot="frame"]')
|
|
58
|
+
if (slotted) {
|
|
59
|
+
const frame = slotted.cloneNode(true)
|
|
60
|
+
frame.removeAttribute('slot')
|
|
61
|
+
return frame
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {Document} doc
|
|
68
|
+
* @param {string} tag
|
|
69
|
+
* @param {string} [className]
|
|
70
|
+
*/
|
|
71
|
+
function el(doc, tag, className) {
|
|
72
|
+
const node = doc.createElement(tag)
|
|
73
|
+
if (className) node.className = className
|
|
74
|
+
return node
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {Document} doc
|
|
79
|
+
* @param {string} text
|
|
80
|
+
* @param {string} [className]
|
|
81
|
+
*/
|
|
82
|
+
function label(doc, text, className) {
|
|
83
|
+
const node = el(doc, 'span', className ?? 'bp-label')
|
|
84
|
+
node.textContent = text
|
|
85
|
+
return node
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {Document} doc
|
|
90
|
+
* @param {string} kicker
|
|
91
|
+
* @param {Array<{ value: string, title: string }>} options
|
|
92
|
+
*/
|
|
93
|
+
function buildVerdict(doc, kicker, options, meta = 'You · just now') {
|
|
94
|
+
const verdict = el(doc, 'div', 'bp-choice__verdict')
|
|
95
|
+
verdict.append(label(doc, kicker))
|
|
96
|
+
for (const opt of options) {
|
|
97
|
+
const pick = el(doc, 'p', 'bp-choice__verdict-pick')
|
|
98
|
+
pick.dataset.for = opt.value
|
|
99
|
+
pick.textContent = opt.title
|
|
100
|
+
verdict.append(pick)
|
|
101
|
+
}
|
|
102
|
+
if (meta) {
|
|
103
|
+
const metaEl = el(doc, 'span', 'bp-choice__verdict-meta')
|
|
104
|
+
metaEl.textContent = meta
|
|
105
|
+
verdict.append(metaEl)
|
|
106
|
+
}
|
|
107
|
+
return verdict
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {Document} doc
|
|
112
|
+
* @param {string} hint
|
|
113
|
+
* @param {string} reconsider
|
|
114
|
+
*/
|
|
115
|
+
function buildFooter(doc, hint, reconsider) {
|
|
116
|
+
const actions = el(doc, 'div', 'bp-choice__actions')
|
|
117
|
+
if (hint) {
|
|
118
|
+
const hintEl = el(doc, 'span', 'bp-choice__hint')
|
|
119
|
+
hintEl.textContent = hint
|
|
120
|
+
actions.append(hintEl)
|
|
121
|
+
}
|
|
122
|
+
const reset = el(doc, 'button', 'bp-choice__reset')
|
|
123
|
+
reset.type = 'reset'
|
|
124
|
+
reset.textContent = reconsider
|
|
125
|
+
actions.append(reset)
|
|
126
|
+
return actions
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {string} scope
|
|
131
|
+
* @param {string} viewName
|
|
132
|
+
* @param {string} pickName
|
|
133
|
+
* @param {Array<{ value: string, title: string }>} options
|
|
134
|
+
*/
|
|
135
|
+
function scopedTabRules(scope, viewName, pickName, options) {
|
|
136
|
+
const lines = options.map(
|
|
137
|
+
(opt) =>
|
|
138
|
+
`${scope}:has([name="${viewName}"][value="${opt.value}"]:checked) .bp-choice__panel[data-value="${opt.value}"]{display:block}` +
|
|
139
|
+
`${scope}:has([name="${pickName}"][value="${opt.value}"]:checked) .bp-choice__verdict-pick[data-for="${opt.value}"]{display:block}`
|
|
140
|
+
)
|
|
141
|
+
return lines.join('\n')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** @param {HTMLElement} host */
|
|
145
|
+
function readOptions(host) {
|
|
146
|
+
return [...host.querySelectorAll(':scope > bp-choice-option')].map((node) => ({
|
|
147
|
+
el: node,
|
|
148
|
+
value: node.getAttribute('value') ?? '',
|
|
149
|
+
tab: node.getAttribute('tab') ?? node.getAttribute('label') ?? node.getAttribute('value') ?? '',
|
|
150
|
+
title: node.getAttribute('title') ?? node.getAttribute('caption') ?? node.getAttribute('tab') ?? '',
|
|
151
|
+
optionLabel: node.getAttribute('label') ?? node.getAttribute('tab') ?? '',
|
|
152
|
+
caption: node.getAttribute('caption') ?? node.getAttribute('title') ?? '',
|
|
153
|
+
}))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Static, non-interactive rendering: the decision is already made. The option
|
|
158
|
+
* named by `resolved` carries the verdict; the rest read as considered-and-set-
|
|
159
|
+
* aside. No form, no inputs, no reset.
|
|
160
|
+
*
|
|
161
|
+
* @param {Document} doc
|
|
162
|
+
* @param {{ options: ReturnType<typeof readOptions>, verdictKicker: string, resolvedValue: string }} spec
|
|
163
|
+
*/
|
|
164
|
+
function buildResolvedChoice(doc, { options, verdictKicker, resolvedValue }) {
|
|
165
|
+
const root = el(doc, 'div', 'bp-choice bp-choice--resolved')
|
|
166
|
+
const winner = options.find((o) => o.value === resolvedValue) ?? options[0]
|
|
167
|
+
|
|
168
|
+
const verdict = buildVerdict(
|
|
169
|
+
doc,
|
|
170
|
+
verdictKicker,
|
|
171
|
+
options.map((o) => ({ value: o.value, title: o.title })),
|
|
172
|
+
''
|
|
173
|
+
)
|
|
174
|
+
const winnerPick = winner && verdict.querySelector(`.bp-choice__verdict-pick[data-for="${winner.value}"]`)
|
|
175
|
+
if (winnerPick) winnerPick.setAttribute('data-resolved', '')
|
|
176
|
+
root.append(verdict)
|
|
177
|
+
|
|
178
|
+
const stack = el(doc, 'div', 'bp-choice__stack')
|
|
179
|
+
for (const opt of options) {
|
|
180
|
+
const card = el(doc, 'div', 'bp-choice__card')
|
|
181
|
+
if (winner && opt.value === winner.value) card.setAttribute('data-resolved', '')
|
|
182
|
+
|
|
183
|
+
const head = el(doc, 'div', 'bp-choice__card-head')
|
|
184
|
+
if (opt.optionLabel) head.append(label(doc, opt.optionLabel))
|
|
185
|
+
const chosen = el(doc, 'span', 'bp-choice__tag bp-choice__tag--ink bp-choice__card-chosen')
|
|
186
|
+
chosen.textContent = `✓ ${verdictKicker}`
|
|
187
|
+
const rejected = el(doc, 'span', 'bp-choice__tag bp-choice__tag--out bp-choice__card-rejected')
|
|
188
|
+
rejected.textContent = 'Not chosen'
|
|
189
|
+
head.append(chosen, rejected)
|
|
190
|
+
card.append(head)
|
|
191
|
+
|
|
192
|
+
const h4 = el(doc, 'h4')
|
|
193
|
+
h4.textContent = opt.title
|
|
194
|
+
card.append(h4)
|
|
195
|
+
card.append(bodyWithoutRationale(opt.el))
|
|
196
|
+
const rationale = extractRationale(opt.el)
|
|
197
|
+
if (rationale) card.append(rationale)
|
|
198
|
+
stack.append(card)
|
|
199
|
+
}
|
|
200
|
+
root.append(stack)
|
|
201
|
+
return root
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
class BlueprintRationaleElement extends HTMLElement {
|
|
205
|
+
connectedCallback() {
|
|
206
|
+
if (this.dataset.bpRendered) return
|
|
207
|
+
this.dataset.bpRendered = '1'
|
|
208
|
+
this.classList.add('bp-choice-rationale')
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
class BlueprintChoiceOptionElement extends HTMLElement {}
|
|
213
|
+
|
|
214
|
+
class BlueprintChoiceElement extends HTMLElement {
|
|
215
|
+
connectedCallback() {
|
|
216
|
+
if (this.dataset.bpRendered) return
|
|
217
|
+
this.dataset.bpRendered = '1'
|
|
218
|
+
|
|
219
|
+
const doc = this.ownerDocument
|
|
220
|
+
/** @type {ChoiceLayout} */
|
|
221
|
+
const layout = (this.getAttribute('layout') || 'stack').toLowerCase()
|
|
222
|
+
const verdictKicker = this.getAttribute('verdict') ?? 'Chosen'
|
|
223
|
+
const adoptLabel = this.getAttribute('adopt') ?? 'Adopt this direction'
|
|
224
|
+
const hint = this.getAttribute('hint') ?? ''
|
|
225
|
+
const reconsider = this.getAttribute('reconsider') ?? 'Reconsider'
|
|
226
|
+
const compareSummary = this.getAttribute('compare')
|
|
227
|
+
const compareBody = this.querySelector(':scope > [slot="compare"]')
|
|
228
|
+
|
|
229
|
+
const options = readOptions(this)
|
|
230
|
+
|
|
231
|
+
// resolved: render the decision as already made (no interaction).
|
|
232
|
+
const resolvedValue = this.getAttribute('resolved')
|
|
233
|
+
if (resolvedValue !== null) {
|
|
234
|
+
this.replaceChildren(buildResolvedChoice(doc, { options, verdictKicker, resolvedValue }))
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const scopeId = nextId('bp-choice')
|
|
239
|
+
const viewName = `${scopeId}-view`
|
|
240
|
+
const pickName = `${scopeId}-pick`
|
|
241
|
+
|
|
242
|
+
const form = el(doc, 'form', `bp-choice bp-choice--${layout}`)
|
|
243
|
+
form.dataset.bpChoice = scopeId
|
|
244
|
+
|
|
245
|
+
const titleOpts = options.map((o) => ({ value: o.value, title: o.title }))
|
|
246
|
+
form.append(buildVerdict(doc, verdictKicker, titleOpts))
|
|
247
|
+
|
|
248
|
+
if (layout === 'tabs') {
|
|
249
|
+
const seg = el(doc, 'div', 'bp-choice__seg')
|
|
250
|
+
seg.setAttribute('role', 'tablist')
|
|
251
|
+
for (const [index, opt] of options.entries()) {
|
|
252
|
+
const segOpt = el(doc, 'label', 'bp-choice__seg-opt')
|
|
253
|
+
const input = doc.createElement('input')
|
|
254
|
+
input.type = 'radio'
|
|
255
|
+
input.name = viewName
|
|
256
|
+
input.value = opt.value
|
|
257
|
+
input.className = 'bp-choice__view'
|
|
258
|
+
if (index === 0) input.checked = true
|
|
259
|
+
segOpt.append(input, doc.createTextNode(opt.tab))
|
|
260
|
+
seg.append(segOpt)
|
|
261
|
+
}
|
|
262
|
+
form.append(seg)
|
|
263
|
+
|
|
264
|
+
const panels = el(doc, 'div', 'bp-choice__panels')
|
|
265
|
+
for (const opt of options) {
|
|
266
|
+
const panel = el(doc, 'div', 'bp-choice__panel')
|
|
267
|
+
panel.dataset.value = opt.value
|
|
268
|
+
const h3 = el(doc, 'h3')
|
|
269
|
+
h3.textContent = opt.title
|
|
270
|
+
panel.append(h3)
|
|
271
|
+
panel.append(bodyWithoutRationale(opt.el))
|
|
272
|
+
const rationale = extractRationale(opt.el)
|
|
273
|
+
if (rationale) panel.append(rationale)
|
|
274
|
+
|
|
275
|
+
const actions = el(doc, 'div', 'bp-choice__actions')
|
|
276
|
+
const adopt = el(doc, 'label', 'bp-choice__adopt')
|
|
277
|
+
const commit = doc.createElement('input')
|
|
278
|
+
commit.type = 'radio'
|
|
279
|
+
commit.name = pickName
|
|
280
|
+
commit.value = opt.value
|
|
281
|
+
commit.className = 'bp-choice__commit'
|
|
282
|
+
adopt.append(commit, doc.createTextNode(adoptLabel))
|
|
283
|
+
actions.append(adopt)
|
|
284
|
+
panel.append(actions)
|
|
285
|
+
panels.append(panel)
|
|
286
|
+
}
|
|
287
|
+
form.append(panels)
|
|
288
|
+
|
|
289
|
+
const style = el(doc, 'style')
|
|
290
|
+
style.textContent = scopedTabRules(`[data-bp-choice="${scopeId}"]`, viewName, pickName, titleOpts)
|
|
291
|
+
form.prepend(style)
|
|
292
|
+
|
|
293
|
+
form.append(
|
|
294
|
+
buildFooter(
|
|
295
|
+
doc,
|
|
296
|
+
hint || 'Switch tabs to preview · adopt to commit',
|
|
297
|
+
reconsider
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (layout === 'stack') {
|
|
303
|
+
const stack = el(doc, 'div', 'bp-choice__stack')
|
|
304
|
+
for (const opt of options) {
|
|
305
|
+
const card = el(doc, 'label', 'bp-choice__card')
|
|
306
|
+
const input = doc.createElement('input')
|
|
307
|
+
input.type = 'radio'
|
|
308
|
+
input.name = pickName
|
|
309
|
+
input.value = opt.value
|
|
310
|
+
input.className = 'bp-choice__pick'
|
|
311
|
+
card.append(input)
|
|
312
|
+
|
|
313
|
+
const head = el(doc, 'div', 'bp-choice__card-head')
|
|
314
|
+
if (opt.optionLabel) head.append(label(doc, opt.optionLabel))
|
|
315
|
+
const chosen = el(doc, 'span', 'bp-choice__tag bp-choice__tag--ink bp-choice__card-chosen')
|
|
316
|
+
chosen.textContent = '✓ Chosen'
|
|
317
|
+
const rejected = el(doc, 'span', 'bp-choice__tag bp-choice__tag--out bp-choice__card-rejected')
|
|
318
|
+
rejected.textContent = 'Not chosen'
|
|
319
|
+
head.append(chosen, rejected)
|
|
320
|
+
card.append(head)
|
|
321
|
+
|
|
322
|
+
const h4 = el(doc, 'h4')
|
|
323
|
+
h4.textContent = opt.title
|
|
324
|
+
card.append(h4)
|
|
325
|
+
card.append(bodyWithoutRationale(opt.el))
|
|
326
|
+
const rationale = extractRationale(opt.el)
|
|
327
|
+
if (rationale) card.append(rationale)
|
|
328
|
+
stack.append(card)
|
|
329
|
+
}
|
|
330
|
+
form.append(stack)
|
|
331
|
+
form.append(
|
|
332
|
+
buildFooter(
|
|
333
|
+
doc,
|
|
334
|
+
hint || 'Select a card to commit · rejected drafts stay readable below',
|
|
335
|
+
reconsider
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (layout === 'gallery') {
|
|
341
|
+
const gallery = el(doc, 'div', 'bp-choice__gallery')
|
|
342
|
+
for (const opt of options) {
|
|
343
|
+
const mock = el(doc, 'label', 'bp-choice__mock')
|
|
344
|
+
const input = doc.createElement('input')
|
|
345
|
+
input.type = 'radio'
|
|
346
|
+
input.name = pickName
|
|
347
|
+
input.value = opt.value
|
|
348
|
+
input.className = 'bp-choice__pick'
|
|
349
|
+
mock.append(input)
|
|
350
|
+
|
|
351
|
+
const frame = el(doc, 'div', 'bp-choice__mock-frame')
|
|
352
|
+
const frameInner = frameContent(opt.el)
|
|
353
|
+
if (frameInner) frame.append(frameInner)
|
|
354
|
+
mock.append(frame)
|
|
355
|
+
|
|
356
|
+
const cap = el(doc, 'div', 'bp-choice__mock-cap')
|
|
357
|
+
const h4 = el(doc, 'h4')
|
|
358
|
+
h4.textContent = opt.caption
|
|
359
|
+
cap.append(h4)
|
|
360
|
+
const chosen = el(doc, 'span', 'bp-choice__tag bp-choice__tag--ink bp-choice__mock-pick bp-choice__mock-chosen')
|
|
361
|
+
chosen.textContent = '✓ Chosen'
|
|
362
|
+
const rejected = el(doc, 'span', 'bp-choice__tag bp-choice__tag--out bp-choice__mock-pick bp-choice__mock-rejected')
|
|
363
|
+
rejected.textContent = 'Not chosen'
|
|
364
|
+
cap.append(chosen, rejected)
|
|
365
|
+
mock.append(cap)
|
|
366
|
+
gallery.append(mock)
|
|
367
|
+
}
|
|
368
|
+
form.append(gallery)
|
|
369
|
+
|
|
370
|
+
if (compareSummary && compareBody) {
|
|
371
|
+
const details = el(doc, 'details', 'bp-choice__compare')
|
|
372
|
+
const summary = el(doc, 'summary')
|
|
373
|
+
summary.textContent = compareSummary
|
|
374
|
+
details.append(summary)
|
|
375
|
+
const body = el(doc, 'div', 'bp-choice__compare-body')
|
|
376
|
+
body.append(compareBody.cloneNode(true))
|
|
377
|
+
details.append(body)
|
|
378
|
+
form.append(details)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
form.append(
|
|
382
|
+
buildFooter(doc, hint || 'Select a mockup to track the decision', reconsider)
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
this.replaceChildren(form)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
class BlueprintPreflightAElement extends HTMLElement {}
|
|
391
|
+
|
|
392
|
+
class BlueprintPreflightQElement extends HTMLElement {}
|
|
393
|
+
|
|
394
|
+
class BlueprintPreflightElement extends HTMLElement {
|
|
395
|
+
connectedCallback() {
|
|
396
|
+
if (this.dataset.bpRendered) return
|
|
397
|
+
this.dataset.bpRendered = '1'
|
|
398
|
+
|
|
399
|
+
const doc = this.ownerDocument
|
|
400
|
+
const title = this.getAttribute('title') ?? 'Before drafting'
|
|
401
|
+
const draftName = this.getAttribute('draft') ?? 'section'
|
|
402
|
+
const hint = this.getAttribute('hint') ?? 'Answer all questions to unlock · choices stay editable'
|
|
403
|
+
const reconsider = this.getAttribute('reconsider') ?? 'Reset answers'
|
|
404
|
+
const scopeId = nextId('bp-preflight')
|
|
405
|
+
|
|
406
|
+
const questions = [...this.querySelectorAll(':scope > bp-preflight-q')]
|
|
407
|
+
const form = el(doc, 'form')
|
|
408
|
+
const panel = el(doc, 'div', 'bp-preflight')
|
|
409
|
+
panel.dataset.bpPreflight = scopeId
|
|
410
|
+
|
|
411
|
+
const bar = el(doc, 'div', 'bp-preflight__bar')
|
|
412
|
+
bar.append(label(doc, title))
|
|
413
|
+
const count = el(doc, 'span', 'bp-preflight__count')
|
|
414
|
+
count.textContent = `${questions.length} required`
|
|
415
|
+
bar.append(count)
|
|
416
|
+
panel.append(bar)
|
|
417
|
+
|
|
418
|
+
const list = el(doc, 'div', 'bp-preflight__questions')
|
|
419
|
+
/** @type {string[]} */
|
|
420
|
+
const gateSelectors = []
|
|
421
|
+
|
|
422
|
+
for (const qNode of questions) {
|
|
423
|
+
const qName = qNode.getAttribute('name') ?? nextId('q')
|
|
424
|
+
const kind = (qNode.getAttribute('kind') || 'one').toLowerCase()
|
|
425
|
+
const prompt = qNode.getAttribute('prompt') ?? ''
|
|
426
|
+
const answers = [...qNode.querySelectorAll(':scope > bp-preflight-a')]
|
|
427
|
+
|
|
428
|
+
const q = el(doc, 'div', 'bp-preflight__q')
|
|
429
|
+
const promptEl = el(doc, 'p', 'bp-preflight__prompt')
|
|
430
|
+
promptEl.append(doc.createTextNode(prompt))
|
|
431
|
+
const kindEl = el(doc, 'span', 'bp-preflight__kind')
|
|
432
|
+
kindEl.textContent = kind === 'many' ? '· choose any' : '· choose one'
|
|
433
|
+
promptEl.append(kindEl)
|
|
434
|
+
const resolved = el(doc, 'span', 'bp-choice__tag bp-choice__tag--ink bp-preflight__resolved')
|
|
435
|
+
resolved.textContent = '✓ Resolved'
|
|
436
|
+
promptEl.append(resolved)
|
|
437
|
+
q.append(promptEl)
|
|
438
|
+
|
|
439
|
+
const chips = el(doc, 'div', 'bp-preflight__chips')
|
|
440
|
+
for (const aNode of answers) {
|
|
441
|
+
const chip = el(doc, 'label', 'bp-preflight__chip')
|
|
442
|
+
const input = doc.createElement('input')
|
|
443
|
+
input.type = kind === 'many' ? 'checkbox' : 'radio'
|
|
444
|
+
input.name = qName
|
|
445
|
+
input.value = aNode.getAttribute('value') ?? aNode.textContent?.trim() ?? ''
|
|
446
|
+
chip.append(input, doc.createTextNode(aNode.textContent?.trim() ?? ''))
|
|
447
|
+
chips.append(chip)
|
|
448
|
+
}
|
|
449
|
+
q.append(chips)
|
|
450
|
+
|
|
451
|
+
const rationale = extractRationale(qNode)
|
|
452
|
+
if (rationale) q.append(rationale)
|
|
453
|
+
list.append(q)
|
|
454
|
+
gateSelectors.push(`[name="${qName}"]:checked`)
|
|
455
|
+
}
|
|
456
|
+
panel.append(list)
|
|
457
|
+
|
|
458
|
+
const gateWrap = el(doc, 'div')
|
|
459
|
+
gateWrap.style.padding = 'var(--bp-space-3)'
|
|
460
|
+
const gate = el(doc, 'div', 'bp-preflight__gate')
|
|
461
|
+
const locked = el(doc, 'div', 'bp-preflight__gate-locked')
|
|
462
|
+
const lockedTag = el(doc, 'span', 'bp-choice__tag')
|
|
463
|
+
lockedTag.textContent = `⌧ Section fenced — resolve the ${questions.length} decisions above to draft`
|
|
464
|
+
locked.append(lockedTag)
|
|
465
|
+
const ready = el(doc, 'div', 'bp-preflight__gate-ready')
|
|
466
|
+
const readyP = el(doc, 'p')
|
|
467
|
+
readyP.style.margin = '0 0 var(--bp-space-1)'
|
|
468
|
+
readyP.textContent = 'All decisions resolved.'
|
|
469
|
+
const draftBtn = el(doc, 'span', 'bp-preflight__draft')
|
|
470
|
+
draftBtn.textContent = `Draft “${draftName}” →`
|
|
471
|
+
ready.append(readyP, draftBtn)
|
|
472
|
+
gate.append(locked, ready)
|
|
473
|
+
gateWrap.append(gate)
|
|
474
|
+
panel.append(gateWrap)
|
|
475
|
+
|
|
476
|
+
const style = el(doc, 'style')
|
|
477
|
+
const gateRule = gateSelectors.map((sel) => `:has(${sel})`).join('')
|
|
478
|
+
style.textContent =
|
|
479
|
+
`[data-bp-preflight="${scopeId}"]${gateRule}{--bp-preflight-ready:1}` +
|
|
480
|
+
`[data-bp-preflight="${scopeId}"]${gateRule}{border-color:var(--bp-ink-line)}` +
|
|
481
|
+
`[data-bp-preflight="${scopeId}"]${gateRule} .bp-preflight__gate{background-image:none;border-style:solid}` +
|
|
482
|
+
`[data-bp-preflight="${scopeId}"]${gateRule} .bp-preflight__gate-locked{display:none}` +
|
|
483
|
+
`[data-bp-preflight="${scopeId}"]${gateRule} .bp-preflight__gate-ready{display:block}`
|
|
484
|
+
panel.prepend(style)
|
|
485
|
+
|
|
486
|
+
const footer = el(doc, 'div', 'bp-preflight__footer')
|
|
487
|
+
const hintEl = el(doc, 'span', 'bp-choice__hint')
|
|
488
|
+
hintEl.textContent = hint
|
|
489
|
+
const reset = el(doc, 'button', 'bp-choice__reset')
|
|
490
|
+
reset.type = 'reset'
|
|
491
|
+
reset.textContent = reconsider
|
|
492
|
+
footer.append(hintEl, reset)
|
|
493
|
+
|
|
494
|
+
form.append(panel, footer)
|
|
495
|
+
this.replaceChildren(form)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
class BlueprintChoiceRecordRowElement extends HTMLElement {
|
|
500
|
+
connectedCallback() {
|
|
501
|
+
if (this.dataset.bpRendered) return
|
|
502
|
+
this.dataset.bpRendered = '1'
|
|
503
|
+
|
|
504
|
+
const doc = this.ownerDocument
|
|
505
|
+
const rowLabel = this.getAttribute('label') ?? ''
|
|
506
|
+
const value = this.getAttribute('value') ?? ''
|
|
507
|
+
const alts = this.getAttribute('alts') ?? ''
|
|
508
|
+
|
|
509
|
+
const dl = el(doc, 'dl', 'bp-choice-record__row')
|
|
510
|
+
const dt = el(doc, 'dt')
|
|
511
|
+
dt.textContent = rowLabel
|
|
512
|
+
const dd = el(doc, 'dd')
|
|
513
|
+
dd.textContent = value
|
|
514
|
+
dl.append(dt, dd)
|
|
515
|
+
if (alts) {
|
|
516
|
+
const tag = el(doc, 'span', 'bp-choice__tag bp-choice__tag--out')
|
|
517
|
+
tag.textContent = alts
|
|
518
|
+
dl.append(tag)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const considered = this.querySelector(':scope > [slot="considered"]')
|
|
522
|
+
const nodes = [dl]
|
|
523
|
+
if (considered) {
|
|
524
|
+
const details = el(doc, 'details', 'bp-choice__compare')
|
|
525
|
+
details.style.marginTop = 'var(--bp-space-2)'
|
|
526
|
+
const summary = el(doc, 'summary')
|
|
527
|
+
summary.textContent = '+ Show the options considered'
|
|
528
|
+
details.append(summary)
|
|
529
|
+
const body = el(doc, 'div', 'bp-choice__compare-body')
|
|
530
|
+
body.append(considered.cloneNode(true))
|
|
531
|
+
details.append(body)
|
|
532
|
+
nodes.push(details)
|
|
533
|
+
}
|
|
534
|
+
this.replaceChildren(...nodes)
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
class BlueprintChoiceRecordElement extends HTMLElement {
|
|
539
|
+
connectedCallback() {
|
|
540
|
+
if (this.dataset.bpRendered) return
|
|
541
|
+
this.dataset.bpRendered = '1'
|
|
542
|
+
const wrap = this.ownerDocument.createElement('div')
|
|
543
|
+
wrap.className = 'bp-choice-record'
|
|
544
|
+
wrap.append(...this.childNodes)
|
|
545
|
+
this.replaceChildren(wrap)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** @param {string} name @param {typeof HTMLElement} ctor */
|
|
550
|
+
function define(name, ctor) {
|
|
551
|
+
if (typeof customElements !== 'undefined' && !customElements.get(name)) {
|
|
552
|
+
customElements.define(name, ctor)
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function registerBlueprintChoiceElements() {
|
|
557
|
+
define('bp-rationale', BlueprintRationaleElement)
|
|
558
|
+
define('bp-choice-option', BlueprintChoiceOptionElement)
|
|
559
|
+
define('bp-choice', BlueprintChoiceElement)
|
|
560
|
+
define('bp-preflight-a', BlueprintPreflightAElement)
|
|
561
|
+
define('bp-preflight-q', BlueprintPreflightQElement)
|
|
562
|
+
define('bp-preflight', BlueprintPreflightElement)
|
|
563
|
+
define('bp-choice-record-row', BlueprintChoiceRecordRowElement)
|
|
564
|
+
define('bp-choice-record', BlueprintChoiceRecordElement)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
registerBlueprintChoiceElements()
|