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")