diff --git a/css/surveydown.css b/css/surveydown.css index 7ac82e48..b086aea5 100644 --- a/css/surveydown.css +++ b/css/surveydown.css @@ -260,25 +260,35 @@ p:last-child, width: 100%; } -.matrix-question .shiny-options-group .radio { +.matrix-question .shiny-options-group .radio, +.matrix-question .shiny-options-group .checkbox { margin: 0; flex: 1; text-align: center; } -.matrix-question .shiny-options-group .radio label { +.matrix-question .shiny-options-group .radio label, +.matrix-question .shiny-options-group .checkbox label { padding-left: 0; display: inline-block; } -.matrix-question .shiny-options-group .radio input { +.matrix-question .shiny-options-group .radio input, +.matrix-question .shiny-options-group .checkbox input { margin: 0; position: relative; top: 2px; } -/* Hide radio button labels */ -.matrix-question .shiny-options-group .radio span { +/* Hide radio/checkbox option labels (column headers show the options) */ +.matrix-question .shiny-options-group .radio span, +.matrix-question .shiny-options-group .checkbox span { + display: none; +} + +/* Hide control-label in matrix question radio/checkbox groups */ +.matrix-question .shiny-input-radiogroup > .control-label, +.matrix-question .shiny-input-checkboxgroup > .control-label { display: none; } @@ -316,3 +326,136 @@ div:where(.swal2-container) .swal2-html-container { text-align: center; width: 100%; } + +/* Slider styling (ionRangeSlider, used by slider and slider_numeric) */ +.irs--shiny .irs-line { + height: 6px; + border: none; + border-radius: 3px; + background: #e3e6ea; + top: 27px; +} + +.irs--shiny .irs-bar { + height: 6px; + border: none; + background: var(--theme-color); + top: 27px; +} + +.irs--shiny .irs-handle { + width: 22px; + height: 22px; + top: 19px; + border: 2px solid var(--theme-color); + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); +} + +.irs--shiny .irs-handle.state_hover, +.irs--shiny .irs-handle:hover { + background: color-mix(in srgb, var(--theme-color) 10%, white); +} + +.irs--shiny .irs-single, +.irs--shiny .irs-from, +.irs--shiny .irs-to { + background: var(--theme-color); + border-radius: 4px; + font-size: 12px; +} + +.irs--shiny .irs-min, +.irs--shiny .irs-max { + background: transparent; + color: #8a8a8a; + font-size: 11px; +} + +/* Hide the small minor tick marks; keep only the labeled major ticks */ +.irs--shiny .irs-grid-pol.small { + display: none; +} + +.irs--shiny .irs-grid-pol { + width: 2px; + background: #a8adb3; +} + +.irs--shiny .irs-grid-text { + color: #8a8a8a; + font-size: 11px; +} + +/* When a labeled grid is shown, the min/max labels above the track are + redundant with the grid's end labels */ +.irs--shiny:has(.irs-grid) .irs-min, +.irs--shiny:has(.irs-grid) .irs-max { + display: none; +} + +/* Image choice (mc_image / mc_multiple_image) */ +.sd-image-choice .shiny-options-group { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 0.5rem; +} + +.sd-image-choice .radio, +.sd-image-choice .checkbox { + margin: 0; +} + +.sd-image-choice .radio label, +.sd-image-choice .checkbox label { + cursor: pointer; + padding: 0; + font-weight: normal; +} + +/* Hide the native control but keep it accessible (still keyboard/focusable) */ +.sd-image-choice input[type="radio"], +.sd-image-choice input[type="checkbox"] { + position: absolute; + opacity: 0; + width: 1px; + height: 1px; +} + +.sd-image-card { + border: 2px solid #ddd; + border-radius: 8px; + padding: 8px; + text-align: center; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.sd-image-card-img { + display: block; + max-width: 160px; + max-height: 160px; + border-radius: 4px; + margin: 0 auto; +} + +.sd-image-card-caption { + display: block; + margin-top: 6px; +} + +.sd-image-choice label:hover .sd-image-card { + border-color: color-mix(in srgb, var(--theme-color) 50%, white); +} + +/* Selected state: highlight the card whose input is checked */ +.sd-image-choice label:has(input:checked) .sd-image-card { + border-color: var(--theme-color); + box-shadow: 0 0 0 1px var(--theme-color); +} + +/* Keyboard focus visibility (the native control is visually hidden) */ +.sd-image-choice label:has(input:focus-visible) .sd-image-card { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--theme-color) 35%, transparent); +} diff --git a/docs/page-navigation.qmd b/docs/page-navigation.qmd index 4079276f..3c2e1549 100644 --- a/docs/page-navigation.qmd +++ b/docs/page-navigation.qmd @@ -70,9 +70,18 @@ Here is what the **Survey Page Gadget** looks like in RStudio: ## Navigation buttons -By default, navigation buttons are automatically added to each page to allow respondents to move forward through the survey. To change the global settings (e.g., add a previous button on all pages, change the message shown on all next buttons, etc.), you can edit the survey settings in the YAML header (see the [Survey Settings](survey-settings.html#default-settings) page for details). +By default, a **Next** button is automatically added to each page to allow respondents to move forward through the survey. You don't need to add these buttons yourself. -Additionally, you can also manually add navigation buttons using the `sd_nav()` function inside code chunks on any page, which will override the global settings for that specific page, like this: +To add a **Previous** button to every page so respondents can go back, you don't need to edit each page individually. Just set `show-previous: yes` under `survey-settings` in your **survey.qmd** YAML header: + +```yaml +survey-settings: + show-previous: yes +``` + +This (and other global navigation defaults, like the button labels) is applied to every page automatically. See the [Survey Settings](survey-settings.html#default-settings) page for the full list of options. + +If instead you want to control the buttons on a **single page only**, you can manually add navigation buttons using the `sd_nav()` function inside a code chunk on that page, which overrides the global settings for that specific page, like this: ```{r} #| echo: fenced diff --git a/docs/question-types.qmd b/docs/question-types.qmd index 9bda5bea..fa829c4a 100644 --- a/docs/question-types.qmd +++ b/docs/question-types.qmd @@ -441,6 +441,105 @@ sd_question( ::: +## `mc_image` + +Use `type = 'mc_image'` for a single-choice question where each option is a clickable image card. Provide an `image` argument: a vector of image paths or URLs, one per option, in the same order as `option`. Paths are used directly in the image `src`, so they should resolve against your survey's `images` or `www` folder (e.g. `"images/cat.png"`) or be full URLs. + +::: {.callout-note} +## Captions are optional + +For image-choice questions, the `option` **names** control whether a text caption appears beneath each image: + +- **With captions** - give your options names, e.g. `option = c('Cat' = 'cat', 'Dog' = 'dog')`. The names ("Cat", "Dog") are shown as captions; the values ("cat", "dog") are stored in your data. +- **Without captions (images only)** - pass an *unnamed* vector, e.g. `option = c('cat', 'dog')`. No captions are shown, and the values ("cat", "dog") are still stored in your data. + +This is different from the other multiple choice types, where an unnamed option vector uses the values as the visible labels. For image questions an unnamed vector means "no caption". +::: + +{{< include ../chunks/question-screenshot-note.qmd >}} + +**With captions**: + +::: {.panel-tabset} + +## Output + +
+ +
+ +## Code + +```{r} +#| echo: fenced + +sd_question( + type = 'mc_image', + id = 'pet', + label = "Which pet do you prefer?", + option = c('Cat' = 'cat', 'Dog' = 'dog'), + image = c('images/cat.png', 'images/dog.png') +) +``` + +::: + +**Without captions** (unnamed `option`, images only): + +::: {.panel-tabset} + +## Output + +
+ +
+ +## Code + +```{r} +#| echo: fenced + +sd_question( + type = 'mc_image', + id = 'pet_no_caption', + label = "Which pet do you prefer?", + option = c('cat', 'dog'), + image = c('images/cat.png', 'images/dog.png') +) +``` + +::: + +## `mc_multiple_image` + +Use `type = 'mc_multiple_image'` for the multiple-selection version of `mc_image`, where respondents can select more than one image card. + +{{< include ../chunks/question-screenshot-note.qmd >}} + +::: {.panel-tabset} + +## Output + +
+ +
+ +## Code + +```{r} +#| echo: fenced + +sd_question( + type = 'mc_multiple_image', + id = 'pets_owned', + label = "Which pets have you owned? (select all that apply)", + option = c('Cat' = 'cat', 'Dog' = 'dog'), + image = c('images/cat.png', 'images/dog.png') +) +``` + +::: + ## `select` Use `type = 'select'` to specify a drop down select type question. @@ -501,7 +600,7 @@ sd_question( ## Output
- +
## Code chunk @@ -511,15 +610,16 @@ sd_question( sd_question( type = 'slider', - id = 'climate_care', - label = "To what extent do you believe human-caused climate change is real?", + id = 'experience', + label = "How would you rate your overall experience?", option = c( - "Don't Believe" = "dont_believe", - "Somewhat Believe" = "somewhat", - "Neutral" = "neutral", - "Believe" = "believe", - "Strongly Believe" = "strongly_believe" - ) + "Very poor" = "very_poor", + "Poor" = "poor", + "Neutral" = "neutral", + "Good" = "good", + "Very good" = "very_good" + ), + selected = 'neutral' ) ``` @@ -538,7 +638,7 @@ If your slider uses numeric inputs, use the `slider_numeric` question type. This ## Output
- +
## Code chunk @@ -563,7 +663,7 @@ sd_question( ## Output
- +
## Code chunk @@ -688,3 +788,56 @@ sd_question( ``` ::: + +## `matrix_multiple` + +Use `type = 'matrix_multiple'` to specify a matrix input type where each row allows **multiple** selections (checkboxes) instead of a single selection (radio buttons). + +Each row becomes its own sub-question with the ID `_`, stored as a separate column in the resulting data. Multiple selections within a row are stored as a single pipe-separated string (e.g., `"online|app"`), just like the [`mc_multiple`](#mc_multiple) type. + +::: {.panel-tabset} + +## Output + +```{r} +#| eval: true +#| echo: false + +sd_question( + type = "matrix_multiple", + id = "purchase_channels", + label = "How have you purchased each of the following? (select all that apply)", + row = c( + "Groceries" = "groceries", + "Electronics" = "electronics" + ), + option = c( + "In store" = "store", + "Online" = "online", + "Mobile app" = "app" + ) +) +``` + +## Code + +```{r} +#| echo: fenced + +sd_question( + type = "matrix_multiple", + id = "purchase_channels", + label = "How have you purchased each of the following? (select all that apply)", + row = c( + "Groceries" = "groceries", + "Electronics" = "electronics" + ), + option = c( + "In store" = "store", + "Online" = "online", + "Mobile app" = "app" + ) +) +``` + +::: diff --git a/docs/reactivity.qmd b/docs/reactivity.qmd index ac52b9ac..ea66fd9f 100644 --- a/docs/reactivity.qmd +++ b/docs/reactivity.qmd @@ -210,7 +210,7 @@ To make these values display properly, you can create reactive values with the ` The `sd_reactive()` function takes an `id`, which is the name that will be used in the resulting survey response data to store the returned value. The created object is a reactive expression that can also be used anywhere else in the server using the `()` symbols. Inside the function can be any expression that returns a value. -To create all of the objects in the example page above, our server would look like the following below. Note that we first create intermediate objects (`n1` and `n2`) purely for convenience as we use the numbers in multiple places. +To create all of the objects in the example page above, our server would look like the following below. {{< include ../chunks/sdvalue.qmd >}} @@ -222,19 +222,18 @@ library(surveydown) ui <- sd_ui() server <- function(input, output, session) { - observe({ - # Create local objects for each number - n1 <- sd_value("first_number") - n2 <- sd_value("second_number") - - # Create reactive values for 'product' and 'sum' - product <- sd_reactive("product", n1 * n2) - sum <- sd_reactive("sum", n1 + n2) - - # Use the reactive values to create an additional 'summary' output - sd_reactive("summary", { - paste("The product is", product(), "and the sum is", sum()) - }) + # Create reactive values for 'product' and 'sum' + product <- sd_reactive("product", { + sd_value("first_number") * sd_value("second_number") + }) + + sum_val <- sd_reactive("sum", { + sd_value("first_number") + sd_value("second_number") + }) + + # Use the reactive values to create an additional 'summary' output + sd_reactive("summary", { + paste("The product is", product(), "and the sum is", sum_val()) }) sd_server() @@ -244,9 +243,30 @@ server <- function(input, output, session) { shiny::shinyApp(ui = ui, server = server) ``` -In this server, we create two reactive values, `product` and `sum`, which get stored in our survey data under those respective names. We also use `product()` and `sum()` to create the `summary` object, which is just some rendered text to display on the survey page. +In this server, we create two reactive values, `product` and `sum`, which get stored in our survey data under those respective names. We also use `product()` and `sum_val()` to create the `summary` object, which is just some rendered text to display on the survey page. (We name the R object `sum_val` rather than `sum` to avoid masking base R's `sum()` function; the value is still stored in the data as `"sum"`.) + +Note that the `sd_value()` calls are made *inside* each `sd_reactive()` expression. This is what makes the expressions reactive - they re-compute whenever the referenced answers change. It also lets `sd_reactive()` recognize when a calculation fails simply because a question hasn't been answered yet (e.g., arithmetic on a blank value), in which case it quietly stores an empty value instead of raising a warning. Repeating a `sd_value()` call is essentially free - it's just a fast lookup of the stored answer. + +There is no need to wrap `sd_reactive()` calls in `observe()`. Each `sd_reactive()` manages its own reactivity, and capturing question values into local objects outside of the `sd_reactive()` call (e.g., `n1 <- sd_value("first_number")` followed by `sd_reactive("product", n1 * n2)`) breaks both the reactivity and the quiet handling of unanswered questions. + +::: {.callout-tip} -Note also that we wrap the logic for creating these objects inside an `observe()` call. This is because initially the objects `n1` and `n2` will be `NULL` until a respondent answers the question, so using `observe()` will make sure these calculations don't run until a value is present. +## Many calculated values from the same questions? + +If you have many `sd_reactive()` calls built from the same handful of answers, you can avoid repeating the question ids by defining shared reactive expressions plus a "ready" guard, and returning `NA` until the questions are answered (`sd_reactive()` stores `NA` results as empty values without warnings): + +```{r} +n1 <- reactive(sd_value("first_number")) +n2 <- reactive(sd_value("second_number")) +ready <- reactive(is.numeric(n1()) && is.numeric(n2())) + +product <- sd_reactive("product", if (ready()) n1() * n2() else NA) +sum_val <- sd_reactive("sum", if (ready()) n1() + n2() else NA) +``` + +The guard is required with this approach: question ids hidden behind `n1()` can't be detected by `sd_reactive()`, so without it you would see warnings at survey startup while the questions are still unanswered. + +::: ## Defining questions in the server function diff --git a/images/cat.png b/images/cat.png new file mode 100644 index 00000000..4b274460 Binary files /dev/null and b/images/cat.png differ diff --git a/images/dog.png b/images/dog.png new file mode 100644 index 00000000..56b235ac Binary files /dev/null and b/images/dog.png differ diff --git a/images/screenshots/mc_image.png b/images/screenshots/mc_image.png new file mode 100644 index 00000000..7e1b1f78 Binary files /dev/null and b/images/screenshots/mc_image.png differ diff --git a/images/screenshots/mc_image_no_caption.png b/images/screenshots/mc_image_no_caption.png new file mode 100644 index 00000000..2f24d7b9 Binary files /dev/null and b/images/screenshots/mc_image_no_caption.png differ diff --git a/images/screenshots/mc_multiple_image.png b/images/screenshots/mc_multiple_image.png new file mode 100644 index 00000000..d13f0a9e Binary files /dev/null and b/images/screenshots/mc_multiple_image.png differ diff --git a/images/screenshots/slider.png b/images/screenshots/slider.png index be88ebfa..0ee280d2 100644 Binary files a/images/screenshots/slider.png and b/images/screenshots/slider.png differ diff --git a/images/screenshots/slider_numeric_range.png b/images/screenshots/slider_numeric_range.png index b16248d2..973fb1aa 100644 Binary files a/images/screenshots/slider_numeric_range.png and b/images/screenshots/slider_numeric_range.png differ diff --git a/images/screenshots/slider_numeric_single.png b/images/screenshots/slider_numeric_single.png index a098f869..578a93a3 100644 Binary files a/images/screenshots/slider_numeric_single.png and b/images/screenshots/slider_numeric_single.png differ diff --git a/make-screenshots.R b/make-screenshots.R new file mode 100644 index 00000000..4b57ad6f --- /dev/null +++ b/make-screenshots.R @@ -0,0 +1,188 @@ +# Regenerate the question screenshots in images/screenshots/ that the docs +# embed for widgets that only render well in a live Shiny environment +# (see chunks/question-screenshot-note.qmd). +# +# Run from the website repo root, with the current surveydown version +# installed: Rscript make-screenshots.R +# Requires: surveydown, shiny, chromote, and Google Chrome. + +library(chromote) + +port <- 8121 +shots <- list( + # selector = output file (relative to website root) + "#container-experience" = "images/screenshots/slider.png", + "#container-slider_single_val" = "images/screenshots/slider_numeric_single.png", + "#container-slider_range" = "images/screenshots/slider_numeric_range.png", + "#container-pet" = "images/screenshots/mc_image.png", + "#container-pet_no_caption" = "images/screenshots/mc_image_no_caption.png", + "#container-pets_owned" = "images/screenshots/mc_multiple_image.png" +) + +# Image-choice cards whose inputs to click (to show the selected state) +# before screenshotting, keyed by container selector +clicks <- list( + "#container-pet" = "#pet input[value=\"cat\"]", + "#container-pet_no_caption" = "#pet_no_caption input[value=\"cat\"]", + "#container-pets_owned" = c( + "#pets_owned input[value=\"cat\"]", + "#pets_owned input[value=\"dog\"]" + ) +) + +# -- Build a temporary survey app with the questions to screenshot ------- +# Question code must match the examples shown on docs/question-types.qmd. + +app_dir <- file.path(tempdir(), "screenshot_app") +dir.create(app_dir, showWarnings = FALSE) + +# Image-choice examples need their images on Shiny's resource path +dir.create(file.path(app_dir, "images"), showWarnings = FALSE) +file.copy( + c("images/cat.png", "images/dog.png"), + file.path(app_dir, "images"), + overwrite = TRUE +) + +writeLines( + c( + "---", + "survey-settings:", + " mode: preview", + " use-cookies: false", + "---", + "", + "```{r}", + "library(surveydown)", + "```", + "", + "--- page1", + "", + "```{r}", + "sd_question(", + " type = 'slider',", + " id = 'experience',", + " label = \"How would you rate your overall experience?\",", + " option = c(", + " \"Very poor\" = \"very_poor\",", + " \"Poor\" = \"poor\",", + " \"Neutral\" = \"neutral\",", + " \"Good\" = \"good\",", + " \"Very good\" = \"very_good\"", + " ),", + " selected = 'neutral'", + ")", + "", + "sd_question(", + " type = 'slider_numeric',", + " id = 'slider_single_val',", + " label = 'Single value example',", + " option = seq(0, 10, 1)", + ")", + "", + "sd_question(", + " type = 'slider_numeric',", + " id = 'slider_range',", + " label = 'Range example',", + " option = seq(0, 10, 1),", + " default = c(3, 5)", + ")", + "", + "sd_question(", + " type = 'mc_image',", + " id = 'pet',", + " label = \"Which pet do you prefer?\",", + " option = c('Cat' = 'cat', 'Dog' = 'dog'),", + " image = c('images/cat.png', 'images/dog.png')", + ")", + "", + "sd_question(", + " type = 'mc_image',", + " id = 'pet_no_caption',", + " label = \"Which pet do you prefer?\",", + " option = c('cat', 'dog'),", + " image = c('images/cat.png', 'images/dog.png')", + ")", + "", + "sd_question(", + " type = 'mc_multiple_image',", + " id = 'pets_owned',", + " label = \"Which pets have you owned? (select all that apply)\",", + " option = c('Cat' = 'cat', 'Dog' = 'dog'),", + " image = c('images/cat.png', 'images/dog.png')", + ")", + "```", + "", + "--- end", + "", + "End.", + "", + "```{r}", + "sd_close()", + "```" + ), + file.path(app_dir, "survey.qmd") +) + +writeLines( + c( + "library(surveydown)", + "ui <- sd_ui()", + "server <- function(input, output, session) {", + " sd_server()", + "}", + "shiny::shinyApp(ui = ui, server = server)" + ), + file.path(app_dir, "app.R") +) + +# -- Launch the app ------------------------------------------------------- + +system(sprintf("pkill -f 'shiny::runApp.*%d'", port), ignore.stderr = TRUE) +Sys.sleep(1) +cat("Launching screenshot app (first render takes ~1 min)...\n") +system(sprintf( + "Rscript -e 'shiny::runApp(\"%s\", port = %d)' > /tmp/sd_screenshot_app.log 2>&1 &", + normalizePath(app_dir), port +)) +url <- sprintf("http://127.0.0.1:%d/", port) +up <- FALSE +for (i in 1:60) { + up <- tryCatch( + { + suppressWarnings(readLines(url, n = 1)) + TRUE + }, + error = function(e) FALSE + ) + if (up) break + Sys.sleep(3) +} +if (!up) stop("App did not start. See /tmp/sd_screenshot_app.log") + +# -- Take the screenshots ------------------------------------------------- + +b <- ChromoteSession$new(width = 1000, height = 1600) +b$Page$navigate(url) +Sys.sleep(6) + +# Click image-choice cards to show the selected state in the screenshots +for (container in names(clicks)) { + for (target in clicks[[container]]) { + b$Runtime$evaluate(sprintf( + "document.querySelector('%s').click();", target + )) + } +} +Sys.sleep(1) + +for (sel in names(shots)) { + out <- shots[[sel]] + b$screenshot(out, selector = sel, scale = 2) + cat("Saved", out, "\n") +} + +b$close() +system(sprintf("pkill -f 'shiny::runApp.*%d'", port)) +unlink(app_dir, recursive = TRUE) +cat("Done.\n")