|
- ---
- 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.
-
- <details><summary>Shiny Input Binding Template</summary>
-
- ```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');
- ```
-
- </details>
-
- `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<InputName>()` 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.
-
- <details><summary>`repl_example("this-simple")`</summary>
-
- ```js
- const person = {
- name: "Christelle",
- sayHello: function() {
- console.log(`Hello, ${this.name}!`)
- }
- }
-
- person.sayHello()
-
- person.name = 'Mateo'
- person.sayHello()
- ```
-
- </details>
-
- #### 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.
|