glin / reactable

Interactive data tables for R
https://glin.github.io/reactable
Other
613 stars 79 forks source link

Is there an 'onClick()' for the header? #262

Closed daattali closed 1 year ago

daattali commented 1 year ago

I could not find any documentation or examples or discussions online about this. Is there a way to add a JavaScript click handler for clicking on a column header, that returns the column that was clicked on?

glin commented 1 year ago

There's no custom click action for column headers since that's currently used by the sort toggle. Are you looking for a way to do something when a user sorts a column? There might be other, more reliable ways of doing that.

Or to add interactive controls to a column header, I'd recommend custom rendering a button in the header instead, similar to the Custom cell click action example which custom renders buttons in cells. With a separate control, you'd get keyboard accessibility, more customization, and prevent conflicts with the default sorting behavior of headers.

daattali commented 1 year ago

I do want to add a button to each header. I was able to render a custom button, but if sorting is turned on then I could not figure out how to disable the sorting functionality when this button is clicked. I still want sorting to work, but when clicking on the custom button it should not trigger sorting.

glin commented 1 year ago

Oh, you'll probably need an event.stopPropagation() to stop the click event from bubbling up. Testing it out real quick, something like this seems to work. Here's an example that adds a simple "toggle group by" button to one of the headers.

library(reactable)
library(htmltools)

reactable(
  MASS::Cars93[, 1:4],
  columns = list(
    Type = colDef(
      header = function(name) {
        tagList(
          name,
          tags$button(
            onclick = "event.stopPropagation(); Reactable.toggleGroupBy('cars-tbl', 'Type')",
            style = "margin-left: 0.5rem;",
            "Group by"
          )
        )
      }
    )
  ),
  elementId = "cars-tbl"
)

I'm noticing a few usability quirks though - putting a button within a button is kind of odd and I'd probably prefer a single button with a dropdown control for sorting and other actions. And since the button is inside the column header, it gets added to the accessible name of the header, which could make table navigation unnecessarily verbose for screen reader users.

daattali commented 1 year ago

Thanks, using onclick does indeed work. What I was doing before was only creating the HTML in the colDef() and then adding a click handler separately, and then the stopPropagation did not work.

You're absolutely correct that this is generally going to be a usability issue, but in my case specifically it will not, because the button I'm adding is a "delete this column" button, so the moment it's clicked, the column disappears :)

Example of what I was doing that doesn't stop propagation (I now switched to your approach, but I wonder if you know why it wasn't working):

css <- ".delete-btn { opacity: 0; } .rt-th:hover .delete-btn { display: inline-block; opacity: 1 }"
js <- "$(document).on('click', '.delete-btn', function(event) { event.stopPropagation(); alert('drop'); });"

print(
  tagList(
    shiny:::jqueryDependency(),
    tags$style(css),
    tags$script(HTML(js)),
    reactable(
      mtcars,
      defaultColDef = colDef(
        header = function(name) {
          tags$div(name, icon("trash-alt", class = "delete-btn"))
        }
      )
    )
  ),
  browse = TRUE
)
glin commented 1 year ago

Hmm, probably because $(document).on('click', '.delete-btn', ... adds the event listener to the document object, so by the time the click event from the button bubbles up to document at the very top, it's too late - the event has to be stopped at the button level.

daattali commented 1 year ago

This is definitely out of scope of {reactable} at this point, but out of curiosity: do you know how to approach it using my method correctly, applying the listener to the button that's generated dynamically? I'm using your approach in my code now, you can close the issue, I'm just wondering.

glin commented 1 year ago

With the externally added event listeners, I think you'll need a way to wait for the table to finish rendering before running that code. With jQuery, $(document).ready() could work, e.g.:

$(document).ready(function() {
  $('.delete-btn').on('click', function(event) { event.stopPropagation(); alert('drop'); });
});

I also discovered htmlwidgets::onRender(widget, jsFunc) recently, which lets you run arbitrary JS after a widget renders, and would probably work on an individual widget level. Every other JS-driven HTML widget has this kind of issue, so I get why htmlwidgets would have a function like this built in.

Or alternatively, there is now an experimental feature to render reactable to static HTML, so the table would actually exist on page load rather than being dynamically rendered some time later. With a statically rendered table, you could put the script tag after the table, and the table would be guaranteed to exist by the time the script runs. But $(document).ready() is still probably the safest and easiest option to use.

daattali commented 1 year ago

The $(document).read() approach technically works for the reprex I posted here but it's not great for shiny because it will only work for a table that exists when the page loads. But you're right about using onRender(), that solves it!

For completeness, in case someone ever stumbles across this thread, here is how it can be done:

library(shiny)
library(reactable)
library(magrittr)

css <- ".delete-btn { opacity: 0; } .rt-th:hover .delete-btn { display: inline-block; opacity: 1 }"

ui <- fluidPage(
  tags$style(css),
  numericInput("num", "N rows", 5),
  reactableOutput("table")
)

server <- function(input, output, session) {
  output$table <- renderReactable({
    reactable(
      mtcars[seq(input$num), ],
      defaultColDef = colDef(
        header = function(name) {
          tags$div(name, icon("trash-alt", class = "delete-btn"))
        }
      )
    ) %>%
      htmlwidgets::onRender("
        function(el, x, data) {
          $(el).find('.delete-btn').on('click', function(event) { event.stopPropagation(); alert('drop'); });
        }")
  })
}

shinyApp(ui, server)

Or the other approach like @glin gave above:

library(shiny)
library(reactable)

css <- ".delete-btn { opacity: 0; } .rt-th:hover .delete-btn { display: inline-block; opacity: 1 }"

ui <- fluidPage(
  tags$style(css),
  numericInput("num", "N rows", 5),
  reactableOutput("table")
)

server <- function(input, output, session) {
  output$table <- renderReactable({
    reactable(
      mtcars[seq(input$num), ],
      defaultColDef = colDef(
        header = function(name) {
          tags$div(
            name,
            icon("trash-alt", class = "delete-btn",
                 onclick = "event.stopPropagation(); alert('drop');")
          )
        }
      )
    )
  })
}

shinyApp(ui, server)