Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/format/dashboard/format-dashboard-card.ts
6451 views
1
/*
2
* format-dashboard-card.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { Document, Element, Node } from "../../core/deno-dom.ts";
8
import { recursiveApplyFillClasses } from "./format-dashboard-layout.ts";
9
import {
10
applyAttributes,
11
applyClasses,
12
attrToCardBodyStyle,
13
attrToStyle,
14
DashboardMeta,
15
ensureCssUnits,
16
hasFlowLayout,
17
kCardClass,
18
kFillAttr,
19
kLayoutFill,
20
kLayoutFlow,
21
kValueboxClass,
22
makeEl,
23
processAndRemoveAttr,
24
} from "./format-dashboard-shared.ts";
25
import { makeSidebar } from "./format-dashboard-sidebar.ts";
26
27
// The html to generate the expand button
28
const kExpandBtnHtml = `
29
<bslib-tooltip placement="auto" bsoptions="[]" data-require-bs-version="5" data-require-bs-caller="tooltip()">
30
<template>Expand</template>
31
<span class="bslib-full-screen-enter badge rounded-pill">
32
<svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 24 24" style="height:1em;width:1em;" aria-hidden="true" role="img"><path d="M20 5C20 4.4 19.6 4 19 4H13C12.4 4 12 3.6 12 3C12 2.4 12.4 2 13 2H21C21.6 2 22 2.4 22 3V11C22 11.6 21.6 12 21 12C20.4 12 20 11.6 20 11V5ZM4 19C4 19.6 4.4 20 5 20H11C11.6 20 12 20.4 12 21C12 21.6 11.6 22 11 22H3C2.4 22 2 21.6 2 21V13C2 12.4 2.4 12 3 12C3.6 12 4 12.4 4 13V19Z"></path></svg>
33
</span>
34
</bslib-tooltip>
35
`;
36
37
// Card classes
38
const kCardBodyClass = "card-body";
39
const kCardHeaderClass = "card-header";
40
const kCardFooterClass = "card-footer";
41
const kCardTitleClass = "card-title";
42
const kCardToolbarClass = "card-toolbar";
43
44
const kCardSidebarClass = "card-sidebar";
45
46
// Tabset classes
47
const kTabsetClass = "tabset";
48
49
// Tabset BS values
50
const kTabsetIdPrefix = "card-tabset-";
51
52
// Card attributes (our options are expressed using these attributes)
53
const kAttrTitle = "data-title";
54
const kAttrExpandable = "data-expandable";
55
const kAttrMaxHeight = "data-max-height";
56
const kAttrMinHeight = "data-min-height";
57
const kAttrHeight = "data-height";
58
const kAttrPadding = "data-padding";
59
60
// BSLib Attributes
61
const kAttrFullScreen = "data-full-screen";
62
63
// BSLib Card Classes
64
const kBsCardClasses = ["bslib-card", "html-fill-container"];
65
const kBsCardScriptInitAttrs = { ["data-bslib-card-init"]: "" };
66
67
const kBsTabsetCardHeaderClasses = ["bslib-navs-card-title"];
68
const kQuartoHideTitleClass = "dashboard-card-no-title";
69
70
// BSLib Card Attributes
71
const kBsCardAttributes: Record<string, string> = {
72
["data-bslib-card-init"]: "",
73
["data-require-bs-caller"]: "card()",
74
};
75
76
// How to process card attributes (card attributes express options that the
77
// user has provided via markdown) - this converts them into their final rendered
78
// form (e.g. turn a height attribute into a css style enforcing height)
79
const cardAttrHandlers = (doc: Document, dashboardMeta: DashboardMeta) => {
80
return [
81
{
82
attr: kAttrExpandable,
83
handle: (el: Element, attrValue: string) => {
84
if (attrValue !== "false") {
85
const shellEl = doc.createElement("DIV");
86
shellEl.innerHTML = kExpandBtnHtml;
87
for (const childEl of shellEl.children) {
88
el.appendChild(childEl);
89
}
90
el.setAttribute(kAttrFullScreen, "false");
91
}
92
},
93
defaultValue: (el: Element) => {
94
if (el.classList.contains(kValueboxClass) || hasFlowLayout(el)) {
95
return "false";
96
} else {
97
return dashboardMeta.expandable ? "true" : "false";
98
}
99
},
100
},
101
{
102
attr: kAttrMaxHeight,
103
handle: ensureCssUnits(attrToStyle("max-height")),
104
},
105
{ attr: kAttrMinHeight, handle: ensureCssUnits(attrToStyle("min-height")) },
106
{ attr: kAttrHeight, handle: ensureCssUnits(attrToStyle("height")) },
107
{
108
attr: kAttrPadding,
109
handle: ensureCssUnits(attrToCardBodyStyle("padding")),
110
},
111
];
112
};
113
114
// How to process card body attributes (card attributes express options that the
115
// user has provided via markdown) - this converts them into their final rendered
116
// form (e.g. turn a height attribute into a css style enforcing height)
117
const cardBodyAttrHandlers = () => {
118
return [
119
{
120
attr: kAttrMaxHeight,
121
handle: ensureCssUnits(attrToStyle("max-height")),
122
},
123
{ attr: kAttrMinHeight, handle: ensureCssUnits(attrToStyle("min-height")) },
124
{ attr: kAttrHeight, handle: ensureCssUnits(attrToStyle("height")) },
125
];
126
};
127
128
export function processCards(doc: Document, dashboardMeta: DashboardMeta) {
129
// We need to process cards specially
130
const cardNodes = doc.body.querySelectorAll(`.${kCardClass}`);
131
let cardCount = 0;
132
for (const cardNode of cardNodes) {
133
cardCount++;
134
const cardEl = cardNode as Element;
135
136
// Sort the children
137
const cardBodyEls: Element[] = [];
138
let cardHeaderEl = undefined;
139
let cardSidebarEl = undefined;
140
for (const cardChildEl of cardEl.children) {
141
if (cardChildEl.classList.contains(kCardBodyClass)) {
142
cardBodyEls.push(cardChildEl);
143
} else if (cardChildEl.classList.contains(kCardHeaderClass)) {
144
cardHeaderEl = cardChildEl;
145
} else if (cardChildEl.classList.contains(kCardSidebarClass)) {
146
cardSidebarEl = cardChildEl;
147
}
148
}
149
150
// Process the header
151
if (cardHeaderEl) {
152
// Loose text gets grouped into a div for alignment purposes
153
// Always place this element first no matter what else is going on
154
const looseText: Node[] = [];
155
156
// See if there is a toolbar in the header
157
const cardToolbarEl = cardHeaderEl.querySelector(`.${kCardToolbarClass}`);
158
159
const isText = (node: Node) => node.nodeType === Node.TEXT_NODE;
160
const isEmphasis = (node: Node) => node.nodeName === "EM";
161
const isBold = (node: Node) => node.nodeName === "STRONG";
162
const isMath = (node: Node) =>
163
node.nodeName === "SPAN" &&
164
(node as Element).classList.contains("math") &&
165
(node as Element).classList.contains("inline");
166
167
for (const headerChildNode of Array.from(cardHeaderEl.childNodes)) {
168
if (
169
(isText(headerChildNode) ||
170
isEmphasis(headerChildNode) ||
171
isBold(headerChildNode) ||
172
isMath(headerChildNode)) &&
173
headerChildNode.textContent.trim() !== ""
174
) {
175
looseText.push(headerChildNode);
176
headerChildNode.parentNode?.removeChild(headerChildNode);
177
}
178
}
179
180
if (looseText.length > 0) {
181
// Inject the text into a div that we can use for layout
182
const classes = [kCardTitleClass];
183
184
const titleTextDiv = makeEl("DIV", { classes }, doc);
185
const innerSpan = makeEl("SPAN", {
186
attributes: { style: "display: inline" },
187
}, doc);
188
titleTextDiv.appendChild(innerSpan);
189
for (const node of looseText) {
190
innerSpan.appendChild(node);
191
}
192
if (cardToolbarEl) {
193
cardToolbarEl.insertBefore(titleTextDiv, cardToolbarEl.firstChild);
194
} else {
195
cardHeaderEl.insertBefore(titleTextDiv, cardHeaderEl.firstChild);
196
}
197
} else {
198
cardHeaderEl.classList.add(kQuartoHideTitleClass);
199
}
200
}
201
202
// Add card attributes
203
applyClasses(cardEl, kBsCardClasses);
204
applyAttributes(cardEl, kBsCardAttributes);
205
206
// If this is a tabset, we need to do more
207
const tabSetId = cardEl.classList.contains(kTabsetClass)
208
? `${kTabsetIdPrefix}${cardCount}`
209
: undefined;
210
if (tabSetId) {
211
// Fix up the header
212
if (cardHeaderEl) {
213
convertToTabsetHeader(tabSetId, cardHeaderEl, cardBodyEls, doc);
214
}
215
// Convert the body to tabs
216
convertToTabs(tabSetId, cardEl, cardBodyEls, cardSidebarEl, doc);
217
} else {
218
// Process a card sidebar, if present
219
if (cardSidebarEl) {
220
cardBodyEls.forEach((el) => el.remove);
221
// TODO: Make a cooler id if possible
222
const sidebarId = `card-${cardCount}-card-sidebar`;
223
const sidebarContainerEl = makeSidebar(
224
sidebarId,
225
cardSidebarEl,
226
cardBodyEls,
227
doc,
228
);
229
cardEl.appendChild(sidebarContainerEl);
230
}
231
}
232
233
// Process card attributes
234
for (const cardAttrHandler of cardAttrHandlers(doc, dashboardMeta)) {
235
const defaultValue = cardAttrHandler.defaultValue
236
? cardAttrHandler.defaultValue(cardEl)
237
: undefined;
238
processAndRemoveAttr(
239
cardEl,
240
cardAttrHandler.attr,
241
cardAttrHandler.handle,
242
defaultValue,
243
);
244
}
245
246
// Process card body attributes
247
for (const cardBodyEl of cardBodyEls) {
248
const layout = cardBodyLayout(cardBodyEl);
249
if (layout === kLayoutFlow) {
250
cardBodyEl.classList.add(kLayoutFlow);
251
}
252
253
for (const cardBodyAttrHandler of cardBodyAttrHandlers()) {
254
processAndRemoveAttr(
255
cardBodyEl,
256
cardBodyAttrHandler.attr,
257
cardBodyAttrHandler.handle,
258
);
259
if (!tabSetId) {
260
// If this was converted to tab, this will already be taken care of
261
recursiveApplyFillClasses(cardBodyEl);
262
}
263
}
264
}
265
266
// Initialize the cards
267
cardEl.appendChild(initCardScript(doc));
268
}
269
}
270
271
export function isFlowCard(el: Element) {
272
const layouts = cardBodyLayouts(el);
273
return layouts.every((layout) => {
274
return layout === kLayoutFlow;
275
});
276
}
277
278
function cardBodyLayouts(el: Element) {
279
// Find card-bodies and inspect card bodies to see
280
// what is up
281
const cardBodyNodes = el.querySelectorAll(`.${kCardBodyClass}`);
282
const layouts: string[] = [];
283
for (const cardBodyNode of cardBodyNodes) {
284
layouts.push(cardBodyLayout(cardBodyNode as Element));
285
}
286
return layouts;
287
}
288
289
function cardBodyLayout(cardBodyEl: Element) {
290
const explicitFill = cardBodyEl.getAttribute(kFillAttr);
291
if (explicitFill !== null) {
292
// If there is an explicitly specified layout, use that
293
return explicitFill !== "false" ? kLayoutFill : kLayoutFlow;
294
} else if (shinyInputs(cardBodyEl)) {
295
// If the card only contains shiny inputs, that is a flow layout
296
return kLayoutFlow;
297
} else {
298
// Otherwise assume this is a flow
299
return kLayoutFill;
300
}
301
}
302
303
function shinyInputs(cardBodyEl: Element) {
304
for (const childEl of cardBodyEl.children) {
305
if (!childEl.classList.contains("cell-output")) {
306
return false;
307
}
308
309
if (childEl.childElementCount < 1) {
310
return false;
311
}
312
313
const firstChildEl = childEl.children.item(0);
314
if (!firstChildEl.classList.contains("shiny-input-container")) {
315
return false;
316
}
317
}
318
return true;
319
}
320
321
function initCardScript(doc: Document) {
322
const scriptInitEl = doc.createElement("SCRIPT");
323
applyAttributes(scriptInitEl, kBsCardScriptInitAttrs);
324
scriptInitEl.innerText = "bslib.Card.initializeAllCards();";
325
return scriptInitEl;
326
}
327
328
function convertToTabsetHeader(
329
tabSetId: string,
330
cardHeaderEl: Element,
331
cardBodyEls: Element[],
332
doc: Document,
333
) {
334
// Decorate it
335
applyClasses(cardHeaderEl, kBsTabsetCardHeaderClasses);
336
337
// Add the tab nav element
338
const ulEl = doc.createElement("UL");
339
applyClasses(ulEl, ["nav", "nav-tabs", "card-header-tabs"]);
340
applyAttributes(ulEl, {
341
role: "tablist",
342
["data-tabsetid"]: tabSetId,
343
});
344
345
let cardBodyCount = 0;
346
for (const cardBodyEl of cardBodyEls) {
347
cardBodyCount++;
348
349
// If the user has provided a title, use that
350
let cardBodyTitle = cardBodyEl.getAttribute(kAttrTitle);
351
if (cardBodyTitle == null) {
352
cardBodyTitle = `Tab ${cardBodyCount}`;
353
}
354
355
// Add the liEls for each tab
356
const liEl = doc.createElement("LI");
357
applyClasses(liEl, ["nav-item"]);
358
applyAttributes(liEl, { role: "presentation" });
359
360
const aEl = doc.createElement("A");
361
applyAttributes(aEl, {
362
href: `#${tabSetId}-${cardBodyCount}`,
363
role: "tab",
364
["data-toggle"]: "tab",
365
["data-bs-toggle"]: "tab",
366
["data-value"]: cardBodyTitle,
367
["aria-selected"]: cardBodyCount === 1 ? "true" : "false",
368
});
369
370
const clz = ["nav-link"];
371
if (cardBodyCount === 1) {
372
clz.push("active");
373
}
374
applyClasses(aEl, clz);
375
376
aEl.innerText = cardBodyTitle;
377
liEl.appendChild(aEl);
378
379
// Add the li
380
ulEl.appendChild(liEl);
381
}
382
cardHeaderEl.appendChild(ulEl);
383
}
384
385
function findFooterEl(cardEl: Element) {
386
for (const childEl of cardEl.children) {
387
if (childEl.classList.contains(kCardFooterClass)) {
388
return childEl;
389
}
390
}
391
}
392
393
function convertToTabs(
394
tabSetId: string,
395
cardEl: Element,
396
cardBodyEls: Element[],
397
cardSidebarEl: Element | undefined,
398
doc: Document,
399
) {
400
// Make sure we place this above the card footer
401
const cardFooterEl = findFooterEl(cardEl);
402
403
const tabContainerEl = tabSetId ? doc.createElement("DIV") : undefined;
404
if (tabContainerEl) {
405
tabContainerEl.classList.add("tab-content");
406
tabContainerEl.setAttribute("data-tabset-id", tabSetId);
407
408
if (cardFooterEl) {
409
cardEl.insertBefore(tabContainerEl, cardFooterEl);
410
} else {
411
cardEl.appendChild(tabContainerEl);
412
}
413
}
414
415
let cardBodyCount = 0;
416
for (const cardBodyEl of cardBodyEls) {
417
cardBodyCount++;
418
419
for (const cardBodyAttrHandler of cardBodyAttrHandlers()) {
420
processAndRemoveAttr(
421
cardBodyEl,
422
cardBodyAttrHandler.attr,
423
cardBodyAttrHandler.handle,
424
);
425
}
426
427
// Deal with tabs
428
if (tabContainerEl) {
429
const tabPaneEl = doc.createElement("DIV");
430
tabPaneEl.classList.add("tab-pane");
431
if (cardBodyCount === 1) {
432
tabPaneEl.classList.add("active");
433
tabPaneEl.classList.add("show");
434
}
435
tabPaneEl.setAttribute("role", "tabpanel");
436
tabPaneEl.id = `${tabSetId}-${cardBodyCount}`;
437
tabPaneEl.appendChild(cardBodyEl);
438
tabContainerEl.appendChild(tabPaneEl);
439
}
440
}
441
442
if (tabContainerEl) {
443
recursiveApplyFillClasses(tabContainerEl);
444
}
445
446
// If there is a sidebar, wrap it around the tabset
447
if (cardSidebarEl && tabContainerEl) {
448
const sidebarId = `${tabSetId}-card-sidebar`;
449
const sidebarContainerEl = makeSidebar(
450
sidebarId,
451
cardSidebarEl,
452
[tabContainerEl],
453
doc,
454
);
455
456
if (cardFooterEl) {
457
cardEl.insertBefore(sidebarContainerEl, cardFooterEl);
458
} else {
459
cardEl.appendChild(sidebarContainerEl);
460
}
461
}
462
}
463
464