daattali / shinyjs

💡 Easily improve the user experience of your Shiny apps in seconds
https://deanattali.com/shinyjs
Other
734 stars 119 forks source link

Using disabled() prevents dependencies of custom input from being loaded #224

Closed matthijsvanderloos closed 3 years ago

matthijsvanderloos commented 3 years ago

I have a Shiny app with a custom input control that uses htmltools::htmlDependency() to load JavaScript and CSS dependencies. However, when the custom input is initially disabled using shinyjs::disabled() the dependencies fail to load.

Below is a reproducible example based on a somewhat modified version of https://github.com/rstudio/shiny-examples/tree/master/035-custom-input-bindings. The main difference is the use of htmltools::htmlDependency().

app.R:

library(shiny)
library(htmltools)
library(shinyjs)

# This function generates the client-side HTML for a URL input
urlInput <- function(inputId, label, value = "") {
  # This makes web page load the JS file in the HTML head.
  # The call to singleton ensures it's only included once

  deps <- htmlDependency("url-input",
                         "1.0.0",
                         src = "www",
                         script = c(
                           "url-input-binding.js"
                           )
                         )

  url_input <- tagList(
    tags$label(label, `for` = inputId),
    tags$input(id = inputId, type = "url", value = value)
  )

  attachDependencies(url_input, deps)
}

# Send an update message to a URL input on the client.
# This update message can change the value and/or label.
updateUrlInput <- function(session, inputId,
                           label = NULL, value = NULL) {

  message <- dropNulls(list(label = label, value = value))
  session$sendInputMessage(inputId, message)
}

# Given a vector or list, drop all the NULL items in it
dropNulls <- function(x) {
  x[!vapply(x, is.null, FUN.VALUE=logical(1))]
}

server <- function(input, output, session) {

  output$urlText <- renderText({
    as.character(input$my_url)
  })

  observe({
    # Run whenever reset button is pressed
    input$reset

    # Send an update to my_url, resetting its value
    updateUrlInput(session, "my_url", value = "http://www.r-project.org/")
  })
}

ui <- fluidPage(
  titlePanel("Custom input example"),

  fluidRow(
    column(4, wellPanel(
      # when urlInput is initially disabled using shinyjs::disabled() the file
      # url-input-binding.js fails to load
      urlInput("my_url", "URL: ", "http://www.r-project.org/"),
      actionButton("reset", "Reset URL")
    )),
    column(8, wellPanel(
      verbatimTextOutput("urlText")
    ))
  )
)

shinyApp(ui, server)

www/url-input-binding.js:

// URL input binding
// This input binding is very similar to textInputBinding from
// shiny.js.
var urlInputBinding = new Shiny.InputBinding();

// An input binding must implement these methods
$.extend(urlInputBinding, {

  // This returns a jQuery object with the DOM element
  find: function(scope) {
    return $(scope).find('input[type="url"]');
  },

  // return the ID of the DOM element
  getId: function(el) {
    return el.id;
  },

  // Given the DOM element for the input, return the value
  getValue: function(el) {
    return el.value;
  },

  // Given the DOM element for the input, set the value
  setValue: function(el, value) {
    el.value = value;
  },

  // Set up the event listeners so that interactions with the
  // input will result in data being sent to server.
  // callback is a function that queues data to be sent to
  // the server.
  subscribe: function(el, callback) {
    $(el).on('keyup.urlInputBinding input.urlInputBinding', function(event) {
      callback(true);
      // When called with true, it will use the rate policy,
      // which in this case is to debounce at 500ms.
    });
    $(el).on('change.urlInputBinding', function(event) {
      callback(false);
      // When called with false, it will NOT use the rate policy,
      // so changes will be sent immediately
    });
  },

  // Remove the event listeners
  unsubscribe: function(el) {
    $(el).off('.urlInputBinding');
  },

  // Receive messages from the server.
  // Messages sent by updateUrlInput() are received by this function.
  receiveMessage: function(el, data) {
    if (data.hasOwnProperty('value'))
      this.setValue(el, data.value);

    if (data.hasOwnProperty('label'))
      $(el).parent().find('label[for="' + $escape(el.id) + '"]').text(data.label);

    $(el).trigger('change');
  },

  // This returns a full description of the input's state.
  // Note that some inputs may be too complex for a full description of the
  // state to be feasible.
  getState: function(el) {
    return {
      label: $(el).parent().find('label[for="' + $escape(el.id) + '"]').text(),
      value: el.value
    };
  },

  // The input rate limiting policy
  getRatePolicy: function() {
    return {
      // Can be 'debounce' or 'throttle'
      policy: 'debounce',
      delay: 500
    };
  }
});

Shiny.inputBindings.register(urlInputBinding, 'shiny.urlInput');

In the original version the JavaScript dependency url-input-binding.js is correcty loaded by the browser: image

However, after changing the line in the UI function that creates the custom input to:

shinyjs::disabled(urlInput("my_url", "URL: ", "http://www.r-project.org/"))

the JavaScript dependency is not loaded anymore: image

Not sure if this is a bug in shinyjs, htmltools, or even shiny itself?

daattali commented 3 years ago

Thanks for reporting. I haven't had time yet to look into what's causing this, but this is technically out of scope so I'm not sure I'll prioritize it. hide/show/disable/reset/etc are meant to work with any native shiny input and anything that resembles shiny inputs as much as possible.

For this input specifically, looking at the code it seems that it deviates from normal shiny inputs by using tagList(tags$label, tags$input) instead of div(tags$label, tags$input). It's possible that changing that to a div might resolve the issue.

matthijsvanderloos commented 3 years ago

Interesting, changing from tagList() to div() indeed seems to do the trick, thanks! Closing for now.