Last active
October 20, 2025 14:55
-
-
Save alekrutkowski/e8052bb1ffbc812a6540eee20249ac59 to your computer and use it in GitHub Desktop.
Observablehq's "Observable Framework" markdown file example with Web-R (https://docs.r-wasm.org/webr), Grid.js table (https://gridjs.io), and a global spinner
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| --- | |
| toc: false | |
| theme: [dashboard] | |
| footer: "" | |
| --- | |
| <link href="https://unpkg.com/gridjs/dist/theme/mermaid.min.css" rel="stylesheet" /> | |
| <script src="https://unpkg.com/gridjs/dist/gridjs.umd.js"></script> | |
| <div id="global-spinner" style=" | |
| display: none; | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| z-index: 9999; | |
| "> | |
| <div class="spinner"></div> | |
| </div> | |
| # My Dashboard | |
| ```js | |
| // Message displayed before the first Excel file upload | |
| // Reactively watch for a table being added | |
| // Create a message element | |
| const msg = document.createElement("div"); | |
| msg.textContent = "Upload an Excel file, select color, pick a date."; | |
| msg.classList.add("info-box"); | |
| document.querySelector("h1").insertAdjacentElement("afterend", msg); | |
| // NOT USED SINCE myTable IS IN view() | |
| // // Function to check for a table | |
| // function checkForTable() { | |
| // msg.remove(); // remove message once a table appears | |
| // observer.disconnect(); // stop watching | |
| // } | |
| // const target = document.getElementById("myTable"); | |
| // // Create the observer | |
| // const observer = new MutationObserver(checkForTable); | |
| // // Start observing #myTable | |
| // observer.observe(target, { | |
| // childList: true, // detect added/removed rows/cells | |
| // subtree: true, // detect changes inside descendants | |
| // characterData: true, // detect text changes in cells | |
| // attributes: true // uncomment if you also want attribute changes (like style/class) | |
| // }); | |
| import * as XLSX from "https://cdn.jsdelivr.net/npm/[email protected]/+esm"; | |
| import { WebR } from "https://webr.r-wasm.org/v0.4.4/webr.mjs"; | |
| const webR = new WebR(); | |
| await webR.init(); | |
| await webR.installPackages(["openxlsx2"]); | |
| ``` | |
| ```js | |
| const spinner = document.getElementById("global-spinner"); | |
| spinner.style.display = "flex"; | |
| // Read the uploaded file into a workbook | |
| const selected = await fileInput; // value of the view() is the selected File (or array if multiple) | |
| const file = Array.isArray(selected) ? selected[0] : selected; | |
| const arrayBuffer = await file.arrayBuffer(); | |
| const uint8Array = new Uint8Array(arrayBuffer); | |
| // Load the uploaded file into WebR | |
| await webR.FS.writeFile("input.xlsx", uint8Array); | |
| // Run R script to process the file with openxlsx2 | |
| const rCode = ` | |
| library(openxlsx2) | |
| wb <- wb_load("/home/web_user/input.xlsx") | |
| wb <- wb_add_worksheet(wb, sheet="Summary", tab_color = wb_color("${input_radios_single_choice}")) | |
| wb <- wb_add_data(wb, sheet="Summary", x=data.frame(Info="Processed with WebR by ${input_text} on ${input_date}")) | |
| wb_save(wb, "/home/web_user/output.xlsx", overwrite = TRUE) | |
| `; | |
| console.log(rCode); | |
| await webR.evalR(rCode); | |
| // https://docs.r-wasm.org/webr/latest/convert-r-to-js.html#:~:text=converted%20into%20a-,D3%2Dstyle%20data%20array | |
| const df = await webR.evalR("wb_to_df(wb)"); | |
| const rows = await df.toD3(); | |
| // Obtain the contents of the file from the VFS | |
| const xlsxdata = await webR.FS.readFile("/home/web_user/output.xlsx"); | |
| // Serialize to a Blob | |
| const outBlob = new Blob([xlsxdata], { | |
| type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |
| }); | |
| // Derive a filename like originalname-processed.xlsx | |
| const originalName = file?.name?.replace(/\.(xlsx|xls)$/i, "") ?? "data"; | |
| const downloadName = `${originalName}-processed.xlsx`; | |
| spinner.style.display = "none"; | |
| const element = document.querySelector(".info-box"); | |
| if (element) { | |
| element.remove(); // removes it from the DOM | |
| } | |
| ``` | |
| <div class="grid grid-cols-4" style="align-items: flex-start; grid-auto-rows: auto"> | |
| <div class="card"> | |
| ```js | |
| const input_switch = view( | |
| Inputs.toggle({ label: "Not really used", value: true }) | |
| ); | |
| ``` | |
| ```js | |
| const input_radios_single_choice = view( | |
| Inputs.radio(["red", "green", "blue"], { label: "Color" , value: "red"}) | |
| ); | |
| ``` | |
| ```js | |
| const input_date = view(Inputs.date({ label: "Date", required: true })); | |
| ``` | |
| ```js | |
| const fileInput = view( | |
| Inputs.file({ | |
| label: "Excel file", | |
| accept: ".xlsx", | |
| required: true, | |
| multiple: true, | |
| }) | |
| ); | |
| ``` | |
| ```js | |
| const input_text = view( | |
| Inputs.text({ | |
| label: "Name", | |
| placeholder: "Enter your name", | |
| value: "Anonymous", | |
| }) | |
| ); | |
| ``` | |
| </div> | |
| <div class="card grid-colspan-3">${rows ? "" : ""} <!-- to display the built-in Observable spinner rather than an empty block when waiting for user input --> | |
| ```js | |
| view(html`<table id="myTable" style="width:100%;"></table>`); | |
| new gridjs.Grid({ | |
| columns: Object.keys(rows[0]).map((k) => ({ | |
| id: k, | |
| name: k, | |
| sort: true, | |
| resizable: true, | |
| })), | |
| data: rows, | |
| resizable: true, | |
| search: true, | |
| sort: true, | |
| pagination: { enabled: true, limit: 10 }, | |
| height: "600px", | |
| }).render(document.getElementById("myTable")); | |
| ``` | |
| ```js | |
| // Download button | |
| const button = html`<button>⤓ Download processed Excel</button>`; | |
| button.onclick = () => { | |
| const a = document.createElement("a"); | |
| a.href = URL.createObjectURL(outBlob); | |
| a.download = downloadName; | |
| a.click(); | |
| URL.revokeObjectURL(a.href); | |
| }; | |
| display(button); | |
| ``` | |
| </div> | |
| </div> | |
| <style> | |
| /* --- Grid.js table compact + resizable + wrap headers --- */ | |
| /* Table layout tweaks */ | |
| .gridjs-table { | |
| width: 100%; | |
| table-layout: fixed; /* keeps columns stable when resizing */ | |
| } | |
| /* Headers: allow wrapping, prevent clipping */ | |
| .gridjs-th { | |
| position: relative; /* needed for the resize handle */ | |
| white-space: normal !important; /* let long names wrap */ | |
| overflow: visible !important; | |
| text-overflow: unset !important; | |
| vertical-align: middle; | |
| } | |
| /* Body cells: normal overflow (so content can be seen or wrapped if you wish) */ | |
| .gridjs-td { | |
| vertical-align: middle; | |
| white-space: normal; /* set to nowrap if you prefer */ | |
| overflow: hidden; /* avoid spilling beyond cell */ | |
| text-overflow: ellipsis; /* adjust to taste */ | |
| } | |
| /* Compact, balanced padding (headers and cells) */ | |
| .gridjs-th, | |
| .gridjs-td { | |
| padding: 6px 10px 5px 10px !important; /* top right bottom left */ | |
| } | |
| /* Optional: a touch more padding for header row only */ | |
| .gridjs-tr:nth-child(1) .gridjs-th { | |
| padding-top: 8px !important; | |
| padding-bottom: 6px !important; | |
| } | |
| /* Column min width so wrapped headers do not collapse too narrowly */ | |
| .gridjs-th, | |
| .gridjs-td { | |
| min-width: 100px; /* adjust to your data */ | |
| } | |
| /* Show full header content on hover if you keep ellipsis somewhere */ | |
| .gridjs-th:hover .gridjs-th-content { | |
| overflow: visible; | |
| } | |
| /* Resize handle: thin right-edge strip, not a full overlay */ | |
| .gridjs-th .gridjs-resizable { | |
| position: absolute !important; | |
| top: 0 !important; | |
| bottom: 0 !important; | |
| right: 0 !important; | |
| left: auto !important; | |
| width: 8px !important; /* thickness of the drag area */ | |
| height: 100% !important; | |
| cursor: col-resize !important; | |
| background: transparent !important; | |
| z-index: 2; | |
| pointer-events: auto !important; | |
| } | |
| /* Keep header text and sort control above the cell background */ | |
| .gridjs-th .gridjs-th-content, | |
| .gridjs-th .gridjs-sort { | |
| position: relative; | |
| z-index: 3; | |
| pointer-events: auto; | |
| } | |
| /* Optional: subtle visual cue when hovering near the edge */ | |
| .gridjs-th .gridjs-resizable:hover { | |
| background: rgba(0, 0, 0, 0.06) !important; | |
| } | |
| /* Ensure the last column can also be resized, if desired */ | |
| .gridjs-th:last-child .gridjs-resizable { | |
| display: block !important; | |
| } | |
| /* Optional: slightly thinner borders for a denser look */ | |
| .gridjs-td, | |
| .gridjs-th { | |
| border-width: 2px; | |
| } | |
| /* Remove the blue/gray rectangle for column resize handles */ | |
| .gridjs-th .gridjs-resizable, | |
| .gridjs-th .gridjs-resizable:hover, | |
| .gridjs-th .gridjs-resizable:active { | |
| background: transparent !important; | |
| } | |
| .info-box { | |
| display: inline-block; /* shrink to fit text */ | |
| background-color: #f0f8ff; | |
| border: 1px solid #b3d4fc; /* thin border around */ | |
| padding: 8px 12px; | |
| margin: 8px 0; | |
| border-radius: 8px; | |
| font-family: Arial, sans-serif; | |
| font-size: 12px; | |
| color: #333; | |
| box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); | |
| width: fit-content; /* adapts to text width */ | |
| max-width: 80%; /* prevent over-expansion */ | |
| } | |
| .spinner { | |
| width: 40px; | |
| height: 40px; | |
| border: 4px solid #ccc; | |
| border-top-color: #333; | |
| border-radius: 50%; | |
| animation: spin 0.8s linear infinite; | |
| } | |
| @keyframes spin { | |
| to { transform: rotate(360deg); } | |
| } | |
| </style> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment