handsontable(
data = iris[1:2, ],
adaptiveHeight = TRUE
|>
) hot_col(
col = "Species",
type = "dropdown",
source = unique(iris$Species) |> as.character()
)
in shiny
shiny provides the most interesting use of handsontable.
normal stuff
- config (
handsontableOutput()
+renderHandsontable()
) - observers + built-in hooks
- set data for specific cells
fun stuff
you can do a lot of interesting stuff when you throw a little js into the mix.
any application that can be written in javascript, will eventually be written in javascript
— atwood’s law
dropdowns in short tables
problem statement: when your handsontable is short, length of dropdowns will be limited to the table height. users won’t even know they can scroll downwards.
here’s an example:
what i do in such cases is add hooks for all dropdown columns:
- when clicked, show a modal dialog with radio buttons.
- when user picks another option from the radio buttons, dismiss modal.
this way, we maintain the same number of clicks the user would have made without the modal.
here’s how to achieve that:
app.R
library(shiny)
library(bslib)
library(handsontable)
addResourcePath(prefix = "static", directoryPath = "public")
#' The Dropdown Modal
#'
#' Uses bootstrap 5.
#'
#' @return [htmltools::tags]
<- \() {
dropdown_modal $div(
tagsclass = "modal fade",
id = "dropdownModal",
tabindex = "-1",
`aria-labelledby` = "dropdownModalLabel",
`aria-hidden` = "true",
$div(
tagsclass = "modal-dialog",
$div(
tagsclass = "modal-content",
$div(
tagsclass = "modal-header",
$h5(
tagsclass = "modal-title",
id = "dropdownModalLabel",
"Select Option"
),$button(
tagstype = "button",
class = "btn-close",
`data-bs-dismiss` = "modal",
`aria-label` = "Close"
)
),$div(
tagsclass = "modal-body",
$div(id = "modalRadioContainer")
tags
)
)
)
)
}
<- page(
ui title = "dropdown modal example",
# i'll use bootstrap 5 for the demo:
theme = bs_theme(version = 5L),
lang = "en",
$h3("dropdown modal example"),
tagshandsontableOutput("table"),
dropdown_modal(),
$script(src = "/static/dropdown-modal.js")
tags
)
<- function(input, output, session) {
server $table <- renderHandsontable({
outputhandsontable(
data = iris[1:3, ],
adaptiveHeight = TRUE
|>
) hot_col(
col = "Species",
type = "dropdown",
source = unique(iris$Species) |> as.character()
)
})
# detect changes in the table
observeEvent(input$table, {
<- hot_to_r(data = input$table)
updated_data cat("Updated data:\n")
print(updated_data)
})
}
shinyApp(ui, server)
create ./public/dropdown-modal.js
:
./public/dropdown-modal.js
let bootstrapModal;
$(document).ready(function () {
const modalElement = document.getElementById("dropdownModal");
if (modalElement) {
= new bootstrap.Modal(modalElement);
bootstrapModal
};
})
window.showBootstrapDropdownModal = function (hot, row, col) {
const currentValue = hot.getDataAtCell(row, col);
const choices = hot.getSettings().columns[col].source;
const radioContainer = document.getElementById("modalRadioContainer");
.innerHTML = "";
radioContainer
const handleSelection = function (selectedValue) {
.setDataAtCell(row, col, selectedValue);
hotif (bootstrapModal) {
.hide();
bootstrapModal
};
}
.forEach((choice, index) => {
choicesconst radioDiv = document.createElement("div");
.className = "form-check";
radioDiv
const radioInput = document.createElement("input");
.className = "form-check-input";
radioInput.type = "radio";
radioInput.name = "modalRadio";
radioInput.value = choice;
radioInput.id = `radio_${index}`;
radioInputif (choice === currentValue) {
.checked = true;
radioInput
}
const radioLabel = document.createElement("label");
.className = "form-check-label";
radioLabel.htmlFor = `radio_${index}`;
radioLabel.textContent = choice;
radioLabel
.appendChild(radioInput);
radioDiv.appendChild(radioLabel);
radioDiv.appendChild(radioDiv);
radioContainer
.addEventListener("change", function () {
radioInputif (this.checked) {
handleSelection(this.value);
};
});
})
if (bootstrapModal) {
.show();
bootstrapModal
};
}
window.setupDropdownModalHooks = function () {
$(".handsontable").each(function () {
const container = this;
const hot = container.hot;
if (!hot || hot._modalHooksSetup) {
return;
}
.addHook("afterSelectionEnd", function (row, col, row2, col2) {
hotconst columnConfig =
.getSettings().columns && hot.getSettings().columns[col];
hotconst isDropdown =
&& columnConfig.type === "dropdown" && columnConfig.source;
columnConfig
const rowConfig =
.getSettings().rowConfig && hot.getSettings().rowConfig[row];
hotconst isReadOnly =
&& rowConfig.readOnly) ||
(rowConfig && columnConfig.readOnly);
(columnConfig
const needsDropdownModal = !isReadOnly && isDropdown;
if (needsDropdownModal) {
showBootstrapDropdownModal(hot, row, col);
};
})
._modalHooksSetup = true;
hot;
});
}
$(document).ready(function () {
setupDropdownModalHooks();
setInterval(setupDropdownModalHooks, 500);
; })
key points:
use
afterSelectionEnd
hook to detect dropdown cell clickscheck if column is dropdown type and not readonly before showing modal
use
hot.setDataAtCell()
to update cell valueauto-dismiss modal
set up hooks with interval polling to catch dynamically rendered tables. tbh this is inefficient because i use
setInterval()
and it runs every 500ms regardless of whether new tables exist. it’s good enough for the demo though. more efficient approaches would be:- setup an
afterInit
hook on the server. - setup your own custom message & handler eg.
session$sendCustomMessage("setup-handsontable-hooks", tableId)
.
- setup an
click btn, set col values
sometimes you need to set all values in a column at once. common use case: “approve all”/“reject all” buttons for approval workflows.
the approach:
- add buttons above/beside the table
- use javascript to find the target column and update all rows
here’s an example with approval workflow:
app.R
library(shiny)
library(bslib)
library(handsontable)
addResourcePath(prefix = "static", directoryPath = "public")
# sample data
set.seed(123)
<- data.frame(
sample_data id = 1:10,
name = paste("Item", 1:10),
category = sample(c("A", "B", "C"), 10, replace = TRUE),
value = round(runif(10, 10, 100), 2),
approval = sample(c("pending", "approved", "denied"), 10, replace = TRUE)
)
<- page(
ui title = "bulk actions example",
theme = bs_theme(version = 5L),
lang = "en",
$h3("bulk actions example"),
tags
$div(
tags# we'll use the ".approvals" class to get the table
class = "approvals mb-3",
$button(
tagsclass = "btn btn-success me-2 approve-all",
type = "button",
"Approve All"
),$button(
tagsclass = "btn btn-danger reject-all",
type = "button",
"Reject All"
),handsontableOutput("table")
),
$script(src = "/static/bulk-actions.js")
tags
)
<- function(input, output, session) {
server $table <- renderHandsontable({
outputhandsontable(
data = sample_data,
adaptiveHeight = TRUE,
rowHeaders = TRUE
|>
) hot_col(
col = "approval",
type = "dropdown",
source = c("pending", "approved", "denied")
)
})
# detect changes in the table
observeEvent(input$table, {
<- hot_to_r(data = input$table)
updated_data cat("Updated data:\n")
print(updated_data)
})
}
shinyApp(ui, server)
create ./public/bulk-actions.js
:
./public/bulk-actions.js
/**
* Bulk Actions for Handsontable
*/
/**
* Main function to handle approve/reject all button clicks
* @param {string} action - 'approve' or 'reject'
*/
function handleBulkAction(action) {
const hot = findHandsontableInstance();
if (!hot) return;
const approvalColIndex = findApprovalColumnIndex(hot);
if (approvalColIndex === -1) {
console.warn("Approval column not found in handsontable");
return;
}
const targetValue = action === "approve" ? "approved" : "denied";
updateAllRows(hot, approvalColIndex, targetValue);
}
/**
* Find handsontable instance in ".approvals" div
* @returns {Object|null} - Handsontable instance or null
*/
function findHandsontableInstance() {
const $container = $(".approvals").find(".handsontable");
if (!$container) {
console.warn("Handsontable instance not found");
return null;
}
const $hot = $container[0].hot;
if (!$hot) {
console.warn("Cannot convert to hot");
return null;
}
return $hot;
}
/**
* Find the column index for the approval column
* @param {Object} hot - Handsontable instance
* @returns {number} - Column index or -1 if not found
*/
function findApprovalColumnIndex(hot) {
const colHeaders = hot.getSettings().colHeaders;
// Check column headers first
if (colHeaders) {
for (let i = 0; i < colHeaders.length; i++) {
if (colHeaders[i]?.toLowerCase().includes("approval")) {
return i;
}
}
}
// Fallback: check first row data for approval values
const data = hot.getData();
if (data.length > 0) {
const approvalValues = ["pending", "approved", "denied"];
for (let i = 0; i < data[0].length; i++) {
const cellValue = hot.getDataAtCell(0, i);
if (typeof cellValue === "string" && approvalValues.includes(cellValue)) {
return i;
}
}
}
return -1;
}
/**
* Update all rows to target status
* @param {Object} hot - Handsontable instance
* @param {number} colIndex - Column index for approval column
* @param {string} targetValue - Target value ('approved' or 'denied')
*/
function updateAllRows(hot, colIndex, targetValue) {
const rowCount = hot.countRows();
const changes = [];
for (let row = 0; row < rowCount; row++) {
const currentValue = hot.getDataAtCell(row, colIndex);
if (currentValue !== targetValue) {
.push([row, colIndex, targetValue]);
changes
}
}
if (changes.length > 0) {
.setDataAtCell(changes);
hot
}
}
/**
* Initialize the bulk actions functionality
*/
$(document).ready(function () {
$(document).on("click", ".approve-all", function (e) {
.preventDefault();
e.stopPropagation();
ehandleBulkAction("approve");
;
})
$(document).on("click", ".reject-all", function (e) {
.preventDefault();
e.stopPropagation();
ehandleBulkAction("reject");
;
}); })
key points:
- find handsontable instance in “.approvals” div
- use
hot.setDataAtCell(changes)
with an array of changes for efficiency - find column index dynamically by header name or data values
undo/redo with visual feedback
- cell highlighting during undo/redo: briefly flash to indicate changes.
conditional cell formatting
- heat maps based on cell values
add margin totals
- row/col totals