diff --git a/NEWS.md b/NEWS.md index 4940fda9..26ba1295 100644 --- a/NEWS.md +++ b/NEWS.md @@ -8,7 +8,7 @@ - New feature: `matrix_multiple` question type — a matrix where each row allows multiple selections (checkboxes), complementing the single-selection `matrix` type (radio buttons). Usage is identical to `matrix` (same `row` and `option` parameters); each row becomes a `_` sub-question stored as its own column, with multiple selections pipe-joined (e.g., `"fast|cheap"`). Row shuffling, required-question handling, and session restoration work the same as for `matrix`. - Enhancement: Sliders have been restyled. `slider` and `slider_numeric` questions now get a slim, theme-colored track with a clean circular handle and value bubble (previously ionRangeSlider shipped completely unstyled). The dense minor tick marks between positions are hidden; only the labeled major tick marks at the main breaks are shown. -- Enhancement: `sd_reactive()` no longer warns when its expression fails simply because the questions it references are not answered yet (e.g., arithmetic on a blank value at session start). Question references (`input$x`, `all_data$x`, `sd_value()`, `sd_values()`) are extracted from the expression; if any referenced value is still blank when an error occurs, the error is treated as the expected "not answered yet" state and `""` is stored quietly. Genuine errors (all referenced values filled in, or no detectable references) still warn. For this detection to work, call `sd_value()` *inside* the `sd_reactive()` expression rather than capturing values in local variables beforehand. +- Enhancement: `sd_reactive()` no longer warns when its expression fails simply because the questions it references are not answered yet (e.g., arithmetic on a blank value at session start). Question references (`input$x`, `all_data$x`, `sd_value()`, `sd_values()`) are extracted from the expression; if any referenced value is still blank when an error occurs, the error is treated as the expected "not answered yet" state and `""` is stored quietly. Genuine errors (all referenced values filled in, or no detectable references) still warn. For this detection to work, call `sd_value()` _inside_ the `sd_reactive()` expression rather than capturing values in local variables beforehand. - Bug fix: Navigating back to and then forward again past an answered matrix (or `matrix_multiple`) question no longer crashes the survey. The matrix parent question has no input of its own, so its stored value can be `NA`; the restoration guard now handles `NA` safely instead of erroring. - Bug fix: Multi-value answers (`mc_multiple`, `mc_multiple_buttons`, `daterange`, and range `slider_numeric`) are now correctly restored when a respondent navigates back to a page or refreshes. These answers are stored pipe-joined (e.g. `"red|blue"`), but the restoration logic was splitting on commas, so previously the inputs came back blank. They now split on the pipe to match how the values are stored. @@ -50,7 +50,7 @@ # surveydown 1.1.0 - Bug fix (#246): Added a helper function that provides a vector of all preserved words that cannot be used as IDs and use it to prevent preserved IDs in `sd_store_value()`. -- New feature (#247): `sd_values()` and `sd_value()` as new approach of accessing question values, replacing the Shiny default `input$`. `sd_values()` and `sd_value()` are able to restore user inputs from db after refreshing the page. They also accept either quoted question IDs or unquoted, so that either `sd_value(fruit)` or `sd_value("fruit")` is fine. They also support multiple parameters, which return into a vector of values. For example, `sd_value(fruit, vegetable)` returns `c("apple", "lettuce")`. sd_value()` is the alias of `sd_values()`, so they function the same. +- New feature (#247): `sd_values()` and `sd_value()` as new approach of accessing question values, replacing the Shiny default `input$`. `sd_values()` and `sd_value()` are able to restore user inputs from db after refreshing the page. They also accept either quoted question IDs or unquoted, so that either `sd_value(fruit)` or `sd_value("fruit")` is fine. They also support multiple parameters, which return into a vector of values. For example, `sd_value(fruit, vegetable)` returns `c("apple", "lettuce")`. sd_value()`is the alias of`sd_values()`, so they function the same. - New feature (#248): Option shuffling supported for `mc`, `mc_buttons`, `mc_multiple`, and `mc_multiple_buttons`. Subquestion shuffling supported for `matrix`. In YAML of `survey.qmd`, 2 new keys are available: `shuffled` and `all-shuffled`. `shuffled` is used to list the question IDs of these 5 question types to have their options/subquestions randomly shuffled. `all-shuffled` is by default `false` and can be set to `true` to apply shuffling to all questions. - New feature (#248, continued): Indexed option shuffling. Use indexing to define what positions of options/subquestions you want to shuffle. For example, `question_id: 1-5` means to shuffle the first 5 options of the question, or the first 5 subquestions if it's a matrix question. Other supported syntax: `question_id: [1, 2, 4, 7]`, `question_id: [1-5, 8-10]`. No indexing means to shuffle all options/subquestions. - Breaking change: The `required-questions` and `all-questions-required` YAML keys are renamed as `required` and `all-required`, respectively. @@ -68,7 +68,7 @@ # surveydown 1.0.1 -- Bug fix (#243): Updated all internal templates and roxygen examples to latest UI established in v1.0.0. +- Bug fix (#243): Updated all internal templates and roxygen examples to latest UI established in v1.0.0. - Bug fix (#244): theme assignment with custom scss files in YAML now works properly, e.g. `theme: [united, custom.scss]`. - Progress bar update: Now users can define `barcolor` under the `theme-settings` YAML section to customize the progress bar color, accepting both text color names (over 140 supported by browser CSS) and hex color codes in 3 or 6 digits, e.g. `#FF5733` or `#F53`. If not defined, it shows `~` as the value in `settings.yml`, indicating it's using the default setting which follows the theme. - Documentation update: Removed the `inst/examples/` directory. Now all exported functions are documented with simple execution showcases, plus a designated template from one of the supported [templates](https://github.com/surveydown-dev). @@ -111,7 +111,7 @@ # surveydown 0.13.1 -- Updated citation to include *PLOS One* publication +- Updated citation to include _PLOS One_ publication # surveydown 0.13.0 @@ -173,7 +173,7 @@ # surveydown 0.11.0 - Questions can now be defined using an external yml file, defaulting to `"questions.yml"`. -- New `yml` argument in `sd_question()`, defaults to `"questions.yml"`, to allow users which yml file to use for questions. For details, refer to the [Defining Questions](https://surveydown.org/docs/defining-questions) documentation page. +- New `yml` argument in `sd_question()`, defaults to `"questions.yml"`, to allow users which yml file to use for questions. For details, refer to the [Defining Questions](https://surveydown.org/docs/defining-questions) documentation page. - Update to `sd_create_survey()`: now the the `template` argument is by default `"default"`. - Update to `sd_create_survey()`: new `template = "questions_yml"` option to create a survey with the `questions_yml` template. @@ -282,9 +282,9 @@ # surveydown 0.4.1 -- Modified survey rendering to move all rendered files into "_survey" folder. -- Export survey question metadata to "_survey/questions.yml" file (see #132). -- Survey content is now extracted and saved to '_survey/pages.rds', '_survey/head.rds', and '_survey/questions.yml' files for faster loading. +- Modified survey rendering to move all rendered files into "\_survey" folder. +- Export survey question metadata to "\_survey/questions.yml" file (see #132). +- Survey content is now extracted and saved to '\_survey/pages.rds', '\_survey/head.rds', and '\_survey/questions.yml' files for faster loading. - Survey will load content from stored files if no changes detected in 'survey.qmd' or 'app.R' files. # surveydown 0.4.0 @@ -331,9 +331,9 @@ # surveydown 0.3.3 - Enhance: `sd_server()` now has a new parameter called `auto_scroll`. It's default to `TRUE`, which enables auto scrolling that tracks the user's input, can be turned off by changing to `FALSE`. Thanks to the contribution from [Zain Hoda](https://github.com/zainhoda1). -- Enhance: `sd_question()` now has the `"matrix"` type. +- Enhance: `sd_question()` now has the `"matrix"` type. - Enhance: Asterisk, as an indication of required questions, is now moved to the top right corner of question containers. -- Enhance: Replaced the default shiny alert with `sweetalert`. +- Enhance: Replaced the default shiny alert with `sweetalert`. # surveydown 0.3.2 @@ -419,12 +419,12 @@ - Removed `sd_admin_ui()` and `sd_add_admin()` from the package. - Moved the surveydown.css and page_nav.js files into the R package and out of the Quarto extension. - Added keep alive functionality to the survey. -- Bug fixes: +- Bug fixes: - The admin page password was looking for the `SUPABASE_PASSWORD` environment variable, but it should be `SURVEYDOWN_PASSWORD`. - The data fetching was not working to download the data as a CSV file in the admin page, now uses `sd_get_data()` and works. - The `show_if_custom` logic was not working for multiple conditions, now it does. - The `skip_if_custom` logic could error if a condition was `NULL` (fixed with `isTRUE()`). - - In `sd_config()`, items were being assigned with the ` <- ` operator, causing them to be deleted when the thing being assigned was `NULL`. Now changed to ` = `. + - In `sd_config()`, items were being assigned with the `<-` operator, causing them to be deleted when the thing being assigned was `NULL`. Now changed to `=`. # surveydown 0.1.0 @@ -467,7 +467,6 @@ - (extension) Now the `example.qmd` survey has instructions for supabase configuration and shinyapps deployment. - (extension) Now the `.gitignore` file has `.Renviron` included. This file will store supabase password and is essential for shinyapps deployment. Eliminating this file from pushing to GitHub will ensure that your password is only saved locally. - # surveydown 0.0.5 - `create_survey()` changed to `sd_create_survey()` for function name consistency. @@ -483,7 +482,6 @@ - (extension) Options of `mc_button` and `mu_multiple_buttons` types of questions are now centered. - Now the `surveydown::create_survey()` function will download the whole extension repo, containing the extension, an example survey, and an RStudio project. - # surveydown 0.0.3 - Now `show_if` can be applied to multiple options of the same question, which allows users to have more than one option in a question that triggers the revealing of a hidden question. diff --git a/R/config.R b/R/config.R index 9b649cfc..96b0e235 100644 --- a/R/config.R +++ b/R/config.R @@ -1634,6 +1634,21 @@ extract_question_structure_html <- function(html_content) { } } + # Extract options for ranking questions. Ranking lists are hand-built markup, + # so they carry none of the Shiny input classes the option + # extraction below keys off. + if(grepl("sd-ranking-list", type, fixed = TRUE)){ + item_nodes <- rvest::html_nodes(question_node, ".sd-ranking-item") + if(length(item_nodes) > 0){ + options <- rvest::html_attr(item_nodes, "data-value") + # Read the label span, not the whole
  • : the item also contains + # the drag handle, whose glyph would otherwise end up in the label + label_nodes <- rvest::html_element(item_nodes, ".sd-ranking-label") + names(options) <- rvest::html_text(label_nodes, trim = TRUE) + question_structure[[question_id]]$options <- as.list(options) + } + } + # Extract options for the question ( mc, *_multiple, *_buttons, and select) if (length(type) > 0 && grepl("radio|checkbox|select|matrix", type)) { if (is_matrix) { @@ -1855,7 +1870,8 @@ write_question_structure_yaml <- function(question_structure, file_yaml) { 'js-range-slider sw-slider-text' = 'slider', 'js-range-slider' = 'slider_numeric', 'shiny-date-input form-group shiny-input-container' = 'date', - 'shiny-date-range-input form-group shiny-input-container' = 'daterange' + 'shiny-date-range-input form-group shiny-input-container' = 'daterange', + 'sd-ranking-list' = 'ranking' ) # Add index to each question (1-based, for display order tracking) diff --git a/R/question_types.R b/R/question_types.R index 0f24d416..b0c6c472 100644 --- a/R/question_types.R +++ b/R/question_types.R @@ -393,7 +393,7 @@ qt_render_daterange <- function(a, ...) { id ) - shiny::tagAppendChild( + shiny::tagAppendChild( output, shiny::tags$script(htmltools::HTML(js_init)) ) @@ -473,6 +473,38 @@ qt_render_matrix <- function(a, ...) { }) } +qt_render_ranking <- function(a, ...){ + with(a, { + # Hand-built markup rather than a Shiny input widget: the value is + # reported by ranking.js via Shiny.setInputValue() as the drag order + # changes (same approach the slider renderer uses for its label -> + # value remapping). + labels <- names(choice_html(option)) + items <- lapply(seq_along(option), function(i) { + shiny::tags$li( + class = "sd-ranking-item", + `data-value` = unname(option[i]), + shiny::tags$span( + class = "sd-ranking-label", + shiny::HTML(labels[i]) + ), + shiny::tags$span( + class = "sd-ranking-handle", + `aria-hidden` = "true", + "\u2261" + ) + ) + }) + + shiny::div( + class = "sd-ranking-container", + shiny::tags$label(class = "control-label", label), + shiny::tags$ul(id = id, class = "sd-ranking-list", items) + ) + + }) +} + # -- Restorers -------------------------------------------------------------- qt_restore_mc <- function(session, id, value, info) { @@ -589,6 +621,15 @@ qt_restore_daterange <- function(session, id, value, info) { } } +qt_restore_ranking <- function(session, id, value, info){ + # Stored pipe-joined; as.list() keeps it a JSON array even for one item + order <- split_stored_value(value) + session$sendCustomMessage( + "restoreRankingOrder", + list(id = id, order = as.list(order)) + ) +} + # -- Registry ---------------------------------------------------------------- question_type_registry <- list( @@ -675,5 +716,10 @@ question_type_registry <- list( render = qt_render_matrix, restore = NULL, requires_option = TRUE + ), + ranking = list( + render = qt_render_ranking, + restore = qt_restore_ranking, + requires_option = TRUE ) ) diff --git a/R/ui.R b/R/ui.R index 621d9186..70cadfa9 100644 --- a/R/ui.R +++ b/R/ui.R @@ -687,7 +687,7 @@ extract_head_content <- function(html_content) { #' `"mc"`, `"mc_multiple"`, `"mc_buttons"`, `"mc_multiple_buttons"`, #' `"mc_image"`, `"mc_multiple_image"`, `"text"`, `"textarea"`, `"numeric"`, #' `"slider"`, `"slider_numeric"`, `"date"`, `"daterange"`, `"matrix"`, and -#' `"matrix_multiple"`. Defaults to `NULL`. +#' `"matrix_multiple"`, `"ranking"`. Defaults to `NULL`. #' @param label Character string. The label for the UI element, which can be #' formatted with markdown. Defaults to `NULL` #' @param cols Integer. Number of columns for the `"textarea"` question type. @@ -789,6 +789,7 @@ extract_head_content <- function(html_content) { #' selection per row, radio buttons) #' - `"matrix_multiple"`: Matrix-style question where each row allows #' multiple selections (checkboxes) +#' - `"ranking"`: Ranking question where respondents can order options by preference #' #' For `"matrix"` and `"matrix_multiple"` type questions, use the `row` #' parameter to define the rows of the matrix. Each element in the `row` diff --git a/inst/css/surveydown.css b/inst/css/surveydown.css index 85494a61..31339f28 100644 --- a/inst/css/surveydown.css +++ b/inst/css/surveydown.css @@ -431,6 +431,65 @@ a:hover { box-shadow: 0 0 0 3px color-mix(in srgb, var(--theme-color) 35%, transparent); } +/* Ranking drag to reorder */ +.sd-ranking-list{ + list-style: none; + padding : 0; + margin: 0.5rem 0 0 0; + counter-reset: sd-rank; +} + +.sd-ranking-item{ + display:flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + margin-bottom: 6px; + border: 1px solid #ddd; + border-radius: 6px; + background-color: #fff; + user-select: none; + --webkit-user-select: none; + cursor: grab; +} + +/* Position number via CSS counter, so a reorder renumbers for free with no JS bookkeeping. */ +.sd-ranking-item::before{ + counter-increment: sd-rank; + content: counter(sd-rank); + flex: 0 0 1.75em; + height: 1.75em; + line-height: 1.75em; + text-align: center; + border-radius: 50%; + background-color: var(--theme-color); + color: #fff; + font-size: 0.85em; +} + +/* Only the handle disables touch scrolling (see the pointerdown guard in ranking.js), + so swiping over the body of a long list still scrolls the page on mobile. +*/ +.sd-ranking-handle{ + touch-action: none; + cursor: grab; + margin-left: auto; + padding: 0 4px; + color: #999; + line-height: 1; +} + +.sd-ranking-item:hover{ + border-color: var(--theme-color); +} + +.sd-ranking-dragging{ + cursor: grabbing; + opacity: 0.65; + border-color: var(--theme-color); + box-shadow: 0 2px 8px rgba(0,0,0, 0.15); +} + /* Survey mode banner (preview mode / database not connected) */ .sd-mode-banner { position: fixed; diff --git a/inst/js/ranking.js b/inst/js/ranking.js new file mode 100644 index 00000000..509b675e --- /dev/null +++ b/inst/js/ranking.js @@ -0,0 +1,135 @@ +// Drag to reorder behavior for ranking questions +// Use Pointer Events so mouse and touch run through one code path. +// (The native HTML 5 drag and drop API is not usable here: it does not fire on touch devices.) +// Handlers are registered ONCE on the document and work for every current and future ranking question, +// including reactive questions that re-render -- the same delegated approach used in interaction.js. + +(function () { + var dragging = null; // the
  • currently being dragged + var draggingList = null; //its parent
      + var interacted = {}; // question ids the respondent has actually dragged + + function itemsOf(list) { + return Array.prototype.slice.call( + list.querySelectorAll(".sd-ranking-item"), + ); + } + + // Report the current top-to-bottom order to Shiny, pipe-joined to match + // format_question_value()'s convention for multi-value answers(R/utils.R). + function reportOrder(list) { + var values = itemsOf(list).map(function (item) { + return item.getAttribute("data-value"); + }); + Shiny.setInputValue(list.id, values.join("|")); + } + + // Reorder the DOM to match a list of values. Values not present are + // ignored; items not named in `order` keep their relative position after + // the ones that are. + + function applyOrder(list, order) { + var values = [].concat(order || []); + var byValue = {}; + itemsOf(list).forEach(function (item) { + byValue[item.getAttribute("data-value")] = item; + }); + values.forEach(function (value) { + if (byValue[value]) list.appendChild(byValue[value]); + }); + } + + // The item the dragged element should be inserted before: the closest one + // whose vertical midpoint sits below the pointer, null = past the last + // item, so append. + function itemAfter(list, y) { + return itemsOf(list).reduce( + function (closest, item) { + if (item === dragging) return closest; + var box = item.getBoundingClientRect(); + var offset = y - box.top - box.height / 2; + if (offset < 0 && offset > closest.offset) { + return { offset: offset, item: item }; + } + return closest; + }, + { offset: Number.NEGATIVE_INFINITY, item: null }, + ).item; + } + + function markInteracted(id) { + if (interacted[id]) return; + interacted[id] = true; + Shiny.setInputValue(id + "_interacted", true, { priority: "event" }); + } + + document.addEventListener("pointerdown", function (e) { + var item = e.target.closest(".sd-ranking-item"); + if (!item) return; + var list = item.closest(".sd-ranking-list"); + if (!list) return; + + // On touch, only the handle starts a drag. The item body keeps its + // default touch-action, so swiping over a long list still scrolls the + // page on mobile. A mouse can grab anywhere on the row. + if (e.pointerType !== "mouse" && !e.target.closest(".sd-ranking-handle")) { + return; + } + + dragging = item; + draggingList = list; + item.classList.add("sd-ranking-dragging"); + + // Capture on the LIST, not the item: reordering re-inserts the dragged + //
    • into the DOM< which drops a capture held by the item itself. + // ( and touch implicitly captures to the pointerdown target). + list.setPointerCapture(e.pointerId); + }); + + document.addEventListener( + "pointermove", + function (e) { + if (!dragging) return; + e.preventDefault(); + var next = itemAfter(draggingList, e.clientY); + if (next) { + draggingList.insertBefore(dragging, next); + } else { + draggingList.appendChild(dragging); + } + }, + { passive: false }, + ); + + function endDrag() { + if (!dragging) return; + var list = draggingList; + dragging.classList.remove("sd-ranking-dragging"); + dragging = null; + draggingList = null; + reportOrder(list); + markInteracted(list.id); + } + + document.addEventListener("pointerup", endDrag); + document.addEventListener("pointercancel", endDrag); + + // Restoration (Previous button, resumed session). Must NOT mark the + // question as interacted -- restoring a saved answer is not a new + // interaction, and treating it as one would advance the progress bar on + // page load. + Shiny.addCustomMessageHandler("restoreRankingOrder", function (message) { + var list = document.getElementById(message.id); + if (list) applyOrder(list, message.order); + }); + + // Test hook: headless browsers cannot reliably synthesize a drag gesture, + // so the browser tests drive ordering through this instead. + window.sdSetRankingOrder = function (id, order) { + var list = document.getElementById(id); + if (!list) return; + applyOrder(list, order); + reportOrder(list); + markInteracted(id); + }; +})(); diff --git a/inst/lua/include-resources.lua b/inst/lua/include-resources.lua index bd1782a4..3f337d52 100644 --- a/inst/lua/include-resources.lua +++ b/inst/lua/include-resources.lua @@ -25,7 +25,8 @@ function Meta(meta) "interaction.js", "keep_alive.js", "option_attr.js", - "progressbar.js" + "progressbar.js", + "ranking.js" } local css_files = { diff --git a/man/sd_question.Rd b/man/sd_question.Rd index 80acd1f4..ec7795bc 100644 --- a/man/sd_question.Rd +++ b/man/sd_question.Rd @@ -40,7 +40,7 @@ variable name in the resulting survey data.} \code{"mc"}, \code{"mc_multiple"}, \code{"mc_buttons"}, \code{"mc_multiple_buttons"}, \code{"mc_image"}, \code{"mc_multiple_image"}, \code{"text"}, \code{"textarea"}, \code{"numeric"}, \code{"slider"}, \code{"slider_numeric"}, \code{"date"}, \code{"daterange"}, \code{"matrix"}, and -\code{"matrix_multiple"}. Defaults to \code{NULL}.} +\code{"matrix_multiple"}, \code{"ranking"}. Defaults to \code{NULL}.} \item{label}{Character string. The label for the UI element, which can be formatted with markdown. Defaults to \code{NULL}} @@ -174,6 +174,7 @@ The function supports various question types: selection per row, radio buttons) \item \code{"matrix_multiple"}: Matrix-style question where each row allows multiple selections (checkboxes) +\item \code{"ranking"}: Ranking question where respondents can order options by preference } For \code{"matrix"} and \code{"matrix_multiple"} type questions, use the \code{row}