--- output: github_document --- [shiny-debounce]: https://shiny.rstudio.com/reference/shiny/latest/debounce.html [updateText-shiny-input-binding]: https://github.com/rstudio/shiny/blob/a2a4e40821b9811a40e461f67e3622196d8aa726/srcjs/input_binding_text.js#L31-L41 [checkboxInput-shiny-input-binding]: https://github.com/rstudio/shiny/blob/a2a4e40821b9811a40e461f67e3622196d8aa726/srcjs/input_binding_checkbox.js ```{r setup, include=FALSE} knitr::opts_chunk$set(eval = FALSE) github_sha_link <- function(sha) { glue::glue("[{sha}](https://github.com/gadenbuie/js4shiny-frappeCharts/commit/{sha})") } get_last_sha <- function() { commit <- git2r::commits()[[1]] message(commit$summary) message(commit$sha) clipr::write_clip(glue::glue( "`r github_sha_link(\"{commit$sha}\")`" )) } ``` # Building a Shiny Input In this project, we're going to create a typing speed app using a custom Shiny input. The app will give users typing prompts, monitor their typing speed, and use a Frappe Chart line chart to show their speed over time as they type. ## Setup a folder for our app inside the frappeCharts package `r github_sha_link("113340074c3af9c2cdf46cd7787829d4ec56bfcf")` Create the directory `inst/shiny-input-app` and add `app.R` and `typing.js`. ```{r} usethis::use_directory("inst/shiny-input-app") file.create("inst/shiny-input-app/app.R") file.create("inst/shiny-input-app/typing.js") ``` ## Create a basic Shiny app with a typing area `r github_sha_link("ff3a962f7e6d16a75ebdb620aac0fdfc9949086e")` We'll start with typical Shiny inputs. ```r library(shiny) ui <- fluidPage( textAreaInput("typing", "Type here..."), verbatimTextOutput("debug") ) server <- function(input, output, session) { output$debug <- renderPrint(input$typing) } shinyApp(ui, server) ``` Run this app and type in the box. ## Create our own typingSpeedInput() `r github_sha_link("b7108029b58635652ce87f3e1ea9a2c5a6232020")` Use Shiny's `textAreaInput()` to get the template for our own `typingSpeedInput()` ```{r, eval=TRUE} shiny_text_input <- shiny::textAreaInput( "INPUT", "LABEL", placeholder = "PLACEHOLDER" ) cat(format(shiny_text_input)) ``` ```{r} typingSpeedInput <- function(inputId, label, placeholder = NULL) { .label <- label htmltools::withTags( div( class = "form-group typing-speed", label(class = "control-label", `for` = inputId, .label), textarea(id = inputId, class = "form-control", placeholder = placeholder) ) ) } ``` Two points: 1. Notice that I used `htmltools::withTags()`, which makes it easier to write multiple tags at once. But it has the downside of masking the `label` argument of `typingSpeedInput()`. Hence, the first line `.label <- label`. 1. I added `.typing-speed` to our parent container so that we can find or style our custom input. Replace the `textAreaInput()` with our new `typingSpeedInput()` and run the app. It works the same! Wait, why? ```{r} ui <- fluidPage( # textAreaInput("typing", "Type here..."), typingSpeedInput("typing", "Type here..."), verbatimTextOutput("debug") ) ``` ## Start creating an input binding for `typingSpeedInput()` `r github_sha_link("02adb896c50a97ebe7f9a7fef2a6f00488f9418d")` Now we can open `typing.js` and create a Shiny input binding. If you used `js4shiny::snippets_install()`, you have a `ShinyInputBinding` snippet that provides a template for you. Or you can copy the chunk below.
Shiny Input Binding Template ```js // Ref: https://shiny.rstudio.com/articles/building-inputs.html // Ref: https://github.com/rstudio/shiny/blob/master/srcjs/input_binding.js const bindingName = new Shiny.InputBinding(); $.extend(bindingName, { find: function(scope) { // Specify the selector that identifies your input. `scope` is a general // parent of your input elements. This function should return the nodes of // ALL of the inputs that are inside `scope`. These elements should all // have IDs that are used as the inputId on the server side. return scope.querySelectorAll("inputBindingSelector"); }, getValue: function(el) { // For a particular input, this function is given the element containing // your input. In this function, find or construct the value that will be // returned to Shiny. The ID of `el` is used for the inputId. // e.g: return el.value return 'FIXME'; }, setValue: function(el, value) { // This method is used for restoring the bookmarked state of your input // and allows you to set the input's state without triggering reactivity. // Basically, reverses .getValue() // e.g.; el.value = value console.error('bindingName.setValue() is not yet defined'); }, receiveMessage: function(el, data) { // Given the input's container and data, update the input // and its elements to reflect the given data. // The messages are sent from R/Shiny via // R> session$sendInputMessage(inputId, data) console.error('bindingName.receiveMessage() is not yet defined'); // If you want the update to trigger reactivity, trigger a subscribed event $(el).trigger("change") }, subscribe: function(el, callback) { // Listen to events on your input element. The following block listens to // the change event, but you might want to listen to another event. // Repeat the block for each event type you want to subscribe to. $(el).on("change.bindingName", function(e) { // Use callback() or callback(true). // If using callback(true) the rate policy applies, // for example if you need to throttle or debounce // the values being sent back to the server. callback(); }); }, getRatePolicy: function() { return { policy: 'debounce', // 'debounce', 'throttle' or 'direct' (default) delay: 100 // milliseconds for debounce or throttle }; }, unsubscribe: function(el) { $(el).off(".bindingName"); } }); Shiny.inputBindings.register(bindingName, 'pkgName.bindingName'); ```
`r github_sha_link("6fffc2337ad475af2ab09ee571014afdbb680fb6")` If you use the snippet, it automatically walks you through the first pass of parts that need to be changed. If you copied the template, you need to find and replace all: - `bindingName`: the name of the JavaScript object with the specifics of your Shiny input. This name isn't user facing, but helps Shiny keep track of which the inputs in an app. We'll call ours `typingSpeed` - `inputBindingSelector`: this is the CSS selector that can be used to find the HTML element of your input. The element you find here is the element in your input's HTML that has the `id` with the input's `inputId`. In our case, we want the `textarea` element that's a descendent of `.typing-speed`, so we use ```css .typing-speed textarea ``` - `change`: This is the event that will be listened to by Shiny to know when the input has updated. For our typing speed input, we're going to listen to the `keyup` event. - `pkgName`: if you're writing this input as part of a package, use your package name. It's not critical; this just provides some namespacing in case there's another package that impelements an in put with the same `bindingName`. Here we use `js4shiny`. ## Add a dependency to the typing input `r github_sha_link("0be8efb83bef664f707b8716eab1d5478de933c7")` To have `typing.js` included with our input, we use `htmltools::htmlDependency()` inside our input function. This guarantees that `typing.js` is loaded once per page and is included whenever a `typingSpeedInput()` is created. ```{r} # I used the htmldeps snippet for this htmltools::htmlDependency( name = "typingSpeed", version = "0.0.1", src = ".", script = "typing.js", all_files = FALSE ) ``` Now when you run the app, you'll see `"FIXME"` as the output of `input$typing`. That's our next step. ## Explore the input binding Read through the comments of the input binding template. They explain the role of each function. In a nutshell, as a Shiny input author, your job is to tell Shiny a few key things: 1. **`.find()`** — How to find your input elements on the page 1. **`.subscribe()`** — What browser events will trigger an update from your input. The template show how to listen `'change'` events, but you may want to listen for a different event (or multiple events!), like a button `'click'` or a `'keydown'` or `'keyup'` event. 1. **`.getRatePolicy()`** — How often to send updates back to the server. There are three options here: `'debounce'`, `'throttle'`, and `'direct'`. See the [shiny debounce documentation][shiny-debounce] and my slides for more info. 1. **`.getValue()`** — What is the value of your input? This method is called whenever a subscribed event happens, and if the value of the input has changed it is reported back to Shiny. It's up to you, though, in this function, to read the HTML to determine the current value of the input and to construct the data that's sent back to the server. 1. **`.receiveMessage()`** - Let the input receive messages from the server. This works just like custom message handles, except that you call `shiny::sendInputMessage(inputId, data)` on the server side. This method receives the data and can be used to update the state of the input from values sent by the server. Ideally you would write an `update()` function that wraps `shiny::sendInputMessage()`. This is how [`updateTextInput()`][updateText-shiny-input-binding] and others works. If you want the updated values to flow through the reactive graph (you probably do), then you need to trigger a subscribed event after you make the changes. This calls `.getValue()` which sets the input values and reports back to the server. 1. **`.setValue()`** — This method takes a value sent from your input and updates your HTML to match. This method is required to be able to restore the input from a bookmarked state. It also allows you to set the input's value without triggering reactivity. ### Read the input binding for `checkboxInput()` The input binding JavaScript for `checkboxInput()` is available in the [Shiny GitHub repository][checkboxInput-shiny-input-binding]. Read through it and discuss what each method does. ## Setup our input binding Now it's time to setup our input binding. Replace `FIXME` with `el.value`. (`r github_sha_link("85205fbcbb93ccb506558e62f73a8f0d6a88c83c")`) ### Your Turn The `.value` of a `textarea` element is a string containing the text inside the element. Update `.getValue()` to 1. Return the number of characters the user has typed (`r github_sha_link("b3958d5e3f60fbac3a0100673696bd413a276fb4")`) 1. Return the number of words the user has entered (`r github_sha_link("e3fa485bb60853e391302289213d3cd5f0846b3e")`) 1. Create variables for both number of characters and words and return both in an object (`r github_sha_link("edfb9399eae5207cc4a3ef9b48e62c9c92230477")`) ### Tracking the timing You can add your own properties and methods to the input binding. As a convention, the property or method names start with `_`. Let's add a `_timing` property that with initialize with `null`. (`r github_sha_link("9568fdcd06fa1fe1815dc48bf6efb03f9af68b29")`) ```js $.extend(typingSpeed, { _timing: null, // ... } ``` Inside our input binding methods, we can now access `this._timing` to get the timing property for the input. (And a new input binding is created for each input, so if there are multiple typingSpeed inputs, we'll automatically get the right `_timing` value.) For methods called on objects, `this` refers the the parent object. Try the `repl_example("this-simple")` example to see how this works.
`repl_example("this-simple")` ```js const person = { name: "Christelle", sayHello: function() { console.log(`Hello, ${this.name}!`) } } person.sayHello() person.name = 'Mateo' person.sayHello() ```
#### Your Turn: Start `_timing` We're going to use this property to start timing the user's typing. On the one hand, when they delete all the text in the text area, we want to reset the timers. On the other hand, when they start typing, we want to know when they started. Update the `.getValue()` method so that whenever - there are no characters in the textarea `this._timing` is set to `null` and a `null` value is returned to Shiny. Similarly, when - `this._timing` is `null` - and there are any characters in the text area `this._timing` is updated to the current time (`Date.now()`) and a `null` value is returned. Include the timing value in the data returned to Shiny so that you can verify it's working. (`r github_sha_link("073186bcc9741cb2494b4b9a17042ea5716a7dd5")`) ### Your Turn: How Fast? Now you're ready to calculate typing statistics. Here's the idea: each time the user presses a key, we calculate the `elapsed` time in seconds since they started typing. Then, from that value calculate: - words per minute (`wpm`) - characters per second (`cps`) Return an object/list with - `wpm`, - `cps`, - the current timestamp as `time` and - the `text` in the input