Path: blob/main/src/format/dashboard/format-dashboard-card.ts
6451 views
/*1* format-dashboard-card.ts2*3* Copyright (C) 2020-2022 Posit Software, PBC4*/56import { Document, Element, Node } from "../../core/deno-dom.ts";7import { recursiveApplyFillClasses } from "./format-dashboard-layout.ts";8import {9applyAttributes,10applyClasses,11attrToCardBodyStyle,12attrToStyle,13DashboardMeta,14ensureCssUnits,15hasFlowLayout,16kCardClass,17kFillAttr,18kLayoutFill,19kLayoutFlow,20kValueboxClass,21makeEl,22processAndRemoveAttr,23} from "./format-dashboard-shared.ts";24import { makeSidebar } from "./format-dashboard-sidebar.ts";2526// The html to generate the expand button27const kExpandBtnHtml = `28<bslib-tooltip placement="auto" bsoptions="[]" data-require-bs-version="5" data-require-bs-caller="tooltip()">29<template>Expand</template>30<span class="bslib-full-screen-enter badge rounded-pill">31<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>32</span>33</bslib-tooltip>34`;3536// Card classes37const kCardBodyClass = "card-body";38const kCardHeaderClass = "card-header";39const kCardFooterClass = "card-footer";40const kCardTitleClass = "card-title";41const kCardToolbarClass = "card-toolbar";4243const kCardSidebarClass = "card-sidebar";4445// Tabset classes46const kTabsetClass = "tabset";4748// Tabset BS values49const kTabsetIdPrefix = "card-tabset-";5051// Card attributes (our options are expressed using these attributes)52const kAttrTitle = "data-title";53const kAttrExpandable = "data-expandable";54const kAttrMaxHeight = "data-max-height";55const kAttrMinHeight = "data-min-height";56const kAttrHeight = "data-height";57const kAttrPadding = "data-padding";5859// BSLib Attributes60const kAttrFullScreen = "data-full-screen";6162// BSLib Card Classes63const kBsCardClasses = ["bslib-card", "html-fill-container"];64const kBsCardScriptInitAttrs = { ["data-bslib-card-init"]: "" };6566const kBsTabsetCardHeaderClasses = ["bslib-navs-card-title"];67const kQuartoHideTitleClass = "dashboard-card-no-title";6869// BSLib Card Attributes70const kBsCardAttributes: Record<string, string> = {71["data-bslib-card-init"]: "",72["data-require-bs-caller"]: "card()",73};7475// How to process card attributes (card attributes express options that the76// user has provided via markdown) - this converts them into their final rendered77// form (e.g. turn a height attribute into a css style enforcing height)78const cardAttrHandlers = (doc: Document, dashboardMeta: DashboardMeta) => {79return [80{81attr: kAttrExpandable,82handle: (el: Element, attrValue: string) => {83if (attrValue !== "false") {84const shellEl = doc.createElement("DIV");85shellEl.innerHTML = kExpandBtnHtml;86for (const childEl of shellEl.children) {87el.appendChild(childEl);88}89el.setAttribute(kAttrFullScreen, "false");90}91},92defaultValue: (el: Element) => {93if (el.classList.contains(kValueboxClass) || hasFlowLayout(el)) {94return "false";95} else {96return dashboardMeta.expandable ? "true" : "false";97}98},99},100{101attr: kAttrMaxHeight,102handle: ensureCssUnits(attrToStyle("max-height")),103},104{ attr: kAttrMinHeight, handle: ensureCssUnits(attrToStyle("min-height")) },105{ attr: kAttrHeight, handle: ensureCssUnits(attrToStyle("height")) },106{107attr: kAttrPadding,108handle: ensureCssUnits(attrToCardBodyStyle("padding")),109},110];111};112113// How to process card body attributes (card attributes express options that the114// user has provided via markdown) - this converts them into their final rendered115// form (e.g. turn a height attribute into a css style enforcing height)116const cardBodyAttrHandlers = () => {117return [118{119attr: kAttrMaxHeight,120handle: ensureCssUnits(attrToStyle("max-height")),121},122{ attr: kAttrMinHeight, handle: ensureCssUnits(attrToStyle("min-height")) },123{ attr: kAttrHeight, handle: ensureCssUnits(attrToStyle("height")) },124];125};126127export function processCards(doc: Document, dashboardMeta: DashboardMeta) {128// We need to process cards specially129const cardNodes = doc.body.querySelectorAll(`.${kCardClass}`);130let cardCount = 0;131for (const cardNode of cardNodes) {132cardCount++;133const cardEl = cardNode as Element;134135// Sort the children136const cardBodyEls: Element[] = [];137let cardHeaderEl = undefined;138let cardSidebarEl = undefined;139for (const cardChildEl of cardEl.children) {140if (cardChildEl.classList.contains(kCardBodyClass)) {141cardBodyEls.push(cardChildEl);142} else if (cardChildEl.classList.contains(kCardHeaderClass)) {143cardHeaderEl = cardChildEl;144} else if (cardChildEl.classList.contains(kCardSidebarClass)) {145cardSidebarEl = cardChildEl;146}147}148149// Process the header150if (cardHeaderEl) {151// Loose text gets grouped into a div for alignment purposes152// Always place this element first no matter what else is going on153const looseText: Node[] = [];154155// See if there is a toolbar in the header156const cardToolbarEl = cardHeaderEl.querySelector(`.${kCardToolbarClass}`);157158const isText = (node: Node) => node.nodeType === Node.TEXT_NODE;159const isEmphasis = (node: Node) => node.nodeName === "EM";160const isBold = (node: Node) => node.nodeName === "STRONG";161const isMath = (node: Node) =>162node.nodeName === "SPAN" &&163(node as Element).classList.contains("math") &&164(node as Element).classList.contains("inline");165166for (const headerChildNode of Array.from(cardHeaderEl.childNodes)) {167if (168(isText(headerChildNode) ||169isEmphasis(headerChildNode) ||170isBold(headerChildNode) ||171isMath(headerChildNode)) &&172headerChildNode.textContent.trim() !== ""173) {174looseText.push(headerChildNode);175headerChildNode.parentNode?.removeChild(headerChildNode);176}177}178179if (looseText.length > 0) {180// Inject the text into a div that we can use for layout181const classes = [kCardTitleClass];182183const titleTextDiv = makeEl("DIV", { classes }, doc);184const innerSpan = makeEl("SPAN", {185attributes: { style: "display: inline" },186}, doc);187titleTextDiv.appendChild(innerSpan);188for (const node of looseText) {189innerSpan.appendChild(node);190}191if (cardToolbarEl) {192cardToolbarEl.insertBefore(titleTextDiv, cardToolbarEl.firstChild);193} else {194cardHeaderEl.insertBefore(titleTextDiv, cardHeaderEl.firstChild);195}196} else {197cardHeaderEl.classList.add(kQuartoHideTitleClass);198}199}200201// Add card attributes202applyClasses(cardEl, kBsCardClasses);203applyAttributes(cardEl, kBsCardAttributes);204205// If this is a tabset, we need to do more206const tabSetId = cardEl.classList.contains(kTabsetClass)207? `${kTabsetIdPrefix}${cardCount}`208: undefined;209if (tabSetId) {210// Fix up the header211if (cardHeaderEl) {212convertToTabsetHeader(tabSetId, cardHeaderEl, cardBodyEls, doc);213}214// Convert the body to tabs215convertToTabs(tabSetId, cardEl, cardBodyEls, cardSidebarEl, doc);216} else {217// Process a card sidebar, if present218if (cardSidebarEl) {219cardBodyEls.forEach((el) => el.remove);220// TODO: Make a cooler id if possible221const sidebarId = `card-${cardCount}-card-sidebar`;222const sidebarContainerEl = makeSidebar(223sidebarId,224cardSidebarEl,225cardBodyEls,226doc,227);228cardEl.appendChild(sidebarContainerEl);229}230}231232// Process card attributes233for (const cardAttrHandler of cardAttrHandlers(doc, dashboardMeta)) {234const defaultValue = cardAttrHandler.defaultValue235? cardAttrHandler.defaultValue(cardEl)236: undefined;237processAndRemoveAttr(238cardEl,239cardAttrHandler.attr,240cardAttrHandler.handle,241defaultValue,242);243}244245// Process card body attributes246for (const cardBodyEl of cardBodyEls) {247const layout = cardBodyLayout(cardBodyEl);248if (layout === kLayoutFlow) {249cardBodyEl.classList.add(kLayoutFlow);250}251252for (const cardBodyAttrHandler of cardBodyAttrHandlers()) {253processAndRemoveAttr(254cardBodyEl,255cardBodyAttrHandler.attr,256cardBodyAttrHandler.handle,257);258if (!tabSetId) {259// If this was converted to tab, this will already be taken care of260recursiveApplyFillClasses(cardBodyEl);261}262}263}264265// Initialize the cards266cardEl.appendChild(initCardScript(doc));267}268}269270export function isFlowCard(el: Element) {271const layouts = cardBodyLayouts(el);272return layouts.every((layout) => {273return layout === kLayoutFlow;274});275}276277function cardBodyLayouts(el: Element) {278// Find card-bodies and inspect card bodies to see279// what is up280const cardBodyNodes = el.querySelectorAll(`.${kCardBodyClass}`);281const layouts: string[] = [];282for (const cardBodyNode of cardBodyNodes) {283layouts.push(cardBodyLayout(cardBodyNode as Element));284}285return layouts;286}287288function cardBodyLayout(cardBodyEl: Element) {289const explicitFill = cardBodyEl.getAttribute(kFillAttr);290if (explicitFill !== null) {291// If there is an explicitly specified layout, use that292return explicitFill !== "false" ? kLayoutFill : kLayoutFlow;293} else if (shinyInputs(cardBodyEl)) {294// If the card only contains shiny inputs, that is a flow layout295return kLayoutFlow;296} else {297// Otherwise assume this is a flow298return kLayoutFill;299}300}301302function shinyInputs(cardBodyEl: Element) {303for (const childEl of cardBodyEl.children) {304if (!childEl.classList.contains("cell-output")) {305return false;306}307308if (childEl.childElementCount < 1) {309return false;310}311312const firstChildEl = childEl.children.item(0);313if (!firstChildEl.classList.contains("shiny-input-container")) {314return false;315}316}317return true;318}319320function initCardScript(doc: Document) {321const scriptInitEl = doc.createElement("SCRIPT");322applyAttributes(scriptInitEl, kBsCardScriptInitAttrs);323scriptInitEl.innerText = "bslib.Card.initializeAllCards();";324return scriptInitEl;325}326327function convertToTabsetHeader(328tabSetId: string,329cardHeaderEl: Element,330cardBodyEls: Element[],331doc: Document,332) {333// Decorate it334applyClasses(cardHeaderEl, kBsTabsetCardHeaderClasses);335336// Add the tab nav element337const ulEl = doc.createElement("UL");338applyClasses(ulEl, ["nav", "nav-tabs", "card-header-tabs"]);339applyAttributes(ulEl, {340role: "tablist",341["data-tabsetid"]: tabSetId,342});343344let cardBodyCount = 0;345for (const cardBodyEl of cardBodyEls) {346cardBodyCount++;347348// If the user has provided a title, use that349let cardBodyTitle = cardBodyEl.getAttribute(kAttrTitle);350if (cardBodyTitle == null) {351cardBodyTitle = `Tab ${cardBodyCount}`;352}353354// Add the liEls for each tab355const liEl = doc.createElement("LI");356applyClasses(liEl, ["nav-item"]);357applyAttributes(liEl, { role: "presentation" });358359const aEl = doc.createElement("A");360applyAttributes(aEl, {361href: `#${tabSetId}-${cardBodyCount}`,362role: "tab",363["data-toggle"]: "tab",364["data-bs-toggle"]: "tab",365["data-value"]: cardBodyTitle,366["aria-selected"]: cardBodyCount === 1 ? "true" : "false",367});368369const clz = ["nav-link"];370if (cardBodyCount === 1) {371clz.push("active");372}373applyClasses(aEl, clz);374375aEl.innerText = cardBodyTitle;376liEl.appendChild(aEl);377378// Add the li379ulEl.appendChild(liEl);380}381cardHeaderEl.appendChild(ulEl);382}383384function findFooterEl(cardEl: Element) {385for (const childEl of cardEl.children) {386if (childEl.classList.contains(kCardFooterClass)) {387return childEl;388}389}390}391392function convertToTabs(393tabSetId: string,394cardEl: Element,395cardBodyEls: Element[],396cardSidebarEl: Element | undefined,397doc: Document,398) {399// Make sure we place this above the card footer400const cardFooterEl = findFooterEl(cardEl);401402const tabContainerEl = tabSetId ? doc.createElement("DIV") : undefined;403if (tabContainerEl) {404tabContainerEl.classList.add("tab-content");405tabContainerEl.setAttribute("data-tabset-id", tabSetId);406407if (cardFooterEl) {408cardEl.insertBefore(tabContainerEl, cardFooterEl);409} else {410cardEl.appendChild(tabContainerEl);411}412}413414let cardBodyCount = 0;415for (const cardBodyEl of cardBodyEls) {416cardBodyCount++;417418for (const cardBodyAttrHandler of cardBodyAttrHandlers()) {419processAndRemoveAttr(420cardBodyEl,421cardBodyAttrHandler.attr,422cardBodyAttrHandler.handle,423);424}425426// Deal with tabs427if (tabContainerEl) {428const tabPaneEl = doc.createElement("DIV");429tabPaneEl.classList.add("tab-pane");430if (cardBodyCount === 1) {431tabPaneEl.classList.add("active");432tabPaneEl.classList.add("show");433}434tabPaneEl.setAttribute("role", "tabpanel");435tabPaneEl.id = `${tabSetId}-${cardBodyCount}`;436tabPaneEl.appendChild(cardBodyEl);437tabContainerEl.appendChild(tabPaneEl);438}439}440441if (tabContainerEl) {442recursiveApplyFillClasses(tabContainerEl);443}444445// If there is a sidebar, wrap it around the tabset446if (cardSidebarEl && tabContainerEl) {447const sidebarId = `${tabSetId}-card-sidebar`;448const sidebarContainerEl = makeSidebar(449sidebarId,450cardSidebarEl,451[tabContainerEl],452doc,453);454455if (cardFooterEl) {456cardEl.insertBefore(sidebarContainerEl, cardFooterEl);457} else {458cardEl.appendChild(sidebarContainerEl);459}460}461}462463464