@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
package/dist/blueprint.js
CHANGED
|
@@ -1,20 +1,967 @@
|
|
|
1
|
+
// Document chrome — site nav, reading progress, contents rail, and footer
|
|
2
|
+
// metadata are injected at runtime so authored pages stay semantic (<main> only).
|
|
3
|
+
// Chrome glyphs are Iconoir (MIT) line icons, stored as raw path data and
|
|
4
|
+
// drawn at runtime so the framework ships zero icon-pack dependencies. Each
|
|
5
|
+
// stroke inherits currentColor, so the ink-scale tokens theme them for free.
|
|
6
|
+
const SIDEBAR_COLLAPSE_ICON = [
|
|
7
|
+
'M19 21L5 21C3.89543 21 3 20.1046 3 19L3 5C3 3.89543 3.89543 3 5 3L19 3C20.1046 3 21 3.89543 21 5L21 19C21 20.1046 20.1046 21 19 21Z',
|
|
8
|
+
'M7.25 10L5.5 12L7.25 14',
|
|
9
|
+
'M9.5 21V3',
|
|
10
|
+
]
|
|
11
|
+
const SIDEBAR_EXPAND_ICON = [
|
|
12
|
+
'M19 21L5 21C3.89543 21 3 20.1046 3 19L3 5C3 3.89543 3.89543 3 5 3L19 3C20.1046 3 21 3.89543 21 5L21 19C21 20.1046 20.1046 21 19 21Z',
|
|
13
|
+
'M9.5 21V3',
|
|
14
|
+
'M5.5 10L7.25 12L5.5 14',
|
|
15
|
+
]
|
|
16
|
+
const SEARCH_ICON = [
|
|
17
|
+
'M3 11C3 15.4183 6.58172 19 11 19C13.213 19 15.2161 18.1015 16.6644 16.6493C18.1077 15.2022 19 13.2053 19 11C19 6.58172 15.4183 3 11 3C6.58172 3 3 6.58172 3 11Z',
|
|
18
|
+
'M17 17L21 21',
|
|
19
|
+
]
|
|
20
|
+
const DOC_CHEVRON_ICON = 'M6 9L12 15L18 9'
|
|
21
|
+
const THEME_MOON_ICON =
|
|
22
|
+
'M3 11.5066C3 16.7497 7.25034 21 12.4934 21C16.2209 21 19.4466 18.8518 21 15.7259C12.4934 15.7259 8.27411 11.5066 8.27411 3C5.14821 4.55344 3 7.77915 3 11.5066Z'
|
|
23
|
+
const THEME_BULB_ICON = [
|
|
24
|
+
'M9 18H15',
|
|
25
|
+
'M10 21H14',
|
|
26
|
+
'M9.00082 15C9.00098 13 8.50098 12.5 7.50082 11.5C6.50067 10.5 6.02422 9.48689 6.00082 8C5.95284 4.95029 8.00067 3 12.0008 3C16.001 3 18.0488 4.95029 18.0008 8C17.9774 9.48689 17.5007 10.5 16.5008 11.5C15.501 12.5 15.001 13 15.0008 15',
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
function metaSelector({ name, property } = {}) {
|
|
30
|
+
if (property) return `meta[property="${property}"]`
|
|
31
|
+
if (name) return `meta[name="${name}"]`
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readMetaContent(doc, target) {
|
|
36
|
+
const selector = metaSelector(target)
|
|
37
|
+
return selector ? doc.querySelector(selector)?.content?.trim() ?? '' : ''
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readMetaContentAll(doc, target) {
|
|
41
|
+
const selector = metaSelector(target)
|
|
42
|
+
if (!selector) return []
|
|
43
|
+
return [...doc.querySelectorAll(selector)]
|
|
44
|
+
.map((meta) => meta.content?.trim())
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readMetaJson(doc, name) {
|
|
49
|
+
const raw = readMetaContent(doc, { name })
|
|
50
|
+
if (!raw) return null
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(raw)
|
|
53
|
+
} catch {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatPublishedDate(iso) {
|
|
59
|
+
if (!iso) return null
|
|
60
|
+
const day = iso.includes('T') ? iso.split('T')[0] : iso
|
|
61
|
+
const date = new Date(day.includes('T') ? day : `${day}T12:00:00`)
|
|
62
|
+
if (Number.isNaN(date.getTime())) return null
|
|
63
|
+
return {
|
|
64
|
+
iso: day,
|
|
65
|
+
label: date.toLocaleDateString(undefined, {
|
|
66
|
+
day: 'numeric',
|
|
67
|
+
month: 'long',
|
|
68
|
+
year: 'numeric',
|
|
69
|
+
timeZone: 'UTC',
|
|
70
|
+
}),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pageBasename(href, win) {
|
|
75
|
+
try {
|
|
76
|
+
return new URL(href, win.location.href).pathname.split('/').pop() || 'index.html'
|
|
77
|
+
} catch {
|
|
78
|
+
return href.split('/').pop() || 'index.html'
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isCurrentHref(href, win) {
|
|
83
|
+
const current = win.location.pathname.split('/').pop() || 'index.html'
|
|
84
|
+
return pageBasename(href, win) === current
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function createChromeSvg(doc, pathD, size = 16) {
|
|
88
|
+
const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
89
|
+
svg.setAttribute('viewBox', '0 0 24 24')
|
|
90
|
+
svg.setAttribute('fill', 'none')
|
|
91
|
+
svg.setAttribute('stroke', 'currentColor')
|
|
92
|
+
svg.setAttribute('stroke-width', '1.5')
|
|
93
|
+
svg.setAttribute('stroke-linecap', 'round')
|
|
94
|
+
svg.setAttribute('stroke-linejoin', 'round')
|
|
95
|
+
svg.setAttribute('width', String(size))
|
|
96
|
+
svg.setAttribute('height', String(size))
|
|
97
|
+
svg.setAttribute('aria-hidden', 'true')
|
|
98
|
+
for (const d of Array.isArray(pathD) ? pathD : [pathD]) {
|
|
99
|
+
const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
100
|
+
path.setAttribute('d', d)
|
|
101
|
+
svg.append(path)
|
|
102
|
+
}
|
|
103
|
+
return svg
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function injectScrollProgress(doc) {
|
|
107
|
+
if (doc.querySelector('.scroll-progress')) return null
|
|
108
|
+
const bar = doc.createElement('div')
|
|
109
|
+
bar.className = 'scroll-progress'
|
|
110
|
+
bar.setAttribute('aria-hidden', 'true')
|
|
111
|
+
return bar
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function injectSiteNav(doc, win) {
|
|
115
|
+
if (doc.querySelector('.site-nav')) return null
|
|
116
|
+
const links = readMetaJson(doc, 'bp:site-nav')
|
|
117
|
+
if (!links?.length) return null
|
|
118
|
+
|
|
119
|
+
const nav = doc.createElement('nav')
|
|
120
|
+
nav.className = 'site-nav'
|
|
121
|
+
nav.setAttribute('aria-label', 'Site')
|
|
122
|
+
|
|
123
|
+
const brandLabel =
|
|
124
|
+
readMetaContent(doc, { property: 'og:site_name' }) ||
|
|
125
|
+
doc.title.split('—')[0]?.trim() ||
|
|
126
|
+
'Home'
|
|
127
|
+
const brandHref = readMetaContent(doc, { name: 'bp:site-home' }) || 'index.html'
|
|
128
|
+
|
|
129
|
+
const brand = doc.createElement('a')
|
|
130
|
+
brand.className = 'site-nav__brand'
|
|
131
|
+
brand.href = brandHref
|
|
132
|
+
brand.setAttribute('aria-label', `${brandLabel} home`)
|
|
133
|
+
|
|
134
|
+
const logo = doc.createElement('span')
|
|
135
|
+
logo.className = 'site-nav__logo'
|
|
136
|
+
logo.setAttribute('aria-hidden', 'true')
|
|
137
|
+
|
|
138
|
+
const wordmark = doc.createElement('span')
|
|
139
|
+
wordmark.className = 'site-nav__wordmark'
|
|
140
|
+
wordmark.textContent = brandLabel
|
|
141
|
+
|
|
142
|
+
brand.append(logo, wordmark)
|
|
143
|
+
|
|
144
|
+
const list = doc.createElement('ul')
|
|
145
|
+
list.className = 'site-nav__links'
|
|
146
|
+
|
|
147
|
+
for (const item of links) {
|
|
148
|
+
const li = doc.createElement('li')
|
|
149
|
+
const link = doc.createElement('a')
|
|
150
|
+
link.href = item.href
|
|
151
|
+
link.textContent = item.label
|
|
152
|
+
if (item.current || isCurrentHref(item.href, win)) {
|
|
153
|
+
link.setAttribute('aria-current', 'page')
|
|
154
|
+
}
|
|
155
|
+
li.append(link)
|
|
156
|
+
list.append(li)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
nav.append(brand, list)
|
|
160
|
+
|
|
161
|
+
if (readMetaContent(doc, { name: 'bp:site-theme' }) === 'nav') {
|
|
162
|
+
const theme = doc.createElement('button')
|
|
163
|
+
theme.className = 'toggle site-nav__theme'
|
|
164
|
+
theme.type = 'button'
|
|
165
|
+
theme.id = 'themeToggle'
|
|
166
|
+
theme.setAttribute('aria-label', 'Toggle light and dark theme')
|
|
167
|
+
theme.textContent = 'Theme'
|
|
168
|
+
nav.append(theme)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return nav
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function injectSidebarToggle(doc) {
|
|
175
|
+
if (doc.querySelector('.sidebar-toggle')) return null
|
|
176
|
+
const toggle = doc.createElement('button')
|
|
177
|
+
toggle.className = 'sidebar-toggle'
|
|
178
|
+
toggle.type = 'button'
|
|
179
|
+
toggle.setAttribute('aria-controls', 'bp-contents-rail')
|
|
180
|
+
toggle.setAttribute('aria-expanded', 'true')
|
|
181
|
+
toggle.setAttribute('aria-label', 'Hide contents sidebar')
|
|
182
|
+
|
|
183
|
+
const icons = doc.createElement('span')
|
|
184
|
+
icons.className = 'sidebar-toggle__icons'
|
|
185
|
+
icons.setAttribute('aria-hidden', 'true')
|
|
186
|
+
|
|
187
|
+
const collapse = doc.createElement('span')
|
|
188
|
+
collapse.className =
|
|
189
|
+
'sidebar-toggle__icon sidebar-toggle__icon--collapse bp-transition-opacity bp-duration-slow bp-ease-in-out'
|
|
190
|
+
collapse.append(createChromeSvg(doc, SIDEBAR_COLLAPSE_ICON))
|
|
191
|
+
|
|
192
|
+
const expand = doc.createElement('span')
|
|
193
|
+
expand.className =
|
|
194
|
+
'sidebar-toggle__icon sidebar-toggle__icon--expand bp-transition-opacity bp-duration-slow bp-ease-in-out'
|
|
195
|
+
expand.append(createChromeSvg(doc, SIDEBAR_EXPAND_ICON))
|
|
196
|
+
|
|
197
|
+
icons.append(collapse, expand)
|
|
198
|
+
toggle.append(icons)
|
|
199
|
+
return toggle
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function buildDocSwitcher(doc, win) {
|
|
203
|
+
const collections = readMetaJson(doc, 'bp:collections')
|
|
204
|
+
if (!collections?.length) return null
|
|
205
|
+
|
|
206
|
+
const details = doc.createElement('details')
|
|
207
|
+
details.className = 'doc-switcher'
|
|
208
|
+
|
|
209
|
+
const summary = doc.createElement('summary')
|
|
210
|
+
summary.className = 'doc-switcher__current sidebar-field'
|
|
211
|
+
summary.setAttribute('aria-label', 'Switch blueprint collection')
|
|
212
|
+
|
|
213
|
+
const current =
|
|
214
|
+
collections.find((item) => item.current || isCurrentHref(item.href, win)) ??
|
|
215
|
+
collections[0]
|
|
216
|
+
const collectionMeta = readMetaContent(doc, { name: 'bp:collection-meta' })
|
|
217
|
+
|
|
218
|
+
const label = doc.createElement('span')
|
|
219
|
+
label.className = 'doc-switcher__label'
|
|
220
|
+
|
|
221
|
+
const title = doc.createElement('span')
|
|
222
|
+
title.className = 'doc-switcher__title'
|
|
223
|
+
title.textContent = current.title
|
|
224
|
+
|
|
225
|
+
const meta = doc.createElement('span')
|
|
226
|
+
meta.className = 'doc-switcher__meta'
|
|
227
|
+
meta.textContent = collectionMeta || current.desc || ''
|
|
228
|
+
|
|
229
|
+
label.append(title)
|
|
230
|
+
if (meta.textContent) label.append(meta)
|
|
231
|
+
|
|
232
|
+
const chevron = doc.createElement('span')
|
|
233
|
+
chevron.className =
|
|
234
|
+
'doc-switcher__chevron bp-transition-transform bp-duration-normal bp-ease-in-out'
|
|
235
|
+
chevron.setAttribute('aria-hidden', 'true')
|
|
236
|
+
chevron.append(createChromeSvg(doc, DOC_CHEVRON_ICON))
|
|
237
|
+
|
|
238
|
+
summary.append(label, chevron)
|
|
239
|
+
|
|
240
|
+
const menu = doc.createElement('div')
|
|
241
|
+
menu.className = 'doc-switcher__menu'
|
|
242
|
+
menu.setAttribute('role', 'menu')
|
|
243
|
+
|
|
244
|
+
for (const item of collections) {
|
|
245
|
+
const link = doc.createElement('a')
|
|
246
|
+
link.href = item.href
|
|
247
|
+
link.setAttribute('role', 'menuitem')
|
|
248
|
+
if (item.current || isCurrentHref(item.href, win)) {
|
|
249
|
+
link.className = 'is-current'
|
|
250
|
+
link.setAttribute('aria-current', 'page')
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const option = doc.createElement('span')
|
|
254
|
+
option.className = 'doc-switcher__option'
|
|
255
|
+
|
|
256
|
+
const name = doc.createElement('span')
|
|
257
|
+
name.className = 'doc-switcher__name'
|
|
258
|
+
name.textContent = item.title
|
|
259
|
+
|
|
260
|
+
option.append(name)
|
|
261
|
+
if (item.desc) {
|
|
262
|
+
const desc = doc.createElement('span')
|
|
263
|
+
desc.className = 'doc-switcher__desc'
|
|
264
|
+
desc.textContent = item.desc
|
|
265
|
+
option.append(desc)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const go = doc.createElement('span')
|
|
269
|
+
go.className = 'doc-switcher__go bp-transition-opacity'
|
|
270
|
+
go.setAttribute('aria-hidden', 'true')
|
|
271
|
+
go.append(createChromeSvg(doc, DOC_CHEVRON_ICON, 14))
|
|
272
|
+
|
|
273
|
+
link.append(option, go)
|
|
274
|
+
menu.append(link)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
details.append(summary, menu)
|
|
278
|
+
return details
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function buildSidebarFooter(doc) {
|
|
282
|
+
const footer = doc.createElement('div')
|
|
283
|
+
footer.className = 'sidebar-footer'
|
|
284
|
+
|
|
285
|
+
const theme = doc.createElement('button')
|
|
286
|
+
theme.className = 'theme-toggle'
|
|
287
|
+
theme.type = 'button'
|
|
288
|
+
theme.setAttribute('role', 'switch')
|
|
289
|
+
theme.setAttribute('aria-checked', 'false')
|
|
290
|
+
theme.setAttribute('aria-label', 'Switch to dark theme')
|
|
291
|
+
|
|
292
|
+
const thumb = doc.createElement('span')
|
|
293
|
+
thumb.className =
|
|
294
|
+
'theme-toggle__thumb bp-transition-transform bp-duration-normal bp-ease-in-out'
|
|
295
|
+
thumb.setAttribute('aria-hidden', 'true')
|
|
296
|
+
|
|
297
|
+
const moon = doc.createElement('span')
|
|
298
|
+
moon.className = 'theme-toggle__icon theme-toggle__icon--moon'
|
|
299
|
+
moon.setAttribute('aria-hidden', 'true')
|
|
300
|
+
moon.append(createChromeSvg(doc, THEME_MOON_ICON, 14))
|
|
301
|
+
|
|
302
|
+
const bulb = doc.createElement('span')
|
|
303
|
+
bulb.className = 'theme-toggle__icon theme-toggle__icon--bulb'
|
|
304
|
+
bulb.setAttribute('aria-hidden', 'true')
|
|
305
|
+
bulb.append(createChromeSvg(doc, THEME_BULB_ICON, 14))
|
|
306
|
+
|
|
307
|
+
theme.append(thumb, moon, bulb)
|
|
308
|
+
|
|
309
|
+
const meta = doc.createElement('div')
|
|
310
|
+
meta.className = 'sidebar-meta'
|
|
311
|
+
|
|
312
|
+
const authors = readMetaContentAll(doc, { property: 'article:author' })
|
|
313
|
+
const published = formatPublishedDate(
|
|
314
|
+
readMetaContent(doc, { property: 'article:published_time' })
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
if (authors.length) {
|
|
318
|
+
const item = doc.createElement('div')
|
|
319
|
+
item.className = 'sidebar-meta__item'
|
|
320
|
+
const label = doc.createElement('p')
|
|
321
|
+
label.className = 'bp-meta'
|
|
322
|
+
label.textContent = authors.length > 1 ? 'Authors' : 'Author'
|
|
323
|
+
const value = doc.createElement('p')
|
|
324
|
+
value.className = 'bp-note'
|
|
325
|
+
value.textContent = authors.join(', ')
|
|
326
|
+
item.append(label, value)
|
|
327
|
+
meta.append(item)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (published) {
|
|
331
|
+
const item = doc.createElement('div')
|
|
332
|
+
item.className = 'sidebar-meta__item'
|
|
333
|
+
const label = doc.createElement('p')
|
|
334
|
+
label.className = 'bp-meta'
|
|
335
|
+
label.textContent = 'Date'
|
|
336
|
+
const value = doc.createElement('p')
|
|
337
|
+
value.className = 'bp-note'
|
|
338
|
+
const time = doc.createElement('time')
|
|
339
|
+
time.dateTime = published.iso
|
|
340
|
+
time.textContent = published.label
|
|
341
|
+
value.append(time)
|
|
342
|
+
item.append(label, value)
|
|
343
|
+
meta.append(item)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
footer.append(theme)
|
|
347
|
+
if (meta.childNodes.length) footer.append(meta)
|
|
348
|
+
return footer
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function injectSidebar(doc, win) {
|
|
352
|
+
if (doc.querySelector('.bp-sidebar')) return null
|
|
353
|
+
|
|
354
|
+
const nav = doc.createElement('nav')
|
|
355
|
+
nav.className = 'bp-sidebar'
|
|
356
|
+
nav.id = 'bp-contents-rail'
|
|
357
|
+
nav.setAttribute('aria-label', 'Contents')
|
|
358
|
+
|
|
359
|
+
const panel = doc.createElement('div')
|
|
360
|
+
panel.className = 'bp-sidebar__panel'
|
|
361
|
+
|
|
362
|
+
const search = doc.createElement('div')
|
|
363
|
+
search.className = 'sidebar-search'
|
|
364
|
+
// The trigger is a button (it opens a dialog, it is not a text field): the
|
|
365
|
+
// field carries the chrome (border/radius/surface) and the magnifier + ⌘K
|
|
366
|
+
// hint sit inside it beside the label. Pressing it — or ⌘K / Ctrl+K — opens
|
|
367
|
+
// the command palette; see wireSearchPalette().
|
|
368
|
+
const searchField = doc.createElement('button')
|
|
369
|
+
searchField.type = 'button'
|
|
370
|
+
searchField.className =
|
|
371
|
+
'sidebar-search__field sidebar-field bp-transition-colors bp-ease'
|
|
372
|
+
searchField.setAttribute('aria-haspopup', 'dialog')
|
|
373
|
+
searchField.setAttribute('aria-keyshortcuts', 'Meta+K Control+K')
|
|
374
|
+
searchField.setAttribute('aria-label', 'Search (⌘K)')
|
|
375
|
+
const searchIcon = createChromeSvg(doc, SEARCH_ICON, 14)
|
|
376
|
+
searchIcon.classList.add('sidebar-search__icon')
|
|
377
|
+
const searchLabel = doc.createElement('span')
|
|
378
|
+
searchLabel.className = 'sidebar-search__label'
|
|
379
|
+
searchLabel.textContent = 'Search'
|
|
380
|
+
const searchHint = doc.createElement('span')
|
|
381
|
+
searchHint.className = 'sidebar-search__hint'
|
|
382
|
+
searchHint.setAttribute('aria-hidden', 'true')
|
|
383
|
+
const searchHintCmd = doc.createElement('kbd')
|
|
384
|
+
searchHintCmd.textContent = '⌘'
|
|
385
|
+
const searchHintKey = doc.createElement('kbd')
|
|
386
|
+
searchHintKey.textContent = 'K'
|
|
387
|
+
searchHint.append(searchHintCmd, searchHintKey)
|
|
388
|
+
searchField.append(searchIcon, searchLabel, searchHint)
|
|
389
|
+
search.append(searchField)
|
|
390
|
+
|
|
391
|
+
panel.append(search)
|
|
392
|
+
|
|
393
|
+
const switcher = buildDocSwitcher(doc, win)
|
|
394
|
+
if (switcher) panel.append(switcher)
|
|
395
|
+
|
|
396
|
+
const list = doc.createElement('ul')
|
|
397
|
+
panel.append(list)
|
|
398
|
+
|
|
399
|
+
nav.append(panel, buildSidebarFooter(doc))
|
|
400
|
+
return nav
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function syncThemeSwitch(doc, button) {
|
|
404
|
+
const dark = doc.documentElement.dataset.obviousTheme === 'dark'
|
|
405
|
+
button.setAttribute('aria-checked', String(dark))
|
|
406
|
+
button.setAttribute(
|
|
407
|
+
'aria-label',
|
|
408
|
+
dark ? 'Switch to light theme' : 'Switch to dark theme'
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// A reader's explicit pick is persisted here so the page reopens in their
|
|
413
|
+
// theme — mirrors the bp-sidebar collapse key.
|
|
414
|
+
const THEME_KEY = 'bp-theme'
|
|
415
|
+
|
|
416
|
+
function readStoredTheme(win) {
|
|
417
|
+
try {
|
|
418
|
+
const value = win.localStorage.getItem(THEME_KEY)
|
|
419
|
+
return value === 'dark' || value === 'light' ? value : null
|
|
420
|
+
} catch {
|
|
421
|
+
return null
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function writeStoredTheme(win, value) {
|
|
426
|
+
try {
|
|
427
|
+
win.localStorage.setItem(THEME_KEY, value)
|
|
428
|
+
} catch {}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function systemPrefersDark(win) {
|
|
432
|
+
try {
|
|
433
|
+
return win.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false
|
|
434
|
+
} catch {
|
|
435
|
+
return false
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Opt-in (head meta `bp:theme` = "auto"): when no explicit pick exists the page
|
|
440
|
+
// follows the OS color scheme. Off by default, so an author/bridge-set
|
|
441
|
+
// data-obvious-theme is left untouched.
|
|
442
|
+
function followsSystemTheme(doc) {
|
|
443
|
+
return readMetaContent(doc, { name: 'bp:theme' }).toLowerCase() === 'auto'
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function themePrefersReducedMotion(win) {
|
|
447
|
+
try {
|
|
448
|
+
return win.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
449
|
+
} catch {
|
|
450
|
+
return false
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function setTheme(doc, theme) {
|
|
455
|
+
doc.documentElement.dataset.obviousTheme = theme
|
|
456
|
+
for (const button of doc.querySelectorAll('.theme-toggle')) {
|
|
457
|
+
syncThemeSwitch(doc, button)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Flip with a single compositor crossfade (View Transitions API). Timing is
|
|
462
|
+
// owned by ::view-transition-* rules in blueprint.css. If the update callback
|
|
463
|
+
// is deferred (some embedded hosts), apply immediately so the toggle never
|
|
464
|
+
// desyncs from persistence; instant when reduced motion is on or VT is missing.
|
|
465
|
+
function setThemeAnimated(doc, win, theme) {
|
|
466
|
+
const apply = () => setTheme(doc, theme)
|
|
467
|
+
if (themePrefersReducedMotion(win) || typeof doc.startViewTransition !== 'function') {
|
|
468
|
+
apply()
|
|
469
|
+
return
|
|
470
|
+
}
|
|
471
|
+
const root = doc.documentElement
|
|
472
|
+
let applied = false
|
|
473
|
+
const clearSwitching = () => {
|
|
474
|
+
delete root.dataset.bpThemeSwitching
|
|
475
|
+
}
|
|
476
|
+
const runUpdate = () => {
|
|
477
|
+
applied = true
|
|
478
|
+
apply()
|
|
479
|
+
}
|
|
480
|
+
root.dataset.bpThemeSwitching = ''
|
|
481
|
+
try {
|
|
482
|
+
let transition
|
|
483
|
+
try {
|
|
484
|
+
transition = doc.startViewTransition({ update: runUpdate, types: ['bp-theme'] })
|
|
485
|
+
} catch {
|
|
486
|
+
transition = doc.startViewTransition(runUpdate)
|
|
487
|
+
}
|
|
488
|
+
if (!applied) runUpdate()
|
|
489
|
+
if (transition?.finished) {
|
|
490
|
+
transition.finished.finally(clearSwitching).catch(clearSwitching)
|
|
491
|
+
} else {
|
|
492
|
+
clearSwitching()
|
|
493
|
+
}
|
|
494
|
+
} catch {
|
|
495
|
+
clearSwitching()
|
|
496
|
+
apply()
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Resolve the theme at load: a persisted pick always wins; otherwise, when the
|
|
501
|
+
// page opts into bp:theme=auto, follow the OS scheme. No animation on load.
|
|
502
|
+
function resolveInitialTheme(doc, win) {
|
|
503
|
+
const stored = readStoredTheme(win)
|
|
504
|
+
if (stored) {
|
|
505
|
+
setTheme(doc, stored)
|
|
506
|
+
return
|
|
507
|
+
}
|
|
508
|
+
if (followsSystemTheme(doc)) {
|
|
509
|
+
setTheme(doc, systemPrefersDark(win) ? 'dark' : 'light')
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function wireThemeToggle(doc, win) {
|
|
514
|
+
const root = doc.documentElement
|
|
515
|
+
const toggleTheme = () => {
|
|
516
|
+
const next = root.dataset.obviousTheme === 'dark' ? 'light' : 'dark'
|
|
517
|
+
setThemeAnimated(doc, win, next)
|
|
518
|
+
writeStoredTheme(win, next)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
for (const button of doc.querySelectorAll('.theme-toggle')) {
|
|
522
|
+
if (button.dataset.bpThemeWired) continue
|
|
523
|
+
button.dataset.bpThemeWired = 'true'
|
|
524
|
+
syncThemeSwitch(doc, button)
|
|
525
|
+
button.addEventListener('click', toggleTheme)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const navTheme = doc.getElementById('themeToggle')
|
|
529
|
+
if (navTheme && !navTheme.dataset.bpThemeWired) {
|
|
530
|
+
navTheme.dataset.bpThemeWired = 'true'
|
|
531
|
+
navTheme.addEventListener('click', toggleTheme)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Track the OS scheme live, but only until the reader makes an explicit pick
|
|
535
|
+
// (a persisted value), which wins thereafter.
|
|
536
|
+
if (followsSystemTheme(doc) && win.matchMedia) {
|
|
537
|
+
const query = win.matchMedia('(prefers-color-scheme: dark)')
|
|
538
|
+
query.addEventListener?.('change', (event) => {
|
|
539
|
+
if (readStoredTheme(win)) return
|
|
540
|
+
setThemeAnimated(doc, win, event.matches ? 'dark' : 'light')
|
|
541
|
+
})
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function wireSidebarCollapse(doc, win) {
|
|
546
|
+
const root = doc.documentElement
|
|
547
|
+
const toggle = doc.querySelector('.sidebar-toggle')
|
|
548
|
+
if (!toggle || toggle.dataset.bpSidebarWired) return
|
|
549
|
+
toggle.dataset.bpSidebarWired = 'true'
|
|
550
|
+
|
|
551
|
+
const KEY = 'bp-sidebar'
|
|
552
|
+
const read = () => {
|
|
553
|
+
try {
|
|
554
|
+
return win.localStorage.getItem(KEY)
|
|
555
|
+
} catch {
|
|
556
|
+
return null
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
const write = (value) => {
|
|
560
|
+
try {
|
|
561
|
+
win.localStorage.setItem(KEY, value)
|
|
562
|
+
} catch {}
|
|
563
|
+
}
|
|
564
|
+
const apply = (collapsed) => {
|
|
565
|
+
root.dataset.sidebar = collapsed ? 'collapsed' : 'open'
|
|
566
|
+
toggle.setAttribute('aria-expanded', String(!collapsed))
|
|
567
|
+
toggle.setAttribute(
|
|
568
|
+
'aria-label',
|
|
569
|
+
collapsed ? 'Show contents sidebar' : 'Hide contents sidebar'
|
|
570
|
+
)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
apply(read() === 'collapsed')
|
|
574
|
+
win.requestAnimationFrame(() => {
|
|
575
|
+
root.classList.add('bp-sidebar-animate')
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
toggle.addEventListener('click', () => {
|
|
579
|
+
const collapsed = root.dataset.sidebar !== 'collapsed'
|
|
580
|
+
apply(collapsed)
|
|
581
|
+
write(collapsed ? 'collapsed' : 'open')
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Search command palette. The sidebar trigger — and ⌘K / Ctrl+K from anywhere —
|
|
586
|
+
// opens a centered dialog over a scrim. It is wired to the document's own
|
|
587
|
+
// headings: the rail's section links populate the list, the input filters them
|
|
588
|
+
// by a case-insensitive substring, ↑/↓ move the selection, and Enter / click
|
|
589
|
+
// jump to the section and close. Richer search (snippets, ranking, a built
|
|
590
|
+
// index, cross-document scope) is deferred to the search-logic owner; this is
|
|
591
|
+
// the open/close + jump-to-section surface.
|
|
592
|
+
function wireSearchPalette(doc, win) {
|
|
593
|
+
const trigger = doc.querySelector('.sidebar-search__field')
|
|
594
|
+
if (!trigger || trigger.dataset.bpSearchWired) return
|
|
595
|
+
trigger.dataset.bpSearchWired = 'true'
|
|
596
|
+
|
|
597
|
+
const isMacLike = /Mac|iPhone|iPad/.test(win.navigator?.platform || '')
|
|
598
|
+
if (!isMacLike) {
|
|
599
|
+
const cmd = trigger.querySelector('.sidebar-search__hint kbd')
|
|
600
|
+
if (cmd) cmd.textContent = 'Ctrl'
|
|
601
|
+
trigger.setAttribute('aria-label', 'Search (Ctrl+K)')
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
let overlay = null
|
|
605
|
+
let panel = null
|
|
606
|
+
let input = null
|
|
607
|
+
let results = null
|
|
608
|
+
let footStatus = null
|
|
609
|
+
let entries = []
|
|
610
|
+
let visible = []
|
|
611
|
+
let selected = 0
|
|
612
|
+
let returnFocus = null
|
|
613
|
+
let prevOverflow = ''
|
|
614
|
+
|
|
615
|
+
// Mirror the document's own structure from the rendered rail, so the palette
|
|
616
|
+
// stays portable (no per-document hardcoding). Each top-level entry carries
|
|
617
|
+
// its group eyebrow as the breadcrumb; nested entries fold the parent in too.
|
|
618
|
+
const collectEntries = () => {
|
|
619
|
+
const list =
|
|
620
|
+
doc.querySelector('.bp-sidebar .bp-sidebar__panel > ul') ??
|
|
621
|
+
doc.querySelector('.bp-toc > ul')
|
|
622
|
+
if (!list) return []
|
|
623
|
+
const out = []
|
|
624
|
+
let group = ''
|
|
625
|
+
for (const li of list.children) {
|
|
626
|
+
if (li.classList.contains('bp-nav-group')) {
|
|
627
|
+
group = (li.textContent || '').trim()
|
|
628
|
+
continue
|
|
629
|
+
}
|
|
630
|
+
const link = li.querySelector(':scope > a[href^="#"]')
|
|
631
|
+
if (!link) continue
|
|
632
|
+
const title = (link.textContent || '').trim()
|
|
633
|
+
out.push({ href: link.getAttribute('href') || '', title, crumb: group })
|
|
634
|
+
for (const sub of li.querySelectorAll(':scope > ul > li > a[href^="#"]')) {
|
|
635
|
+
out.push({
|
|
636
|
+
href: sub.getAttribute('href') || '',
|
|
637
|
+
title: (sub.textContent || '').trim(),
|
|
638
|
+
crumb: [group, title].filter(Boolean).join(' › '),
|
|
639
|
+
})
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return out
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const keyHint = (caps, labelText) => {
|
|
646
|
+
const span = doc.createElement('span')
|
|
647
|
+
span.className = 'docs-search__key'
|
|
648
|
+
for (const cap of caps) {
|
|
649
|
+
const kbd = doc.createElement('kbd')
|
|
650
|
+
kbd.textContent = cap
|
|
651
|
+
span.append(kbd)
|
|
652
|
+
}
|
|
653
|
+
span.append(doc.createTextNode(` ${labelText}`))
|
|
654
|
+
return span
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const highlight = (text, q) => {
|
|
658
|
+
if (!q) return [doc.createTextNode(text)]
|
|
659
|
+
const idx = text.toLowerCase().indexOf(q)
|
|
660
|
+
if (idx < 0) return [doc.createTextNode(text)]
|
|
661
|
+
const mark = doc.createElement('mark')
|
|
662
|
+
mark.textContent = text.slice(idx, idx + q.length)
|
|
663
|
+
return [
|
|
664
|
+
doc.createTextNode(text.slice(0, idx)),
|
|
665
|
+
mark,
|
|
666
|
+
doc.createTextNode(text.slice(idx + q.length)),
|
|
667
|
+
]
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const buildRow = (entry, index, q) => {
|
|
671
|
+
const row = doc.createElement('a')
|
|
672
|
+
row.className = 'docs-search__row'
|
|
673
|
+
row.id = `docs-search-row-${index}`
|
|
674
|
+
row.setAttribute('role', 'option')
|
|
675
|
+
row.href = entry.href
|
|
676
|
+
row.dataset.index = String(index)
|
|
677
|
+
row.addEventListener('click', () => close())
|
|
678
|
+
row.addEventListener('mousemove', () => {
|
|
679
|
+
if (selected !== index) {
|
|
680
|
+
selected = index
|
|
681
|
+
applySelection()
|
|
682
|
+
}
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
const mainCol = doc.createElement('span')
|
|
686
|
+
mainCol.className = 'docs-search__row-main'
|
|
687
|
+
if (entry.crumb) {
|
|
688
|
+
const crumb = doc.createElement('span')
|
|
689
|
+
crumb.className = 'docs-search__crumb'
|
|
690
|
+
crumb.textContent = entry.crumb
|
|
691
|
+
mainCol.append(crumb)
|
|
692
|
+
}
|
|
693
|
+
const title = doc.createElement('span')
|
|
694
|
+
title.className = 'docs-search__title'
|
|
695
|
+
const hash = doc.createElement('span')
|
|
696
|
+
hash.className = 'docs-search__hash'
|
|
697
|
+
hash.setAttribute('aria-hidden', 'true')
|
|
698
|
+
hash.textContent = '#'
|
|
699
|
+
title.append(hash, ...highlight(entry.title, q))
|
|
700
|
+
mainCol.append(title)
|
|
701
|
+
|
|
702
|
+
const enter = doc.createElement('kbd')
|
|
703
|
+
enter.className = 'docs-search__enter'
|
|
704
|
+
enter.textContent = '↵'
|
|
705
|
+
|
|
706
|
+
row.append(mainCol, enter)
|
|
707
|
+
return row
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const buildEmpty = (query) => {
|
|
711
|
+
const wrap = doc.createElement('div')
|
|
712
|
+
wrap.className = 'docs-search__empty'
|
|
713
|
+
const iconWrap = doc.createElement('span')
|
|
714
|
+
iconWrap.className = 'docs-search__empty-icon'
|
|
715
|
+
iconWrap.append(createChromeSvg(doc, SEARCH_ICON, 28))
|
|
716
|
+
const title = doc.createElement('span')
|
|
717
|
+
title.className = 'docs-search__empty-title'
|
|
718
|
+
title.textContent = `No results for “${(query || '').trim()}”`
|
|
719
|
+
const note = doc.createElement('span')
|
|
720
|
+
note.className = 'docs-search__empty-note'
|
|
721
|
+
note.textContent = 'Try a different term, or check your spelling.'
|
|
722
|
+
wrap.append(iconWrap, title, note)
|
|
723
|
+
return wrap
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const applySelection = () => {
|
|
727
|
+
const rows = results.querySelectorAll('.docs-search__row[role="option"]')
|
|
728
|
+
let activeId = ''
|
|
729
|
+
rows.forEach((row, index) => {
|
|
730
|
+
const isSel = index === selected
|
|
731
|
+
row.setAttribute('aria-selected', String(isSel))
|
|
732
|
+
if (isSel) {
|
|
733
|
+
activeId = row.id
|
|
734
|
+
row.scrollIntoView({ block: 'nearest' })
|
|
735
|
+
}
|
|
736
|
+
})
|
|
737
|
+
if (activeId) input.setAttribute('aria-activedescendant', activeId)
|
|
738
|
+
else input.removeAttribute('aria-activedescendant')
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const move = (delta) => {
|
|
742
|
+
if (!visible.length) return
|
|
743
|
+
selected = (selected + delta + visible.length) % visible.length
|
|
744
|
+
applySelection()
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const render = (query) => {
|
|
748
|
+
const q = (query || '').trim().toLowerCase()
|
|
749
|
+
visible = q
|
|
750
|
+
? entries.filter((entry) => entry.title.toLowerCase().includes(q))
|
|
751
|
+
: entries
|
|
752
|
+
selected = 0
|
|
753
|
+
results.replaceChildren()
|
|
754
|
+
|
|
755
|
+
if (visible.length === 0) {
|
|
756
|
+
results.append(buildEmpty(query))
|
|
757
|
+
footStatus.textContent = '0 results'
|
|
758
|
+
input.removeAttribute('aria-activedescendant')
|
|
759
|
+
return
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const group = doc.createElement('div')
|
|
763
|
+
group.className = 'docs-search__group'
|
|
764
|
+
group.append(doc.createTextNode(q ? 'Results' : 'On this page'))
|
|
765
|
+
const count = doc.createElement('span')
|
|
766
|
+
count.className = 'docs-search__group-count'
|
|
767
|
+
count.textContent = String(visible.length)
|
|
768
|
+
group.append(count)
|
|
769
|
+
results.append(group)
|
|
770
|
+
|
|
771
|
+
visible.forEach((entry, index) => results.append(buildRow(entry, index, q)))
|
|
772
|
+
applySelection()
|
|
773
|
+
footStatus.textContent = q
|
|
774
|
+
? `${visible.length} result${visible.length === 1 ? '' : 's'}`
|
|
775
|
+
: 'Jump to a section'
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const onKeydown = (event) => {
|
|
779
|
+
if (event.key === 'Escape') {
|
|
780
|
+
event.preventDefault()
|
|
781
|
+
close()
|
|
782
|
+
} else if (event.key === 'ArrowDown') {
|
|
783
|
+
event.preventDefault()
|
|
784
|
+
move(1)
|
|
785
|
+
} else if (event.key === 'ArrowUp') {
|
|
786
|
+
event.preventDefault()
|
|
787
|
+
move(-1)
|
|
788
|
+
} else if (event.key === 'Enter') {
|
|
789
|
+
const row = results.querySelector(
|
|
790
|
+
`.docs-search__row[data-index="${selected}"]`
|
|
791
|
+
)
|
|
792
|
+
if (row) {
|
|
793
|
+
event.preventDefault()
|
|
794
|
+
row.click()
|
|
795
|
+
}
|
|
796
|
+
} else if (event.key === 'Tab') {
|
|
797
|
+
const focusable = panel.querySelectorAll(
|
|
798
|
+
'a[href], button, input, [tabindex]:not([tabindex="-1"])'
|
|
799
|
+
)
|
|
800
|
+
const first = focusable[0]
|
|
801
|
+
const last = focusable[focusable.length - 1]
|
|
802
|
+
if (
|
|
803
|
+
first &&
|
|
804
|
+
last &&
|
|
805
|
+
(event.shiftKey ? doc.activeElement === first : doc.activeElement === last)
|
|
806
|
+
) {
|
|
807
|
+
event.preventDefault()
|
|
808
|
+
;(event.shiftKey ? last : first).focus()
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const build = () => {
|
|
814
|
+
overlay = doc.createElement('div')
|
|
815
|
+
overlay.className = 'docs-search-overlay'
|
|
816
|
+
overlay.addEventListener('click', (event) => {
|
|
817
|
+
if (event.target === overlay) close()
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
panel = doc.createElement('div')
|
|
821
|
+
panel.className = 'docs-search'
|
|
822
|
+
panel.setAttribute('role', 'dialog')
|
|
823
|
+
panel.setAttribute('aria-modal', 'true')
|
|
824
|
+
panel.setAttribute('aria-label', 'Search')
|
|
825
|
+
|
|
826
|
+
const field = doc.createElement('div')
|
|
827
|
+
field.className = 'docs-search__field'
|
|
828
|
+
const icon = createChromeSvg(doc, SEARCH_ICON, 16)
|
|
829
|
+
icon.classList.add('docs-search__icon')
|
|
830
|
+
input = doc.createElement('input')
|
|
831
|
+
input.className = 'docs-search__input'
|
|
832
|
+
input.type = 'search'
|
|
833
|
+
input.placeholder = 'Search the docs…'
|
|
834
|
+
input.setAttribute('role', 'combobox')
|
|
835
|
+
input.setAttribute('aria-autocomplete', 'list')
|
|
836
|
+
input.setAttribute('aria-controls', 'docs-search-listbox')
|
|
837
|
+
input.setAttribute('aria-expanded', 'false')
|
|
838
|
+
input.setAttribute('aria-label', 'Search the docs')
|
|
839
|
+
input.autocomplete = 'off'
|
|
840
|
+
input.spellcheck = false
|
|
841
|
+
const esc = doc.createElement('span')
|
|
842
|
+
esc.className = 'docs-search__esc'
|
|
843
|
+
esc.textContent = 'esc'
|
|
844
|
+
field.append(icon, input, esc)
|
|
845
|
+
|
|
846
|
+
results = doc.createElement('div')
|
|
847
|
+
results.className = 'docs-search__results'
|
|
848
|
+
results.id = 'docs-search-listbox'
|
|
849
|
+
results.setAttribute('role', 'listbox')
|
|
850
|
+
results.setAttribute('aria-label', 'Search results')
|
|
851
|
+
|
|
852
|
+
const foot = doc.createElement('div')
|
|
853
|
+
foot.className = 'docs-search__foot'
|
|
854
|
+
footStatus = doc.createElement('span')
|
|
855
|
+
const keys = doc.createElement('span')
|
|
856
|
+
keys.className = 'docs-search__keys'
|
|
857
|
+
keys.append(
|
|
858
|
+
keyHint(['↑', '↓'], 'navigate'),
|
|
859
|
+
keyHint(['↵'], 'open'),
|
|
860
|
+
keyHint(['esc'], 'close')
|
|
861
|
+
)
|
|
862
|
+
foot.append(footStatus, keys)
|
|
863
|
+
|
|
864
|
+
panel.append(field, results, foot)
|
|
865
|
+
overlay.append(panel)
|
|
866
|
+
;(doc.body || doc.documentElement).append(overlay)
|
|
867
|
+
|
|
868
|
+
input.addEventListener('input', () => render(input.value))
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const open = () => {
|
|
872
|
+
if (!overlay) build()
|
|
873
|
+
if (overlay.classList.contains('is-open')) return
|
|
874
|
+
entries = collectEntries()
|
|
875
|
+
returnFocus = doc.activeElement
|
|
876
|
+
prevOverflow = doc.documentElement.style.overflow
|
|
877
|
+
doc.documentElement.style.overflow = 'hidden'
|
|
878
|
+
input.value = ''
|
|
879
|
+
render('')
|
|
880
|
+
overlay.classList.add('is-open')
|
|
881
|
+
input.setAttribute('aria-expanded', 'true')
|
|
882
|
+
doc.addEventListener('keydown', onKeydown)
|
|
883
|
+
input.focus({ preventScroll: true })
|
|
884
|
+
win.requestAnimationFrame(() => input.focus({ preventScroll: true }))
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function close() {
|
|
888
|
+
if (!overlay || !overlay.classList.contains('is-open')) return
|
|
889
|
+
overlay.classList.remove('is-open')
|
|
890
|
+
input.setAttribute('aria-expanded', 'false')
|
|
891
|
+
input.removeAttribute('aria-activedescendant')
|
|
892
|
+
doc.documentElement.style.overflow = prevOverflow ?? ''
|
|
893
|
+
doc.removeEventListener('keydown', onKeydown)
|
|
894
|
+
if (returnFocus && typeof returnFocus.focus === 'function') returnFocus.focus()
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
trigger.addEventListener('click', open)
|
|
898
|
+
|
|
899
|
+
doc.addEventListener('keydown', (event) => {
|
|
900
|
+
if ((event.metaKey || event.ctrlKey) && (event.key === 'k' || event.key === 'K')) {
|
|
901
|
+
event.preventDefault()
|
|
902
|
+
if (overlay && overlay.classList.contains('is-open')) close()
|
|
903
|
+
else open()
|
|
904
|
+
}
|
|
905
|
+
})
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function injectDocumentChrome(doc, win = doc.defaultView) {
|
|
909
|
+
if (doc.body?.dataset.bpChromeReady === 'true') return
|
|
910
|
+
const main = doc.querySelector('main')
|
|
911
|
+
if (!main) return
|
|
912
|
+
|
|
913
|
+
const sidebar = injectSidebar(doc, win)
|
|
914
|
+
const chrome = [
|
|
915
|
+
injectSiteNav(doc, win),
|
|
916
|
+
injectScrollProgress(doc),
|
|
917
|
+
sidebar ? injectSidebarToggle(doc) : null,
|
|
918
|
+
sidebar,
|
|
919
|
+
].filter(Boolean)
|
|
920
|
+
|
|
921
|
+
if (chrome.length > 0) {
|
|
922
|
+
main.before(...chrome)
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
if (sidebar) {
|
|
926
|
+
doc.documentElement.dataset.bpDocument = ''
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
wireThemeToggle(doc, win)
|
|
930
|
+
wireSidebarCollapse(doc, win)
|
|
931
|
+
doc.body.dataset.bpChromeReady = 'true'
|
|
932
|
+
}
|
|
933
|
+
|
|
1
934
|
// Optional behavior for TOC current state and reading progress.
|
|
935
|
+
import { registerBlueprintChoiceElements } from './blueprint-choices.js'
|
|
936
|
+
|
|
937
|
+
registerBlueprintChoiceElements()
|
|
938
|
+
|
|
2
939
|
export function initializeBlueprintRuntime(doc = document, win = window) {
|
|
3
940
|
const root = doc.documentElement
|
|
4
941
|
if (root.dataset.blueprintRuntime === 'ready') return
|
|
5
942
|
root.dataset.blueprintRuntime = 'ready'
|
|
6
943
|
|
|
7
|
-
|
|
944
|
+
// Resolve the theme before chrome is injected so the toggle renders in the
|
|
945
|
+
// correct state and the page doesn't repaint a second time.
|
|
946
|
+
resolveInitialTheme(doc, win)
|
|
947
|
+
injectDocumentChrome(doc, win)
|
|
948
|
+
generateNavigationFromHeadings(doc)
|
|
949
|
+
wireSectionHeadingLinks(doc, win)
|
|
950
|
+
wireSearchPalette(doc, win)
|
|
8
951
|
wireFigureDiagramSize(doc)
|
|
9
952
|
|
|
10
|
-
//
|
|
953
|
+
// <main> becomes the scroll container only while it is fixed (wide
|
|
11
954
|
// layout). At ≤700px the CSS reverts it to static and the window scrolls, so
|
|
12
955
|
// resolve the scroller live rather than caching it — a wide→narrow resize
|
|
13
956
|
// otherwise leaves the scroll listener bound to an element that no longer
|
|
14
957
|
// scrolls, silently freezing reading-progress and TOC highlighting.
|
|
15
|
-
const
|
|
958
|
+
const main = doc.querySelector('main')
|
|
16
959
|
const resolveScroller = () =>
|
|
17
|
-
|
|
960
|
+
main &&
|
|
961
|
+
root.dataset.bpDocument !== undefined &&
|
|
962
|
+
win.getComputedStyle(main).position === 'fixed'
|
|
963
|
+
? main
|
|
964
|
+
: null
|
|
18
965
|
let shellScroller = resolveScroller()
|
|
19
966
|
const scrollTop = () => (shellScroller ? shellScroller.scrollTop : win.scrollY)
|
|
20
967
|
const scrollHeight = () =>
|
|
@@ -23,10 +970,23 @@ export function initializeBlueprintRuntime(doc = document, win = window) {
|
|
|
23
970
|
shellScroller ? shellScroller.clientHeight : win.innerHeight
|
|
24
971
|
|
|
25
972
|
const progress = doc.querySelector('.scroll-progress')
|
|
973
|
+
const isPrimaryNavLink = (link) => {
|
|
974
|
+
const listItem = link.closest('li')
|
|
975
|
+
const list = listItem?.parentElement
|
|
976
|
+
if (!list || !listItem) return true
|
|
977
|
+
const container = link.closest('.bp-sidebar, .bp-toc, .sidebar')
|
|
978
|
+
if (!container) return true
|
|
979
|
+
const topList =
|
|
980
|
+
container.querySelector(':scope > .bp-sidebar__panel > ul') ??
|
|
981
|
+
container.querySelector(':scope > ul')
|
|
982
|
+
return list === topList
|
|
983
|
+
}
|
|
26
984
|
// Only the fixed rails track an active entry. A full-width .bp-contents
|
|
27
985
|
// index (typically parked at the top of a document) has no "current" entry,
|
|
28
|
-
// so it stays a static index and is intentionally excluded here.
|
|
986
|
+
// so it stays a static index and is intentionally excluded here. Sub-nav
|
|
987
|
+
// links nest one level beneath a primary entry and are click targets only.
|
|
29
988
|
const entries = [...doc.querySelectorAll('.bp-sidebar a[href^="#"], .bp-toc a[href^="#"], .sidebar a[href^="#"]')]
|
|
989
|
+
.filter(isPrimaryNavLink)
|
|
30
990
|
.map((link) => ({
|
|
31
991
|
link,
|
|
32
992
|
section: doc.getElementById(decodeURIComponent(link.hash.slice(1))),
|
|
@@ -72,19 +1032,24 @@ export function initializeBlueprintRuntime(doc = document, win = window) {
|
|
|
72
1032
|
progress.style.transform = `scaleX(${max > 0 ? scrollTop() / max : 0})`
|
|
73
1033
|
}
|
|
74
1034
|
if (sections.length === 0) return
|
|
1035
|
+
const threshold = win.innerHeight * 0.35
|
|
75
1036
|
if (scrollingTo) {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
1037
|
+
const rect = scrollingTo.getBoundingClientRect()
|
|
1038
|
+
const top = rect.top
|
|
1039
|
+
const visible = rect.bottom > 0 && top < win.innerHeight
|
|
1040
|
+
if (top <= threshold && top >= -8) {
|
|
1041
|
+
scrollingTo = null
|
|
1042
|
+
} else if (visible) {
|
|
79
1043
|
setCurrent(scrollingTo)
|
|
80
1044
|
return
|
|
1045
|
+
} else {
|
|
1046
|
+
scrollingTo = null
|
|
81
1047
|
}
|
|
82
1048
|
}
|
|
83
1049
|
if (scrollTop() + clientHeight() >= scrollHeight() - 2) {
|
|
84
1050
|
setCurrent(sections.at(-1))
|
|
85
1051
|
return
|
|
86
1052
|
}
|
|
87
|
-
const threshold = win.innerHeight * 0.35
|
|
88
1053
|
setCurrent(
|
|
89
1054
|
sections.reduce(
|
|
90
1055
|
(current, section) => (section.getBoundingClientRect().top <= threshold ? section : current),
|
|
@@ -97,11 +1062,18 @@ export function initializeBlueprintRuntime(doc = document, win = window) {
|
|
|
97
1062
|
scheduled = true
|
|
98
1063
|
win.requestAnimationFrame(update)
|
|
99
1064
|
}
|
|
1065
|
+
const prefersReducedMotion = () => themePrefersReducedMotion(win)
|
|
100
1066
|
const updateFromHash = () => {
|
|
101
1067
|
const match = entries.find(({ link }) => link.hash === win.location.hash)
|
|
102
1068
|
if (match) {
|
|
103
1069
|
scrollingTo = match.section
|
|
104
1070
|
setCurrent(match.section)
|
|
1071
|
+
const top = match.section.getBoundingClientRect().top
|
|
1072
|
+
const threshold = win.innerHeight * 0.35
|
|
1073
|
+
if (top > threshold || top < -8) {
|
|
1074
|
+
match.section.scrollIntoView({ block: 'start' })
|
|
1075
|
+
}
|
|
1076
|
+
scheduleUpdate()
|
|
105
1077
|
return
|
|
106
1078
|
}
|
|
107
1079
|
scrollingTo = null
|
|
@@ -110,7 +1082,6 @@ export function initializeBlueprintRuntime(doc = document, win = window) {
|
|
|
110
1082
|
|
|
111
1083
|
const navLinkSelector =
|
|
112
1084
|
'.bp-sidebar a[href^="#"], .bp-toc a[href^="#"], .sidebar a[href^="#"], .bp-contents a[href^="#"]'
|
|
113
|
-
const prefersReducedMotion = () => win.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
114
1085
|
|
|
115
1086
|
const scrollToSection = (section) => {
|
|
116
1087
|
scrollingTo = section
|
|
@@ -174,14 +1145,17 @@ export function initializeBlueprintRuntime(doc = document, win = window) {
|
|
|
174
1145
|
updateFromHash()
|
|
175
1146
|
}
|
|
176
1147
|
|
|
177
|
-
// Section heading permalink —
|
|
178
|
-
// heading hover, copy on icon hover, check after a successful copy.
|
|
179
|
-
const HEADING_LINK_ICON =
|
|
180
|
-
'
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
'
|
|
1148
|
+
// Section heading permalink — Iconoir line glyphs in the main-column gutter;
|
|
1149
|
+
// link on heading hover, copy on icon hover, check after a successful copy.
|
|
1150
|
+
const HEADING_LINK_ICON = [
|
|
1151
|
+
'M14 11.9976C14 9.5059 11.683 7 8.85714 7C8.52241 7 7.41904 7.00001 7.14286 7.00001C4.30254 7.00001 2 9.23752 2 11.9976C2 14.376 3.70973 16.3664 6 16.8714C6.36756 16.9525 6.75006 16.9952 7.14286 16.9952',
|
|
1152
|
+
'M10 11.9976C10 14.4893 12.317 16.9952 15.1429 16.9952C15.4776 16.9952 16.581 16.9952 16.8571 16.9952C19.6975 16.9952 22 14.7577 22 11.9976C22 9.6192 20.2903 7.62884 18 7.12383C17.6324 7.04278 17.2499 6.99999 16.8571 6.99999',
|
|
1153
|
+
]
|
|
1154
|
+
const HEADING_COPY_ICON = [
|
|
1155
|
+
'M19.4 20H9.6C9.26863 20 9 19.7314 9 19.4V9.6C9 9.26863 9.26863 9 9.6 9H19.4C19.7314 9 20 9.26863 20 9.6V19.4C20 19.7314 19.7314 20 19.4 20Z',
|
|
1156
|
+
'M15 9V4.6C15 4.26863 14.7314 4 14.4 4H4.6C4.26863 4 4 4.26863 4 4.6V14.4C4 14.7314 4.26863 15 4.6 15H9',
|
|
1157
|
+
]
|
|
1158
|
+
const HEADING_CHECK_ICON = 'M5 13L9 17L19 7'
|
|
185
1159
|
const headingLinkTimers = new WeakMap()
|
|
186
1160
|
|
|
187
1161
|
function createHeadingPixelIcon(doc, name, pathD) {
|
|
@@ -189,12 +1163,18 @@ function createHeadingPixelIcon(doc, name, pathD) {
|
|
|
189
1163
|
svg.setAttribute('viewBox', '0 0 24 24')
|
|
190
1164
|
svg.setAttribute('width', '16')
|
|
191
1165
|
svg.setAttribute('height', '16')
|
|
192
|
-
svg.setAttribute('fill', '
|
|
1166
|
+
svg.setAttribute('fill', 'none')
|
|
1167
|
+
svg.setAttribute('stroke', 'currentColor')
|
|
1168
|
+
svg.setAttribute('stroke-width', '1.5')
|
|
1169
|
+
svg.setAttribute('stroke-linecap', 'round')
|
|
1170
|
+
svg.setAttribute('stroke-linejoin', 'round')
|
|
193
1171
|
svg.setAttribute('aria-hidden', 'true')
|
|
194
1172
|
svg.classList.add('bp-heading-link__icon', `bp-heading-link__icon--${name}`)
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
1173
|
+
for (const d of Array.isArray(pathD) ? pathD : [pathD]) {
|
|
1174
|
+
const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
1175
|
+
path.setAttribute('d', d)
|
|
1176
|
+
svg.append(path)
|
|
1177
|
+
}
|
|
198
1178
|
return svg
|
|
199
1179
|
}
|
|
200
1180
|
|
|
@@ -226,7 +1206,7 @@ function showHeadingLinkState(button, state, label) {
|
|
|
226
1206
|
const previousTimer = headingLinkTimers.get(button)
|
|
227
1207
|
if (previousTimer) clearTimeout(previousTimer)
|
|
228
1208
|
|
|
229
|
-
const heading = button.closest('h2')
|
|
1209
|
+
const heading = button.closest('h1, h2, h3, h4, h5, h6')
|
|
230
1210
|
|
|
231
1211
|
if (state === 'idle') {
|
|
232
1212
|
delete button.dataset.bpCopyState
|
|
@@ -258,24 +1238,322 @@ function wireFigureDiagramSize(doc) {
|
|
|
258
1238
|
}
|
|
259
1239
|
}
|
|
260
1240
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
section.querySelector(':scope > h2:first-child') ??
|
|
265
|
-
section.querySelector(':scope > hgroup:first-child > h2')
|
|
266
|
-
if (!heading || heading.querySelector(':scope > .bp-heading-row')) continue
|
|
1241
|
+
const SIDEBAR_HEADING_SELECTOR =
|
|
1242
|
+
'h2[data-sidebar], h3[data-sidebar], h4[data-sidebar], h5[data-sidebar], h6[data-sidebar]'
|
|
1243
|
+
const NAV_CONTENT_ROOT = 'main, [data-bp-nav-root]'
|
|
267
1244
|
|
|
268
|
-
|
|
269
|
-
|
|
1245
|
+
function slugifyHeadingId(value) {
|
|
1246
|
+
return value
|
|
1247
|
+
.trim()
|
|
1248
|
+
.toLowerCase()
|
|
1249
|
+
.replace(/['']/g, '')
|
|
1250
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
1251
|
+
.replace(/^-+|-+$/g, '')
|
|
1252
|
+
}
|
|
270
1253
|
|
|
271
|
-
|
|
272
|
-
|
|
1254
|
+
function isSectionLeadHeading(heading) {
|
|
1255
|
+
const section = heading.closest('section')
|
|
1256
|
+
if (!section) return false
|
|
1257
|
+
return (
|
|
1258
|
+
heading === section.querySelector(':scope > h2:first-child') ||
|
|
1259
|
+
heading === section.querySelector(':scope > hgroup:first-child > h2')
|
|
1260
|
+
)
|
|
1261
|
+
}
|
|
273
1262
|
|
|
274
|
-
|
|
1263
|
+
function ensureUniqueHeadingId(doc, base, heading) {
|
|
1264
|
+
let candidate = base || 'section'
|
|
1265
|
+
let suffix = 2
|
|
1266
|
+
while (doc.getElementById(candidate) && doc.getElementById(candidate) !== heading) {
|
|
1267
|
+
candidate = `${base}-${suffix}`
|
|
1268
|
+
suffix += 1
|
|
1269
|
+
}
|
|
1270
|
+
return candidate
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function resolveHeadingId(doc, heading) {
|
|
1274
|
+
if (heading.id) return heading.id
|
|
1275
|
+
|
|
1276
|
+
const section = heading.closest('section[id]')
|
|
1277
|
+
if (section && isSectionLeadHeading(heading)) {
|
|
1278
|
+
// `<section id>` is the single anchoring contract: leave the id on the
|
|
1279
|
+
// section and point navigation, permalinks, and scroll-spy at it.
|
|
1280
|
+
return section.id
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
const base = slugifyHeadingId(
|
|
1284
|
+
heading.getAttribute('data-sidebar') || heading.textContent || 'section'
|
|
1285
|
+
)
|
|
1286
|
+
heading.id = ensureUniqueHeadingId(doc, base, heading)
|
|
1287
|
+
return heading.id
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
function getSidebarLabel(heading) {
|
|
1291
|
+
return heading.getAttribute('data-sidebar')?.trim() || heading.textContent.trim()
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// A repeated <header> in the nav root (every one after the masthead) is a
|
|
1295
|
+
// navigation GROUP divider: it reuses the masthead structure (eyebrow + h1 +
|
|
1296
|
+
// optional <bp-subheader>) and its title becomes a quiet eyebrow above the
|
|
1297
|
+
// sections that follow, in both the fixed rail and the inline contents.
|
|
1298
|
+
function findNavMasthead(root) {
|
|
1299
|
+
return root.querySelector(':scope > header')
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function getGroupLabel(header) {
|
|
1303
|
+
const explicit = header.getAttribute('data-sidebar')?.trim()
|
|
1304
|
+
if (explicit) return explicit
|
|
1305
|
+
const heading = header.querySelector('h1, h2, h3, h4, h5, h6')
|
|
1306
|
+
if (heading?.textContent.trim()) return heading.textContent.trim()
|
|
1307
|
+
const eyebrow = header.querySelector('bp-eyebrow')
|
|
1308
|
+
if (eyebrow?.textContent.trim()) return eyebrow.textContent.trim()
|
|
1309
|
+
return header.textContent.trim() || 'Section'
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
function resolveGroupId(doc, header) {
|
|
1313
|
+
if (header.id) return header.id
|
|
1314
|
+
const base = slugifyHeadingId(getGroupLabel(header))
|
|
1315
|
+
header.id = ensureUniqueHeadingId(doc, base, header)
|
|
1316
|
+
return header.id
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// Collect group dividers (nav-root <header>s after the masthead) and every
|
|
1320
|
+
// data-sidebar heading in a single document-ordered pass.
|
|
1321
|
+
function collectNavNodes(doc) {
|
|
1322
|
+
const root = doc.querySelector(NAV_CONTENT_ROOT) ?? doc.body
|
|
1323
|
+
const masthead = findNavMasthead(root)
|
|
1324
|
+
const selector = `:scope > header, ${SIDEBAR_HEADING_SELECTOR}`
|
|
1325
|
+
return [...root.querySelectorAll(selector)].filter((node) => node !== masthead)
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function buildSidebarTree(nodes, doc) {
|
|
1329
|
+
const tree = []
|
|
1330
|
+
let currentGroup = null
|
|
1331
|
+
let currentPrimary = null
|
|
1332
|
+
|
|
1333
|
+
for (const node of nodes) {
|
|
1334
|
+
if (node.tagName === 'HEADER') {
|
|
1335
|
+
currentGroup = {
|
|
1336
|
+
type: 'group',
|
|
1337
|
+
label: getGroupLabel(node),
|
|
1338
|
+
id: resolveGroupId(doc, node),
|
|
1339
|
+
children: [],
|
|
1340
|
+
}
|
|
1341
|
+
currentPrimary = null
|
|
1342
|
+
tree.push(currentGroup)
|
|
1343
|
+
continue
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const level = Number(node.tagName.slice(1))
|
|
1347
|
+
const item = {
|
|
1348
|
+
type: 'section',
|
|
1349
|
+
heading: node,
|
|
1350
|
+
label: getSidebarLabel(node),
|
|
1351
|
+
id: node.id || resolveHeadingId(doc, node),
|
|
1352
|
+
children: [],
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
if (level === 2) {
|
|
1356
|
+
currentPrimary = item
|
|
1357
|
+
;(currentGroup ? currentGroup.children : tree).push(item)
|
|
1358
|
+
continue
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
if (currentPrimary) {
|
|
1362
|
+
currentPrimary.children.push(item)
|
|
1363
|
+
continue
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
;(currentGroup ? currentGroup.children : tree).push(item)
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
return tree
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function createNavLink(doc, { id, label }) {
|
|
1373
|
+
const link = doc.createElement('a')
|
|
1374
|
+
link.href = `#${id}`
|
|
1375
|
+
link.textContent = label
|
|
1376
|
+
return link
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// Group eyebrow + its section entries stay siblings in one flat list so the
|
|
1380
|
+
// gliding pill, auto-numbering, scroll-spy, and search keep working unchanged;
|
|
1381
|
+
// the group row carries no link and is skipped by the numbering counter.
|
|
1382
|
+
function createNavGroupRow(doc, group, labelClass) {
|
|
1383
|
+
const li = doc.createElement('li')
|
|
1384
|
+
li.className = 'bp-nav-group'
|
|
1385
|
+
const label = doc.createElement('span')
|
|
1386
|
+
label.className = labelClass
|
|
1387
|
+
label.textContent = group.label
|
|
1388
|
+
li.append(label)
|
|
1389
|
+
return li
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function renderNavTree(doc, tree) {
|
|
1393
|
+
const fragment = doc.createDocumentFragment()
|
|
1394
|
+
|
|
1395
|
+
const appendSection = (section) => {
|
|
1396
|
+
const li = doc.createElement('li')
|
|
1397
|
+
li.append(createNavLink(doc, section))
|
|
1398
|
+
|
|
1399
|
+
if (section.children?.length > 0) {
|
|
1400
|
+
const subList = doc.createElement('ul')
|
|
1401
|
+
for (const child of section.children) {
|
|
1402
|
+
const subItem = doc.createElement('li')
|
|
1403
|
+
subItem.append(createNavLink(doc, child))
|
|
1404
|
+
subList.append(subItem)
|
|
1405
|
+
}
|
|
1406
|
+
li.append(subList)
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
fragment.append(li)
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
for (const item of tree) {
|
|
1413
|
+
if (item.type === 'group') {
|
|
1414
|
+
fragment.append(createNavGroupRow(doc, item, 'bp-nav-group__label'))
|
|
1415
|
+
item.children.forEach(appendSection)
|
|
1416
|
+
continue
|
|
1417
|
+
}
|
|
1418
|
+
appendSection(item)
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
return fragment
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function renderContentsTree(doc, tree) {
|
|
1425
|
+
const fragment = doc.createDocumentFragment()
|
|
1426
|
+
|
|
1427
|
+
const appendSection = (section) => {
|
|
1428
|
+
const li = doc.createElement('li')
|
|
1429
|
+
li.append(createNavLink(doc, section))
|
|
1430
|
+
fragment.append(li)
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
for (const item of tree) {
|
|
1434
|
+
if (item.type === 'group') {
|
|
1435
|
+
const li = createNavGroupRow(doc, item, 'bp-contents-group__label')
|
|
1436
|
+
li.className = 'bp-contents-group'
|
|
1437
|
+
fragment.append(li)
|
|
1438
|
+
item.children.forEach(appendSection)
|
|
1439
|
+
continue
|
|
1440
|
+
}
|
|
1441
|
+
appendSection(item)
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
return fragment
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function findSidebarNavLists(doc) {
|
|
1448
|
+
const lists = []
|
|
1449
|
+
|
|
1450
|
+
for (const nav of doc.querySelectorAll('.bp-sidebar:not([data-bp-nav-manual])')) {
|
|
1451
|
+
const list =
|
|
1452
|
+
nav.querySelector(':scope > .bp-sidebar__panel > ul') ??
|
|
1453
|
+
nav.querySelector(':scope > ul')
|
|
1454
|
+
if (list) lists.push(list)
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
for (const nav of doc.querySelectorAll('.bp-toc:not([data-bp-nav-manual])')) {
|
|
1458
|
+
const list = nav.querySelector(':scope > ul')
|
|
1459
|
+
if (list) lists.push(list)
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
return lists
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function ensureDocumentNavTree(doc) {
|
|
1466
|
+
const nodes = collectNavNodes(doc)
|
|
1467
|
+
if (nodes.length === 0) return []
|
|
1468
|
+
|
|
1469
|
+
for (const node of nodes) {
|
|
1470
|
+
if (node.tagName === 'HEADER') resolveGroupId(doc, node)
|
|
1471
|
+
else resolveHeadingId(doc, node)
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
return buildSidebarTree(nodes, doc)
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function populateBpTocElement(element, doc, tree) {
|
|
1478
|
+
if (element.dataset.bpTocReady === 'true') return
|
|
1479
|
+
if (!tree?.length) return
|
|
1480
|
+
|
|
1481
|
+
element.dataset.bpTocReady = 'true'
|
|
1482
|
+
|
|
1483
|
+
const label =
|
|
1484
|
+
element.getAttribute('label')?.trim() ||
|
|
1485
|
+
element.textContent.trim() ||
|
|
1486
|
+
'Contents'
|
|
1487
|
+
const ariaLabel = element.getAttribute('aria-label')?.trim() || label
|
|
1488
|
+
|
|
1489
|
+
const nav = doc.createElement('nav')
|
|
1490
|
+
nav.className = 'bp-contents'
|
|
1491
|
+
nav.setAttribute('aria-label', ariaLabel)
|
|
1492
|
+
|
|
1493
|
+
const eyebrow = doc.createElement('p')
|
|
1494
|
+
eyebrow.className = 'bp-eyebrow'
|
|
1495
|
+
eyebrow.textContent = label
|
|
1496
|
+
|
|
1497
|
+
const list = doc.createElement('ol')
|
|
1498
|
+
list.append(renderContentsTree(doc, tree))
|
|
1499
|
+
|
|
1500
|
+
nav.append(eyebrow, list)
|
|
1501
|
+
element.replaceChildren(nav)
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
function populateAllBpToc(doc, tree) {
|
|
1505
|
+
for (const element of doc.querySelectorAll('bp-toc')) {
|
|
1506
|
+
populateBpTocElement(element, doc, tree)
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function generateNavigationFromHeadings(doc) {
|
|
1511
|
+
const tree = ensureDocumentNavTree(doc)
|
|
1512
|
+
if (tree.length === 0) return
|
|
1513
|
+
|
|
1514
|
+
const navFragment = renderNavTree(doc, tree)
|
|
1515
|
+
|
|
1516
|
+
for (const list of findSidebarNavLists(doc)) {
|
|
1517
|
+
list.replaceChildren(navFragment.cloneNode(true))
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
populateAllBpToc(doc, tree)
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
function formatHeadingEyebrow(number) {
|
|
1524
|
+
return String(number).padStart(2, '0')
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
function wireSectionHeadingLinks(doc, win = doc.defaultView) {
|
|
1528
|
+
const root = doc.querySelector(NAV_CONTENT_ROOT) ?? doc.body
|
|
1529
|
+
let primaryNumber = 0
|
|
1530
|
+
|
|
1531
|
+
for (const heading of root.querySelectorAll(SIDEBAR_HEADING_SELECTOR)) {
|
|
1532
|
+
if (heading.querySelector(':scope > .bp-heading-row')) continue
|
|
1533
|
+
|
|
1534
|
+
const level = Number(heading.tagName.slice(1))
|
|
1535
|
+
const label = getSidebarLabel(heading)
|
|
1536
|
+
const titleNodes = [...heading.childNodes]
|
|
1537
|
+
const id = resolveHeadingId(doc, heading)
|
|
1538
|
+
|
|
1539
|
+
heading.replaceChildren()
|
|
1540
|
+
|
|
1541
|
+
if (level === 2) {
|
|
1542
|
+
primaryNumber += 1
|
|
1543
|
+
const eyebrow = doc.createElement('span')
|
|
1544
|
+
eyebrow.className = 'bp-heading-eyebrow'
|
|
1545
|
+
eyebrow.textContent = formatHeadingEyebrow(primaryNumber)
|
|
1546
|
+
heading.append(eyebrow)
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
const row = doc.createElement('span')
|
|
1550
|
+
row.className = 'bp-heading-row'
|
|
1551
|
+
|
|
1552
|
+
const button = createHeadingLinkButton(doc, label)
|
|
275
1553
|
|
|
276
1554
|
const title = doc.createElement('span')
|
|
277
1555
|
title.className = 'bp-heading-title'
|
|
278
|
-
title.
|
|
1556
|
+
title.append(...titleNodes)
|
|
279
1557
|
|
|
280
1558
|
row.append(button, title)
|
|
281
1559
|
heading.append(row)
|
|
@@ -308,7 +1586,7 @@ function wireSectionHeadingLinks(doc, win = doc.defaultView) {
|
|
|
308
1586
|
|
|
309
1587
|
button.addEventListener('click', (event) => {
|
|
310
1588
|
event.preventDefault()
|
|
311
|
-
const url = new URL(`#${
|
|
1589
|
+
const url = new URL(`#${id}`, win?.location.href ?? '').href
|
|
312
1590
|
copySectionUrl(url)
|
|
313
1591
|
.then(() => showHeadingLinkState(button, 'copied', label))
|
|
314
1592
|
.catch(() => showHeadingLinkState(button, 'error', label))
|
|
@@ -363,6 +1641,25 @@ function createCalloutIcon(doc, pathD) {
|
|
|
363
1641
|
return svg
|
|
364
1642
|
}
|
|
365
1643
|
|
|
1644
|
+
// ---------------------------------------------------------------------
|
|
1645
|
+
// <bp-toc> — inline table of contents (full-width .bp-contents family).
|
|
1646
|
+
//
|
|
1647
|
+
// Place the element wherever the index should appear; it expands to a
|
|
1648
|
+
// numbered, multi-column nav on load from h2 headings marked data-sidebar.
|
|
1649
|
+
// Sub-sections appear in the fixed rail only; the inline block stays flat.
|
|
1650
|
+
//
|
|
1651
|
+
// <bp-toc label="Contents"></bp-toc>
|
|
1652
|
+
//
|
|
1653
|
+
// label: eyebrow text (default: Contents). aria-label overrides the nav label.
|
|
1654
|
+
// ---------------------------------------------------------------------
|
|
1655
|
+
class BlueprintTocElement extends HTMLElement {
|
|
1656
|
+
connectedCallback() {
|
|
1657
|
+
const doc = this.ownerDocument
|
|
1658
|
+
if (doc.documentElement.dataset.blueprintRuntime !== 'ready') return
|
|
1659
|
+
populateBpTocElement(this, doc, ensureDocumentNavTree(doc))
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
|
|
366
1663
|
class BlueprintCalloutElement extends HTMLElement {
|
|
367
1664
|
#rendered = false
|
|
368
1665
|
|
|
@@ -403,10 +1700,1141 @@ class BlueprintCalloutElement extends HTMLElement {
|
|
|
403
1700
|
}
|
|
404
1701
|
}
|
|
405
1702
|
|
|
1703
|
+
// ---------------------------------------------------------------------
|
|
1704
|
+
// <bp-mock> — a drafting "browser frame" for UX mockups.
|
|
1705
|
+
//
|
|
1706
|
+
// Wraps a low-fidelity component sketch in an ink chrome frame (window
|
|
1707
|
+
// ticks + a mono URL/title). The frame is light-DOM and the authored
|
|
1708
|
+
// children are moved into a scalable canvas, so the mock is contained by
|
|
1709
|
+
// construction — no shadow DOM. Two optional behaviours:
|
|
1710
|
+
// • viewport="1280" draws the canvas at that pixel width and scales it
|
|
1711
|
+
// to fit the frame (crisp 1:1 when the frame is wide enough or when
|
|
1712
|
+
// expanded), so a desktop layout can be mocked inside a narrow column.
|
|
1713
|
+
// • expandable an expand control lifts the frame into a near-
|
|
1714
|
+
// fullscreen overlay (Esc / the control dismiss; focus is restored).
|
|
1715
|
+
//
|
|
1716
|
+
// <bp-mock label="app.example.com" viewport="1280"> … </bp-mock>
|
|
1717
|
+
//
|
|
1718
|
+
// label: mono caption in the chrome bar. viewport: canvas width in px.
|
|
1719
|
+
// expandable="false" drops the expand control.
|
|
1720
|
+
// ---------------------------------------------------------------------
|
|
1721
|
+
const MOCK_EXPAND_ICON = [
|
|
1722
|
+
'M9 9L4 4M4 4V8M4 4H8',
|
|
1723
|
+
'M15 9L20 4M20 4V8M20 4H16',
|
|
1724
|
+
'M9 15L4 20M4 20V16M4 20H8',
|
|
1725
|
+
'M15 15L20 20M20 20V16M20 20H16',
|
|
1726
|
+
]
|
|
1727
|
+
const MOCK_COLLAPSE_ICON = [
|
|
1728
|
+
'M20 20L15 15M15 15V19M15 15H19',
|
|
1729
|
+
'M4 20L9 15M9 15V19M9 15H5',
|
|
1730
|
+
'M20 4L15 9M15 9V5M15 9H19',
|
|
1731
|
+
'M4 4L9 9M9 9V5M9 9H5',
|
|
1732
|
+
]
|
|
1733
|
+
|
|
1734
|
+
class BlueprintMockElement extends HTMLElement {
|
|
1735
|
+
#rendered = false
|
|
1736
|
+
#ro = null
|
|
1737
|
+
#wrap = null
|
|
1738
|
+
#stage = null
|
|
1739
|
+
#canvas = null
|
|
1740
|
+
#btn = null
|
|
1741
|
+
#naturalWidth = 0
|
|
1742
|
+
#onKeydown = null
|
|
1743
|
+
#returnFocus = null
|
|
1744
|
+
#prevOverflow = ''
|
|
1745
|
+
|
|
1746
|
+
connectedCallback() {
|
|
1747
|
+
if (this.#rendered) return
|
|
1748
|
+
this.#rendered = true
|
|
1749
|
+
|
|
1750
|
+
const doc = this.ownerDocument
|
|
1751
|
+
const label = this.getAttribute('label') || ''
|
|
1752
|
+
const vw = Number.parseInt(this.getAttribute('viewport') || '', 10)
|
|
1753
|
+
this.#naturalWidth = Number.isFinite(vw) && vw > 0 ? vw : 0
|
|
1754
|
+
const expandable = this.getAttribute('expandable') !== 'false'
|
|
1755
|
+
|
|
1756
|
+
const wrap = doc.createElement('div')
|
|
1757
|
+
wrap.className = 'bp-mock'
|
|
1758
|
+
wrap.setAttribute('role', 'group')
|
|
1759
|
+
if (label) wrap.setAttribute('aria-label', `Mockup: ${label}`)
|
|
1760
|
+
|
|
1761
|
+
const frame = doc.createElement('div')
|
|
1762
|
+
frame.className = 'bp-mock__frame'
|
|
1763
|
+
|
|
1764
|
+
const bar = doc.createElement('div')
|
|
1765
|
+
bar.className = 'bp-mock__bar'
|
|
1766
|
+
|
|
1767
|
+
const ticks = doc.createElement('span')
|
|
1768
|
+
ticks.className = 'bp-mock__ticks'
|
|
1769
|
+
ticks.setAttribute('aria-hidden', 'true')
|
|
1770
|
+
ticks.append(doc.createElement('i'), doc.createElement('i'), doc.createElement('i'))
|
|
1771
|
+
bar.append(ticks)
|
|
1772
|
+
|
|
1773
|
+
const cap = doc.createElement('span')
|
|
1774
|
+
cap.className = 'bp-mock__label'
|
|
1775
|
+
cap.textContent = label
|
|
1776
|
+
bar.append(cap)
|
|
1777
|
+
|
|
1778
|
+
if (expandable) {
|
|
1779
|
+
const btn = doc.createElement('button')
|
|
1780
|
+
btn.type = 'button'
|
|
1781
|
+
btn.className = 'bp-mock__btn'
|
|
1782
|
+
btn.setAttribute('aria-expanded', 'false')
|
|
1783
|
+
btn.setAttribute('aria-label', 'Expand mockup')
|
|
1784
|
+
btn.append(createChromeSvg(doc, MOCK_EXPAND_ICON, 14))
|
|
1785
|
+
btn.addEventListener('click', () => this.#toggle())
|
|
1786
|
+
bar.append(btn)
|
|
1787
|
+
this.#btn = btn
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
const stage = doc.createElement('div')
|
|
1791
|
+
stage.className = 'bp-mock__stage'
|
|
1792
|
+
|
|
1793
|
+
const canvas = doc.createElement('div')
|
|
1794
|
+
canvas.className = 'bp-mock__canvas'
|
|
1795
|
+
if (this.#naturalWidth) canvas.style.width = `${this.#naturalWidth}px`
|
|
1796
|
+
canvas.append(...this.childNodes)
|
|
1797
|
+
stage.append(canvas)
|
|
1798
|
+
|
|
1799
|
+
frame.append(bar, stage)
|
|
1800
|
+
wrap.append(frame)
|
|
1801
|
+
this.replaceChildren(wrap)
|
|
1802
|
+
|
|
1803
|
+
this.#wrap = wrap
|
|
1804
|
+
this.#stage = stage
|
|
1805
|
+
this.#canvas = canvas
|
|
1806
|
+
|
|
1807
|
+
if (this.#naturalWidth && typeof ResizeObserver !== 'undefined') {
|
|
1808
|
+
this.#ro = new ResizeObserver(() => this.#relayout())
|
|
1809
|
+
this.#ro.observe(stage)
|
|
1810
|
+
this.#ro.observe(canvas)
|
|
1811
|
+
this.#relayout()
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
disconnectedCallback() {
|
|
1816
|
+
this.#ro?.disconnect()
|
|
1817
|
+
if (this.#onKeydown) this.ownerDocument.removeEventListener('keydown', this.#onKeydown)
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// Scale the fixed-width canvas down to whatever width the stage offers,
|
|
1821
|
+
// then collapse the stage to the scaled height so there is no dead space.
|
|
1822
|
+
#relayout() {
|
|
1823
|
+
if (!this.#naturalWidth || !this.#stage) return
|
|
1824
|
+
const avail = this.#stage.clientWidth
|
|
1825
|
+
if (!avail) return
|
|
1826
|
+
const scale = Math.min(1, avail / this.#naturalWidth)
|
|
1827
|
+
this.#canvas.style.setProperty('--bp-mock-scale', String(scale))
|
|
1828
|
+
this.#stage.style.height = `${Math.round(this.#canvas.offsetHeight * scale)}px`
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
#toggle() {
|
|
1832
|
+
const doc = this.ownerDocument
|
|
1833
|
+
const expanded = this.#wrap.classList.toggle('is-expanded')
|
|
1834
|
+
if (this.#btn) {
|
|
1835
|
+
this.#btn.setAttribute('aria-expanded', String(expanded))
|
|
1836
|
+
this.#btn.setAttribute('aria-label', expanded ? 'Collapse mockup' : 'Expand mockup')
|
|
1837
|
+
this.#btn.replaceChildren(
|
|
1838
|
+
createChromeSvg(doc, expanded ? MOCK_COLLAPSE_ICON : MOCK_EXPAND_ICON, 14)
|
|
1839
|
+
)
|
|
1840
|
+
}
|
|
1841
|
+
if (expanded) {
|
|
1842
|
+
this.#returnFocus = doc.activeElement
|
|
1843
|
+
// The page scrolls, so freeze the background behind the overlay.
|
|
1844
|
+
this.#prevOverflow = doc.documentElement.style.overflow
|
|
1845
|
+
doc.documentElement.style.overflow = 'hidden'
|
|
1846
|
+
this.#onKeydown = (event) => {
|
|
1847
|
+
if (event.key === 'Escape') this.#toggle()
|
|
1848
|
+
}
|
|
1849
|
+
doc.addEventListener('keydown', this.#onKeydown)
|
|
1850
|
+
this.#btn?.focus()
|
|
1851
|
+
} else {
|
|
1852
|
+
doc.documentElement.style.overflow = this.#prevOverflow ?? ''
|
|
1853
|
+
if (this.#onKeydown) {
|
|
1854
|
+
doc.removeEventListener('keydown', this.#onKeydown)
|
|
1855
|
+
this.#onKeydown = null
|
|
1856
|
+
}
|
|
1857
|
+
if (this.#returnFocus && typeof this.#returnFocus.focus === 'function') {
|
|
1858
|
+
this.#returnFocus.focus()
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
// The stage width changes with the overlay; rescale on the next frame.
|
|
1862
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
1863
|
+
requestAnimationFrame(() => this.#relayout())
|
|
1864
|
+
} else {
|
|
1865
|
+
this.#relayout()
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// ---------------------------------------------------------------------
|
|
1871
|
+
// <bp-gallery> — an elegant grid of images that expands to a lightbox.
|
|
1872
|
+
//
|
|
1873
|
+
// Wraps author-provided images (screenshots, generated mockups, or user
|
|
1874
|
+
// uploads) in a tidy responsive grid. Each thumbnail wears a bluescale
|
|
1875
|
+
// treatment — desaturated and washed in the illustration ramp — until it
|
|
1876
|
+
// is hovered or focused, when its true color is revealed. Activating a
|
|
1877
|
+
// thumbnail lifts the full-color image into a near-fullscreen lightbox
|
|
1878
|
+
// over a drafting scrim (Esc / the close control dismiss; arrow keys and
|
|
1879
|
+
// the prev/next controls page between images; focus is restored).
|
|
1880
|
+
//
|
|
1881
|
+
// <bp-gallery label="Concept explorations">
|
|
1882
|
+
// <figure>
|
|
1883
|
+
// <img src="hero.png" alt="Landing hero" />
|
|
1884
|
+
// <figcaption>Landing hero</figcaption>
|
|
1885
|
+
// </figure>
|
|
1886
|
+
// <img src="settings.png" alt="Settings panel" />
|
|
1887
|
+
// </bp-gallery>
|
|
1888
|
+
//
|
|
1889
|
+
// label: optional mono caption for the gallery as a whole (accessible
|
|
1890
|
+
// name + lightbox kicker). Captions come from a child <figcaption> when
|
|
1891
|
+
// present, otherwise the image's alt text. The component is light-DOM and
|
|
1892
|
+
// expands to documented .bp-gallery* markup styled by blueprint.css.
|
|
1893
|
+
// ---------------------------------------------------------------------
|
|
1894
|
+
const GALLERY_ZOOM_ICON = [
|
|
1895
|
+
'M8 11H11M14 11H11M11 11V8M11 11V14',
|
|
1896
|
+
'M17 17L21 21',
|
|
1897
|
+
'M3 11C3 15.4183 6.58172 19 11 19C13.213 19 15.2161 18.1015 16.6644 16.6493C18.1077 15.2022 19 13.2053 19 11C19 6.58172 15.4183 3 11 3C6.58172 3 3 6.58172 3 11Z',
|
|
1898
|
+
]
|
|
1899
|
+
const GALLERY_CLOSE_ICON =
|
|
1900
|
+
'M6.75827 17.2426L12.0009 12M17.2435 6.75736L12.0009 12M12.0009 12L6.75827 6.75736M12.0009 12L17.2435 17.2426'
|
|
1901
|
+
const GALLERY_PREV_ICON = 'M15 6L9 12L15 18'
|
|
1902
|
+
const GALLERY_NEXT_ICON = 'M9 6L15 12L9 18'
|
|
1903
|
+
|
|
1904
|
+
class BlueprintGalleryElement extends HTMLElement {
|
|
1905
|
+
#rendered = false
|
|
1906
|
+
/** @type {{ src: string, alt: string, caption: string }[]} */
|
|
1907
|
+
#slides = []
|
|
1908
|
+
#label = ''
|
|
1909
|
+
#box = null
|
|
1910
|
+
#boxImg = null
|
|
1911
|
+
#boxCaption = null
|
|
1912
|
+
#boxCounter = null
|
|
1913
|
+
#boxKicker = null
|
|
1914
|
+
#index = 0
|
|
1915
|
+
#onKeydown = null
|
|
1916
|
+
#returnFocus = null
|
|
1917
|
+
#prevOverflow = ''
|
|
1918
|
+
|
|
1919
|
+
connectedCallback() {
|
|
1920
|
+
if (this.#rendered) return
|
|
1921
|
+
this.#rendered = true
|
|
1922
|
+
|
|
1923
|
+
const doc = this.ownerDocument
|
|
1924
|
+
this.#label = this.getAttribute('label') || ''
|
|
1925
|
+
|
|
1926
|
+
// Collect the authored images and their captions before replacing the
|
|
1927
|
+
// light-DOM children. A <figcaption> wins; otherwise fall back to alt.
|
|
1928
|
+
for (const img of this.querySelectorAll('img')) {
|
|
1929
|
+
const figure = img.closest('figure')
|
|
1930
|
+
const cap = figure?.querySelector('figcaption')
|
|
1931
|
+
const caption = (cap?.textContent || img.getAttribute('alt') || '').trim()
|
|
1932
|
+
this.#slides.push({
|
|
1933
|
+
src: img.getAttribute('src') || '',
|
|
1934
|
+
alt: (img.getAttribute('alt') || caption || '').trim(),
|
|
1935
|
+
caption,
|
|
1936
|
+
})
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
const wrap = doc.createElement('div')
|
|
1940
|
+
wrap.className = 'bp-gallery'
|
|
1941
|
+
wrap.setAttribute('role', 'group')
|
|
1942
|
+
if (this.#label) wrap.setAttribute('aria-label', `Gallery: ${this.#label}`)
|
|
1943
|
+
|
|
1944
|
+
const grid = doc.createElement('div')
|
|
1945
|
+
grid.className = 'bp-gallery__grid'
|
|
1946
|
+
|
|
1947
|
+
this.#slides.forEach((slide, index) => {
|
|
1948
|
+
const item = doc.createElement('button')
|
|
1949
|
+
item.type = 'button'
|
|
1950
|
+
item.className = 'bp-gallery__item'
|
|
1951
|
+
item.setAttribute(
|
|
1952
|
+
'aria-label',
|
|
1953
|
+
slide.caption ? `View image: ${slide.caption}` : `View image ${index + 1}`
|
|
1954
|
+
)
|
|
1955
|
+
item.addEventListener('click', () => this.#open(index))
|
|
1956
|
+
|
|
1957
|
+
const thumb = doc.createElement('span')
|
|
1958
|
+
thumb.className = 'bp-gallery__thumb'
|
|
1959
|
+
|
|
1960
|
+
const img = doc.createElement('img')
|
|
1961
|
+
img.className = 'bp-gallery__img'
|
|
1962
|
+
img.src = slide.src
|
|
1963
|
+
img.alt = slide.alt
|
|
1964
|
+
img.loading = 'lazy'
|
|
1965
|
+
img.decoding = 'async'
|
|
1966
|
+
|
|
1967
|
+
// The bluescale wash: a blue veil that color-blends the grayscale
|
|
1968
|
+
// thumbnail, faded away on hover/focus to reveal true color.
|
|
1969
|
+
const wash = doc.createElement('span')
|
|
1970
|
+
wash.className = 'bp-gallery__wash'
|
|
1971
|
+
wash.setAttribute('aria-hidden', 'true')
|
|
1972
|
+
|
|
1973
|
+
const zoom = doc.createElement('span')
|
|
1974
|
+
zoom.className = 'bp-gallery__zoom'
|
|
1975
|
+
zoom.setAttribute('aria-hidden', 'true')
|
|
1976
|
+
zoom.append(createChromeSvg(doc, GALLERY_ZOOM_ICON, 16))
|
|
1977
|
+
|
|
1978
|
+
thumb.append(img, wash, zoom)
|
|
1979
|
+
item.append(thumb)
|
|
1980
|
+
|
|
1981
|
+
if (slide.caption) {
|
|
1982
|
+
const cap = doc.createElement('span')
|
|
1983
|
+
cap.className = 'bp-gallery__caption'
|
|
1984
|
+
cap.textContent = slide.caption
|
|
1985
|
+
item.append(cap)
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
grid.append(item)
|
|
1989
|
+
})
|
|
1990
|
+
|
|
1991
|
+
wrap.append(grid)
|
|
1992
|
+
this.replaceChildren(wrap)
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
disconnectedCallback() {
|
|
1996
|
+
if (this.#onKeydown) this.ownerDocument.removeEventListener('keydown', this.#onKeydown)
|
|
1997
|
+
this.#box?.remove()
|
|
1998
|
+
this.#box = null
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
#ensureBox() {
|
|
2002
|
+
if (this.#box) return
|
|
2003
|
+
const doc = this.ownerDocument
|
|
2004
|
+
|
|
2005
|
+
const box = doc.createElement('div')
|
|
2006
|
+
box.className = 'bp-lightbox'
|
|
2007
|
+
box.setAttribute('role', 'dialog')
|
|
2008
|
+
box.setAttribute('aria-modal', 'true')
|
|
2009
|
+
box.setAttribute('aria-label', this.#label ? `${this.#label} — image viewer` : 'Image viewer')
|
|
2010
|
+
box.addEventListener('click', (event) => {
|
|
2011
|
+
if (event.target === box) this.#close()
|
|
2012
|
+
})
|
|
2013
|
+
|
|
2014
|
+
const close = doc.createElement('button')
|
|
2015
|
+
close.type = 'button'
|
|
2016
|
+
close.className = 'bp-lightbox__close'
|
|
2017
|
+
close.setAttribute('aria-label', 'Close image viewer')
|
|
2018
|
+
close.append(createChromeSvg(doc, GALLERY_CLOSE_ICON, 18))
|
|
2019
|
+
close.addEventListener('click', () => this.#close())
|
|
2020
|
+
|
|
2021
|
+
const prev = doc.createElement('button')
|
|
2022
|
+
prev.type = 'button'
|
|
2023
|
+
prev.className = 'bp-lightbox__nav bp-lightbox__nav--prev'
|
|
2024
|
+
prev.setAttribute('aria-label', 'Previous image')
|
|
2025
|
+
prev.append(createChromeSvg(doc, GALLERY_PREV_ICON, 22))
|
|
2026
|
+
prev.addEventListener('click', () => this.#step(-1))
|
|
2027
|
+
|
|
2028
|
+
const next = doc.createElement('button')
|
|
2029
|
+
next.type = 'button'
|
|
2030
|
+
next.className = 'bp-lightbox__nav bp-lightbox__nav--next'
|
|
2031
|
+
next.setAttribute('aria-label', 'Next image')
|
|
2032
|
+
next.append(createChromeSvg(doc, GALLERY_NEXT_ICON, 22))
|
|
2033
|
+
next.addEventListener('click', () => this.#step(1))
|
|
2034
|
+
|
|
2035
|
+
const figure = doc.createElement('figure')
|
|
2036
|
+
figure.className = 'bp-lightbox__figure'
|
|
2037
|
+
|
|
2038
|
+
const img = doc.createElement('img')
|
|
2039
|
+
img.className = 'bp-lightbox__img'
|
|
2040
|
+
img.alt = ''
|
|
2041
|
+
|
|
2042
|
+
const meta = doc.createElement('figcaption')
|
|
2043
|
+
meta.className = 'bp-lightbox__meta'
|
|
2044
|
+
|
|
2045
|
+
const kicker = doc.createElement('span')
|
|
2046
|
+
kicker.className = 'bp-lightbox__kicker'
|
|
2047
|
+
kicker.textContent = this.#label
|
|
2048
|
+
|
|
2049
|
+
const counter = doc.createElement('span')
|
|
2050
|
+
counter.className = 'bp-lightbox__counter'
|
|
2051
|
+
|
|
2052
|
+
const caption = doc.createElement('span')
|
|
2053
|
+
caption.className = 'bp-lightbox__caption'
|
|
2054
|
+
|
|
2055
|
+
meta.append(kicker, counter, caption)
|
|
2056
|
+
figure.append(img, meta)
|
|
2057
|
+
box.append(close, prev, figure, next)
|
|
2058
|
+
|
|
2059
|
+
const host = doc.body || doc.documentElement
|
|
2060
|
+
host.append(box)
|
|
2061
|
+
|
|
2062
|
+
this.#box = box
|
|
2063
|
+
this.#boxImg = img
|
|
2064
|
+
this.#boxCaption = caption
|
|
2065
|
+
this.#boxCounter = counter
|
|
2066
|
+
this.#boxKicker = kicker
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
#render() {
|
|
2070
|
+
const slide = this.#slides[this.#index]
|
|
2071
|
+
if (!slide) return
|
|
2072
|
+
this.#boxImg.src = slide.src
|
|
2073
|
+
this.#boxImg.alt = slide.alt
|
|
2074
|
+
this.#boxCaption.textContent = slide.caption
|
|
2075
|
+
this.#boxCaption.hidden = !slide.caption
|
|
2076
|
+
this.#boxKicker.hidden = !this.#label
|
|
2077
|
+
const many = this.#slides.length > 1
|
|
2078
|
+
this.#boxCounter.textContent = many ? `${this.#index + 1} / ${this.#slides.length}` : ''
|
|
2079
|
+
this.#boxCounter.hidden = !many
|
|
2080
|
+
this.#box.classList.toggle('is-single', !many)
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
#open(index) {
|
|
2084
|
+
if (!this.#slides.length) return
|
|
2085
|
+
this.#ensureBox()
|
|
2086
|
+
this.#index = index
|
|
2087
|
+
this.#render()
|
|
2088
|
+
|
|
2089
|
+
const doc = this.ownerDocument
|
|
2090
|
+
this.#returnFocus = doc.activeElement
|
|
2091
|
+
this.#prevOverflow = doc.documentElement.style.overflow
|
|
2092
|
+
doc.documentElement.style.overflow = 'hidden'
|
|
2093
|
+
this.#box.classList.add('is-open')
|
|
2094
|
+
|
|
2095
|
+
this.#onKeydown = (event) => {
|
|
2096
|
+
if (event.key === 'Escape') this.#close()
|
|
2097
|
+
else if (event.key === 'ArrowRight') this.#step(1)
|
|
2098
|
+
else if (event.key === 'ArrowLeft') this.#step(-1)
|
|
2099
|
+
}
|
|
2100
|
+
doc.addEventListener('keydown', this.#onKeydown)
|
|
2101
|
+
this.#box.querySelector('.bp-lightbox__close')?.focus()
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
#step(delta) {
|
|
2105
|
+
if (this.#slides.length < 2) return
|
|
2106
|
+
const count = this.#slides.length
|
|
2107
|
+
this.#index = (this.#index + delta + count) % count
|
|
2108
|
+
this.#render()
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
#close() {
|
|
2112
|
+
if (!this.#box) return
|
|
2113
|
+
const doc = this.ownerDocument
|
|
2114
|
+
this.#box.classList.remove('is-open')
|
|
2115
|
+
doc.documentElement.style.overflow = this.#prevOverflow ?? ''
|
|
2116
|
+
if (this.#onKeydown) {
|
|
2117
|
+
doc.removeEventListener('keydown', this.#onKeydown)
|
|
2118
|
+
this.#onKeydown = null
|
|
2119
|
+
}
|
|
2120
|
+
if (this.#returnFocus && typeof this.#returnFocus.focus === 'function') {
|
|
2121
|
+
this.#returnFocus.focus()
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
// Presentational text-role elements. No behavior — styled by tag name in
|
|
2127
|
+
// blueprint.css (lede paragraph, quiet aside, muted mono micro-label). They
|
|
2128
|
+
// exist as elements so authors compose with markup instead of utility classes.
|
|
2129
|
+
class BlueprintSubheaderElement extends HTMLElement {}
|
|
2130
|
+
class BlueprintNoteElement extends HTMLElement {}
|
|
2131
|
+
class BlueprintEyebrowElement extends HTMLElement {}
|
|
2132
|
+
|
|
2133
|
+
// ---------------------------------------------------------------------
|
|
2134
|
+
// <bp-table> — an opt-in enhanced data table.
|
|
2135
|
+
//
|
|
2136
|
+
// A light-DOM wrapper around a plain author <table>; the <table> keeps its
|
|
2137
|
+
// base styling and the expanded form stays a valid stored document. On
|
|
2138
|
+
// upgrade the runtime wraps the table in a .bp-table-x scroll shell so
|
|
2139
|
+
// horizontal overflow is contained without making <bp-table> the sticky
|
|
2140
|
+
// header's scroll ancestor; wide / hover / sticky / compact are pure CSS
|
|
2141
|
+
// (attribute selectors in blueprint.css). The runtime's other job is the
|
|
2142
|
+
// value-specific work CSS cannot do: scan each column's <tbody> data cells
|
|
2143
|
+
// and, when they all read as numbers, end-align that column's <td> AND its
|
|
2144
|
+
// <th scope="col"> by stamping a .bp-col-num class.
|
|
2145
|
+
//
|
|
2146
|
+
// <bp-table sticky density="compact" wide><table>…</table></bp-table>
|
|
2147
|
+
//
|
|
2148
|
+
// numeric: omit to auto-detect; `off` disables alignment; an explicit
|
|
2149
|
+
// 1-based list ("2,3") forces those columns. sticky pins the header + key
|
|
2150
|
+
// column; density="compact" tightens cells; wide breaks past the measure.
|
|
2151
|
+
// ---------------------------------------------------------------------
|
|
2152
|
+
|
|
2153
|
+
// A cell reads as numeric: optional sign / currency, digits with optional
|
|
2154
|
+
// grouping and decimals, optional percent.
|
|
2155
|
+
const TABLE_NUMERIC = /^[+-]?[$€£¥]?\s?\d[\d,]*(\.\d+)?\s?%?$/
|
|
2156
|
+
// Dash / "n/a" placeholders count as empty (not as text), so a numeric
|
|
2157
|
+
// column with a few gaps still resolves as numeric.
|
|
2158
|
+
const TABLE_BLANK = /^(—|–|-|n\/?a)$/i
|
|
2159
|
+
|
|
2160
|
+
class BlueprintTableElement extends HTMLElement {
|
|
2161
|
+
static get observedAttributes() {
|
|
2162
|
+
return ['numeric']
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
#observer = null
|
|
2166
|
+
#applying = false
|
|
2167
|
+
|
|
2168
|
+
connectedCallback() {
|
|
2169
|
+
this.#ensureScrollShell()
|
|
2170
|
+
this.#apply()
|
|
2171
|
+
if (typeof MutationObserver !== 'undefined' && !this.#observer) {
|
|
2172
|
+
// Re-detect when the author's table content changes. Our own class
|
|
2173
|
+
// edits are attribute mutations, which this observer ignores, so it
|
|
2174
|
+
// cannot loop; the flag is belt-and-suspenders.
|
|
2175
|
+
this.#observer = new MutationObserver(() => {
|
|
2176
|
+
if (!this.#applying) this.#apply()
|
|
2177
|
+
})
|
|
2178
|
+
this.#observer.observe(this, { childList: true, subtree: true, characterData: true })
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
disconnectedCallback() {
|
|
2183
|
+
this.#observer?.disconnect()
|
|
2184
|
+
this.#observer = null
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
attributeChangedCallback() {
|
|
2188
|
+
if (this.isConnected) {
|
|
2189
|
+
this.#ensureScrollShell()
|
|
2190
|
+
this.#apply()
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
// Wrap the author's <table> in a horizontal scroll shell. Sticky header
|
|
2195
|
+
// cells must stick to the page scrollport, not to <bp-table> — so overflow-x
|
|
2196
|
+
// lives here, on .bp-table-x, instead of on the host.
|
|
2197
|
+
#ensureScrollShell() {
|
|
2198
|
+
const table = this.querySelector(':scope > table')
|
|
2199
|
+
if (!table) return
|
|
2200
|
+
let shell = this.querySelector(':scope > .bp-table-x')
|
|
2201
|
+
if (!shell) {
|
|
2202
|
+
shell = document.createElement('div')
|
|
2203
|
+
shell.className = 'bp-table-x'
|
|
2204
|
+
this.insertBefore(shell, table)
|
|
2205
|
+
}
|
|
2206
|
+
if (table.parentElement !== shell) shell.append(table)
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
// Resolve which 1-based columns end-align, then stamp .bp-col-num on the
|
|
2210
|
+
// matching cells across thead / tbody / tfoot.
|
|
2211
|
+
#apply() {
|
|
2212
|
+
const table = this.querySelector('table')
|
|
2213
|
+
if (!table) return
|
|
2214
|
+
this.#applying = true
|
|
2215
|
+
try {
|
|
2216
|
+
for (const el of this.querySelectorAll('.bp-col-num')) {
|
|
2217
|
+
el.classList.remove('bp-col-num')
|
|
2218
|
+
}
|
|
2219
|
+
const attr = (this.getAttribute('numeric') || '').trim().toLowerCase()
|
|
2220
|
+
if (attr === 'off') return
|
|
2221
|
+
let columns
|
|
2222
|
+
if (attr) {
|
|
2223
|
+
columns = new Set(
|
|
2224
|
+
attr.split(',').map((n) => Number.parseInt(n, 10)).filter((n) => n > 0)
|
|
2225
|
+
)
|
|
2226
|
+
if (!columns.size) columns = this.#detect(table)
|
|
2227
|
+
} else {
|
|
2228
|
+
columns = this.#detect(table)
|
|
2229
|
+
}
|
|
2230
|
+
if (columns.size) this.#mark(table, columns)
|
|
2231
|
+
} finally {
|
|
2232
|
+
this.#applying = false
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
// A column is numeric when every non-blank <td> in it parses as a number.
|
|
2237
|
+
// <th> cells (the key column) are skipped but still advance the column
|
|
2238
|
+
// index so it stays aligned with the header row.
|
|
2239
|
+
#detect(table) {
|
|
2240
|
+
const tally = new Map()
|
|
2241
|
+
for (const body of table.tBodies) {
|
|
2242
|
+
for (const row of body.rows) {
|
|
2243
|
+
let col = 0
|
|
2244
|
+
for (const cell of row.cells) {
|
|
2245
|
+
const span = cell.colSpan || 1
|
|
2246
|
+
col += 1
|
|
2247
|
+
if (cell.tagName === 'TH') {
|
|
2248
|
+
col += span - 1
|
|
2249
|
+
continue
|
|
2250
|
+
}
|
|
2251
|
+
const text = cell.textContent.trim()
|
|
2252
|
+
if (!text || TABLE_BLANK.test(text)) continue
|
|
2253
|
+
const rec = tally.get(col) || { total: 0, numeric: 0 }
|
|
2254
|
+
rec.total += 1
|
|
2255
|
+
if (TABLE_NUMERIC.test(text)) rec.numeric += 1
|
|
2256
|
+
tally.set(col, rec)
|
|
2257
|
+
col += span - 1
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
const columns = new Set()
|
|
2262
|
+
for (const [col, rec] of tally) {
|
|
2263
|
+
if (rec.total > 0 && rec.numeric === rec.total) columns.add(col)
|
|
2264
|
+
}
|
|
2265
|
+
return columns
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
// Stamp .bp-col-num on every cell whose 1-based column index is numeric.
|
|
2269
|
+
#mark(table, columns) {
|
|
2270
|
+
const stamp = (rows) => {
|
|
2271
|
+
for (const row of rows) {
|
|
2272
|
+
let col = 0
|
|
2273
|
+
for (const cell of row.cells) {
|
|
2274
|
+
const span = cell.colSpan || 1
|
|
2275
|
+
col += 1
|
|
2276
|
+
for (let i = 0; i < span; i += 1) {
|
|
2277
|
+
if (columns.has(col + i)) {
|
|
2278
|
+
cell.classList.add('bp-col-num')
|
|
2279
|
+
break
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
col += span - 1
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
if (table.tHead) stamp(table.tHead.rows)
|
|
2287
|
+
for (const body of table.tBodies) stamp(body.rows)
|
|
2288
|
+
if (table.tFoot) stamp(table.tFoot.rows)
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
if (typeof customElements !== 'undefined' && !customElements.get('bp-toc')) {
|
|
2293
|
+
customElements.define('bp-toc', BlueprintTocElement)
|
|
2294
|
+
}
|
|
2295
|
+
|
|
406
2296
|
if (typeof customElements !== 'undefined' && !customElements.get('bp-callout')) {
|
|
407
2297
|
customElements.define('bp-callout', BlueprintCalloutElement)
|
|
408
2298
|
}
|
|
409
2299
|
|
|
2300
|
+
if (typeof customElements !== 'undefined' && !customElements.get('bp-mock')) {
|
|
2301
|
+
customElements.define('bp-mock', BlueprintMockElement)
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
if (typeof customElements !== 'undefined' && !customElements.get('bp-gallery')) {
|
|
2305
|
+
customElements.define('bp-gallery', BlueprintGalleryElement)
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
if (typeof customElements !== 'undefined' && !customElements.get('bp-table')) {
|
|
2309
|
+
customElements.define('bp-table', BlueprintTableElement)
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
if (typeof customElements !== 'undefined') {
|
|
2313
|
+
for (const [name, ctor] of [
|
|
2314
|
+
['bp-subheader', BlueprintSubheaderElement],
|
|
2315
|
+
['bp-note', BlueprintNoteElement],
|
|
2316
|
+
['bp-eyebrow', BlueprintEyebrowElement],
|
|
2317
|
+
]) {
|
|
2318
|
+
if (!customElements.get(name)) customElements.define(name, ctor)
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
// ---------------------------------------------------------------------
|
|
2323
|
+
// <bp-workplan> — a live "work plan" surface.
|
|
2324
|
+
//
|
|
2325
|
+
// Visualizes a set of typed, dependent tasks that resolve into waves of
|
|
2326
|
+
// work, rendered four ways (list · gantt · kanban · swimlanes). It is a
|
|
2327
|
+
// frame client: it reads its plan from a `.plan` property, the global
|
|
2328
|
+
// `window.__WORKPLAN__`, or a `{ type: 'workplan:update', plan }`
|
|
2329
|
+
// postMessage — so a host frame drives it and the author writes no task
|
|
2330
|
+
// data. With no plan it paints a "waiting for a plan" state.
|
|
2331
|
+
//
|
|
2332
|
+
// <bp-workplan mode="gantt" header="…" subheader="…"></bp-workplan>
|
|
2333
|
+
//
|
|
2334
|
+
// mode: list|gantt|kanban|swimlanes pins a layout; omit it for the
|
|
2335
|
+
// built-in view selector. header/subheader override the plan's titles.
|
|
2336
|
+
// unit: hours|days sets the gantt axis. ignore-global opts out of the
|
|
2337
|
+
// window.__WORKPLAN__ source (postMessage only).
|
|
2338
|
+
// ---------------------------------------------------------------------
|
|
2339
|
+
;(() => {
|
|
2340
|
+
const VIEW_OPTIONS = [
|
|
2341
|
+
['list', 'List'],
|
|
2342
|
+
['gantt', 'Gantt'],
|
|
2343
|
+
['kanban', 'Kanban'],
|
|
2344
|
+
['swimlanes', 'Swimlanes'],
|
|
2345
|
+
]
|
|
2346
|
+
|
|
2347
|
+
// Waves + readiness + a longest-path schedule, all derived (not authored).
|
|
2348
|
+
function derive(plan) {
|
|
2349
|
+
const byId = new Map(plan.tasks.map((t) => [t.id, t]))
|
|
2350
|
+
const waveMemo = new Map()
|
|
2351
|
+
const startMemo = new Map()
|
|
2352
|
+
|
|
2353
|
+
const wave = (id, trail = new Set()) => {
|
|
2354
|
+
if (waveMemo.has(id)) return waveMemo.get(id)
|
|
2355
|
+
const deps = byId.get(id)?.dependsOn ?? []
|
|
2356
|
+
let w = 0
|
|
2357
|
+
for (const d of deps) {
|
|
2358
|
+
if (!byId.has(d) || trail.has(d)) continue
|
|
2359
|
+
w = Math.max(w, wave(d, new Set([...trail, id])) + 1)
|
|
2360
|
+
}
|
|
2361
|
+
waveMemo.set(id, w)
|
|
2362
|
+
return w
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
const start = (id, trail = new Set()) => {
|
|
2366
|
+
if (startMemo.has(id)) return startMemo.get(id)
|
|
2367
|
+
const deps = byId.get(id)?.dependsOn ?? []
|
|
2368
|
+
let s = 0
|
|
2369
|
+
for (const d of deps) {
|
|
2370
|
+
const dep = byId.get(d)
|
|
2371
|
+
if (!dep || trail.has(d)) continue
|
|
2372
|
+
s = Math.max(s, start(d, new Set([...trail, id])) + (dep.effort ?? 1))
|
|
2373
|
+
}
|
|
2374
|
+
startMemo.set(id, s)
|
|
2375
|
+
return s
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
return plan.tasks.map((t) => {
|
|
2379
|
+
const deps = t.dependsOn ?? []
|
|
2380
|
+
const blockedBy = deps.filter((d) => byId.get(d)?.status !== 'done')
|
|
2381
|
+
const ready = blockedBy.length === 0
|
|
2382
|
+
const s = start(t.id)
|
|
2383
|
+
return {
|
|
2384
|
+
...t,
|
|
2385
|
+
wave: wave(t.id),
|
|
2386
|
+
ready,
|
|
2387
|
+
locked: !ready && t.status !== 'done',
|
|
2388
|
+
start: s,
|
|
2389
|
+
finish: s + (t.effort ?? 1),
|
|
2390
|
+
blockedBy,
|
|
2391
|
+
}
|
|
2392
|
+
})
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
function phase(t) {
|
|
2396
|
+
if (t.status === 'done') return 'done'
|
|
2397
|
+
if (t.locked) return 'blocked'
|
|
2398
|
+
if (t.status === 'active') return 'active'
|
|
2399
|
+
return 'ready'
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
const esc = (s) =>
|
|
2403
|
+
String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))
|
|
2404
|
+
|
|
2405
|
+
const TYPE_LABEL = {
|
|
2406
|
+
research: 'Research',
|
|
2407
|
+
write: 'Write',
|
|
2408
|
+
review: 'Review',
|
|
2409
|
+
design: 'Design',
|
|
2410
|
+
code: 'Code',
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
const CI_LABEL = {
|
|
2414
|
+
running: 'Tests running',
|
|
2415
|
+
failed: 'CI failed',
|
|
2416
|
+
passed: 'CI passed',
|
|
2417
|
+
approved: 'Approved',
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
const UNIT_SUFFIX = { hours: 'h', days: 'd' }
|
|
2421
|
+
const UNIT_AXIS = { hours: 'Hours', days: 'Days' }
|
|
2422
|
+
|
|
2423
|
+
// 16×16 glyphs, drawn in currentColor; UI chrome stays on the ink scale.
|
|
2424
|
+
const TYPE_ICON = {
|
|
2425
|
+
research: '<circle cx="7" cy="7" r="4.2"/><path d="M10 10l4 4"/>',
|
|
2426
|
+
write: '<path d="M3 13l8.5-8.5 2.5 2.5L5.5 15.5 2.5 16z"/><path d="M11 5l2.5 2.5"/>',
|
|
2427
|
+
review: '<path d="M2 8s2.4-4 6-4 6 4 6 4-2.4 4-6 4-6-4-6-4z"/><circle cx="8" cy="8" r="1.8"/>',
|
|
2428
|
+
design: '<path d="M8 2v12"/><path d="M8 2l4.5 10.5a5 5 0 01-9 0z"/>',
|
|
2429
|
+
code: '<path d="M6 4L2 8l4 4"/><path d="M10 4l4 4-4 4"/>',
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
const CI_ICON = {
|
|
2433
|
+
running: '<circle cx="8" cy="8" r="5.4" stroke-dasharray="3 3"/>',
|
|
2434
|
+
failed: '<circle cx="8" cy="8" r="6"/><path d="M5.5 5.5l5 5M10.5 5.5l-5 5"/>',
|
|
2435
|
+
passed: '<circle cx="8" cy="8" r="6"/><path d="M5 8.2l2 2 4-4.4"/>',
|
|
2436
|
+
approved: '<path d="M3 8.4l2.2 2.2 4.4-4.8"/><path d="M7 8.4l2.2 2.2 4.4-4.8"/>',
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
// 8-point spark — the mark for automated (agent) work.
|
|
2440
|
+
const AGENT_ICON = '<path d="M8 1.5v13M1.5 8h13M3.7 3.7l8.6 8.6M12.3 3.7l-8.6 8.6"/>'
|
|
2441
|
+
|
|
2442
|
+
const svgMark = (paths, cls) =>
|
|
2443
|
+
`<svg class="${cls}" viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`
|
|
2444
|
+
|
|
2445
|
+
const typeBadge = (t) =>
|
|
2446
|
+
`<span class="wp-type wp-type--${t}">${svgMark(TYPE_ICON[t], 'wp-type__glyph')}<span class="wp-type__label">${TYPE_LABEL[t]}</span></span>`
|
|
2447
|
+
|
|
2448
|
+
const phaseDot = (p) => `<span class="wp-dot wp-dot--${p}" role="img" aria-label="${p}"></span>`
|
|
2449
|
+
|
|
2450
|
+
// Inline (no pill) PR status: glyph for running/failed, positive ink for
|
|
2451
|
+
// passed/approved, mono throughout. `compact` drops the diff stat.
|
|
2452
|
+
function prStatus(pr, compact = false) {
|
|
2453
|
+
const needsGlyph = pr.ci === 'running' || pr.ci === 'failed'
|
|
2454
|
+
const diff =
|
|
2455
|
+
!compact && pr.additions != null
|
|
2456
|
+
? `<span class="wp-pr__diff"><span class="wp-pr__add">+${pr.additions}</span> <span class="wp-pr__del">\u2212${pr.deletions ?? 0}</span></span>`
|
|
2457
|
+
: ''
|
|
2458
|
+
return (
|
|
2459
|
+
`<span class="wp-pr wp-pr--${pr.ci}" title="${esc(`PR #${pr.number} — ${CI_LABEL[pr.ci]}`)}">` +
|
|
2460
|
+
(needsGlyph ? svgMark(CI_ICON[pr.ci], 'wp-pr__glyph') : '') +
|
|
2461
|
+
`<span class="wp-pr__num">#${pr.number}</span>` +
|
|
2462
|
+
`<span class="wp-pr__ci">${esc(CI_LABEL[pr.ci])}</span>` +
|
|
2463
|
+
diff +
|
|
2464
|
+
`</span>`
|
|
2465
|
+
)
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
function initials(name) {
|
|
2469
|
+
const parts = name.trim().split(/\s+/)
|
|
2470
|
+
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
|
|
2471
|
+
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
// Agent → spark mark (square); human → initials monogram (circle).
|
|
2475
|
+
function ownerMark(owner) {
|
|
2476
|
+
if (!owner) return ''
|
|
2477
|
+
if (owner.kind === 'agent') {
|
|
2478
|
+
return `<span class="wp-owner wp-owner--agent" title="${esc(owner.name)} (agent)">${svgMark(AGENT_ICON, 'wp-owner__glyph')}</span>`
|
|
2479
|
+
}
|
|
2480
|
+
return `<span class="wp-owner wp-owner--human" title="${esc(owner.name)}">${esc(initials(owner.name))}</span>`
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
function waitLabel(t, byId) {
|
|
2484
|
+
const names = t.blockedBy.map((d) => byId.get(d)?.name ?? d)
|
|
2485
|
+
return `Waiting on ${names.join(', ')}`
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
function waitNote(t, byId) {
|
|
2489
|
+
if (!t.locked) return ''
|
|
2490
|
+
const label = waitLabel(t, byId)
|
|
2491
|
+
return `<span class="wp-wait" title="${esc(label)}">${esc(label)}</span>`
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
function groupByWave(tasks) {
|
|
2495
|
+
const map = new Map()
|
|
2496
|
+
for (const t of tasks) {
|
|
2497
|
+
const arr = map.get(t.wave) ?? []
|
|
2498
|
+
arr.push(t)
|
|
2499
|
+
map.set(t.wave, arr)
|
|
2500
|
+
}
|
|
2501
|
+
return [...map.entries()].sort((a, b) => a[0] - b[0])
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
function renderList(tasks, byId) {
|
|
2505
|
+
const blocks = groupByWave(tasks)
|
|
2506
|
+
.map(
|
|
2507
|
+
([w, items]) => `
|
|
2508
|
+
<div class="wp-wave">
|
|
2509
|
+
<div class="wp-wave__head">
|
|
2510
|
+
<span class="wp-wave__no">Wave ${w + 1}</span>
|
|
2511
|
+
<span class="wp-wave__count">${items.length} task${items.length === 1 ? '' : 's'}</span>
|
|
2512
|
+
</div>
|
|
2513
|
+
<div class="wp-list">
|
|
2514
|
+
${items
|
|
2515
|
+
.map((t) => {
|
|
2516
|
+
const p = phase(t)
|
|
2517
|
+
return `
|
|
2518
|
+
<div class="wp-row wp-row--${p}" data-locked="${t.locked}">
|
|
2519
|
+
${phaseDot(p)}
|
|
2520
|
+
${typeBadge(t.type)}
|
|
2521
|
+
<span class="wp-row__main">
|
|
2522
|
+
<span class="wp-row__name">${esc(t.name)}</span>
|
|
2523
|
+
${waitNote(t, byId)}
|
|
2524
|
+
</span>
|
|
2525
|
+
<span class="wp-row__pr">${t.pr ? prStatus(t.pr) : ''}</span>
|
|
2526
|
+
${ownerMark(t.owner)}
|
|
2527
|
+
</div>`
|
|
2528
|
+
})
|
|
2529
|
+
.join('')}
|
|
2530
|
+
</div>
|
|
2531
|
+
</div>`
|
|
2532
|
+
)
|
|
2533
|
+
.join('')
|
|
2534
|
+
return `<div class="wp-view wp-view--list">${blocks}</div>`
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
function renderKanban(tasks, byId) {
|
|
2538
|
+
const cols = [
|
|
2539
|
+
{ key: 'blocked', title: 'Blocked' },
|
|
2540
|
+
{ key: 'ready', title: 'Ready' },
|
|
2541
|
+
{ key: 'active', title: 'In progress' },
|
|
2542
|
+
{ key: 'done', title: 'Done' },
|
|
2543
|
+
]
|
|
2544
|
+
const columns = cols
|
|
2545
|
+
.map(({ key, title }) => {
|
|
2546
|
+
const items = tasks.filter((t) => phase(t) === key)
|
|
2547
|
+
return `
|
|
2548
|
+
<div class="wp-col wp-col--${key}">
|
|
2549
|
+
<div class="wp-col__head">
|
|
2550
|
+
<span class="wp-col__title">${title}</span>
|
|
2551
|
+
<span class="wp-col__count">${items.length}</span>
|
|
2552
|
+
</div>
|
|
2553
|
+
<div class="wp-col__body">
|
|
2554
|
+
${
|
|
2555
|
+
items.length === 0
|
|
2556
|
+
? '<div class="wp-col__empty">No tasks</div>'
|
|
2557
|
+
: items
|
|
2558
|
+
.map(
|
|
2559
|
+
(t) => `
|
|
2560
|
+
<div class="wp-card wp-card--${key}" data-locked="${t.locked}"${t.locked ? ` title="${esc(waitLabel(t, byId))}"` : ''}>
|
|
2561
|
+
<div class="wp-card__top">
|
|
2562
|
+
${typeBadge(t.type)}
|
|
2563
|
+
${phaseDot(phase(t))}
|
|
2564
|
+
</div>
|
|
2565
|
+
<div class="wp-card__name">${esc(t.name)}</div>
|
|
2566
|
+
${t.pr ? `<div class="wp-card__pr">${prStatus(t.pr, true)}</div>` : ''}
|
|
2567
|
+
<div class="wp-card__foot">
|
|
2568
|
+
<span class="wp-card__wave">Wave ${t.wave + 1}</span>
|
|
2569
|
+
${ownerMark(t.owner)}
|
|
2570
|
+
</div>
|
|
2571
|
+
</div>`
|
|
2572
|
+
)
|
|
2573
|
+
.join('')
|
|
2574
|
+
}
|
|
2575
|
+
</div>
|
|
2576
|
+
</div>`
|
|
2577
|
+
})
|
|
2578
|
+
.join('')
|
|
2579
|
+
return `<div class="wp-view wp-view--kanban">${columns}</div>`
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
function renderSwimlanes(tasks) {
|
|
2583
|
+
const order = ['research', 'design', 'write', 'code', 'review']
|
|
2584
|
+
const maxWave = Math.max(0, ...tasks.map((t) => t.wave))
|
|
2585
|
+
const header =
|
|
2586
|
+
`<div class="wp-swim__corner">Type \u00d7 wave</div>` +
|
|
2587
|
+
Array.from({ length: maxWave + 1 }, (_, w) => `<div class="wp-swim__whead">Wave ${w + 1}</div>`).join('')
|
|
2588
|
+
const lanes = order
|
|
2589
|
+
.filter((type) => tasks.some((t) => t.type === type))
|
|
2590
|
+
.map((type) => {
|
|
2591
|
+
const cells = Array.from({ length: maxWave + 1 }, (_, w) => {
|
|
2592
|
+
const items = tasks.filter((t) => t.type === type && t.wave === w)
|
|
2593
|
+
return `<div class="wp-swim__cell">${items
|
|
2594
|
+
.map((t) => {
|
|
2595
|
+
const p = phase(t)
|
|
2596
|
+
return `<div class="wp-chip wp-chip--${p}" data-locked="${t.locked}" title="${esc(t.name)}">
|
|
2597
|
+
<div class="wp-chip__head">
|
|
2598
|
+
${phaseDot(p)}
|
|
2599
|
+
<span class="wp-chip__name">${esc(t.name)}</span>
|
|
2600
|
+
${ownerMark(t.owner)}
|
|
2601
|
+
</div>
|
|
2602
|
+
${t.pr ? `<div class="wp-chip__pr">${prStatus(t.pr, true)}</div>` : ''}
|
|
2603
|
+
</div>`
|
|
2604
|
+
})
|
|
2605
|
+
.join('')}</div>`
|
|
2606
|
+
}).join('')
|
|
2607
|
+
return `<div class="wp-swim__lanehead">${typeBadge(type)}</div>${cells}`
|
|
2608
|
+
})
|
|
2609
|
+
.join('')
|
|
2610
|
+
return `<div class="wp-view wp-view--swim" style="--wp-waves:${maxWave + 1}">${header}${lanes}</div>`
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
function renderGantt(tasks, unit) {
|
|
2614
|
+
const span = Math.max(1, ...tasks.map((t) => t.finish))
|
|
2615
|
+
const suffix = UNIT_SUFFIX[unit]
|
|
2616
|
+
const axis = Array.from(
|
|
2617
|
+
{ length: span + 1 },
|
|
2618
|
+
(_, d) => `<span class="wp-gantt__tick" style="--at:${d}">${d}</span>`
|
|
2619
|
+
).join('')
|
|
2620
|
+
const rows = [...tasks]
|
|
2621
|
+
.sort((a, b) => a.start - b.start || a.wave - b.wave)
|
|
2622
|
+
.map((t) => {
|
|
2623
|
+
const p = phase(t)
|
|
2624
|
+
const pct = (n) => (n / span) * 100
|
|
2625
|
+
return `
|
|
2626
|
+
<div class="wp-gantt__row wp-gantt__row--${p}" data-locked="${t.locked}">
|
|
2627
|
+
<div class="wp-gantt__label">
|
|
2628
|
+
${typeBadge(t.type)}
|
|
2629
|
+
<span class="wp-gantt__name">${esc(t.name)}</span>
|
|
2630
|
+
${ownerMark(t.owner)}
|
|
2631
|
+
</div>
|
|
2632
|
+
<div class="wp-gantt__track">
|
|
2633
|
+
<div class="wp-gantt__bar wp-gantt__bar--${p}" style="left:${pct(t.start)}%;width:${pct(t.finish - t.start)}%">
|
|
2634
|
+
${phaseDot(p)}
|
|
2635
|
+
<span class="wp-gantt__bar-label">${t.pr ? `#${t.pr.number}` : `${t.effort ?? 1}${suffix}`}</span>
|
|
2636
|
+
</div>
|
|
2637
|
+
</div>
|
|
2638
|
+
</div>`
|
|
2639
|
+
})
|
|
2640
|
+
.join('')
|
|
2641
|
+
return `<div class="wp-view wp-view--gantt">
|
|
2642
|
+
<div class="wp-gantt__row wp-gantt__row--axis">
|
|
2643
|
+
<div class="wp-gantt__label wp-gantt__label--axis">${UNIT_AXIS[unit]} \u2192</div>
|
|
2644
|
+
<div class="wp-gantt__track wp-gantt__track--axis" style="--span:${span}">${axis}</div>
|
|
2645
|
+
</div>
|
|
2646
|
+
${rows}
|
|
2647
|
+
</div>`
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
function summarize(tasks) {
|
|
2651
|
+
return {
|
|
2652
|
+
total: tasks.length,
|
|
2653
|
+
done: tasks.filter((t) => t.status === 'done').length,
|
|
2654
|
+
active: tasks.filter((t) => phase(t) === 'active').length,
|
|
2655
|
+
blocked: tasks.filter((t) => t.locked).length,
|
|
2656
|
+
waves: new Set(tasks.map((t) => t.wave)).size,
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
function renderEmpty() {
|
|
2661
|
+
return `<div class="wp-empty" role="status">
|
|
2662
|
+
<svg viewBox="0 0 48 48" width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="wp-empty__art">
|
|
2663
|
+
<rect x="7" y="9" width="34" height="30" rx="2"/>
|
|
2664
|
+
<path d="M7 17h34"/>
|
|
2665
|
+
<path d="M13 24h10M13 30h16" stroke-dasharray="2 3"/>
|
|
2666
|
+
<circle cx="33" cy="29" r="6"/>
|
|
2667
|
+
<path d="M33 26v3l2 2"/>
|
|
2668
|
+
</svg>
|
|
2669
|
+
<div class="wp-empty__title">Waiting for a plan</div>
|
|
2670
|
+
<div class="wp-empty__hint">No work plan has been received yet. The host frame will post one over <span class="wp-mono">postMessage</span>.</div>
|
|
2671
|
+
</div>`
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
const MESSAGE_TYPE = 'workplan:update'
|
|
2675
|
+
const GLOBAL_KEY = '__WORKPLAN__'
|
|
2676
|
+
const MODES = new Set(['list', 'gantt', 'kanban', 'swimlanes'])
|
|
2677
|
+
|
|
2678
|
+
class BlueprintWorkplanElement extends HTMLElement {
|
|
2679
|
+
static get observedAttributes() {
|
|
2680
|
+
return ['mode', 'unit', 'header', 'subheader']
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
#plan = null
|
|
2684
|
+
#root = null
|
|
2685
|
+
#activeView = 'list'
|
|
2686
|
+
#onMessage = (e) => this.#handleMessage(e)
|
|
2687
|
+
#onClick = (e) => this.#handleClick(e)
|
|
2688
|
+
|
|
2689
|
+
set plan(value) {
|
|
2690
|
+
this.#plan = value
|
|
2691
|
+
this.#render()
|
|
2692
|
+
}
|
|
2693
|
+
get plan() {
|
|
2694
|
+
return this.#plan
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
get mode() {
|
|
2698
|
+
const m = this.getAttribute('mode')
|
|
2699
|
+
return MODES.has(m) ? m : null
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
get unit() {
|
|
2703
|
+
return this.getAttribute('unit') === 'days' ? 'days' : 'hours'
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
connectedCallback() {
|
|
2707
|
+
if (!this.#root) {
|
|
2708
|
+
this.#root = this.ownerDocument.createElement('div')
|
|
2709
|
+
this.#root.className = 'bp-workplan'
|
|
2710
|
+
this.#root.addEventListener('click', this.#onClick)
|
|
2711
|
+
this.replaceChildren(this.#root)
|
|
2712
|
+
}
|
|
2713
|
+
if (!this.#plan && !this.hasAttribute('ignore-global')) {
|
|
2714
|
+
const fromGlobal = this.ownerDocument.defaultView?.[GLOBAL_KEY]
|
|
2715
|
+
if (fromGlobal) this.#plan = fromGlobal
|
|
2716
|
+
}
|
|
2717
|
+
this.ownerDocument.defaultView?.addEventListener('message', this.#onMessage)
|
|
2718
|
+
this.#render()
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
disconnectedCallback() {
|
|
2722
|
+
this.ownerDocument.defaultView?.removeEventListener('message', this.#onMessage)
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
attributeChangedCallback() {
|
|
2726
|
+
this.#render()
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
#handleMessage(e) {
|
|
2730
|
+
const data = e.data
|
|
2731
|
+
if (!data || data.type !== MESSAGE_TYPE || !data.plan) return
|
|
2732
|
+
this.#plan = data.plan
|
|
2733
|
+
this.#render()
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
// Delegated: the toolbar's buttons live in re-rendered markup, so we
|
|
2737
|
+
// listen on the persistent root and read intent off data-attributes.
|
|
2738
|
+
#handleClick(e) {
|
|
2739
|
+
const target = e.target
|
|
2740
|
+
const btn = target instanceof Element ? target.closest('[data-wp-view],[data-wp-unit]') : null
|
|
2741
|
+
if (!btn || !this.#root?.contains(btn)) return
|
|
2742
|
+
const view = btn.getAttribute('data-wp-view')
|
|
2743
|
+
if (view) {
|
|
2744
|
+
this.#activeView = view
|
|
2745
|
+
this.#render()
|
|
2746
|
+
return
|
|
2747
|
+
}
|
|
2748
|
+
const unit = btn.getAttribute('data-wp-unit')
|
|
2749
|
+
if (unit) this.setAttribute('unit', unit)
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
get #view() {
|
|
2753
|
+
return this.mode ?? this.#activeView
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
#toolbar(view, showModes) {
|
|
2757
|
+
const modes = showModes
|
|
2758
|
+
? `<div class="wp-modes" role="tablist" aria-label="Choose a view">` +
|
|
2759
|
+
VIEW_OPTIONS.map(
|
|
2760
|
+
([v, label]) =>
|
|
2761
|
+
`<button class="wp-modes__btn" type="button" role="tab" aria-selected="${v === view}" data-wp-view="${v}">${label}</button>`
|
|
2762
|
+
).join('') +
|
|
2763
|
+
`</div>`
|
|
2764
|
+
: ''
|
|
2765
|
+
const unit =
|
|
2766
|
+
view === 'gantt'
|
|
2767
|
+
? `<div class="wp-modes wp-modes--unit" role="group" aria-label="Time unit">` +
|
|
2768
|
+
['hours', 'days']
|
|
2769
|
+
.map(
|
|
2770
|
+
(u) =>
|
|
2771
|
+
`<button class="wp-modes__btn" type="button" aria-selected="${u === this.unit}" data-wp-unit="${u}">${UNIT_AXIS[u]}</button>`
|
|
2772
|
+
)
|
|
2773
|
+
.join('') +
|
|
2774
|
+
`</div>`
|
|
2775
|
+
: ''
|
|
2776
|
+
return modes || unit ? `<div class="wp-toolbar">${modes}${unit}</div>` : ''
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
#render() {
|
|
2780
|
+
if (!this.#root) return
|
|
2781
|
+
const plan = this.#plan
|
|
2782
|
+
const hasData = !!plan && Array.isArray(plan.tasks) && plan.tasks.length > 0
|
|
2783
|
+
const view = this.#view
|
|
2784
|
+
const showModes = this.mode === null
|
|
2785
|
+
|
|
2786
|
+
const title = this.getAttribute('header') ?? plan?.title ?? ''
|
|
2787
|
+
const subtitle = this.getAttribute('subheader') ?? plan?.subtitle ?? ''
|
|
2788
|
+
|
|
2789
|
+
let stats = ''
|
|
2790
|
+
let body = ''
|
|
2791
|
+
if (hasData) {
|
|
2792
|
+
this.#root.dataset.state = 'ready'
|
|
2793
|
+
const derived = derive(plan)
|
|
2794
|
+
const byId = new Map(plan.tasks.map((t) => [t.id, t]))
|
|
2795
|
+
const counts = summarize(derived)
|
|
2796
|
+
stats =
|
|
2797
|
+
`<div class="wp-stats">` +
|
|
2798
|
+
`<div class="wp-stat"><span class="wp-stat__k">Done</span><span class="wp-stat__v">${counts.done}/${counts.total}</span></div>` +
|
|
2799
|
+
`<div class="wp-stat"><span class="wp-stat__k">Active</span><span class="wp-stat__v">${counts.active}</span></div>` +
|
|
2800
|
+
`<div class="wp-stat"><span class="wp-stat__k">Blocked</span><span class="wp-stat__v">${counts.blocked}</span></div>` +
|
|
2801
|
+
`<div class="wp-stat"><span class="wp-stat__k">Waves</span><span class="wp-stat__v">${counts.waves}</span></div>` +
|
|
2802
|
+
`</div>`
|
|
2803
|
+
body =
|
|
2804
|
+
view === 'gantt'
|
|
2805
|
+
? renderGantt(derived, this.unit)
|
|
2806
|
+
: view === 'kanban'
|
|
2807
|
+
? renderKanban(derived, byId)
|
|
2808
|
+
: view === 'swimlanes'
|
|
2809
|
+
? renderSwimlanes(derived)
|
|
2810
|
+
: renderList(derived, byId)
|
|
2811
|
+
} else {
|
|
2812
|
+
this.#root.dataset.state = 'empty'
|
|
2813
|
+
body = renderEmpty()
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
const head =
|
|
2817
|
+
title || subtitle || stats
|
|
2818
|
+
? `<div class="wp-head">` +
|
|
2819
|
+
`<div class="wp-head__title">` +
|
|
2820
|
+
(title ? `<div class="wp-head__name">${esc(title)}</div>` : '') +
|
|
2821
|
+
(subtitle ? `<div class="wp-head__sub">${esc(subtitle)}</div>` : '') +
|
|
2822
|
+
`</div>` +
|
|
2823
|
+
stats +
|
|
2824
|
+
`</div>`
|
|
2825
|
+
: ''
|
|
2826
|
+
|
|
2827
|
+
const toolbar = hasData ? this.#toolbar(view, showModes) : ''
|
|
2828
|
+
|
|
2829
|
+
this.#root.innerHTML = head + toolbar + `<div class="wp-canvas">${body}</div>`
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
if (typeof customElements !== 'undefined' && !customElements.get('bp-workplan')) {
|
|
2834
|
+
customElements.define('bp-workplan', BlueprintWorkplanElement)
|
|
2835
|
+
}
|
|
2836
|
+
})()
|
|
2837
|
+
|
|
410
2838
|
if (typeof document !== 'undefined') {
|
|
411
2839
|
if (document.readyState === 'loading') {
|
|
412
2840
|
document.addEventListener('DOMContentLoaded', () => initializeBlueprintRuntime(), { once: true })
|