diff --git a/R/shiny.R b/R/shiny.R index 41fea9b0..c50460c7 100644 --- a/R/shiny.R +++ b/R/shiny.R @@ -95,6 +95,10 @@ #' isn't provided. #' @param .open Opening template variable delimiter #' @param .close Closing template variable delimiter +#' @param .watch The names of placeholders that should be watched as inputs. +#' Takes a list with names matching the event type that should be "watched" on +#' the server side. Currently only supports watching "click" events. +#' Clicking on these elements triggers `input$<.id>__clicked`. #' @inheritParams glue::glue #' #' @seealso renderEpoxyHTML @@ -112,14 +116,15 @@ epoxyHTML <- function( .open = "{{", .close = "}}", .na = "", - .trim = FALSE + .trim = FALSE, + .watch = NULL ) { .container <- match.arg(.container, names(htmltools::tags)) .container_item <- match.arg(.container_item, names(htmltools::tags)) dots <- list(...) dots$.placeholder = .placeholder - dots$.transformer = transformer_html_markup(.class_item, .container_item) + dots$.transformer = transformer_html_markup(.class_item, .container_item, .watch) dots$.na = .na dots$.sep = .sep dots$.trim = .trim @@ -132,7 +137,7 @@ epoxyHTML <- function( purrr::flatten(purrr::map(tags, htmltools::findDependencies)) } - dots <- purrr::map_if(dots, ~ inherits(.x, "shiny.tag"), format) + dots <- purrr::map_if(dots, is_tag, format) res <- rlang::eval_bare(rlang::call2(glue::glue, !!!dots)) @@ -140,14 +145,8 @@ epoxyHTML <- function( id = .id, class = collapse_space(c("epoxy-html epoxy-init", .class)), htmltools::HTML(res), - htmltools::htmlDependency( - name = "epoxy", - version = "0.0.1", - package = "epoxy", - src = "srcjs", - script = "output-epoxy.js", - all_files = FALSE - ) + epoxy_dependency_output(), + if ("click" %in% names(.watch)) epoxy_dependency_click() ) if (!is.null(deps) && length(deps)) { htmltools::attachDependencies(out, deps) @@ -160,7 +159,7 @@ transformer_js_literal <- function(text, envir) { paste0("${", text, "}") } -transformer_html_markup <- function(class = NULL, element = "span") { +transformer_html_markup <- function(class = NULL, element = "span", watch = NULL) { class <- collapse_space(c("epoxy-item__placeholder", class)) function(text, envir) { markup <- parse_html_markup(text) @@ -179,6 +178,7 @@ transformer_html_markup <- function(class = NULL, element = "span") { class = class, id = markup$id, `data-epoxy-item` = markup$item, + `data-epoxy-input-click` = if (markup$item %in% watch$click) NA, htmltools::HTML(placeholder) ) ) @@ -277,7 +277,30 @@ renderEpoxyHTML <- function(..., .list = NULL, env = parent.frame(), outputArgs format_tags <- function(x) { if (!inherits(x, c("shiny.tag", "shiny.tag.list"))) { - return(x) + return(unname(x)) } format(x) } + +epoxy_dependency_output <- function() { + htmltools::htmlDependency( + name = "epoxy", + version = pkg_version(), + package = "epoxy", + src = "shiny", + script = "output-epoxy.js", + all_files = FALSE + ) +} + +epoxy_dependency_click <- function() { + htmltools::htmlDependency( + name = "epoxy-click", + version = pkg_version(), + package = "epoxy", + src = "shiny", + script = "output-epoxy-click.js", + stylesheet = "epoxy-click.css", + all_files = FALSE + ) +} diff --git a/R/shiny_inlineClickChoice.R b/R/shiny_inlineClickChoice.R new file mode 100644 index 00000000..dc2b7212 --- /dev/null +++ b/R/shiny_inlineClickChoice.R @@ -0,0 +1,95 @@ + +epoxyInlineClickChoice <- function( + inputId, + label, + choices = '', + selected = NULL, + hover_color = NULL, + focus_color = NULL +) { + choices <- as.character(choices) + if (is.null(selected) || !selected %in% choices) selected <- choices[1] + htmltools::tagList( + styleEpoxyInlineClickChoice(inputId, hover_color, focus_color), + htmltools::tags$span( + id = inputId, + class = "epoxy-inline-clickChoice-input", + title = label, + tabindex = 0, + `data-choices` = jsonlite::toJSON(choices), + selected + ), + epoxy_dependency_clickChoice() + ) +} + +updateEpoxyClickChoice <- function( + inputId, + choices = NULL, + selected = NULL, + session = shiny::getDefaultReactiveDomain() +) { + if (!is.null(selected) && length(selected) > 1) { + rlang::abort("`selected` must be a length-1 string.") + } + session$sendInputMessage(inputId, list(value = selected, choices = choices)) +} + +pick_selected <- function(ch, s) { + ch_has_names <- !is.null(names(ch)) + if (is.null(s)) { + val <- unname(ch[[1]]) + name <- if (ch_has_names) { + names(ch)[[1]] + } else val + return(list(value = val, name = name)) + } + + if (ch_has_names) { + if (s %in% names(ch)) { + return(list(value = ch[[s]], name = s)) + } + } + + if (s %in% ch) { + name <- if (ch_has_names) names(ch)[[which(s == ch)]] else s + return(list(value = s, name = name)) + } + + list(value = ch[[1]], name = if (ch_has_names) names(ch)[[1]] else ch[[1]]) +} + +data_clickChoice <- function(id, x) { + x <- list(values = unname(x), names = names(x) %||% x) + jsonlite::toJSON(x, auto_unbox = TRUE) + +} + +styleEpoxyInlineClickChoice <- function(id, hover_color = NULL, focus_color = NULL) { + if (is.null(hover_color) && is.null(focus_color)) return(NULL) + + hover_color <- if (!is.null(hover_color)) { + glue(" --epoxy-inline-clickChoice-color-hover: {hover_color};\n", .trim = FALSE) + } else "" + focus_color <- if(!is.null(focus_color)) { + glue(" --epoxy-inline-clickChoice-color-focus: {focus_color};\n", .trime = FALSE) + } else "" + + htmltools::tags$head( + htmltools::tags$style( + htmltools::HTML(glue('[id="{id}"] {{\n{hover_color}{focus_color}\n}}')) + ) + ) +} + +epoxy_dependency_clickChoice <- function() { + htmltools::htmlDependency( + name = "epoxy-clickChoice", + version = pkg_version(), + package = "epoxy", + src = "shiny", + script = "input-epoxy-clickChoice.js", + stylesheet = "input-epoxy-clickChoice.css", + all_files = FALSE + ) +} diff --git a/R/shiny_inlineText.R b/R/shiny_inlineText.R new file mode 100644 index 00000000..b1a62a91 --- /dev/null +++ b/R/shiny_inlineText.R @@ -0,0 +1,53 @@ + +epoxyInlineText <- function(inputId, label, value = "", hover_color = NULL, focus_color = NULL) { + htmltools::tagList( + styleEpoxyInlineText(inputId, hover_color, focus_color), + htmltools::tags$span( + class = "epoxy-inline-text-input", + htmltools::tags$input( + type = "text", + id = inputId, + name = inputId, + value = value + ), + if (!is.null(label)) htmltools::tags$label(`for` = inputId, label) + ), + epoxy_dependency_text() + ) +} + +updateEpoxyInlineText <- function(inputId, value, session = shiny::getDefaultReactiveDomain()) { + stopifnot(is.character(value)) + stopifnot(length(value) <= 1) + if (!length(value)) value <- "" + session$sendInputMessage(inputId, value) +} + +styleEpoxyInlineText <- function(id, hover_color = NULL, focus_color = NULL) { + if (is.null(hover_color) && is.null(focus_color)) return(NULL) + + hover_color <- if (!is.null(hover_color)) { + glue(" --epoxy-inline-text-color-hover: {hover_color};\n", .trim = FALSE) + } else "" + focus_color <- if(!is.null(focus_color)) { + glue(" --epoxy-inline-text-color-focus: {focus_color};\n", .trime = FALSE) + } else "" + + htmltools::tags$head( + htmltools::tags$style( + htmltools::HTML(glue('[id="{id}"], label[for="{id}"] {{\n{hover_color}{focus_color}\n}}')) + ) + ) +} + +epoxy_dependency_text <- function() { + htmltools::htmlDependency( + name = "epoxy-text", + version = pkg_version(), + package = "epoxy", + src = "shiny", + script = "input-epoxy-text.js", + stylesheet = "epoxy-text.css", + all_files = FALSE + ) +} diff --git a/R/utils.R b/R/utils.R index 7f38b659..a68e331f 100644 --- a/R/utils.R +++ b/R/utils.R @@ -30,4 +30,12 @@ collapse_space <- function(...) { paste(..., collapse = " ") } -is_tag <- function(x) inherits(x, "shiny.tag") +is_tag <- function(x) inherits(x, c("shiny.tag", "shiny.tag.list")) + +file_pkg <- function(...) system.file(..., package = "epoxy", mustWork = TRUE) + +pkg_version <- function() { + x <- read.dcf(file_pkg("DESCRIPTION")) + if (!"Version" %in% colnames(x)) return(NA) + unname(x[, "Version"]) +} diff --git a/inst/demo/epoxyInlineText/app.R b/inst/demo/epoxyInlineText/app.R new file mode 100644 index 00000000..1fc1cb9b --- /dev/null +++ b/inst/demo/epoxyInlineText/app.R @@ -0,0 +1,43 @@ +library(shiny) +library(epoxy) + +ui <- fluidPage( + h3("epoxyInlineText()"), + p( + "Of all of the places I've visited,", + epoxy:::epoxyInlineText("place", "Name of City", "Chicago", hover_color = "#568EA3", focus_color = "#FF715B"), + "was", + epoxyHTML(.id = "feeling", "{{feeling}}.", feeling = "my favorite", .watch = list(click = "feeling"), .container = "span") + ), + verbatimTextOutput("answer"), + actionButton("go_chicago", "Go to Chicago"), + actionButton("go_newyork", "Go to New York") +) + +server <- function(input, output, session) { + output$answer <- renderPrint(str(list(place = input$place, feeling = feeling()))) + + feeling <- reactive({ + # TODO: I need a better input structure that handles this use case better + feelings <- c("my favorite", "the most fun", "terrible to visit", "the stinkiest") + + idx <- input$feeling_feeling_clicked + if (is.null(idx)) idx <- 0 + idx <- (idx + 1) %% length(feelings) + if (idx == 0) idx <- length(feelings) + + feelings[idx] + }) + + output$feeling <- renderEpoxyHTML(feeling = feeling()) + + observeEvent(input$go_chicago, { + epoxy:::updateEpoxyInlineText("place", "Chicago") + }) + + observeEvent(input$go_newyork, { + epoxy:::updateEpoxyInlineText("place", "New York, NY") + }) +} + +shinyApp(ui, server) diff --git a/inst/shiny/epoxy-click.css b/inst/shiny/epoxy-click.css new file mode 100644 index 00000000..7d01cad5 --- /dev/null +++ b/inst/shiny/epoxy-click.css @@ -0,0 +1,10 @@ +.epoxy-html [data-epoxy-input-click]:hover { + padding-bottom: 1px; + border-bottom: 1px dashed currentcolor; + cursor: pointer; +} + +.epoxy-html [data-epoxy-input-click] { + padding-bottom: 1px; + border-bottom: 1px dashed #aaa; +} diff --git a/inst/shiny/epoxy-text.css b/inst/shiny/epoxy-text.css new file mode 100644 index 00000000..18637c7a --- /dev/null +++ b/inst/shiny/epoxy-text.css @@ -0,0 +1,61 @@ +:root { + --epoxy-inline-text-color-hover: red; + --epoxy-inline-text-color-focus: blue; +} + +.epoxy-inline-text-input { + position: relative; + width: auto; +} + +.epoxy-inline-text-input:hover { + color: var(--epoxy-inline-text-color-hover); +} + +.epoxy-inline-text-input input[type="text"] { + display: inline-block; + border: none; + width: auto; + min-width: 25px; + max-width: 100%; + border-bottom: 1px dashed #aaa; + box-sizing: content-box; + padding-bottom: 0; + transition: width 0.15s ease-in-out; +} + +.epoxy-inline-text-input input[type="text"]:hover { + color: var(--epoxy-inline-text-color-hover); + border-bottom: 1px dashed currentcolor; +} + +.epoxy-inline-text-input input[type="text"]:focus { + color: var(--epoxy-inline-text-color-focus); + border-bottom: 1px solid currentcolor; +} + +.epoxy-inline-text-input label { + color: #999; + position: absolute; + top: -0.25em; + left: 0; + height: 0; + font-size: 60%; + min-width: max-content; + transition: height 0.25s linear, top 0.25s linear; + overflow-y: hidden; +} + +.epoxy-inline-text-input:hover label, +.epoxy-inline-text-input input[type="text"]:focus + label {; + height: 100%; + top: -1.1em; +} + +.epoxy-inline-text-input:hover label { + color: var(--epoxy-inline-text-color-hover); +} + +.epoxy-inline-text-input input[type="text"]:focus + label { + color: var(--epoxy-inline-text-color-focus); +} diff --git a/inst/shiny/input-epoxy-clickChoice.css b/inst/shiny/input-epoxy-clickChoice.css new file mode 100644 index 00000000..a2d72a00 --- /dev/null +++ b/inst/shiny/input-epoxy-clickChoice.css @@ -0,0 +1,25 @@ +:root { + --epoxy-inline-clickChoice-color-hover: red; + --epoxy-inline-clickChoice-color-focus: blue; +} + +.epoxy-inline-clickChoice-input { + position: relative; + display: inline-block; + width: auto; + border-bottom: 1px dashed #aaa; +} + +.epoxy-inline-clickChoice-input:hover { + color: var(--epoxy-inline-clickChoice-color-hover); +} + +.epoxy-inline-clickChoice-input:hover { + color: var(--epoxy-inline-clickChoice-color-hover); + border-bottom: 1px dashed currentcolor; +} + +.epoxy-inline-clickChoice-input:focus { + color: var(--epoxy-inline-clickChoice-color-focus); + border-bottom: 1px solid currentcolor; +} diff --git a/inst/shiny/input-epoxy-clickChoice.js b/inst/shiny/input-epoxy-clickChoice.js new file mode 100644 index 00000000..a9fa30bb --- /dev/null +++ b/inst/shiny/input-epoxy-clickChoice.js @@ -0,0 +1,84 @@ +// Ref: https://shiny.rstudio.com/articles/building-inputs.html +// Ref: https://github.com/rstudio/shiny/blob/master/srcjs/input_binding.js + +const epoxyInlineClickChoice = new Shiny.InputBinding(); + +$.extend(epoxyInlineClickChoice, { + 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('.epoxy-inline-clickChoice-input'); + }, + 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 el.textContent; + }, + 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 + el.textContent = value; + }, + 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) + if (data.value) { + this.setValue(el, data.value); + } + + if (data.choices && Array.isArray(data.choices) && data.choices.length) { + el.dataset.choices = JSON.stringify(data.choices); + } + + // If you want the update to trigger reactivity, trigger a subscribed event + $(el).trigger("change"); + }, + subscribe: function(el, callback) { + function nextChoice() { + let choices = JSON.parse(el.dataset.choices); + let sel = el.textContent; + let idx = choices.findIndex((i) => i === sel); + if (++idx >= choices.length) idx = 0; + el.textContent = choices[idx]; + $(el).trigger("change"); + } + + // 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.addEventListener('click', nextChoice); + + el.addEventListener('keydown', function(ev) { + if (ev.code === 'Enter' || ev.code === "Space") nextChoice(); + }); + + $(el).on("change.epoxyInlineClickChoice", 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(".epoxyInlineClickChoice"); + } +}); + +Shiny.inputBindings.register(epoxyInlineClickChoice, 'epoxy.epoxyInlineClickChoice'); diff --git a/inst/shiny/input-epoxy-text.js b/inst/shiny/input-epoxy-text.js new file mode 100644 index 00000000..1e26c743 --- /dev/null +++ b/inst/shiny/input-epoxy-text.js @@ -0,0 +1,87 @@ +// Ref: https://shiny.rstudio.com/articles/building-inputs.html +// Ref: https://github.com/rstudio/shiny/blob/master/srcjs/input_binding.js + +const epoxyInlineText = new Shiny.InputBinding(); + +$.extend(epoxyInlineText, { + 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('.epoxy-inline-text-input input[type="text"]'); + }, + 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 el.value; + }, + 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 + el.value = value + this._resizeElement(el) + }, + 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) + this.setValue(el, data) + + // If you want the update to trigger reactivity, trigger a subscribed event + $(el).trigger("change") + }, + _measureWidth: function(el, text) { + let ruler = document.createElement('span') + ruler.innerHTML = text + ruler.style.visibility = 'hidden' + ruler.style.whiteSpace = 'nowrap' + el.parentElement.appendChild(ruler) + width = ruler.offsetWidth + el.parentElement.removeChild(ruler) + return(width) + }, + _resizeElement: function(el) { + el.style.width = this._measureWidth(el, el.value) + 'px' + }, + subscribe: function(el, callback) { + // Prepare input + this._resizeElement(el) + + // 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.addEventListener('keyup', () => { + let w = this._measureWidth(el, el.value.replace(' ', ' ')) + el.style.width = w + 20 + 'px' + }) + + el.addEventListener('blur', () => this._resizeElement(el)) + + $(el).on("change.epoxyInlineText", 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(".epoxyInlineText"); + } +}); + +Shiny.inputBindings.register(epoxyInlineText, 'epoxy.epoxyInlineText'); diff --git a/inst/shiny/output-epoxy-click.js b/inst/shiny/output-epoxy-click.js new file mode 100644 index 00000000..d727622e --- /dev/null +++ b/inst/shiny/output-epoxy-click.js @@ -0,0 +1,18 @@ +$(document).on('click', '[data-epoxy-input-click]', function(ev) { + let el = ev.target; + let inputName = el.dataset.epoxyItem; + let elId = el.closest('.epoxy-html[id]').id; + let value = +el.dataset.epoxyClickedValue || 0; + value += 1; + el.dataset.epoxyClickedValue = value; + Shiny.setInputValue(`${elId}_${inputName}_clicked`, value); +}); + +document + .querySelectorAll('[data-epoxy-input-click]') + .forEach(el => { + let name = el.dataset.epoxyItem; + let inputId = el.closest('.epoxy-html').id; + value = +el.dataset.epoxyClickedValue || 0; + Shiny.setInputValue(`${inputId}_${name}_clicked`, 0); + }); diff --git a/inst/srcjs/output-epoxy.js b/inst/shiny/output-epoxy.js similarity index 100% rename from inst/srcjs/output-epoxy.js rename to inst/shiny/output-epoxy.js diff --git a/man/epoxyHTML.Rd b/man/epoxyHTML.Rd index 97036a05..8ea69b34 100644 --- a/man/epoxyHTML.Rd +++ b/man/epoxyHTML.Rd @@ -16,7 +16,8 @@ epoxyHTML( .open = "{{", .close = "}}", .na = "", - .trim = FALSE + .trim = FALSE, + .watch = NULL ) } \arguments{ @@ -51,6 +52,9 @@ cause \code{NA} output. Otherwise the value is replaced by the value of \code{.n \item{.trim}{[\code{logical(1)}: \sQuote{TRUE}]\cr Whether to trim the input template with \code{trim()} or not.} + +\item{.watch}{The names of placeholders that should be watched as inputs. +Clicking on these elements triggers \verb{input$<.id>__clicked}.} } \value{ An HTML object.