├── .Rbuildignore ├── .gitattributes ├── .github ├── .gitignore └── workflows │ ├── R-CMD-check.yaml │ └── routine.yaml ├── .gitignore ├── .vscode └── tasks.json ├── CRAN-SUBMISSION ├── DESCRIPTION ├── LICENSE ├── LICENSE.md ├── NAMESPACE ├── NEWS.md ├── R ├── assertions.R ├── bucket_list.R ├── dependencies.R ├── incrementor.R ├── js.R ├── label_ids.R ├── methods.R ├── question_rank.R ├── rank_list.R ├── sortable-package.R ├── sortable_js.R ├── sortable_options.R └── zzz.R ├── README.Rmd ├── README.md ├── _pkgdown.yml ├── codecov.yml ├── cran-comments.md ├── inst ├── WORDLIST ├── examples │ ├── example_bucket_list.R │ ├── example_question_rank.R │ ├── example_rank_list.R │ ├── example_rank_list_multidrag.R │ ├── example_rank_list_swap.R │ ├── example_sortable_js.R │ └── example_sortable_js_capture.R ├── htmlwidgets │ ├── lib │ │ └── sortable │ │ │ └── sortable.js │ ├── plugins │ │ └── sortable-rstudio │ │ │ ├── .gitignore │ │ │ ├── _colors.scss │ │ │ ├── bucket_list.css │ │ │ ├── bucket_list.scss │ │ │ ├── rank_list.css │ │ │ ├── rank_list.scss │ │ │ └── rank_list_binding.js │ ├── sortable.js │ └── sortable.yaml ├── shiny │ ├── bucket_list │ │ └── app.R │ ├── clone_remove │ │ └── app.R │ ├── custom_css │ │ └── app.R │ ├── drag_vars_to_plot │ │ └── app.R │ ├── group_list │ │ └── app.R │ ├── horizontal │ │ └── app.R │ ├── rank_list │ │ └── app.R │ ├── shiny_tabset │ │ └── app.R │ ├── update_bucket_list │ │ └── app.R │ ├── update_rank_list_method │ │ └── app.R │ └── update_rank_list_ui │ │ └── app.R └── tutorials │ └── question_rank │ └── question_rank.Rmd ├── logo.svg ├── man-roxygen └── options.R ├── man ├── add_rank_list.Rd ├── bucket_list.Rd ├── chain_js_events.Rd ├── figures │ ├── README-unnamed-chunk-4-1.png │ ├── README-unnamed-chunk-5-1.png │ ├── bucket_list_shiny.gif │ ├── diagrammer.gif │ ├── lifecycle-archived.svg │ ├── lifecycle-defunct.svg │ ├── lifecycle-deprecated.svg │ ├── lifecycle-experimental.svg │ ├── lifecycle-maturing.svg │ ├── lifecycle-questioning.svg │ ├── lifecycle-retired.svg │ ├── lifecycle-soft-deprecated.svg │ ├── lifecycle-stable.svg │ ├── logo.png │ ├── logo.svg │ ├── rank_list_shiny.gif │ ├── simple_sortable_shiny.gif │ └── sortable-logo.png ├── is_sortable_options.Rd ├── question_rank.Rd ├── rank_list.Rd ├── render_sortable.Rd ├── sortable.Rd ├── sortable_js.Rd ├── sortable_js_capture_input.Rd ├── sortable_options.Rd ├── sortable_output.Rd ├── update_bucket_list.Rd └── update_rank_list.Rd ├── pkgdown └── favicon │ ├── apple-touch-icon-120x120.png │ ├── apple-touch-icon-152x152.png │ ├── apple-touch-icon-180x180.png │ ├── apple-touch-icon-60x60.png │ ├── apple-touch-icon-76x76.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ └── favicon.ico ├── scripts ├── build_docs.R ├── compile_css.R ├── deploy_apps.R ├── download_sortablejs.R ├── load_all_shim.R └── readme.md ├── sortable.Rproj ├── tests ├── spelling.R ├── testthat.R └── testthat │ ├── test-bucket_list.R │ ├── test-creation.R │ ├── test-htmltools.R │ ├── test-js.R │ ├── test-label_ids.R │ ├── test-learnr-question_rank.R │ └── test-rank_list.R └── vignettes ├── .gitignore ├── built_in.Rmd ├── cloning.Rmd ├── figures ├── clone_delete.gif ├── drag_vars_to_plot.gif ├── simple_rank_list.gif └── sortable_tabs.gif ├── novel_solutions.Rmd ├── understanding_sortable_js.Rmd ├── updating_rank_list.Rmd └── using_custom_css.Rmd /.Rbuildignore: -------------------------------------------------------------------------------- 1 | ^.*\.Rproj$ 2 | ^\.Rproj\.user$ 3 | ^LICENSE\.md$ 4 | 5 | ^Readme\.md$ 6 | ^README\.Rmd$ 7 | ^README\.html$ 8 | 9 | ^\.travis\.yml$ 10 | ^\.lintr$ 11 | 12 | ^doc$ 13 | ^Meta$ 14 | ^scripts/ 15 | ^man-roxygen/ 16 | ^docs$ 17 | 18 | ^_pkgdown\.yml$ 19 | ^pkgdown$ 20 | 21 | ^codecov\.yml$ 22 | ^logo.svg$ 23 | 24 | ^inst/tutorials/.*/rsconnect/* 25 | ^inst/shiny/.*/rsconnect/* 26 | ^inst/tutorials/.*\\.html$ 27 | 28 | ^.*\.scss$ 29 | ^cran-comments\.md$ 30 | ^CRAN-RELEASE$ 31 | ^\.github$ 32 | ^CRAN-SUBMISSION$ 33 | 34 | revdep/* 35 | ^.sass_cache_keys$ 36 | inst/htmlwidgets/plugins/sortable-rstudio/.sass_cache_keys 37 | sortable/man-roxygen 38 | sortable/scripts 39 | ^revdep$ 40 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.html linguist-documentation=true 2 | docs/* linguist-documentation=true 3 | inst/htmlwidgets/lib/sortable/* linguist-vendored 4 | -------------------------------------------------------------------------------- /.github/.gitignore: -------------------------------------------------------------------------------- 1 | *.html 2 | -------------------------------------------------------------------------------- /.github/workflows/R-CMD-check.yaml: -------------------------------------------------------------------------------- 1 | # Workflow derived from https://github.com/rstudio/shiny-workflows 2 | # 3 | # NOTE: This Shiny team GHA workflow is overkill for most R packages. 4 | # For most R packages it is better to use https://github.com/r-lib/actions 5 | on: 6 | push: 7 | branches: [main, rc-**] 8 | pull_request: 9 | branches: [main] 10 | # schedule: 11 | # - cron: '0 8 * * 1' # every monday 12 | 13 | name: Package checks 14 | 15 | jobs: 16 | website: 17 | uses: rstudio/shiny-workflows/.github/workflows/website.yaml@v1 18 | R-CMD-check: 19 | uses: rstudio/shiny-workflows/.github/workflows/R-CMD-check.yaml@v1 20 | 21 | deploy-tutorials: 22 | name: Deploy tutorials 23 | if: github.repository == 'rstudio/sortable' && github.event_name == 'push' 24 | needs: R-CMD-check 25 | runs-on: ubuntu-latest 26 | env: 27 | GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} 28 | SHINYAPPS_NAME: ${{ secrets.SHINYAPPS_NAME }} 29 | SHINYAPPS_TOKEN: ${{ secrets.SHINYAPPS_TOKEN }} 30 | SHINYAPPS_SECRET: ${{ secrets.SHINYAPPS_SECRET }} 31 | steps: 32 | - uses: actions/checkout@v2 33 | 34 | - uses: rstudio/shiny-workflows/setup-r-package@v1 35 | with: 36 | extra-packages: | 37 | remotes 38 | rsconnect 39 | glue 40 | withr 41 | 42 | - name: Install package from GitHub 43 | shell: Rscript {0} 44 | run: | 45 | remotes::install_github("rstudio/sortable") 46 | 47 | - name: Deploy tutorials 48 | continue-on-error: true 49 | shell: Rscript {0} 50 | run: source("scripts/deploy_apps.R") 51 | 52 | - name: Returns a success even if the deployment step fails. 53 | run: exit 0 54 | -------------------------------------------------------------------------------- /.github/workflows/routine.yaml: -------------------------------------------------------------------------------- 1 | # Workflow derived from https://github.com/rstudio/shiny-workflows 2 | # 3 | # NOTE: This Shiny team GHA workflow is overkill for most R packages. 4 | # For most R packages it is better to use https://github.com/r-lib/actions 5 | on: 6 | pull_request: 7 | branches: [main] 8 | 9 | name: Routine package checks 10 | 11 | jobs: 12 | routine: 13 | uses: rstudio/shiny-workflows/.github/workflows/routine.yaml@v1 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .Rproj.user 2 | .Rhistory 3 | .RData 4 | docs/ 5 | Meta 6 | README.html 7 | doc 8 | inst/doc 9 | inst/tutorials/*.html 10 | vignettes/*.html 11 | vignettes/*.R 12 | 13 | inst/tutorials/*/rsconnect/* 14 | inst/shiny/*/rsconnect/* 15 | inst/tutorials/*/*.html 16 | /doc/ 17 | /Meta/ 18 | 19 | revdep/* 20 | 21 | /.quarto/ 22 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "rPackageTask", 6 | "problemMatcher": [], 7 | "label": "R: Check R package", 8 | "group": { 9 | "kind": "build", 10 | "isDefault": true 11 | } 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /CRAN-SUBMISSION: -------------------------------------------------------------------------------- 1 | Version: 0.5.0 2 | Date: 2023-03-26 12:26:41 UTC 3 | SHA: 6ce650d0ea9930bc00b300efd44d70a21ed7e775 4 | -------------------------------------------------------------------------------- /DESCRIPTION: -------------------------------------------------------------------------------- 1 | Type: Package 2 | Package: sortable 3 | Title: Drag-and-Drop in 'shiny' Apps with 'SortableJS' 4 | Version: 0.5.0.9000 5 | Authors@R: 6 | c(person(given = "Andrie", 7 | family = "de Vries", 8 | role = c("cre", "aut"), 9 | email = "apdevries@gmail.com"), 10 | person(given = "Barret", 11 | family = "Schloerke", 12 | role = "aut", 13 | email = "barret@rstudio.com"), 14 | person(given = "Kenton", 15 | family = "Russell", 16 | role = c("aut", "ccp"), 17 | email = "kent.russell@timelyportfolio.com", 18 | comment = "Original author"), 19 | person("RStudio", role = c("cph", "fnd")), 20 | person(given = "Lebedev", 21 | family = "Konstantin", 22 | role = "cph", 23 | comment = "'SortableJS', https://sortablejs.github.io/Sortable/")) 24 | Description: Enables drag-and-drop behaviour in Shiny apps, by exposing the 25 | functionality of the 'SortableJS' 26 | JavaScript library as an 'htmlwidget'. 27 | You can use this in Shiny apps and widgets, 'learnr' tutorials as well as 28 | R Markdown. In addition, provides a custom 'learnr' question type - 29 | 'question_rank()' - that allows ranking questions with drag-and-drop. 30 | License: MIT + file LICENSE 31 | URL: https://rstudio.github.io/sortable/ 32 | BugReports: https://github.com/rstudio/sortable/issues 33 | Imports: 34 | htmltools, 35 | htmlwidgets, 36 | learnr (>= 0.10.0), 37 | shiny (>= 1.9.0), 38 | assertthat, 39 | jsonlite, 40 | utils, 41 | rlang (>= 1.0.0) 42 | Suggests: 43 | base64enc, 44 | knitr, 45 | testthat (>= 2.1.0), 46 | withr, 47 | rmarkdown, 48 | magrittr, 49 | webshot, 50 | spelling, 51 | covr 52 | VignetteBuilder: 53 | knitr 54 | Encoding: UTF-8 55 | Roxygen: list(markdown = TRUE) 56 | RoxygenNote: 7.3.2 57 | Language: en-US 58 | Config/testthat/edition: 3 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | YEAR: 2019 2 | COPYRIGHT HOLDER: Andrie de Vries 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2019 Andrie de Vries 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /NAMESPACE: -------------------------------------------------------------------------------- 1 | # Generated by roxygen2: do not edit by hand 2 | 3 | S3method(print,bucket_list) 4 | S3method(print,rank_list) 5 | S3method(question_is_correct,sortable_rank) 6 | S3method(question_ui_completed,sortable_rank) 7 | S3method(question_ui_initialize,sortable_rank) 8 | S3method(question_ui_try_again,sortable_rank) 9 | export(add_rank_list) 10 | export(bucket_list) 11 | export(chain_js_events) 12 | export(is_sortable_options) 13 | export(question_rank) 14 | export(rank_list) 15 | export(render_sortable) 16 | export(sortable_js) 17 | export(sortable_js_capture_bucket_input) 18 | export(sortable_js_capture_input) 19 | export(sortable_options) 20 | export(sortable_output) 21 | export(update_bucket_list) 22 | export(update_rank_list) 23 | importFrom(assertthat,"on_failure<-") 24 | importFrom(assertthat,assert_that) 25 | importFrom(assertthat,is.string) 26 | importFrom(htmltools,tagList) 27 | importFrom(htmltools,tags) 28 | importFrom(htmlwidgets,shinyWidgetOutput) 29 | importFrom(learnr,disable_all_tags) 30 | importFrom(learnr,mark_as) 31 | importFrom(learnr,question_is_correct) 32 | importFrom(learnr,question_is_valid) 33 | importFrom(learnr,question_ui_completed) 34 | importFrom(learnr,question_ui_initialize) 35 | importFrom(learnr,question_ui_try_again) 36 | importFrom(utils,modifyList) 37 | -------------------------------------------------------------------------------- /NEWS.md: -------------------------------------------------------------------------------- 1 | # sortable 0.5.0.9000 2 | 3 | ## Enhancements 4 | 5 | * New function `update_bucket_list()` to update the items in a bucket list 6 | * New functionality to update `labels` in `update_rank_list()` 7 | 8 | ## Updates 9 | 10 | * Update `Sortable.js` to version 1.15.3 11 | 12 | 13 | # sortable 0.5.0 14 | 15 | ## Enhancements 16 | 17 | * Add support for `update_rank_list()` 18 | * Add ability to switch the orientation of `rank_list()` items to horizontal. #92 19 | 20 | ## Changes 21 | 22 | * A `rank_list` now has a unique CSS id, to allow updating the `text` of the 23 | container. 24 | 25 | 26 | # sortable 0.4.6 27 | 28 | ## Upgrade sortable.js 29 | 30 | * Include `sortable.js` version 1.15.0 31 | 32 | 33 | # sortable 0.4.5 34 | 35 | ## Upgrade sortable.js 36 | 37 | * Include `sortable.js` version 1.14.0, as suggested by #82 and #73 38 | 39 | ## Bug fixes 40 | 41 | * Capture error if bucket_list header is empty #31 42 | * Fix rank-list-item that spills outside the container boundary #68 43 | * Allow bucket_list to have empty header, and capture error better #69 44 | 45 | ## Other changes 46 | 47 | * Upgrade tests to `testthat` version 3 48 | 49 | 50 | # sortable 0.4.4 51 | 52 | * No functional changes 53 | * This release removes a Suggests dependency on `lifecycle` to comply with CRAN 54 | policy. The `lifecycle` package was not used in the project. 55 | 56 | # sortable 0.4.3 57 | 58 | ## Breaking changes: 59 | 60 | * Moved the `...` dots argument of `sortable_options()` to the first argument, 61 | where previously it was the last argument. This is to prevent partial name 62 | matching resulting in the incorrect sortable option being set. 63 | 64 | ## Other: 65 | 66 | * Updated `sortable.js` to version 1.10.2 67 | 68 | * Added examples for using the `sortable.js` plugins, specifically multiDrag and 69 | swap. 70 | 71 | * Added vignette on cloning and removing, contributed by Maya Gans 72 | 73 | 74 | # sortable 0.4.2 75 | 76 | * First release accepted by CRAN 77 | 78 | 79 | # sortable 0.4.0 80 | 81 | * First candidate release to CRAN 82 | -------------------------------------------------------------------------------- /R/assertions.R: -------------------------------------------------------------------------------- 1 | #' @importFrom assertthat assert_that on_failure<- 2 | 3 | is_input_id <- function(x) { 4 | is.null(x) || (is.character(x) && length(x) == 1 && !is.na(x)) 5 | } 6 | 7 | on_failure(is_input_id) <- function(call, env) { 8 | paste0(deparse(call$x), " is not a string (length 1 character)") 9 | } 10 | 11 | # -------------------------------------------------------------------- 12 | 13 | 14 | is_header <- function(x) { 15 | is.null(x) || (length(x) == 1 && (is.na(x) || is.character(x))) 16 | } 17 | 18 | on_failure(is_header) <- function(call, env) { 19 | paste0(deparse(call$x), " must be NULL or a string with at least 1 character") 20 | } 21 | 22 | 23 | # ------------------------------------------------------------------------- 24 | 25 | -------------------------------------------------------------------------------- /R/bucket_list.R: -------------------------------------------------------------------------------- 1 | #' Add a rank list inside bucket list. 2 | #' 3 | #' Since a [bucket_list] can contain more than one [rank_list], you need 4 | #' an easy way to define the contents of each individual rank list. This 5 | #' function serves as a specification of a rank list. 6 | #' 7 | #' @inheritParams rank_list 8 | #' @param ... Other arguments passed to `rank_list` 9 | #' 10 | #' @seealso [bucket_list()], [rank_list()] and [update_rank_list()] 11 | #' @return A list of class `add_rank_list` 12 | #' @export 13 | add_rank_list <- function(text, labels = NULL, input_id = NULL, 14 | css_id = input_id, ...) 15 | { # nolint 16 | if (is.null(input_id)) { 17 | input_id <- increment_rank_list_input_id() 18 | } 19 | z <- list( 20 | text = text, 21 | labels = labels, 22 | input_id = input_id, 23 | css_id = css_id, 24 | ... 25 | ) 26 | class(z) <- c("add_rank_list", "list") 27 | z 28 | } 29 | 30 | is_add_rank_list <- function(x) { 31 | inherits(x, "add_rank_list") 32 | } 33 | 34 | 35 | #' Create a bucket list. 36 | #' 37 | #' A bucket list can contain more than one [rank_list] and allows drag-and-drop 38 | #' of items between the different lists. 39 | #' 40 | #' @template options 41 | #' 42 | #' @param header Text that appears at the top of the bucket list. (This is 43 | #' encoded as an HTML `

` tag, so not strictly speaking a header.) Note 44 | #' that you must explicitly provide `header` argument, especially in the case 45 | #' where you want the header to be empty - to do this use `header = NULL` or 46 | #' `header = NA`. 47 | #' 48 | #' @param ... One or more specifications for a rank list, and must be defined by 49 | #' [add_rank_list]. 50 | #' 51 | #' @param class A css class applied to the bucket list and rank lists. This can 52 | #' be used to define custom styling. 53 | #' 54 | #' @param group_name Passed to `SortableJS` as the group name. Also the input 55 | #' value set in Shiny. (`input[[group_name]]`). Items can be dragged between 56 | #' bucket lists which share the same group name. 57 | #' 58 | #' @param group_put_max Not yet implemented 59 | #' 60 | #' @param orientation Either `horizontal` or `vertical`, and specifies the 61 | #' layout of the components on the page. 62 | #' 63 | #' @param css_id This is the css id to use, and must be unique in your shiny 64 | #' app. This defaults to the value of `group_id`, and will be appended to the 65 | #' value "bucket-list-container", to ensure the CSS id is unique for the 66 | #' container as well as the embedded rank lists. 67 | #' 68 | #' @return A list with class `bucket_list` 69 | #' @seealso [rank_list], [update_rank_list] 70 | #' @export 71 | #' 72 | #' @example inst/examples/example_bucket_list.R 73 | #' @examples 74 | #' ## Example of a shiny app 75 | #' if (interactive()) { 76 | #' app <- system.file( 77 | #' "shiny/bucket_list/app.R", 78 | #' package = "sortable" 79 | #' ) 80 | #' shiny::runApp(app) 81 | #' } 82 | bucket_list <- function( 83 | header = NULL, 84 | ..., 85 | group_name, 86 | css_id = group_name, 87 | group_put_max = rep(Inf, length(labels)), 88 | options = sortable_options(), 89 | class = "default-sortable", 90 | orientation = c("horizontal", "vertical") 91 | ) { 92 | 93 | assert_that(is_header(header)) 94 | if (isTRUE(is.na(header))) header <- NULL 95 | 96 | assert_that(is_sortable_options(options)) 97 | if (missing(group_name) || is.null(group_name)) { 98 | group_name <- increment_bucket_group() 99 | } 100 | 101 | orientation <- match.arg(orientation) 102 | 103 | class <- paste(class, collapse = " ") 104 | 105 | # capture the dots 106 | rlang::check_dots_unnamed() 107 | dot_vals <- rlang::list2(...) 108 | 109 | # Remove any NULL elements 110 | dot_vals <- dot_vals[!vapply(dot_vals, is.null, FUN.VALUE = logical(1))] 111 | 112 | # modify the dots by adding the group_name to the sortable options 113 | dots <- lapply(seq_along(dot_vals), function(i) { 114 | dot <- dot_vals[[i]] 115 | assert_that(is_add_rank_list(dot)) 116 | 117 | if (is.null(dot$css_id)) { 118 | dot$css_id <- increment_rank_list() 119 | } 120 | modifyList( 121 | dot, 122 | val = list( 123 | options = sortable_options(group = group_name), 124 | class = paste(class, paste0("column_", i)) 125 | ) 126 | ) 127 | }) 128 | 129 | css_ids <- vapply(dots, function(dot) dot$css_id, character(1)) 130 | input_ids <- vapply(dots, function(dot) dot$input_id, character(1)) 131 | 132 | set_bucket <- sortable_js_capture_bucket_input(group_name, input_ids, css_ids) 133 | display_empty_class <- sortable_js_set_empty_class(css_ids) 134 | 135 | dots <- lapply(dots, function(dot) { 136 | dot$options$onLoad <- chain_js_events(set_bucket, dot$options$onLoad) # nolint 137 | dot$options$onSort <- chain_js_events(set_bucket, dot$options$onSort) # nolint 138 | dot$options$onMove <- chain_js_events(dot$options$onMove, display_empty_class) # nolint 139 | dot 140 | }) 141 | 142 | # construct list rank_list objects 143 | sortables <- lapply(dots, function(dot) do.call(rank_list, dot)) 144 | 145 | title_tag <- 146 | if (!is.null(header)) { 147 | tags$p(class = "bucket-list-header", header) 148 | } else { 149 | NULL 150 | } 151 | 152 | z <- tagList( 153 | tags$div( 154 | class = paste("bucket-list-container", class), 155 | id = as_bucket_list_id(css_id), 156 | title_tag, 157 | tags$div( 158 | class = paste(class, "bucket-list", 159 | paste0("bucket-list-", orientation) 160 | ), 161 | sortables 162 | ) 163 | ), 164 | bucket_list_dependencies() 165 | ) 166 | 167 | as_bucket_list(z) 168 | } 169 | -------------------------------------------------------------------------------- /R/dependencies.R: -------------------------------------------------------------------------------- 1 | css_dependency <- function(name, files, scripts = NULL) { 2 | list( 3 | htmltools::htmlDependency( 4 | name, 5 | version = utils::packageVersion("sortable"), 6 | src = "htmlwidgets/plugins/sortable-rstudio", 7 | package = "sortable", 8 | stylesheet = files, 9 | script = scripts 10 | ) 11 | ) 12 | } 13 | 14 | rank_list_dependencies <- function() { 15 | css_dependency( 16 | "sortable-rank-list", 17 | files = "rank_list.css", 18 | scripts = "rank_list_binding.js" 19 | ) 20 | } 21 | 22 | bucket_list_dependencies <- function() { 23 | append( 24 | rank_list_dependencies(), 25 | css_dependency("sortable-bucket-list", "bucket_list.css") 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /R/incrementor.R: -------------------------------------------------------------------------------- 1 | incrementor <- function(prefix = "increment_"){ 2 | i <- 0 3 | function(){ 4 | i <<- i + 1 5 | paste0(prefix, i) 6 | } 7 | } 8 | 9 | increment_rank_list <- incrementor("rank_list_id_") 10 | 11 | increment_bucket_list <- incrementor("bucket_list_id_") 12 | 13 | increment_bucket_group <- incrementor("bucket_group_") 14 | 15 | increment_rank_list_input_id <- incrementor("rank_list_shiny_") 16 | -------------------------------------------------------------------------------- /R/js.R: -------------------------------------------------------------------------------- 1 | get_child_id_or_text_js_fn <- function() { 2 | paste0(collapse = "\n", 3 | "function(child) {", 4 | " return ", 5 | # use child element attribute 'data-rank-id' 6 | " $(child).attr('data-rank-id') || ", 7 | # otherwise return the inner text of the element 8 | # use inner text vs `.text()` to avoid extra white space 9 | " $.trim(child.innerText);", 10 | "}" 11 | ) 12 | } 13 | 14 | 15 | #' Construct JavaScript method to capture Shiny inputs on change. 16 | #' 17 | #' This captures the state of a `sortable` list. It will look for a `data-rank-id` 18 | #' attribute of the first child for each element. If no? attribute exists for 19 | #' that particular item's first child, the inner text will be used as an 20 | #' identifier. 21 | #' 22 | #' This method is used with the `onSort` option of `sortable_js`. See 23 | #' [sortable_options()]. 24 | #' 25 | #' @param input_id Shiny input name to set 26 | #' 27 | #' @seealso [sortable_js] and [rank_list]. 28 | #' 29 | #' @rdname sortable_js_capture_input 30 | #' 31 | #' @return A character vector with class `JS_EVAL`. See [htmlwidgets::JS()]. 32 | #' @family JavaScript functions 33 | #' @export 34 | #' @example inst/examples/example_sortable_js_capture.R 35 | #' @examples 36 | #' 37 | #' ## ------------------------------------ 38 | #' # For an example, see the Shiny app at 39 | #' system.file("shiny/drag_vars_to_plot/app.R", package = "sortable") 40 | sortable_js_capture_input <- function(input_id) { 41 | # can call jquery as shiny will always have jquery 42 | inner_text <- paste0( 43 | "$.map(", 44 | " this.el.children, ", 45 | get_child_id_or_text_js_fn(), 46 | ")", 47 | collapse = "\n" 48 | ) 49 | js_text <- "function(evt) { 50 | if (typeof Shiny !== \"undefined\") { 51 | Shiny.initializedPromise.then(() => { 52 | Shiny.setInputValue(\"%s:sortablejs.rank_list\", %s) 53 | }); 54 | } 55 | }" 56 | 57 | js <- sprintf(js_text, input_id, inner_text) 58 | 59 | htmlwidgets::JS(js) 60 | } 61 | 62 | 63 | #' @rdname sortable_js_capture_input 64 | #' @param input_ids Set of Shiny input ids to set corresponding to the provided 65 | #' `css_ids` 66 | #' @param css_ids Set of SortableJS `css_id` values to help retrieve all to 67 | #' set as an object 68 | #' 69 | #' @export 70 | sortable_js_capture_bucket_input <- function(input_id, input_ids, css_ids) { 71 | assert_that(length(input_ids) > 0) 72 | assert_that(length(input_ids) == length(css_ids)) 73 | 74 | # can use jquery as shiny will have jquery 75 | js_text <- "function(evt) { 76 | if (typeof Shiny == \"undefined\") { 77 | return; 78 | } 79 | 80 | var child_id_or_text_fn = %s; 81 | 82 | var ret = {}, i; 83 | var css_ids = %s; 84 | var input_ids = %s; 85 | 86 | $.map(css_ids, function(css_id, i) { 87 | var input_id = input_ids[i]; 88 | var item = $('#' + css_id).get(0); 89 | if (item && item.children) { 90 | ret[input_id] = $.map(item.children, child_id_or_text_fn); 91 | } else { 92 | ret[input_id] = undefined; 93 | } 94 | }); 95 | Shiny.setInputValue(\"%s:sortablejs.bucket_list\", ret) 96 | }" 97 | 98 | js <- sprintf( 99 | js_text, 100 | get_child_id_or_text_js_fn(), 101 | to_json_array(css_ids), 102 | to_json_array(input_ids), 103 | input_id 104 | ) 105 | 106 | htmlwidgets::JS(js) 107 | } 108 | 109 | 110 | 111 | # add empty class to all css_ids on execution 112 | # need to setTimeout as dom effects have not executed 113 | # need to pass in all ids, as moving an element from 114 | # group A to B back to A without dropping will remove empty class from B, 115 | # but never add it back. 116 | sortable_js_set_empty_class <- function(css_ids) { 117 | js_text <- 118 | "function(evt) { 119 | var css_ids = %s; 120 | setTimeout(function() { 121 | css_ids.map(function(id) { 122 | var el = window.document.getElementById(id); 123 | if (el) { 124 | Sortable.utils.toggleClass(el, 'rank-list-empty', el.children.length == 0); 125 | } 126 | }) 127 | }, 0); 128 | }" 129 | 130 | js <- sprintf( 131 | js_text, 132 | to_json_array(css_ids) 133 | ) 134 | 135 | htmlwidgets::JS(js) 136 | } 137 | 138 | to_json_array <- function(x) { 139 | as.character( 140 | jsonlite::toJSON( 141 | as.list(x), 142 | auto_unbox = TRUE 143 | ) 144 | ) 145 | } 146 | 147 | 148 | #' Chain multiple JavaScript events 149 | #' 150 | #' SortableJS does not have an event based system. To be able to call multiple 151 | #' JavaScript events under the same event execution, they need to be executed 152 | #' one after another. 153 | #' 154 | #' @param ... JavaScript functions defined by [htmlwidgets::JS] 155 | #' @return A single JavaScript function that will call all methods provided with 156 | #' the event 157 | #' @export 158 | #' @family JavaScript functions 159 | chain_js_events <- function(...) { 160 | 161 | fns <- rlang::list2(...) 162 | fns <- fns[!vapply(fns, is.null, logical(1))] 163 | fns <- lapply(fns, as.character) 164 | 165 | if (length(fns) == 1) { 166 | return(htmlwidgets::JS(fns[[1]])) 167 | } 168 | 169 | js_text <- collapse( 170 | # do not provide arguments to avoid confusion 171 | "function() {", 172 | # call fns with all arguments supplied to outer func 173 | # some event methods have more than one argument (most have 1). 174 | collapse( 175 | " try {", 176 | rep(" (%s).apply(this, arguments);", length(fns)), 177 | " } catch(e) {", 178 | " if (window.console && window.console.error) window.console.error(e);", 179 | " }", 180 | collapse = "\n\n" 181 | ), 182 | "}" 183 | ) 184 | 185 | js <- do.call(sprintf, c(js_text, fns)) 186 | 187 | htmlwidgets::JS(js) 188 | } 189 | 190 | 191 | collapse <- function(..., sep = "\n", collapse = "\n") { 192 | paste(..., sep = sep, collapse = collapse) 193 | } 194 | -------------------------------------------------------------------------------- /R/label_ids.R: -------------------------------------------------------------------------------- 1 | 2 | 3 | label_ids <- function(labels) { 4 | 5 | nms <- names(labels) 6 | if (is.null(nms)) { 7 | return(rep("", length(labels))) 8 | } 9 | 10 | nms[is.na(nms)] <- "" 11 | nms 12 | } 13 | -------------------------------------------------------------------------------- /R/methods.R: -------------------------------------------------------------------------------- 1 | 2 | as_rank_list <- function(x){ 3 | class(x) <- c("rank_list", class(x)) 4 | x 5 | } 6 | 7 | 8 | as_bucket_list <- function(x){ 9 | class(x) <- c("bucket_list", class(x)) 10 | x 11 | } 12 | 13 | 14 | #' @export 15 | print.rank_list <- function(x, ...){ 16 | htmltools::html_print(x) 17 | } 18 | 19 | #' @export 20 | print.bucket_list <- function(x, ...){ 21 | htmltools::html_print(x) 22 | } 23 | 24 | 25 | # The names must be suffix values to allow for modules which use prefix values 26 | # https://github.com/rstudio/sortable/issues/100 27 | as_rank_list_id <- function(id) { 28 | paste0("rank-list-", id) 29 | } 30 | # TODO: in future, change the order of paste, to enable shiny modules 31 | # paste0(id, "-rank-list") 32 | 33 | as_bucket_list_id <- function(id) { 34 | paste0("bucket-list-", id) 35 | } 36 | # TODO: in future, change the order of paste, to enable shiny modules 37 | # paste0(id, "-bucket-list") 38 | -------------------------------------------------------------------------------- /R/question_rank.R: -------------------------------------------------------------------------------- 1 | #' @importFrom learnr question_ui_initialize 2 | #' @importFrom learnr question_ui_completed 3 | #' @importFrom learnr question_ui_try_again 4 | #' @importFrom learnr question_is_valid 5 | #' @importFrom learnr question_is_correct 6 | #' @importFrom learnr mark_as 7 | #' @importFrom learnr disable_all_tags 8 | NULL 9 | 10 | 11 | #' Ranking question for learnr tutorials. 12 | #' 13 | #' Add interactive ranking tasks to your `learnr` tutorials. The student can 14 | #' drag-and-drop the answer options into the desired order. 15 | #' 16 | #' Each set of answer options must contain the same set of answer options. When 17 | #' the question is completed, the first correct answer will be displayed. 18 | #' 19 | #' Note that, by default, the answer order is randomized. 20 | #' 21 | #' @param ... parameters passed onto \code{\link[learnr:quiz]{learnr::question()}}. 22 | #' 23 | #' @template options 24 | #' 25 | #' @inheritParams learnr::question 26 | #' 27 | #' @return A custom `learnr` question, with `type = sortable_rank`. 28 | #' See \code{\link[learnr:quiz]{learnr::question()}}. 29 | #' 30 | #' @export 31 | #' @examples 32 | #' ## Example of rank problem inside a learnr tutorial 33 | #' if (interactive()) { 34 | #' learnr::run_tutorial("question_rank", package = "sortable") 35 | #' } 36 | question_rank <- function( 37 | text, 38 | ..., 39 | correct = "Correct!", 40 | incorrect = "Incorrect", 41 | loading = c("**Loading:** ", text, "


"), 42 | submit_button = "Submit Answer", 43 | try_again_button = "Try Again", 44 | allow_retry = FALSE, 45 | random_answer_order = TRUE, 46 | options = sortable_options() 47 | ) { 48 | learnr::question( 49 | text = text, 50 | ..., 51 | type = "sortable_rank", 52 | correct = correct, 53 | incorrect = incorrect, 54 | loading = loading, 55 | submit_button = submit_button, 56 | try_again_button = try_again_button, 57 | allow_retry = allow_retry, 58 | random_answer_order = random_answer_order, 59 | options = options 60 | ) 61 | } 62 | 63 | 64 | #' @export 65 | #' @seealso [question_rank] 66 | question_ui_initialize.sortable_rank <- function(question, value, ...) { 67 | 68 | # quickly validate the all possible answers are possible 69 | answer <- question$answers[[1]] 70 | possible_answer_vals <- sort(answer$option) 71 | for (answer in question$answers) { 72 | if (!identical( 73 | possible_answer_vals, 74 | sort(answer$option) 75 | )) { 76 | stop( 77 | "All question_rank answers MUST have the same set of answers. (Order does not matter.) ", 78 | "\nBad set: ", paste0(answer$option, collapse = ", "), 79 | call. = FALSE 80 | ) 81 | } 82 | } 83 | 84 | # if no label order has been provided 85 | if (!is.null(value)) { 86 | labels <- value 87 | } else { 88 | labels <- question$answers[[1]]$option 89 | 90 | # if the question is to be displayed in random order, shuffle the options 91 | if ( 92 | isTRUE(question$random_answer_order) # and we should randomize the order 93 | ) { 94 | labels <- sample(labels, length(labels)) 95 | } 96 | } 97 | 98 | 99 | # return the rank_list htmlwidget 100 | rank_list( 101 | text = question$question, 102 | input_id = question$ids$answer, 103 | labels = labels, 104 | options = question$options 105 | ) 106 | } 107 | 108 | #' @export 109 | #' @seealso [question_rank] 110 | question_ui_completed.sortable_rank <- function(question, value, ...) { 111 | # TODO display correct values with X or √ compared to best match 112 | # TODO DON'T display correct values (listen to an option?) 113 | disable_all_tags( 114 | rank_list( 115 | text = question$question, 116 | input_id = question$ids$answer, 117 | labels = value, 118 | options = modifyList( 119 | question$options, 120 | sortable_options(disabled = TRUE) 121 | ) 122 | ) 123 | ) 124 | } 125 | 126 | #' @export 127 | #' @seealso [question_rank] 128 | question_ui_try_again.sortable_rank <- function(question, value, ...) { 129 | disable_all_tags( 130 | rank_list( 131 | text = question$question, 132 | input_id = question$ids$answer, 133 | labels = value, 134 | options = modifyList( 135 | question$options, 136 | sortable_options(disabled = TRUE) 137 | ) 138 | ) 139 | ) 140 | } 141 | 142 | 143 | #' @export 144 | #' @seealso [question_rank] 145 | question_is_correct.sortable_rank <- function(question, value, ...) { 146 | # for each possible answer, check if it matches 147 | for (answer in question$answers) { 148 | if (identical(answer$option, value)) { 149 | # if it matches, return the correct-ness and its message 150 | return(mark_as(answer$correct, answer$message)) 151 | } 152 | } 153 | # no match found. not correct 154 | mark_as(FALSE, NULL) 155 | } 156 | -------------------------------------------------------------------------------- /R/rank_list.R: -------------------------------------------------------------------------------- 1 | # Create label tags for rank_list 2 | as_label_tags <- function(labels) { 3 | mapply( 4 | USE.NAMES = FALSE, 5 | SIMPLIFY = FALSE, 6 | labels, 7 | label_ids(labels), 8 | FUN = function(label, label_id) { 9 | if (identical(label_id, "")) { 10 | label_id <- NULL 11 | } 12 | tags$div( 13 | class = "rank-list-item", 14 | "data-rank-id" = label_id, 15 | label 16 | ) 17 | } 18 | ) 19 | } 20 | 21 | 22 | #' Create a ranking item list. 23 | #' 24 | #' @description Creates a ranking item list using the `SortableJS` framework, 25 | #' and generates an `htmlwidgets` element. The elements of this list can be 26 | #' dragged and dropped in any order. 27 | #' 28 | #' You can embed a ranking question inside a `learnr` tutorial, using 29 | #' [question_rank()]. 30 | #' 31 | #' To embed a `rank_list` inside a shiny app, see the Details section. 32 | #' 33 | #' @details 34 | #' 35 | #' You can embed a `rank_list` inside a Shiny app, to capture the preferred 36 | #' ranking order of your user. 37 | #' 38 | #' The widget automatically updates a Shiny output, with the matching 39 | #' `input_id`. 40 | #' 41 | #' 42 | #' @param input_id output variable to read the plot/image from. 43 | #' 44 | #' @param labels A character vector with the text to display inside the widget. 45 | #' This can also be a list of html tag elements. The text content of each 46 | #' label or label name will be used to set the shiny `input_id` value. 47 | #' To create an empty `rank_list`, use `labels = list()`. 48 | #' 49 | #' @param text Text to appear at top of list. 50 | #' 51 | #' @param css_id This is the css id to use, and must be unique in your shiny 52 | #' app. This defaults to the value of `input_id`, and will be appended to the 53 | #' value "rank-list-container", to ensure the CSS id is unique for the 54 | #' container as well as the labels. 55 | #' If NULL, the function generates an id of the form 56 | #' `rank_list_id_1`, and will automatically increment for every `rank_list`. 57 | #' 58 | #' @param class A css class applied to the rank list. This can be used to 59 | #' define custom styling. 60 | #' 61 | #' @param orientation Set this to "horizontal" to get horizontal orientation of 62 | #' the items. 63 | #' 64 | #' @template options 65 | #' 66 | #' @seealso [update_rank_list], [sortable_js], [bucket_list] and [question_rank] 67 | #' 68 | #' @export 69 | #' @importFrom utils modifyList 70 | #' @importFrom htmltools tagList tags 71 | #' @example inst/examples/example_rank_list.R 72 | #' @example inst/examples/example_rank_list_multidrag.R 73 | #' @example inst/examples/example_rank_list_swap.R 74 | #' @examples 75 | #' ## Example of a shiny app 76 | #' if (interactive()) { 77 | #' app <- system.file("shiny/rank_list/app.R", package = "sortable") 78 | #' shiny::runApp(app) 79 | #' } 80 | #' 81 | rank_list <- function( 82 | text = "", 83 | labels, 84 | input_id, 85 | css_id = input_id, 86 | options = sortable_options(), 87 | orientation = c("vertical", "horizontal"), 88 | class = "default-sortable" 89 | ) { 90 | if (is.null(css_id)) { 91 | css_id <- increment_rank_list() 92 | } 93 | orientation = match.arg(orientation) 94 | if (orientation == "horizontal") class = paste0(class, " horizontal") 95 | assert_that(is_sortable_options(options)) 96 | assert_that(is_input_id(input_id)) 97 | 98 | options$onSort <- chain_js_events( # nolint 99 | options$onSort, # nolint 100 | sortable_js_capture_input(input_id) 101 | ) 102 | options$onLoad <- chain_js_events( # nolint 103 | options$onLoad, # nolint 104 | sortable_js_capture_input(input_id), 105 | sortable_js_set_empty_class(css_id) 106 | ) 107 | 108 | title_tag <- if (!is.null(text) && nchar(text) > 0) { 109 | tags$p(class = "rank-list-title", text) 110 | } else { 111 | NULL 112 | } 113 | 114 | label_tags <- as_label_tags(labels) 115 | 116 | rank_list_tags <- tagList( 117 | tags$div( 118 | class = paste("rank-list-container", paste(class, collapse = " ")), 119 | id = as_rank_list_id(css_id), 120 | title_tag, 121 | tags$div( 122 | class = "rank-list", 123 | id = css_id, 124 | label_tags 125 | ) 126 | ), 127 | sortable_js( 128 | css_id = css_id, 129 | options = options 130 | ), 131 | rank_list_dependencies() 132 | ) 133 | 134 | as_rank_list(rank_list_tags) 135 | 136 | } 137 | 138 | 139 | dropNulls <- function(x) { 140 | x[!vapply(x, is.null, FUN.VALUE = logical(1))] 141 | } 142 | 143 | 144 | #' Change the text or labels of a rank list. 145 | #' 146 | #' 147 | #' @inheritParams rank_list 148 | #' @param session The `session` object passed to function given to 149 | #' `shinyServer`. 150 | #' @seealso [rank_list] 151 | #' @export 152 | #' @examples 153 | #' ## Example of a shiny app that updates a bucket list and rank list 154 | #' if (interactive()) { 155 | #' app <- system.file( 156 | #' "shiny/update_rank_list/app.R", 157 | #' package = "sortable" 158 | #' ) 159 | #' shiny::runApp(app) 160 | #' } 161 | update_rank_list <- function(css_id, text = NULL, labels = NULL, 162 | session = shiny::getDefaultReactiveDomain()) { 163 | inputId <- as_rank_list_id(css_id) 164 | if ( !is.null(labels) && length(labels) > 0) { 165 | labels <- as.character(tagList(as_label_tags(labels))) 166 | } 167 | message <- dropNulls(list(id = inputId, text = text, labels = labels)) 168 | session$sendInputMessage(inputId, message) 169 | 170 | } 171 | 172 | 173 | #' Change the value of a bucket list. 174 | #' 175 | #' You can only update the `header` of the `bucket_list`. 176 | #' To update any of the labels or rank list text, use `update_rank_list()` 177 | #' instead. 178 | #' 179 | #' @inheritParams bucket_list 180 | #' @param session The `session` object passed to function given to 181 | #' `shinyServer`. 182 | #' @seealso [bucket_list], [update_rank_list] 183 | #' @export 184 | #' @examples 185 | #' ## Example of a shiny app that updates a bucket list and rank list 186 | #' if (interactive()) { 187 | #' app <- system.file( 188 | #' "shiny/update/app.R", 189 | #' package = "sortable" 190 | #' ) 191 | #' shiny::runApp(app) 192 | #' } 193 | update_bucket_list <- function(css_id, header = NULL, 194 | session = shiny::getDefaultReactiveDomain()) { 195 | inputId <- paste0("bucket-list-", css_id) 196 | message <- dropNulls(list(id = inputId, header = header)) 197 | session$sendInputMessage(inputId, message) 198 | } 199 | -------------------------------------------------------------------------------- /R/sortable-package.R: -------------------------------------------------------------------------------- 1 | #' @section A new html widget: 2 | #' 3 | #' * [sortable_js()] is a low-level function that adds the `SortableJS` to your widgets. 4 | #' 5 | #' @section Important functions: 6 | #' 7 | #' The important functions in this package are: 8 | #' 9 | #' * [rank_list()] creates a drag-and-drop, rank list 10 | #' 11 | #' * [bucket_list()] lets you add multiple `rank_list` objects in columns 12 | #' 13 | #' @section Custom question types for `learnr`: 14 | #' 15 | #' You can also use new question types in your `learnr` tutorials: 16 | #' 17 | #' * [question_rank()] 18 | #' 19 | #' 20 | #' @importFrom assertthat assert_that is.string 21 | #' @name sortable 22 | #' @aliases sortable-package sortable 23 | #' @keywords internal 24 | "_PACKAGE" 25 | 26 | 27 | # The following block is used by usethis to automatically manage 28 | # roxygen namespace tags. Modify with care! 29 | ## usethis namespace: start 30 | ## usethis namespace: end 31 | NULL 32 | -------------------------------------------------------------------------------- /R/sortable_js.R: -------------------------------------------------------------------------------- 1 | #' Creates an htmlwidget with embedded 'SortableJS' library. 2 | #' 3 | #' Creates an `htmlwidget` that provides 4 | #' [SortableJS](https://github.com/SortableJS/Sortable) to use for 5 | #' drag-and-drop interactivity in Shiny apps and R Markdown. 6 | #' 7 | #' @param css_id `String` css_id id on which to apply `SortableJS`. Note, 8 | #' `sortable_js` works with any html element, not just `ul/li`. 9 | #' @template options 10 | #' @inheritParams htmlwidgets::createWidget 11 | #' 12 | #' @importFrom htmlwidgets shinyWidgetOutput 13 | #' @seealso [sortable_options()] 14 | #' 15 | #' @export 16 | #' @example inst/examples/example_sortable_js.R 17 | sortable_js <- function( 18 | css_id, 19 | options = sortable_options(), 20 | width = 0, 21 | height = 0, 22 | elementId = NULL, # nolint 23 | preRenderHook = NULL # nolint 24 | ) { 25 | 26 | assert_that(is_sortable_options(options)) 27 | 28 | # forward options using x 29 | x <- list( 30 | css_id = css_id, 31 | options = modifyList( 32 | # set default options to be overwritten by supplied options 33 | default_sortable_options(), 34 | options 35 | ) 36 | ) 37 | 38 | # create widget 39 | htmlwidgets::createWidget( 40 | name = "sortable", 41 | x, 42 | width = width, 43 | height = height, 44 | package = "sortable", 45 | elementId = elementId, # nolint 46 | preRenderHook = preRenderHook # nolint 47 | ) 48 | } 49 | 50 | #' Widget output function for use in Shiny. 51 | #' 52 | #' @inheritParams sortable_js 53 | #' @param input_id output variable to use for the sortable object 54 | #' 55 | #' @export 56 | sortable_output <- function(input_id, width = "0px", height = "0px") { 57 | htmlwidgets::shinyWidgetOutput(input_id, "sortable", width, height, package = "sortable") 58 | } 59 | 60 | #' Widget render function for use in Shiny. 61 | #' 62 | #' @param expr An expression 63 | #' @param env The environment in which to evaluate `expr`. 64 | #' @param quoted Is `expr` a quoted expression (with `quote()`)? This is useful 65 | #' if you want to save an expression in a variable. 66 | #' 67 | #' @export 68 | render_sortable <- function(expr, env = parent.frame(), quoted = FALSE) { 69 | if (!quoted) { 70 | expr <- substitute(expr) 71 | } # force quoted 72 | htmlwidgets::shinyRenderWidget(expr, sortable_output, env, quoted = TRUE) 73 | } 74 | -------------------------------------------------------------------------------- /R/sortable_options.R: -------------------------------------------------------------------------------- 1 | #' Check if object is sortable options. 2 | #' 3 | #' @param x Object to test 4 | #' @return Logical vector. TRUE if the object inherits from `sortable_options` 5 | #' @export 6 | #' @examples 7 | #' is_sortable_options("foo") # returns FALSE 8 | is_sortable_options <- function(x) { 9 | inherits(x, "sortable_options") 10 | } 11 | 12 | 13 | #' Define options to pass to a sortable object. 14 | #' 15 | #' Use this function to define the options for [sortable_js] and [rank_list], 16 | #' which will pass these in turn to the `SortableJS` JavaScript library. 17 | #' 18 | #' Many of the `SortableJS` options will accept a JavaScript function. You can 19 | #' do this using the `htmlwidgets::JS` function. 20 | #' 21 | #' @param ... other arguments passed onto `SortableJS` 22 | #' 23 | #' @param swap If `TRUE`, modifies the behaviour of `sortable` to allow for items to 24 | #' be swapped with each other rather than sorted. Once dragging starts, the 25 | #' user can drag over other items and there will be no change in the elements. 26 | #' However, the item that the user drops on will be swapped with the 27 | #' originally dragged item. 28 | #' See also https://github.com/SortableJS/Sortable/tree/master/plugins/Swap 29 | #' 30 | #' @param multiDrag If `TRUE`, allows the selection of multiple items within a 31 | #' `sortable` at once, and drag them as one item. Once placed, the items will 32 | #' unfold into their original order, but all beside each other at the new 33 | #' position. 34 | #' See also https://github.com/SortableJS/Sortable/wiki/Dragging-Multiple-Items-in-Sortable 35 | #' 36 | #' 37 | #' @param group To drag elements from one list into another, both lists must 38 | #' have the same group value. See 39 | #' [Sortable#group-option](https://github.com/sortablejs/Sortable/#group-option) 40 | #' for more details. \[`"name"`\] 41 | #' 42 | #' @param sort Boolean that allows sorting inside a list. \[`TRUE`\] 43 | #' 44 | #' @param delay Time in milliseconds to define when the sorting should start. 45 | #' \[`0`\] 46 | # 47 | #' @param disabled Boolean that disables the `sortable` if set to true. \[`FALSE`\] 48 | # 49 | #' @param animation Millisecond duration of the animation of items when sorting 50 | #' \[`0` (no animation)\] 51 | #' 52 | #' @param handle CSS selector used for the drag handle selector within list 53 | #' items. \[`".my-handle"`\] 54 | #' 55 | #' @param filter CSS selector or JS function used for elements that cannot be 56 | #' dragged. \[`".ignore-elements"`\] 57 | #' 58 | #' @param draggable CSS selector of which items inside the element should be 59 | #' draggable. \[`".item"`\] 60 | #' 61 | #' @param swapThreshold Percentage of the target that the swap zone will take 62 | #' up, as a number between `0` and `1`. \[`1`\] 63 | #' 64 | #' @param invertSwap Set to \code{TRUE} to set the swap zone to the sides of the 65 | #' target, for the effect of sorting "in between" items. \[`FALSE`\] 66 | #' 67 | #' @param direction Direction of `sortable` \[`"horizontal"`\] 68 | #' 69 | #' @param scrollSensitivity Number of pixels the mouse needs to be to an edge to 70 | #' start scrolling. \[`30`\] 71 | #' 72 | #' @param scrollSpeed Number of pixels for the speed of scrolling. \[`10`\] 73 | # 74 | #' @param onStart,onEnd JS function called when an element dragging starts or ends 75 | #' 76 | #' @param onAdd JS function called when an element is dropped into the list from 77 | #' another list 78 | #' 79 | #' @param onUpdate JS function called when the sorting is changed within a list 80 | #' 81 | #' @param onSort JS function called by any change to the list (add / update / 82 | #' remove) 83 | #' 84 | #' @param onRemove JS function called when an element is removed from the list 85 | #' into another list 86 | #' 87 | #' @param onFilter JS function called when an attempt is made to drag a filtered 88 | #' element 89 | #' 90 | #' @param onMove JS function called when an item is moved in a list or between 91 | #' lists 92 | #' 93 | #' @param onLoad JS function dispatched on the "next tick" after SortableJS has 94 | #' initialized 95 | # 96 | # @param delayOnTouchOnly Boolean that will only delay if user is using touch 97 | # (mobile display). \[`FALSE`\] 98 | # 99 | # @param touchStartThreshold Number of pixels a point should move before 100 | # cancelling a delayed drag event. \[`0`\] 101 | # 102 | # @param store Saving and restoring of the sort. See 103 | # [Sortable#store](https://github.com/sortablejs/Sortable/#store) 104 | # 105 | # @param easing Easing for animation. \[`NULL`\] See 106 | # [https://easings.net/](https://easings.net/) for examples. 107 | # 108 | # @param preventOnFilter Boolean that determines if `event.preventDefault()` is 109 | # called when `filter` is triggered. \[`TRUE`\] 110 | # 111 | # @param dataIdAttr no documentation 112 | # 113 | # @param ghostClass CSS class name for the drop placeholder. 114 | # \[`"sortable-ghost"`\] 115 | # 116 | # @param chosenClass CSS class name for the chosen item \[`"sortable-chosen"`\] 117 | # 118 | # @param dragClass CSS class name for the dragging item \[`"sortable-drag"`\] 119 | # 120 | # @param invertedSwapThreshold Percentage of the target that the inverted swap 121 | # zone will take up, as a number between `0` and `1`. \[`swapThreshold`\] 122 | # 123 | # @param forceFallback : false, // ignore the HTML5 DnD behaviour and force the 124 | # fallback to kick in 125 | # 126 | # @param fallbackClass : "sortable-fallback", // Class name for the cloned DOM 127 | # Element when using forceFallback 128 | # 129 | # @param fallbackOnBody : false, // Appends the cloned DOM Element into the 130 | # Document's Body 131 | # 132 | # @param fallbackTolerance : 0, // Specify in pixels how far the mouse should 133 | # move before it's considered as a drag. 134 | # 135 | # @param dragoverBubble If set to true, the dragover event will bubble to parent 136 | # sortables. \[`false`\] 137 | # 138 | # @param removeCloneOnHide: true, // Remove the clone element when it is not 139 | # showing, rather than just hiding it 140 | # 141 | # @param emptyInsertThreshold: Number of pixels a mouse must be from empty 142 | # `sortable` to insert drag element into it. \[`5`\] 143 | # 144 | # @param setData undocumented on website 145 | # https://github.com/SortableJS/Sortable/tree/master/plugins/AutoScroll 146 | # 147 | # @param onChoose,onUnchoose JS function called when an element is chosen or 148 | # unchosen 149 | # 150 | # @param onClone JS function that is called when creating a clone of an element 151 | # 152 | # @param onChange JS function that is called when a dragging element changes 153 | # position 154 | #' 155 | #' 156 | #' @references [https://github.com/sortablejs/Sortable/](https://github.com/sortablejs/Sortable/) 157 | #' 158 | #' @seealso [sortable_js] 159 | #' 160 | #' @return A list with class `sortable_options` 161 | #' @examples 162 | #' sortable_options(sort = FALSE) 163 | #' @export 164 | sortable_options <- function( 165 | ..., 166 | # nolint start 167 | swap = NULL, 168 | multiDrag = NULL, 169 | group = NULL, 170 | sort = NULL, 171 | delay = NULL, 172 | disabled = NULL, 173 | animation = NULL, 174 | handle = NULL, 175 | filter = NULL, 176 | draggable = NULL, 177 | swapThreshold = NULL, 178 | invertSwap = NULL, 179 | direction = NULL, 180 | scrollSensitivity = NULL, 181 | scrollSpeed = NULL, 182 | onStart = NULL, 183 | onEnd = NULL, 184 | onAdd = NULL, 185 | onUpdate = NULL, 186 | onSort = NULL, 187 | onRemove = NULL, 188 | onFilter = NULL, 189 | onMove = NULL, 190 | onLoad = NULL 191 | # nolint end 192 | ) { 193 | extra_args <- rlang::list2(...) 194 | 195 | # get all names and values 196 | args <- names(formals(sortable_options)) 197 | arg_vals <- mget(args[-1], environment()) # remove first element (...) 198 | 199 | # remove null values 200 | is_null <- vapply(arg_vals, is.null, logical(1)) 201 | arg_vals <- arg_vals[!is_null] 202 | 203 | # merge all args 204 | ret <- append(arg_vals, extra_args) 205 | 206 | class(ret) <- "sortable_options" 207 | ret 208 | } 209 | 210 | 211 | default_sortable_options <- function() { 212 | sortable_options( 213 | animation = 150, 214 | emptyInsertThreshold = 50 / 4 215 | ) 216 | } 217 | -------------------------------------------------------------------------------- /R/zzz.R: -------------------------------------------------------------------------------- 1 | # nocov start 2 | 3 | .onLoad <- function(...) { 4 | 5 | as_character_vector <- function(x) { 6 | # works for both x = NULL and x = list() 7 | if (length(x) == 0) { 8 | return(character(0)) 9 | } 10 | unlist(x) 11 | } 12 | 13 | # Register a handler for a bucket_list to unlist each set of values. 14 | # should return a list of character vectors or NULL 15 | shiny::registerInputHandler( 16 | force = TRUE, 17 | "sortablejs.rank_list", 18 | function(val, shinysession, name) { 19 | ret <- as_character_vector(val) 20 | ret 21 | } 22 | ) 23 | 24 | # Register a handler for a bucket_list to unlist each set of values. 25 | # should return a list of character vectors or NULL 26 | shiny::registerInputHandler( 27 | force = TRUE, 28 | "sortablejs.bucket_list", 29 | function(val, shinysession, name) { 30 | ret <- lapply(val, function(x) { 31 | as_character_vector(x) 32 | }) 33 | ret 34 | } 35 | ) 36 | 37 | } 38 | 39 | # nocov end 40 | -------------------------------------------------------------------------------- /README.Rmd: -------------------------------------------------------------------------------- 1 | --- 2 | output: github_document 3 | format: gfm 4 | default-image-extension: "" 5 | --- 6 | 7 | 8 | 9 | ```{r, include = FALSE} 10 | knitr::opts_chunk$set( 11 | collapse = TRUE, 12 | comment = "#>", 13 | fig.path = "man/figures/README-", 14 | out.width = "100%" 15 | ) 16 | ``` 17 | 18 | # sortable 19 | 20 | 21 | [![CRAN status](https://www.r-pkg.org/badges/version/sortable)](https://CRAN.R-project.org/package=sortable) 22 | [![CRAN RStudio mirror downloads](https://cranlogs.r-pkg.org/badges/sortable)](https://www.r-pkg.org/pkg/sortable) 23 | [![R build status](https://github.com/rstudio/sortable/actions/workflows/R-CMD-check.yaml/badge.svg)](https://github.com/rstudio/sortable/actions) 24 | [![Codecov test coverage](https://codecov.io/gh/rstudio/sortable/branch/main/graph/badge.svg)](https://codecov.io/gh/rstudio/sortable?branch=main) 25 | [![Lifecycle: stable](https://img.shields.io/badge/lifecycle-stable-brightgreen.svg)](https://lifecycle.r-lib.org/articles/stages.html#stable) 26 | 27 | 28 | The `sortable` package enables drag-and-drop behaviour in your Shiny apps. It does this by exposing the functionality of the [SortableJS](https://sortablejs.github.io/Sortable/) JavaScript library as an [htmlwidget](http://www.htmlwidgets.org) in R, so you can use this in Shiny apps and widgets, `learnr` tutorials as well as R Markdown. In addition, provides a custom `learnr` question type - `question_rank()` that allows ranking questions with drag-and-drop. 29 | 30 | 31 | 32 | ## Installation 33 | 34 | You can install the released version of sortable from [CRAN](https://CRAN.R-project.org) with: 35 | 36 | ```r 37 | install.packages("sortable") 38 | ``` 39 | 40 | And the development version from [GitHub](https://github.com/rstudio/sortable) with: 41 | 42 | ```r 43 | # install.packages("remotes") 44 | remotes::install_github("rstudio/sortable") 45 | ``` 46 | 47 | ## Examples 48 | 49 | ### Rank list 50 | 51 | You can create a drag-and-drop input object in Shiny, using the `rank_list()` function. 52 | 53 |

54 | 55 |
56 | 57 | ```{r echo=FALSE, cache=FALSE} 58 | knitr::read_chunk( 59 | system.file("shiny/rank_list/app.R", package = "sortable") 60 | ) 61 | ``` 62 | 63 | ```{r rank-list-app, eval=FALSE} 64 | ``` 65 | 66 | 67 | 68 | ### Bucket list 69 | 70 | With a bucket list you can have more than one rank lists in a single object. This can be useful for bucketing tasks, e.g. asking your students to classify objects into multiple categories. 71 | 72 |
73 | 74 |
75 | 76 | 77 | ```{r echo=FALSE, cache=FALSE} 78 | knitr::read_chunk( 79 | system.file("shiny/bucket_list/app.R", package = "sortable") 80 | ) 81 | ``` 82 | 83 | ```{r bucket-list-app, eval=FALSE} 84 | ``` 85 | 86 | 87 | 88 | ### Add drag-and-drop to any HTML element 89 | 90 | You can also use `sortable_js()` to drag and drop other widgets: 91 | 92 |
93 | 94 |
95 | 96 | ```{r, eval=FALSE} 97 | library(DiagrammeR) 98 | library(htmltools) 99 | 100 | html_print(tagList( 101 | tags$p("You can drag and drop the diagrams to switch order:"), 102 | tags$div( 103 | id = "aUniqueId", 104 | tags$div( 105 | style = "border: solid 0.2em gray; float:left; margin: 5px", 106 | mermaid("graph LR; S[SortableJS] -->|sortable| R ", 107 | height = 250, width = 300) 108 | ), 109 | tags$div( 110 | style = "border: solid 0.2em gray; float:left; margin: 5px", 111 | mermaid("graph TD; JavaScript -->|htmlwidgets| R ", 112 | height = 250, width = 150) 113 | ) 114 | ), 115 | sortable_js("aUniqueId") # the CSS id 116 | )) 117 | ``` 118 | 119 | 120 | ## Related work 121 | 122 | I learnt about the following related work after starting on `sortable`: 123 | 124 | * The `esquisse` [package](https://github.com/dreamRs/esquisse): 125 | 126 | > "The purpose of this add-in is to let you explore your data quickly to extract the information they hold. You can only create simple plots, you won't be able to use custom scales and all the power of ggplot2." 127 | 128 | * There is also the `shinyjqui` [package](https://yang-tang.github.io/shinyjqui/): 129 | 130 | > "An R wrapper for jQuery UI javascript library. It allows user to easily add interactions and animation effects to a shiny app." 131 | 132 | * The `shinyDND` [package](https://cran.r-project.org/package=shinyDND): 133 | 134 | > Adds functionality to create drag and drop div elements in shiny. 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | # sortable 6 | 7 | 8 | 9 | [![CRAN 10 | status](https://www.r-pkg.org/badges/version/sortable)](https://CRAN.R-project.org/package=sortable) 11 | [![CRAN RStudio mirror 12 | downloads](https://cranlogs.r-pkg.org/badges/sortable)](https://www.r-pkg.org/pkg/sortable) 13 | [![R build 14 | status](https://github.com/rstudio/sortable/actions/workflows/R-CMD-check.yaml/badge.svg)](https://github.com/rstudio/sortable/actions) 15 | [![Codecov test 16 | coverage](https://codecov.io/gh/rstudio/sortable/branch/main/graph/badge.svg)](https://codecov.io/gh/rstudio/sortable?branch=main) 17 | [![Lifecycle: 18 | stable](https://img.shields.io/badge/lifecycle-stable-brightgreen.svg)](https://lifecycle.r-lib.org/articles/stages.html#stable) 19 | 20 | 21 | The `sortable` package enables drag-and-drop behaviour in your Shiny 22 | apps. It does this by exposing the functionality of the 23 | [SortableJS](https://sortablejs.github.io/Sortable/) JavaScript library 24 | as an [htmlwidget](http://www.htmlwidgets.org) in R, so you can use this 25 | in Shiny apps and widgets, `learnr` tutorials as well as R Markdown. In 26 | addition, provides a custom `learnr` question type - `question_rank()` 27 | that allows ranking questions with drag-and-drop. 28 | 29 | ## Installation 30 | 31 | You can install the released version of sortable from 32 | [CRAN](https://CRAN.R-project.org) with: 33 | 34 | ``` r 35 | install.packages("sortable") 36 | ``` 37 | 38 | And the development version from 39 | [GitHub](https://github.com/rstudio/sortable) with: 40 | 41 | ``` r 42 | # install.packages("remotes") 43 | remotes::install_github("rstudio/sortable") 44 | ``` 45 | 46 | ## Examples 47 | 48 | ### Rank list 49 | 50 | You can create a drag-and-drop input object in Shiny, using the 51 | `rank_list()` function. 52 | 53 |
54 | 55 |
56 | 57 | ``` r 58 | ## Example shiny app with rank list 59 | 60 | library(shiny) 61 | library(sortable) 62 | 63 | labels <- list( 64 | "one", 65 | "two", 66 | "three", 67 | htmltools::tags$div( 68 | htmltools::em("Complex"), " html tag without a name" 69 | ), 70 | "five" = htmltools::tags$div( 71 | htmltools::em("Complex"), " html tag with name: 'five'" 72 | ) 73 | ) 74 | 75 | rank_list_basic <- rank_list( 76 | text = "Drag the items in any desired order", 77 | labels = labels, 78 | input_id = "rank_list_basic" 79 | ) 80 | 81 | rank_list_swap <- rank_list( 82 | text = "Notice that dragging causes items to swap", 83 | labels = labels, 84 | input_id = "rank_list_swap", 85 | options = sortable_options(swap = TRUE) 86 | ) 87 | 88 | rank_list_multi <- rank_list( 89 | text = "You can select multiple items, then drag as a group", 90 | labels = labels, 91 | input_id = "rank_list_multi", 92 | options = sortable_options(multiDrag = TRUE) 93 | ) 94 | 95 | 96 | 97 | ui <- fluidPage( 98 | fluidRow( 99 | column( 100 | width = 12, 101 | tags$h2("Default, multi-drag and swapping behaviour"), 102 | tabsetPanel( 103 | type = "tabs", 104 | tabPanel( 105 | "Default", 106 | tags$b("Exercise"), 107 | rank_list_basic, 108 | tags$b("Result"), 109 | verbatimTextOutput("results_basic") 110 | ), 111 | tabPanel( 112 | "Multi-drag", 113 | tags$b("Exercise"), 114 | rank_list_multi, 115 | tags$b("Result"), 116 | verbatimTextOutput("results_multi") 117 | ), 118 | tabPanel( 119 | "Swap", 120 | tags$b("Exercise"), 121 | rank_list_swap, 122 | tags$b("Result"), 123 | verbatimTextOutput("results_swap") 124 | ) 125 | ) 126 | ) 127 | ) 128 | ) 129 | 130 | server <- function(input, output, session) { 131 | output$results_basic <- renderPrint({ 132 | input$rank_list_basic # This matches the input_id of the rank list 133 | }) 134 | output$results_multi <- renderPrint({ 135 | input$rank_list_multi # This matches the input_id of the rank list 136 | }) 137 | output$results_swap <- renderPrint({ 138 | input$rank_list_swap # This matches the input_id of the rank list 139 | }) 140 | } 141 | 142 | shinyApp(ui, server) 143 | ``` 144 | 145 | ### Bucket list 146 | 147 | With a bucket list you can have more than one rank lists in a single 148 | object. This can be useful for bucketing tasks, e.g. asking your 149 | students to classify objects into multiple categories. 150 | 151 |
152 | 153 |
154 | 155 | ``` r 156 | ## Example shiny app with bucket list 157 | 158 | library(shiny) 159 | library(sortable) 160 | 161 | ui <- fluidPage( 162 | tags$head( 163 | tags$style(HTML(".bucket-list-container {min-height: 350px;}")) 164 | ), 165 | fluidRow( 166 | column( 167 | tags$b("Exercise"), 168 | width = 12, 169 | bucket_list( 170 | header = "Drag the items in any desired bucket", 171 | group_name = "bucket_list_group", 172 | orientation = "horizontal", 173 | add_rank_list( 174 | text = "Drag from here", 175 | labels = list( 176 | "one", 177 | "two", 178 | "three", 179 | htmltools::tags$div( 180 | htmltools::em("Complex"), " html tag without a name" 181 | ), 182 | "five" = htmltools::tags$div( 183 | htmltools::em("Complex"), " html tag with name: 'five'" 184 | ) 185 | ), 186 | input_id = "rank_list_1" 187 | ), 188 | add_rank_list( 189 | text = "to here", 190 | labels = NULL, 191 | input_id = "rank_list_2" 192 | ) 193 | ) 194 | ) 195 | ), 196 | fluidRow( 197 | column( 198 | width = 12, 199 | tags$b("Result"), 200 | column( 201 | width = 12, 202 | 203 | tags$p("input$rank_list_1"), 204 | verbatimTextOutput("results_1"), 205 | 206 | tags$p("input$rank_list_2"), 207 | verbatimTextOutput("results_2"), 208 | 209 | tags$p("input$bucket_list_group"), 210 | verbatimTextOutput("results_3") 211 | ) 212 | ) 213 | ) 214 | ) 215 | 216 | server <- function(input, output, session) { 217 | output$results_1 <- 218 | renderPrint( 219 | input$rank_list_1 # This matches the input_id of the first rank list 220 | ) 221 | output$results_2 <- 222 | renderPrint( 223 | input$rank_list_2 # This matches the input_id of the second rank list 224 | ) 225 | output$results_3 <- 226 | renderPrint( 227 | input$bucket_list_group # Matches the group_name of the bucket list 228 | ) 229 | 230 | } 231 | 232 | 233 | shinyApp(ui, server) 234 | ``` 235 | 236 | ### Add drag-and-drop to any HTML element 237 | 238 | You can also use `sortable_js()` to drag and drop other widgets: 239 | 240 |
241 | 242 |
243 | 244 | ``` r 245 | library(DiagrammeR) 246 | library(htmltools) 247 | 248 | html_print(tagList( 249 | tags$p("You can drag and drop the diagrams to switch order:"), 250 | tags$div( 251 | id = "aUniqueId", 252 | tags$div( 253 | style = "border: solid 0.2em gray; float:left; margin: 5px", 254 | mermaid("graph LR; S[SortableJS] -->|sortable| R ", 255 | height = 250, width = 300) 256 | ), 257 | tags$div( 258 | style = "border: solid 0.2em gray; float:left; margin: 5px", 259 | mermaid("graph TD; JavaScript -->|htmlwidgets| R ", 260 | height = 250, width = 150) 261 | ) 262 | ), 263 | sortable_js("aUniqueId") # the CSS id 264 | )) 265 | ``` 266 | 267 | ## Related work 268 | 269 | I learnt about the following related work after starting on `sortable`: 270 | 271 | - The `esquisse` [package](https://github.com/dreamRs/esquisse): 272 | 273 | > “The purpose of this add-in is to let you explore your data quickly 274 | > to extract the information they hold. You can only create simple 275 | > plots, you won’t be able to use custom scales and all the power of 276 | > ggplot2.” 277 | 278 | - There is also the `shinyjqui` 279 | [package](https://yang-tang.github.io/shinyjqui/): 280 | 281 | > “An R wrapper for jQuery UI javascript library. It allows user to 282 | > easily add interactions and animation effects to a shiny app.” 283 | 284 | - The `shinyDND` [package](https://cran.r-project.org/package=shinyDND): 285 | 286 | > Adds functionality to create drag and drop div elements in shiny. 287 | -------------------------------------------------------------------------------- /_pkgdown.yml: -------------------------------------------------------------------------------- 1 | url: https://rstudio.github.io/sortable 2 | template: 3 | bootstrap: 5 4 | bootswatch: cerulean 5 | 6 | reference: 7 | - title: Learnr question types 8 | desc: New question types for learnr tutorials 9 | contents: 10 | - starts_with("question") 11 | - title: Widgets 12 | desc: These functions create htmlwidgets with drag-and-drop behaviour 13 | contents: 14 | - rank_list 15 | - bucket_list 16 | - add_rank_list 17 | - title: Access the SortableJS library 18 | desc: Lower level access to the SortableJS library 19 | contents: 20 | - starts_with("sortable_js") 21 | - sortable_options 22 | - is_sortable_options 23 | - chain_js_events 24 | - title: Shiny functions 25 | desc: Using sortable functions inside a Shiny app 26 | contents: 27 | - render_sortable 28 | - sortable_output 29 | - update_rank_list 30 | - update_bucket_list 31 | - title: Package documentation 32 | desc: ~ 33 | contents: 34 | - sortable 35 | 36 | tutorials: 37 | - name: tutorial_question_rank 38 | title: Using ranking questions in learnr 39 | url: https://andrie-de-vries.shinyapps.io/sortable_tutorial_question_rank/ 40 | 41 | 42 | articles: 43 | 44 | - title: Getting started 45 | contents: 46 | - built_in 47 | - using_custom_css 48 | 49 | - title: Take control of `sortable.js` 50 | contents: 51 | - understanding_sortable_js 52 | - novel_solutions 53 | - cloning 54 | - updating_rank_list 55 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | 3 | coverage: 4 | status: 5 | project: 6 | default: 7 | target: auto 8 | threshold: 1% 9 | informational: true 10 | patch: 11 | default: 12 | target: auto 13 | threshold: 1% 14 | informational: true 15 | -------------------------------------------------------------------------------- /cran-comments.md: -------------------------------------------------------------------------------- 1 | ## Minor update to fix documentation errors 2 | 3 | This update has no notes and no warnings on all of my test environments. 4 | 5 | 6 | ## Test environments 7 | 8 | * local Windows install, R-4.2.1 and R-devel 9 | * Ubuntu 18.04 (on github actions), R-release, R-dev and R-old-release 10 | * Win-builder (R-devel) 11 | 12 | -------------------------------------------------------------------------------- /inst/WORDLIST: -------------------------------------------------------------------------------- 1 | behaviour 2 | Codecov 3 | colours 4 | conceptor 5 | css 6 | de 7 | draggable 8 | funder 9 | Gans 10 | ggplot 11 | github 12 | htmlwidget 13 | https 14 | io 15 | javascript 16 | jQuery 17 | js 18 | JS 19 | learnr 20 | learnt 21 | Lifecycle 22 | multiDrag 23 | png 24 | sortablejs 25 | SortableJS 26 | Tabset 27 | UI 28 | Vries 29 | widget's 30 | -------------------------------------------------------------------------------- /inst/examples/example_bucket_list.R: -------------------------------------------------------------------------------- 1 | ## -- example-bucket-list --------------------------------------------- 2 | 3 | ## bucket list 4 | 5 | if(interactive()) { 6 | bucket_list( 7 | header = "This is a bucket list. You can drag items between the lists.", 8 | add_rank_list( 9 | text = "Drag from here", 10 | labels = c("a", "bb", "ccc") 11 | ), 12 | add_rank_list( 13 | text = "to here", 14 | labels = NULL 15 | ) 16 | ) 17 | } 18 | 19 | ## bucket list with three columns 20 | 21 | if(interactive()) { 22 | bucket_list( 23 | header = c("Sort these items into Letters and Numbers"), 24 | add_rank_list( 25 | text = "Drag from here", 26 | labels = sample(c(1:3, letters[1:2])) 27 | ), 28 | add_rank_list( 29 | text = "Letters" 30 | ), 31 | add_rank_list( 32 | text = "Numbers" 33 | ) 34 | ) 35 | } 36 | 37 | ## drag items between bucket lists 38 | 39 | if(interactive()) { 40 | 41 | ui <- shiny::fluidPage( 42 | shiny::column(4, bucket_list(NULL, 43 | group_name = "foo", 44 | add_rank_list( 45 | text = "Drag from here...", 46 | labels = sample(c(1:3, letters[1:2])) 47 | ) 48 | )), 49 | shiny::column(4, "Some empty space"), 50 | shiny::column(4, bucket_list(NULL, 51 | group_name = "foo", 52 | add_rank_list( 53 | text = "...To here" 54 | ) 55 | )) 56 | ) 57 | 58 | server <- function(input, output, session) {} 59 | 60 | shiny::shinyApp(ui, server) 61 | 62 | 63 | } 64 | 65 | -------------------------------------------------------------------------------- /inst/examples/example_question_rank.R: -------------------------------------------------------------------------------- 1 | if (require(learnr, quietly = TRUE)) { 2 | # to be used within a learnr tutorial... 3 | question_rank( 4 | "Sort the first 5 letters", 5 | answer(letters[1:5], correct = TRUE), 6 | allow_retry = TRUE, 7 | options = sortable_options(animation = 150) 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /inst/examples/example_rank_list.R: -------------------------------------------------------------------------------- 1 | ## - example-rank-list ------------------------------------------------ 2 | 3 | if (interactive()) { 4 | rank_list( 5 | text = "You can drag, drop and re-order these items:", 6 | labels = c("one", "two", "three", "four", "five"), 7 | input_id = "example_2" 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /inst/examples/example_rank_list_multidrag.R: -------------------------------------------------------------------------------- 1 | ## - example-rank-list-multidrag ------------------------------------------ 2 | 3 | if (interactive()) { 4 | rank_list( 5 | text = "You can select multiple items and drag as a group:", 6 | labels = c("one", "two", "three", "four", "five"), 7 | input_id = "example_2", 8 | options = sortable_options( 9 | multiDrag = TRUE 10 | ) 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /inst/examples/example_rank_list_swap.R: -------------------------------------------------------------------------------- 1 | ## - example-rank-list-swap ----------------------------------------------- 2 | 3 | if (interactive()) { 4 | rank_list( 5 | text = "You can re-order these items, and notice the swapping behaviour:", 6 | labels = c("one", "two", "three", "four", "five"), 7 | input_id = "example_2", 8 | options = sortable_options( 9 | swap = TRUE 10 | ) 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /inst/examples/example_sortable_js.R: -------------------------------------------------------------------------------- 1 | ## -- example-sortable-js ------------------------------------------------- 2 | # Simple example of sortable_js. 3 | # Important: set the tags CSS `id` equal to the sortable_js `css_id` 4 | 5 | if (interactive()) { 6 | if (require(htmltools)) { 7 | html_print( 8 | tagList( 9 | tags$p("You can drag and reorder the items in this list:"), 10 | tags$ul( 11 | id = "example_1", 12 | tags$li("Move"), 13 | tags$li("Or drag"), 14 | tags$li("Each of the items"), 15 | tags$li("To different positions") 16 | ), 17 | sortable_js(css_id = "example_1") 18 | ) 19 | ) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /inst/examples/example_sortable_js_capture.R: -------------------------------------------------------------------------------- 1 | ## -- example-sortable-js-capture ----------------------------------------- 2 | # Simple example of sortable_js_capture. 3 | # Important: set the tags CSS `id` equal to the sortable_js `css_id` 4 | 5 | if(interactive()) { 6 | library(shiny) 7 | library(sortable) 8 | 9 | ui <- fluidPage( 10 | div( 11 | id = "sortable", 12 | div(id = 1, `data-rank-id` = "HELLO", class = "well", "Hello"), 13 | div(id = 2, `data-rank-id` = "WORLD", class = "well", "world") 14 | ), 15 | verbatimTextOutput("chosen"), 16 | sortable_js( 17 | css_id = "sortable", 18 | options = sortable_options( 19 | onSort = sortable_js_capture_input(input_id = "selected") 20 | ) 21 | ) 22 | ) 23 | 24 | server <- function(input, output){ 25 | output$chosen <- renderPrint(input$selected) 26 | } 27 | 28 | shinyApp(ui, server) 29 | } 30 | 31 | 32 | -------------------------------------------------------------------------------- /inst/htmlwidgets/plugins/sortable-rstudio/.gitignore: -------------------------------------------------------------------------------- 1 | .sass_cache_keys -------------------------------------------------------------------------------- /inst/htmlwidgets/plugins/sortable-rstudio/_colors.scss: -------------------------------------------------------------------------------- 1 | $item-color: white; 2 | $rank-background-color: transparent; 3 | $item-background-color: #f8f8f8; 4 | $border-color: #ddd; 5 | $item-dragging-color: #75aadb; 6 | $item-hover-color: scale-color($item-dragging-color, $lightness: 75%); 7 | 8 | $rank-list-min-flex-size: 200px; 9 | $rank-list-min-height: 45px; 10 | 11 | $border-radius: 3px; 12 | -------------------------------------------------------------------------------- /inst/htmlwidgets/plugins/sortable-rstudio/bucket_list.css: -------------------------------------------------------------------------------- 1 | .default-sortable.bucket-list-container { 2 | background-color: transparent; 3 | padding: 10px; 4 | margin: 5px; 5 | } 6 | 7 | .default-sortable.bucket-list { 8 | display: flex; 9 | } 10 | 11 | .default-sortable.bucket-list.bucket-list-horizontal { 12 | flex-direction: row; 13 | flex-wrap: wrap; 14 | } 15 | 16 | .default-sortable.bucket-list.bucket-list-vertical { 17 | flex-direction: column; 18 | } 19 | 20 | .default-sortable.bucket-list.bucket-list-vertical .rank-list-container { 21 | flex: 1 0 auto; 22 | } 23 | -------------------------------------------------------------------------------- /inst/htmlwidgets/plugins/sortable-rstudio/bucket_list.scss: -------------------------------------------------------------------------------- 1 | @import "colors"; 2 | 3 | .default-sortable { 4 | &.bucket-list-container { 5 | background-color: $rank-background-color; 6 | padding: 10px; 7 | margin: 5px; 8 | } 9 | 10 | &.bucket-list { 11 | display: flex; 12 | // flex-direction: column; 13 | // flex-flow: column nowrap; 14 | &.bucket-list-horizontal { 15 | flex-direction: row; 16 | flex-wrap: wrap; 17 | } 18 | 19 | &.bucket-list-vertical { 20 | flex-direction: column; 21 | 22 | .rank-list-container { 23 | flex: 1 0 auto; 24 | } 25 | } 26 | } 27 | 28 | 29 | } 30 | -------------------------------------------------------------------------------- /inst/htmlwidgets/plugins/sortable-rstudio/rank_list.css: -------------------------------------------------------------------------------- 1 | .default-sortable.horizontal .rank-list { 2 | display: flex; 3 | } 4 | 5 | .default-sortable.horizontal .rank-list-item { 6 | flex-grow: 1; 7 | } 8 | 9 | .default-sortable.rank-list-container { 10 | flex: 1 0 200px; 11 | background-color: transparent; 12 | border: 1px solid #ddd; 13 | padding: 10px; 14 | margin: 5px; 15 | display: flex; 16 | flex-flow: column nowrap; 17 | } 18 | 19 | .default-sortable .rank-list-title { 20 | flex: 0 0 auto; 21 | } 22 | 23 | .default-sortable .rank-list { 24 | flex: 1 0 auto; 25 | -webkit-border-radius: 3px; 26 | border-radius: 5px; 27 | background-color: white; 28 | margin: 5px; 29 | min-height: 45px; 30 | } 31 | 32 | .default-sortable .rank-list.rank-list-empty { 33 | border-style: dashed; 34 | border-color: #ddd; 35 | } 36 | 37 | .default-sortable .rank-list-item { 38 | border-radius: 3px; 39 | display: block; 40 | padding: 10px 15px; 41 | background-color: #f8f8f8; 42 | border: 1px solid #ddd; 43 | overflow: hidden; 44 | } 45 | 46 | .default-sortable .rank-list-item:hover:not(.disabled) { 47 | background-color: #ddeaf6; 48 | cursor: grab; 49 | } 50 | 51 | .default-sortable .rank-list-item.sortable-ghost { 52 | color: transparent; 53 | } 54 | 55 | .default-sortable .rank-list-item.sortable-ghost:hover:not(.disabled) { 56 | cursor: grabbing; 57 | } 58 | 59 | .default-sortable .rank-list-item.sortable-selected, .default-sortable .rank-list-item.sortable-chosen, .default-sortable .rank-list-item.sortable-ghost.sortable-chosen, .default-sortable .rank-list-item.sortable-drag { 60 | background-color: #75aadb; 61 | border: 1px solid #4d91d0; 62 | } 63 | 64 | .default-sortable .rank-list-item.sortable-selected:hover:not(.disabled), .default-sortable .rank-list-item.sortable-chosen:hover:not(.disabled), .default-sortable .rank-list-item.sortable-ghost.sortable-chosen:hover:not(.disabled), .default-sortable .rank-list-item.sortable-drag:hover:not(.disabled) { 65 | cursor: grabbing; 66 | } 67 | 68 | .default-sortable .rank-list-item.disabled { 69 | cursor: not-allowed; 70 | } 71 | -------------------------------------------------------------------------------- /inst/htmlwidgets/plugins/sortable-rstudio/rank_list.scss: -------------------------------------------------------------------------------- 1 | @import "colors"; 2 | 3 | 4 | .default-sortable.horizontal { 5 | & .rank-list { 6 | display:flex; //horizontal 7 | } 8 | 9 | & .rank-list-item { 10 | flex-grow: 1; 11 | } 12 | } 13 | 14 | 15 | .default-sortable { 16 | 17 | &.rank-list-container { 18 | flex: 1 0 $rank-list-min-flex-size; 19 | background-color: $rank-background-color; 20 | border: 1px solid $border-color; 21 | padding: 10px; 22 | margin: 5px; 23 | display: flex; 24 | flex-flow: column nowrap; 25 | } 26 | 27 | .rank-list-title { 28 | flex: 0 0 auto; 29 | } 30 | 31 | 32 | .rank-list { 33 | flex: 1 0 auto; // default 34 | -webkit-border-radius: 3px; 35 | border-radius: 5px; 36 | background-color: $item-color; 37 | margin: 5px; 38 | min-height: $rank-list-min-height; 39 | 40 | &.rank-list-empty { 41 | border-style: dashed; 42 | border-color: $border-color; 43 | } 44 | 45 | } 46 | 47 | 48 | .rank-list-item { 49 | border-radius: $border-radius; 50 | display: block; 51 | padding: 10px 15px; 52 | background-color: $item-background-color; 53 | border: 1px solid $border-color; 54 | overflow: hidden; 55 | // width: 100%; 56 | 57 | // horizontal 58 | 59 | 60 | &:hover:not(.disabled) { 61 | background-color: $item-hover-color; 62 | cursor: grab; 63 | } 64 | 65 | // Class name for the drop placeholder 66 | &.sortable-ghost { 67 | color: transparent; 68 | &:hover:not(.disabled) { 69 | cursor: grabbing; 70 | } 71 | } 72 | // Class name for the multi-selected item 73 | &.sortable-selected, 74 | // Class name for the chosen item 75 | &.sortable-chosen, 76 | // Class name for chosen and original item 77 | &.sortable-ghost.sortable-chosen, 78 | // Class name for the dragging item 79 | &.sortable-drag { 80 | // color: white; 81 | background-color: $item-dragging-color; 82 | border: 1px solid darken($item-dragging-color, 10%); 83 | &:hover:not(.disabled) { 84 | cursor: grabbing; 85 | } 86 | } 87 | 88 | &.disabled { 89 | cursor: not-allowed; 90 | } 91 | 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /inst/htmlwidgets/plugins/sortable-rstudio/rank_list_binding.js: -------------------------------------------------------------------------------- 1 | 2 | var ranklistBinding = new Shiny.InputBinding(); 3 | 4 | $.extend(ranklistBinding, { 5 | 6 | find: function(scope) { 7 | // find all instances of class rank-list-container 8 | return $(scope).find(".rank-list-container"); 9 | }, 10 | 11 | // this method will be called on initialization 12 | initialize: function(el){ }, 13 | 14 | // this method will also be called on initialisation (to pass the intial state to input$...) 15 | // and each time when the callback is triggered via the event bound in subscribe 16 | getValue: function(el) { }, 17 | 18 | setValue: function (el, data) { 19 | // console.log(data); 20 | if (data.text) { 21 | $(el).find(".rank-list-title").text(data.text); 22 | } 23 | 24 | if (data.labels) { 25 | const short_id = data.id.replace(/^rank-list-/, ""); 26 | $('#' + short_id).html(data.labels); 27 | const label_ids = $('#' + short_id).children().map((idx, child) => { 28 | return $(child).attr("data-rank-id") || $.trim(child.innerHTML); 29 | }).toArray(); 30 | Shiny.setInputValue(short_id, label_ids, {priority: 'event'}); 31 | } 32 | 33 | }, 34 | 35 | subscribe: function(el, callback) { }, 36 | 37 | receiveMessage: function(el, data) { 38 | this.setValue(el, data); 39 | } 40 | 41 | }); 42 | 43 | 44 | // register the binding so Shiny knows it exists 45 | Shiny.inputBindings.register(ranklistBinding); 46 | 47 | 48 | 49 | 50 | // ---- bucket list binding ---------------------------------------------------- 51 | 52 | 53 | var bucketlistBinding = new Shiny.InputBinding(); 54 | 55 | $.extend(bucketlistBinding, { 56 | 57 | find: function(scope) { 58 | // find all instances of class bucket-list-container 59 | return $(scope).find(".bucket-list-container"); 60 | }, 61 | 62 | // this method will be called on initialization 63 | initialize: function(el){ }, 64 | 65 | // this method will also be called on initialisation (to pass the intial state to input$...) 66 | // and each time when the callback is triggered via the event bound in subscribe 67 | getValue: function(el) { }, 68 | 69 | setValue: function (el, data) { 70 | // console.log(data); 71 | if (data.header) { 72 | $(el).find(".bucket-list-header").text(data.header); 73 | } 74 | }, 75 | 76 | 77 | subscribe: function(el, callback) { }, 78 | 79 | receiveMessage: function(el, data) { 80 | this.setValue(el, data); 81 | } 82 | 83 | }); 84 | 85 | 86 | // register the binding so Shiny knows it exists 87 | Shiny.inputBindings.register(bucketlistBinding); 88 | -------------------------------------------------------------------------------- /inst/htmlwidgets/sortable.js: -------------------------------------------------------------------------------- 1 | HTMLWidgets.widget({ 2 | 3 | name: 'sortable', 4 | 5 | type: 'output', 6 | 7 | initialize: function(el, width, height) { 8 | // for now set display to none if height and width = 0 9 | // however eventually might add option to wrap 10 | // a list or vector within sortable 11 | if(width === 0 && height === 0){ 12 | el.style.display = "none"; 13 | } 14 | return { }; 15 | }, 16 | 17 | renderValue: function(el, x, instance) { 18 | instance.sortable = Sortable.create( 19 | document.getElementById( x.css_id ), 20 | x.options 21 | ); 22 | if (instance.sortable.options.onLoad instanceof Function) { 23 | // init the sortable 24 | setTimeout( 25 | function() { 26 | var evt = document.createEvent('Event'); 27 | evt.initEvent("onLoad", true, true); 28 | instance.sortable.options.onLoad.apply(instance.sortable, [evt]); 29 | }, 30 | 0 31 | ); 32 | } 33 | }, 34 | 35 | resize: function(el, width, height, instance) { 36 | } 37 | 38 | }); 39 | 40 | 41 | -------------------------------------------------------------------------------- /inst/htmlwidgets/sortable.yaml: -------------------------------------------------------------------------------- 1 | dependencies: 2 | - name: SortableJS 3 | version: 1.15.3 4 | src: htmlwidgets/lib/sortable 5 | script: sortable.js 6 | -------------------------------------------------------------------------------- /inst/shiny/bucket_list/app.R: -------------------------------------------------------------------------------- 1 | ## ---- bucket-list-app ----------------------------------------------- 2 | ## Example shiny app with bucket list 3 | 4 | library(shiny) 5 | library(sortable) 6 | 7 | ui <- fluidPage( 8 | tags$head( 9 | tags$style(HTML(".bucket-list-container {min-height: 350px;}")) 10 | ), 11 | fluidRow( 12 | column( 13 | tags$b("Exercise"), 14 | width = 12, 15 | bucket_list( 16 | header = "Drag the items in any desired bucket", 17 | group_name = "bucket_list_group", 18 | orientation = "horizontal", 19 | add_rank_list( 20 | text = "Drag from here", 21 | labels = list( 22 | "one", 23 | "two", 24 | "three", 25 | htmltools::tags$div( 26 | htmltools::em("Complex"), " html tag without a name" 27 | ), 28 | "five" = htmltools::tags$div( 29 | htmltools::em("Complex"), " html tag with name: 'five'" 30 | ) 31 | ), 32 | input_id = "rank_list_1" 33 | ), 34 | add_rank_list( 35 | text = "to here", 36 | labels = NULL, 37 | input_id = "rank_list_2" 38 | ) 39 | ) 40 | ) 41 | ), 42 | fluidRow( 43 | column( 44 | width = 12, 45 | tags$b("Result"), 46 | column( 47 | width = 12, 48 | 49 | tags$p("input$rank_list_1"), 50 | verbatimTextOutput("results_1"), 51 | 52 | tags$p("input$rank_list_2"), 53 | verbatimTextOutput("results_2"), 54 | 55 | tags$p("input$bucket_list_group"), 56 | verbatimTextOutput("results_3") 57 | ) 58 | ) 59 | ) 60 | ) 61 | 62 | server <- function(input, output, session) { 63 | output$results_1 <- 64 | renderPrint( 65 | input$rank_list_1 # This matches the input_id of the first rank list 66 | ) 67 | output$results_2 <- 68 | renderPrint( 69 | input$rank_list_2 # This matches the input_id of the second rank list 70 | ) 71 | output$results_3 <- 72 | renderPrint( 73 | input$bucket_list_group # Matches the group_name of the bucket list 74 | ) 75 | 76 | } 77 | 78 | 79 | shinyApp(ui, server) 80 | -------------------------------------------------------------------------------- /inst/shiny/clone_remove/app.R: -------------------------------------------------------------------------------- 1 | ## ---- shiny-clone-remove ----------------------------------------------------- 2 | ## Example shiny app to demonstrate cloning and other sortable_options 3 | 4 | library(shiny) 5 | library(htmlwidgets) 6 | library(sortable) 7 | library(magrittr) 8 | 9 | icon_list <- function(x){ 10 | lapply( 11 | x, 12 | function(x) { 13 | tags$div( 14 | icon("arrows-alt-h"), 15 | tags$strong(x) 16 | ) 17 | } 18 | ) 19 | } 20 | 21 | 22 | ui <- fluidPage( 23 | fluidRow( 24 | class = "panel panel-heading", 25 | div( 26 | class = "panel-heading", 27 | h3("Illustration of sortable_options()") 28 | ), 29 | fluidRow( 30 | class = "panel-body", 31 | column( 32 | width = 4, 33 | tags$div( 34 | class = "panel panel-default", 35 | tags$div( 36 | class = "panel-heading", 37 | icon("arrow-right"), 38 | "Drag from here (items will clone)" 39 | ), 40 | tags$div( 41 | class = "panel-body", 42 | id = "sort1", 43 | icon_list(c( 44 | "A", 45 | "B", 46 | "C", 47 | "D", 48 | "E" 49 | )) 50 | ) 51 | ) 52 | ), 53 | column( 54 | width = 4, 55 | # analyse as x 56 | tags$div( 57 | class = "panel panel-default", 58 | tags$div( 59 | class = "panel-heading", 60 | icon("exchange"), 61 | "To here(max 3 items)" 62 | ), 63 | tags$div( 64 | class = "panel-body", 65 | id = "sort2" 66 | ) 67 | ), 68 | # analyse as y 69 | tags$div( 70 | class = "panel panel-default", 71 | tags$div( 72 | class = "panel-heading", 73 | icon("exchange"), 74 | "Or here" 75 | ), 76 | tags$div( 77 | class = "panel-body", 78 | id = "sort3" 79 | ) 80 | ) 81 | 82 | ), 83 | column( 84 | width = 4, 85 | # bin 86 | tags$div( 87 | class = "panel panel-default", 88 | tags$div( 89 | class = "panel-heading", 90 | icon("trash"), 91 | "Remove item" 92 | ), 93 | tags$div( 94 | class = "panel-body", 95 | id = "sortable_bin" 96 | ) 97 | ) 98 | 99 | ) 100 | ) 101 | ), 102 | sortable_js( 103 | "sort1", 104 | options = sortable_options( 105 | group = list( 106 | pull = "clone", 107 | name = "sortGroup1", 108 | put = FALSE 109 | ), 110 | # swapClass = "sortable-swap-highlight", 111 | onSort = sortable_js_capture_input("sort_vars") 112 | ) 113 | ), 114 | sortable_js( 115 | "sort2", 116 | options = sortable_options( 117 | group = list( 118 | group = "sortGroup1", 119 | put = htmlwidgets::JS("function (to) { return to.el.children.length < 3; }"), 120 | pull = TRUE 121 | ), 122 | swapClass = "sortable-swap-highlight", 123 | onSort = sortable_js_capture_input("sort_x") 124 | ) 125 | ), 126 | sortable_js( 127 | "sort3", 128 | options = sortable_options( 129 | group = list( 130 | group = "sortGroup1", 131 | put = TRUE, 132 | pull = TRUE 133 | ), 134 | swapClass = "sortable-swap-highlight", 135 | onSort = sortable_js_capture_input("sort_y") 136 | ) 137 | ), 138 | sortable_js( 139 | "sortable_bin", 140 | options = sortable_options( 141 | group = list( 142 | group = "sortGroup1", 143 | put = TRUE, 144 | pull = TRUE 145 | ), 146 | onAdd = htmlwidgets::JS("function (evt) { this.el.removeChild(evt.item); }") 147 | ) 148 | ) 149 | 150 | ) 151 | 152 | server <- function(input, output) { 153 | output$variables <- renderPrint(input[["sort_vars"]]) 154 | output$analyse_x <- renderPrint(input[["sort_x"]]) 155 | output$analyse_y <- renderPrint(input[["sort_y"]]) 156 | 157 | 158 | x <- reactive({ 159 | x <- input$sort_x 160 | if (is.character(x)) x %>% trimws() 161 | }) 162 | 163 | y <- reactive({ 164 | input$sort_y %>% trimws() 165 | }) 166 | 167 | } 168 | shinyApp(ui, server) 169 | -------------------------------------------------------------------------------- /inst/shiny/custom_css/app.R: -------------------------------------------------------------------------------- 1 | ## ---- custom-css-app ------------------------------------------------ 2 | ## Example shiny app with custom css 3 | 4 | library(shiny) 5 | library(sortable) 6 | 7 | ui <- fluidPage( 8 | fluidRow( 9 | column( 10 | width = 12, 11 | tags$b("Exercise"), 12 | rank_list( 13 | text = "Drag the items in any desired order", 14 | labels = list( 15 | "one", 16 | "two", 17 | "three", 18 | "four", 19 | "five" 20 | ), 21 | input_id = "rank_list_1", 22 | class = c("default-sortable", "custom-sortable") # add custom style 23 | ), 24 | tags$style( 25 | HTML(" 26 | .rank-list-container.custom-sortable { 27 | background-color: #8A8; 28 | } 29 | .custom-sortable .rank-list-item { 30 | background-color: #BDB; 31 | } 32 | ") 33 | ), 34 | tags$b("Result"), 35 | verbatimTextOutput("results") 36 | ) 37 | ) 38 | ) 39 | 40 | server <- function(input, output) { 41 | output$results <- renderPrint({ 42 | input$rank_list_1 # This matches the input_id of the rank list 43 | }) 44 | } 45 | 46 | shinyApp(ui, server) 47 | -------------------------------------------------------------------------------- /inst/shiny/drag_vars_to_plot/app.R: -------------------------------------------------------------------------------- 1 | ## ---- shiny-drag-vars-to-plot ------------------------------------------- 2 | ## Example shiny app to create a plot from sortable inputs 3 | 4 | library(shiny) 5 | library(htmlwidgets) 6 | library(sortable) 7 | library(magrittr) 8 | 9 | colnames_to_tags <- function(df){ 10 | lapply( 11 | colnames(df), 12 | function(co) { 13 | tag( 14 | "p", 15 | list( 16 | class = class(df[, co]), 17 | tags$span(class = "glyphicon glyphicon-move"), 18 | tags$strong(co) 19 | ) 20 | ) 21 | } 22 | ) 23 | } 24 | 25 | 26 | ui <- fluidPage( 27 | fluidRow( 28 | class = "panel panel-heading", 29 | div( 30 | class = "panel-heading", 31 | h3("Dragging variables to define a plot") 32 | ), 33 | fluidRow( 34 | class = "panel-body", 35 | column( 36 | width = 3, 37 | tags$div( 38 | class = "panel panel-default", 39 | tags$div(class = "panel-heading", "Variables"), 40 | tags$div( 41 | class = "panel-body", 42 | id = "sort1", 43 | colnames_to_tags(mtcars) 44 | ) 45 | ) 46 | ), 47 | column( 48 | width = 3, 49 | # analyse as x 50 | tags$div( 51 | class = "panel panel-default", 52 | tags$div( 53 | class = "panel-heading", 54 | tags$span(class = "glyphicon glyphicon-stats"), 55 | "Analyze as x (drag here)" 56 | ), 57 | tags$div( 58 | class = "panel-body", 59 | id = "sort2" 60 | ) 61 | ), 62 | # analyse as y 63 | tags$div( 64 | class = "panel panel-default", 65 | tags$div( 66 | class = "panel-heading", 67 | tags$span(class = "glyphicon glyphicon-stats"), 68 | "Analyze as y (drag here)" 69 | ), 70 | tags$div( 71 | class = "panel-body", 72 | id = "sort3" 73 | ) 74 | ) 75 | 76 | ), 77 | column( 78 | width = 6, 79 | plotOutput("plot") 80 | 81 | ) 82 | ) 83 | ), 84 | sortable_js( 85 | "sort1", 86 | options = sortable_options( 87 | group = list( 88 | name = "sortGroup1", 89 | put = TRUE 90 | ), 91 | sort = FALSE, 92 | onSort = sortable_js_capture_input("sort_vars") 93 | ) 94 | ), 95 | sortable_js( 96 | "sort2", 97 | options = sortable_options( 98 | group = list( 99 | group = "sortGroup1", 100 | put = htmlwidgets::JS("function (to) { return to.el.children.length < 1; }"), 101 | pull = TRUE 102 | ), 103 | onSort = sortable_js_capture_input("sort_x") 104 | ) 105 | ), 106 | sortable_js( 107 | "sort3", 108 | options = sortable_options( 109 | group = list( 110 | group = "sortGroup1", 111 | put = htmlwidgets::JS("function (to) { return to.el.children.length < 1; }"), 112 | pull = TRUE 113 | ), 114 | onSort = sortable_js_capture_input("sort_y") 115 | ) 116 | ) 117 | ) 118 | 119 | server <- function(input, output) { 120 | output$variables <- renderPrint(input[["sort_vars"]]) 121 | output$analyse_x <- renderPrint(input[["sort_x"]]) 122 | output$analyse_y <- renderPrint(input[["sort_y"]]) 123 | 124 | 125 | x <- reactive({ 126 | x <- input$sort_x 127 | if (is.character(x)) x %>% trimws() 128 | }) 129 | 130 | y <- reactive({ 131 | input$sort_y %>% trimws() 132 | }) 133 | 134 | output$plot <- 135 | renderPlot({ 136 | validate( 137 | need(x(), "Drag a variable to x"), 138 | need(y(), "Drag a variable to y") 139 | ) 140 | dat <- mtcars[, c(x(), y())] 141 | names(dat) <- c("x", "y") 142 | plot(y ~ x, data = dat, xlab = x(), ylab = y()) 143 | }) 144 | 145 | } 146 | shinyApp(ui, server) 147 | -------------------------------------------------------------------------------- /inst/shiny/group_list/app.R: -------------------------------------------------------------------------------- 1 | library(shiny) 2 | library(sortable) 3 | 4 | 5 | # Example of "independently working" sortable items. 6 | # 7 | # From https://community.rstudio.com/t/trying-to-use-input-text-to-subset-sortable-input/61612 8 | # 9 | # This uses rank_lists to mimic how how a bucket list works, without actually using a bucket_list. Only the CSS formatting of a bucket_list is re-used. 10 | # 11 | # To link the independent rank_lists, use sortable_options(group = "GROUP") in the rank_list definitions. (You can think of bucket lists as rank lists that share the same group value.). See https://rstudio.github.io/sortable/reference/sortable_options.html for more details. 12 | # 13 | # Create an extra wrapping div container to make the grouped rank_lists appear like a bucket_list. 14 | 15 | ui <- fluidPage( 16 | tags$head( 17 | tags$style(HTML(".bucket-list-container {min-height: 350px;}")) 18 | ), 19 | fluidRow( 20 | column( 21 | width = 12, 22 | # choose list of variable names to send to bucket list 23 | radioButtons( 24 | inputId = "variableList", 25 | label = "Choose your variable list", 26 | choices = c( 27 | "names(mtcars)" = "names(mtcars)", 28 | "state.name" = "state.name" 29 | ) 30 | ), 31 | # input text to subset variable names 32 | textInput( 33 | inputId = "subsetChooseListText", 34 | label = "You can subset the input list by typing here", 35 | value = "" 36 | ), 37 | div( 38 | # class value is current default class value for container 39 | class = "bucket-list-container default-sortable", 40 | "Drag the items in any desired bucket", 41 | div( 42 | # class value is current default class value for list 43 | class = "default-sortable bucket-list bucket-list-horizontal", 44 | # need to make sure the outer div size is respected 45 | # use the current default flex value 46 | column( 47 | width = 4, 48 | uiOutput("selection_list", style = "flex:1 0 100px;") 49 | ), 50 | column( 51 | width = 4, 52 | rank_list( 53 | text = "to here", 54 | labels = list(), 55 | input_id = "rank_list_2", 56 | options = sortable_options(group = "mygroup") 57 | )), 58 | column( 59 | width = 4, 60 | rank_list( 61 | text = "and also here", 62 | labels = list(), 63 | input_id = "rank_list_3", 64 | options = sortable_options(group = "mygroup") 65 | ) 66 | ) 67 | ) 68 | ), 69 | uiOutput("dragAndDropList") 70 | ) 71 | ), 72 | fluidRow( 73 | column( 74 | width = 12, 75 | tags$b("Result"), 76 | column( 77 | width = 12, 78 | 79 | tags$p("input$rank_list_1"), 80 | verbatimTextOutput("results_1"), 81 | 82 | tags$p("input$rank_list_2"), 83 | verbatimTextOutput("results_2"), 84 | 85 | tags$p("input$rank_list_3"), 86 | verbatimTextOutput("results_3") 87 | ) 88 | ) 89 | ) 90 | ) 91 | 92 | server <- function(input, output) { 93 | 94 | # initialize reactive values 95 | varList <- reactive({ 96 | req(input$variableList) 97 | if (input$variableList == "state.name") { 98 | state.name 99 | } else { 100 | names(mtcars) 101 | } 102 | }) 103 | 104 | subsetChooseList <- reactive({ 105 | items <- varList() 106 | pattern <- input$subsetChooseListText 107 | if (nchar(pattern) < 1) { 108 | return(items) 109 | } 110 | items[ 111 | grepl( 112 | x = items, 113 | pattern = input$subsetChooseListText, 114 | ignore.case = TRUE 115 | ) 116 | ] 117 | }) 118 | 119 | output$selection_list <- renderUI({ 120 | labels <- subsetChooseList() 121 | 122 | # remove already chosen items 123 | labels <- labels[!( 124 | labels %in% input$rank_list_2 | 125 | labels %in% input$rank_list_3 126 | )] 127 | rank_list( 128 | text = "Drag from here", 129 | labels = labels, 130 | input_id = "rank_list_1", 131 | options = sortable_options(group = "mygroup") 132 | ) 133 | }) 134 | 135 | # visual output for debugging 136 | output$results_1 <- renderPrint(input$rank_list_1) 137 | output$results_2 <- renderPrint(input$rank_list_2) 138 | output$results_3 <- renderPrint(input$rank_list_3) 139 | 140 | } 141 | 142 | 143 | shinyApp(ui, server) 144 | -------------------------------------------------------------------------------- /inst/shiny/horizontal/app.R: -------------------------------------------------------------------------------- 1 | library(shiny) 2 | library(sortable) 3 | 4 | set.seed(123456) # to make sample() reproducible 5 | labels <- sample(month.name[1:5]) 6 | 7 | ui <- fluidPage( 8 | div( 9 | rank_list( 10 | text = "Horizontal rank list", 11 | labels = labels, 12 | input_id = "rank_h", 13 | orientation = "horizontal" 14 | ) 15 | ), 16 | div( 17 | rank_list( 18 | text = "Default rank list (vertical)", 19 | labels = labels, 20 | input_id = "rank" 21 | ) 22 | ) 23 | ) 24 | 25 | server <- function(input, output, session) {} 26 | 27 | shinyApp(ui, server) 28 | -------------------------------------------------------------------------------- /inst/shiny/rank_list/app.R: -------------------------------------------------------------------------------- 1 | ## ---- rank-list-app ------------------------------------------------- 2 | ## Example shiny app with rank list 3 | 4 | library(shiny) 5 | library(sortable) 6 | 7 | labels <- list( 8 | "one", 9 | "two", 10 | "three", 11 | htmltools::tags$div( 12 | htmltools::em("Complex"), " html tag without a name" 13 | ), 14 | "five" = htmltools::tags$div( 15 | htmltools::em("Complex"), " html tag with name: 'five'" 16 | ) 17 | ) 18 | 19 | rank_list_basic <- rank_list( 20 | text = "Drag the items in any desired order", 21 | labels = labels, 22 | input_id = "rank_list_basic" 23 | ) 24 | 25 | rank_list_swap <- rank_list( 26 | text = "Notice that dragging causes items to swap", 27 | labels = labels, 28 | input_id = "rank_list_swap", 29 | options = sortable_options(swap = TRUE) 30 | ) 31 | 32 | rank_list_multi <- rank_list( 33 | text = "You can select multiple items, then drag as a group", 34 | labels = labels, 35 | input_id = "rank_list_multi", 36 | options = sortable_options(multiDrag = TRUE) 37 | ) 38 | 39 | 40 | 41 | ui <- fluidPage( 42 | fluidRow( 43 | column( 44 | width = 12, 45 | tags$h2("Default, multi-drag and swapping behaviour"), 46 | tabsetPanel( 47 | type = "tabs", 48 | tabPanel( 49 | "Default", 50 | tags$b("Exercise"), 51 | rank_list_basic, 52 | tags$b("Result"), 53 | verbatimTextOutput("results_basic") 54 | ), 55 | tabPanel( 56 | "Multi-drag", 57 | tags$b("Exercise"), 58 | rank_list_multi, 59 | tags$b("Result"), 60 | verbatimTextOutput("results_multi") 61 | ), 62 | tabPanel( 63 | "Swap", 64 | tags$b("Exercise"), 65 | rank_list_swap, 66 | tags$b("Result"), 67 | verbatimTextOutput("results_swap") 68 | ) 69 | ) 70 | ) 71 | ) 72 | ) 73 | 74 | server <- function(input, output, session) { 75 | output$results_basic <- renderPrint({ 76 | input$rank_list_basic # This matches the input_id of the rank list 77 | }) 78 | output$results_multi <- renderPrint({ 79 | input$rank_list_multi # This matches the input_id of the rank list 80 | }) 81 | output$results_swap <- renderPrint({ 82 | input$rank_list_swap # This matches the input_id of the rank list 83 | }) 84 | } 85 | 86 | shinyApp(ui, server) 87 | -------------------------------------------------------------------------------- /inst/shiny/shiny_tabset/app.R: -------------------------------------------------------------------------------- 1 | ## ---- shiny-tabset-app -------------------------------------------------- 2 | ## Example shiny app to drag-and-drop tabsets in a shiny app 3 | 4 | 5 | # all credit for code goes to RStudio 6 | # https://github.com/rstudio/shiny/tree/main/006-tabsets 7 | library(sortable) 8 | library(shiny) 9 | 10 | ui = # Define UI for random distribution application 11 | shinyUI(fluidPage( 12 | 13 | # Application title 14 | titlePanel("Tabsets"), 15 | 16 | # Sidebar with controls to select the random distribution type 17 | # and number of observations to generate. Note the use of the 18 | # br() element to introduce extra vertical spacing 19 | sidebarLayout( 20 | sidebarPanel( 21 | radioButtons( 22 | "dist", "Distribution type:", 23 | c( 24 | "Normal" = "norm", 25 | "Uniform" = "unif", 26 | "Log-normal" = "lnorm", 27 | "Exponential" = "exp" 28 | ) 29 | ), 30 | br(), 31 | 32 | sliderInput( 33 | "n", 34 | "Number of observations:", 35 | value = 500, 36 | min = 1, 37 | max = 1000) 38 | ), 39 | 40 | # Show a tabset that includes a plot, summary, and table view 41 | # of the generated distribution 42 | mainPanel( 43 | tabsetPanel( 44 | type = "tabs", 45 | id = "sortTab", 46 | tabPanel("Plot", plotOutput("plot")), 47 | tabPanel("Summary", verbatimTextOutput("summary")), 48 | tabPanel("Table", tableOutput("table")) 49 | ) 50 | ) 51 | ), 52 | sortable_js("sortTab") 53 | )) 54 | 55 | server = function(input, output) { 56 | 57 | # Reactive expression to generate the requested distribution. 58 | # This is called whenever the inputs change. The output 59 | # functions defined below then all use the value computed from 60 | # this expression 61 | data <- reactive({ 62 | dist <- switch( 63 | input$dist, 64 | norm = rnorm, 65 | unif = runif, 66 | lnorm = rlnorm, 67 | exp = rexp, 68 | rnorm 69 | ) 70 | 71 | dist(input$n) 72 | }) 73 | 74 | # Generate a plot of the data. Also uses the inputs to build 75 | # the plot label. Note that the dependencies on both the inputs 76 | # and the data reactive expression are both tracked, and 77 | # all expressions are called in the sequence implied by the 78 | # dependency graph 79 | output$plot <- renderPlot({ 80 | dist <- input$dist 81 | n <- input$n 82 | 83 | hist(data(), 84 | main=paste('r', dist, '(', n, ')', sep='')) 85 | }) 86 | 87 | # Generate a summary of the data 88 | output$summary <- renderPrint({ 89 | summary(data()) 90 | }) 91 | 92 | # Generate an HTML table view of the data 93 | output$table <- renderTable({ 94 | data.frame(x = data()) 95 | }) 96 | 97 | } 98 | 99 | shinyApp( ui, server ) 100 | -------------------------------------------------------------------------------- /inst/shiny/update_bucket_list/app.R: -------------------------------------------------------------------------------- 1 | ## ---- update-bucket-list-app ----------------------------------------------- 2 | ## Example shiny app with bucket list update 3 | 4 | library(shiny) 5 | library(sortable) 6 | library(magrittr) 7 | 8 | 9 | ui <- fluidPage( 10 | tags$head( 11 | tags$style(HTML(".bucket-list-container {min-height: 350px;}")) 12 | ), 13 | fluidRow( 14 | column( 15 | width = 12, 16 | h2("Update the title"), 17 | actionButton("btnUpdateHeader", label = "Update bucket list header"), 18 | actionButton("btnAddLeft", label = "Add element to left"), 19 | actionButton("btnMoveLeft", label = "Move element from right to left"), 20 | ) 21 | ), 22 | fluidRow( 23 | column( 24 | h2("Exercise"), 25 | width = 12, 26 | bucket_list( 27 | header = "Drag the items in any desired bucket", 28 | group_name = "bucket_list_group", 29 | orientation = "horizontal", 30 | add_rank_list( 31 | text = "Drag from here", 32 | labels = list( 33 | "one", 34 | "two", 35 | "three" 36 | ), 37 | input_id = "rank_list_1" 38 | ), 39 | add_rank_list( 40 | text = "to here", 41 | labels = NULL, 42 | input_id = "rank_list_2" 43 | ) 44 | ) 45 | ) 46 | ) 47 | ) 48 | 49 | server <- function(input, output, session) { 50 | 51 | # test updating the bucket list label 52 | counter_bucket <- reactiveVal(1) 53 | counter_option <- reactiveVal(3) 54 | 55 | # Updates the bucket list header when btnUpdateHeader is pressed 56 | observe({ 57 | update_bucket_list( 58 | "bucket_list_group", 59 | header = paste("You pressed the update button", counter_bucket(), "times"), 60 | session = session 61 | ) 62 | counter_bucket(counter_bucket() + 1) 63 | }) %>% 64 | bindEvent(input$btnUpdateHeader) 65 | 66 | # Updates the rank list text with the number of labels 67 | observe({ 68 | len <- length(input$rank_list_1) 69 | count_word <- if(len == 0) "" else glue::glue("({len})") 70 | update_rank_list( 71 | "rank_list_1", 72 | text = paste0("From here ", count_word), 73 | session = session 74 | ) 75 | }) %>% 76 | bindEvent(input$rank_list_1) 77 | 78 | # Updates the rank list text with the number of labels 79 | observe({ 80 | len <- length(input$rank_list_2) 81 | count_word <- if(len == 0) "" else glue::glue("({len})") 82 | update_rank_list( 83 | "rank_list_2", 84 | text = paste0("To here ", count_word), 85 | session = session 86 | ) 87 | }) %>% 88 | bindEvent(input$rank_list_2) 89 | 90 | # Respond to press of btnAddLeft and adds one new element to the left side 91 | observe({ 92 | new_el <- paste("Element", counter_option() + 1) 93 | counter_option(counter_option() + 1) 94 | update_rank_list( 95 | "rank_list_1", 96 | labels = c(input$rank_list_1, new_el) 97 | ) 98 | }) %>% 99 | bindEvent(input$btnAddLeft) 100 | 101 | # Respond to press of btnMoveLeft and moves the last element from R to L 102 | observe({ 103 | bottom_el <- tail(input$rank_list_2, 1) 104 | if (length(bottom_el)){ 105 | update_rank_list( 106 | "rank_list_1", 107 | labels = c(input$rank_list_1, bottom_el) 108 | ) 109 | update_rank_list( 110 | "rank_list_2", 111 | labels = head(input$rank_list_2, -1) 112 | ) 113 | } 114 | }) %>% 115 | bindEvent(input$btnMoveLeft) 116 | 117 | } 118 | 119 | shinyApp(ui, server) 120 | -------------------------------------------------------------------------------- /inst/shiny/update_rank_list_method/app.R: -------------------------------------------------------------------------------- 1 | ## ---- update-rank-list-method-app --------------------------------------- 2 | ## Example shiny app that dynamically updates a rank list 3 | 4 | library(shiny) 5 | library(sortable) 6 | library(magrittr) 7 | 8 | 9 | ui <- fluidPage( 10 | tags$head( 11 | tags$style(HTML(".bucket-list-container {min-height: 350px;}")) 12 | ), 13 | fluidRow( 14 | column( 15 | width = 12, 16 | h2("Modify a rank list"), 17 | actionButton("btnUpdateRank", label = "Update rank list title"), 18 | actionButton("btnChangeLabels", label = "Change labels"), 19 | actionButton("btnSortLabels", label = "Sort labels"), 20 | actionButton("btnEmptyLabels", label = "Empty labels") 21 | ) 22 | ), 23 | fluidRow( 24 | column( 25 | h2("Exercise"), 26 | width = 12, 27 | rank_list( 28 | text = "Change the order", 29 | labels = letters[1:5], 30 | input_id = "rank_list_1" 31 | ) 32 | ) 33 | ), 34 | verbatimTextOutput("results") 35 | ) 36 | 37 | server <- function(input, output, session) { 38 | 39 | # test updating the bucket list label 40 | counter_bucket <- reactiveVal(1) 41 | 42 | output$results <- renderPrint({ 43 | input$rank_list_1 # This matches the input_id of the rank list 44 | }) 45 | 46 | observe({ 47 | update_rank_list( 48 | "rank_list_1", 49 | text = paste("You pressed the update button", counter_bucket(), "times"), 50 | ) 51 | counter_bucket(counter_bucket() + 1) 52 | }) %>% 53 | bindEvent(input$btnUpdateRank) 54 | 55 | 56 | observe({ 57 | update_rank_list( 58 | "rank_list_1", 59 | labels = sample(LETTERS, 5) 60 | ) 61 | }) %>% 62 | bindEvent(input$btnChangeLabels) 63 | 64 | observe({ 65 | update_rank_list( 66 | "rank_list_1", 67 | labels = list() 68 | ) 69 | }) %>% 70 | bindEvent(input$btnEmptyLabels) 71 | 72 | observe({ 73 | update_rank_list( 74 | "rank_list_1", 75 | labels = sort(input$rank_list_1) 76 | ) 77 | }) %>% 78 | bindEvent(input$btnSortLabels) 79 | 80 | 81 | } 82 | 83 | shinyApp(ui, server) 84 | -------------------------------------------------------------------------------- /inst/shiny/update_rank_list_ui/app.R: -------------------------------------------------------------------------------- 1 | ## ---- update-rank-list-ui-app ---------------------------------------------- 2 | ## Example shiny app that dynamically updates a rank list using shiny ui 3 | 4 | library(shiny) 5 | library(sortable) 6 | 7 | ui <- fluidPage( 8 | fluidRow( 9 | column( 10 | width = 4, 11 | selectInput("data", label = "Select the data source", choices = c("mtcars", "iris")), 12 | selectInput("nrow", label = "Number of rows", choices = c("15", "50", "All")), 13 | uiOutput("sortable_ui") 14 | ), 15 | column( 16 | width = 8, 17 | h2("Results"), 18 | tableOutput("table") 19 | ) 20 | ) 21 | ) 22 | 23 | server <- function(input, output, session) { 24 | rv <- reactiveValues(data = data.frame()) 25 | 26 | observeEvent(input$data, { 27 | rv$data <- get(input$data) 28 | }) 29 | 30 | observeEvent(input$sortable, { 31 | rv$data <- rv$data[input$sortable] 32 | }) 33 | 34 | output$sortable_ui <- renderUI({ 35 | rank_list( 36 | "Drag column names to change order", 37 | labels = names(rv$data), 38 | input_id = "sortable") 39 | }) 40 | 41 | output$table <- renderTable({ 42 | if (input$nrow == "All") { 43 | rv$data 44 | } else { 45 | head(rv$data, as.numeric(input$nrow)) 46 | } 47 | }) 48 | } 49 | 50 | shinyApp(ui, server) 51 | -------------------------------------------------------------------------------- /inst/tutorials/question_rank/question_rank.Rmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Using ranking questions in learnr" 3 | author: Andrie de Vries 4 | date: "`r Sys.Date()`" 5 | output: learnr::tutorial 6 | runtime: shiny_prerendered 7 | --- 8 | 9 | ```{r setup, include=FALSE} 10 | library(learnr) 11 | library(sortable) 12 | library(magrittr) 13 | ``` 14 | 15 | ## Introduction 16 | 17 | The `sortable` package allows you to create custom HTML widgets with drag-and-drop capability. As a motivating example, the package exports a function `question_rank()` that lets you design `learnr` quizzes with drag-and-drop questions. 18 | 19 | ### Example 20 | 21 | **Also try the reverse order!** 22 | 23 | ```{r insects-demo, echo=FALSE} 24 | # Define the answer options 25 | insects <- c( 26 | "ant", 27 | "bumble bee", 28 | "cricket", 29 | "dragonfly" 30 | ) 31 | 32 | # Initialize the question 33 | question_rank( 34 | "Sort these insects in alpabetical order:", 35 | answer(insects, correct = TRUE), 36 | answer(rev(insects), correct = FALSE, 37 | message = "You sorted in reverse order!" 38 | ), 39 | allow_retry = TRUE 40 | ) 41 | ``` 42 | 43 | ## Creating a ranking question 44 | 45 | To insert a ranking question into a `learnr` quiz, use `question_rank()`. Note that, unlike standard `learnr` questions, ranking questions will by default randomize the order of the answer options. 46 | 47 | You must provide at least one correct answer, using the `learnr` `answer()` function: 48 | 49 | ```{r insects-code} 50 | # Define the answer options 51 | insects <- c( 52 | "ant", 53 | "bumble bee", 54 | "cricket", 55 | "dragonfly" 56 | ) 57 | 58 | # Initialize the question 59 | question_rank( 60 | "Sort these insects in alpabetical order:", 61 | answer(insects, correct = TRUE), 62 | answer(rev(insects), correct = FALSE, message = "Other direction!"), 63 | allow_retry = TRUE 64 | ) 65 | ``` 66 | 67 | ## Changing the 'SortableJS' options 68 | 69 | You can change the behaviour of the HTML widget by sending different options to the `SortableJS` library. For example, to change the duration of animation, set `sortable_options(animation = ...)`: 70 | 71 | ```{r insects-code-options} 72 | # Define the answer options 73 | insects <- c( 74 | "ant", 75 | "bumble bee", 76 | "cricket", 77 | "dragonfly" 78 | ) 79 | 80 | # Initialize the question 81 | question_rank( 82 | "Sort these insects in alpabetical order:", 83 | answer(insects, correct = TRUE), 84 | answer(rev(insects), correct = FALSE, message = "Other direction!"), 85 | allow_retry = TRUE, 86 | options = sortable_options( 87 | animation = 150 88 | ) 89 | ) 90 | ``` 91 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 25 | 42 | 43 | 46 | 63 | 64 | 67 | 84 | 85 | 88 | 105 | 106 | 107 | 133 | 135 | 136 | 138 | image/svg+xml 139 | 141 | 142 | 143 | 144 | 145 | 150 | 167 | 180 | 187 | 194 | 201 | 206 | 211 | 228 | 233 | 238 | 243 | 248 | 253 | 258 | 263 | 268 | 269 | 270 | -------------------------------------------------------------------------------- /man-roxygen/options.R: -------------------------------------------------------------------------------- 1 | #' @param options Options to be supplied to [sortable_js] object. See [sortable_options] for more details 2 | -------------------------------------------------------------------------------- /man/add_rank_list.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/bucket_list.R 3 | \name{add_rank_list} 4 | \alias{add_rank_list} 5 | \title{Add a rank list inside bucket list.} 6 | \usage{ 7 | add_rank_list(text, labels = NULL, input_id = NULL, css_id = input_id, ...) 8 | } 9 | \arguments{ 10 | \item{text}{Text to appear at top of list.} 11 | 12 | \item{labels}{A character vector with the text to display inside the widget. 13 | This can also be a list of html tag elements. The text content of each 14 | label or label name will be used to set the shiny \code{input_id} value. 15 | To create an empty \code{rank_list}, use \code{labels = list()}.} 16 | 17 | \item{input_id}{output variable to read the plot/image from.} 18 | 19 | \item{css_id}{This is the css id to use, and must be unique in your shiny 20 | app. This defaults to the value of \code{input_id}, and will be appended to the 21 | value "rank-list-container", to ensure the CSS id is unique for the 22 | container as well as the labels. 23 | If NULL, the function generates an id of the form 24 | \code{rank_list_id_1}, and will automatically increment for every \code{rank_list}.} 25 | 26 | \item{...}{Other arguments passed to \code{rank_list}} 27 | } 28 | \value{ 29 | A list of class \code{add_rank_list} 30 | } 31 | \description{ 32 | Since a \link{bucket_list} can contain more than one \link{rank_list}, you need 33 | an easy way to define the contents of each individual rank list. This 34 | function serves as a specification of a rank list. 35 | } 36 | \seealso{ 37 | \code{\link[=bucket_list]{bucket_list()}}, \code{\link[=rank_list]{rank_list()}} and \code{\link[=update_rank_list]{update_rank_list()}} 38 | } 39 | -------------------------------------------------------------------------------- /man/bucket_list.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/bucket_list.R 3 | \name{bucket_list} 4 | \alias{bucket_list} 5 | \title{Create a bucket list.} 6 | \usage{ 7 | bucket_list( 8 | header = NULL, 9 | ..., 10 | group_name, 11 | css_id = group_name, 12 | group_put_max = rep(Inf, length(labels)), 13 | options = sortable_options(), 14 | class = "default-sortable", 15 | orientation = c("horizontal", "vertical") 16 | ) 17 | } 18 | \arguments{ 19 | \item{header}{Text that appears at the top of the bucket list. (This is 20 | encoded as an HTML \verb{

} tag, so not strictly speaking a header.) Note 21 | that you must explicitly provide \code{header} argument, especially in the case 22 | where you want the header to be empty - to do this use \code{header = NULL} or 23 | \code{header = NA}.} 24 | 25 | \item{...}{One or more specifications for a rank list, and must be defined by 26 | \link{add_rank_list}.} 27 | 28 | \item{group_name}{Passed to \code{SortableJS} as the group name. Also the input 29 | value set in Shiny. (\code{input[[group_name]]}). Items can be dragged between 30 | bucket lists which share the same group name.} 31 | 32 | \item{css_id}{This is the css id to use, and must be unique in your shiny 33 | app. This defaults to the value of \code{group_id}, and will be appended to the 34 | value "bucket-list-container", to ensure the CSS id is unique for the 35 | container as well as the embedded rank lists.} 36 | 37 | \item{group_put_max}{Not yet implemented} 38 | 39 | \item{options}{Options to be supplied to \link{sortable_js} object. See \link{sortable_options} for more details} 40 | 41 | \item{class}{A css class applied to the bucket list and rank lists. This can 42 | be used to define custom styling.} 43 | 44 | \item{orientation}{Either \code{horizontal} or \code{vertical}, and specifies the 45 | layout of the components on the page.} 46 | } 47 | \value{ 48 | A list with class \code{bucket_list} 49 | } 50 | \description{ 51 | A bucket list can contain more than one \link{rank_list} and allows drag-and-drop 52 | of items between the different lists. 53 | } 54 | \examples{ 55 | ## -- example-bucket-list --------------------------------------------- 56 | 57 | ## bucket list 58 | 59 | if(interactive()) { 60 | bucket_list( 61 | header = "This is a bucket list. You can drag items between the lists.", 62 | add_rank_list( 63 | text = "Drag from here", 64 | labels = c("a", "bb", "ccc") 65 | ), 66 | add_rank_list( 67 | text = "to here", 68 | labels = NULL 69 | ) 70 | ) 71 | } 72 | 73 | ## bucket list with three columns 74 | 75 | if(interactive()) { 76 | bucket_list( 77 | header = c("Sort these items into Letters and Numbers"), 78 | add_rank_list( 79 | text = "Drag from here", 80 | labels = sample(c(1:3, letters[1:2])) 81 | ), 82 | add_rank_list( 83 | text = "Letters" 84 | ), 85 | add_rank_list( 86 | text = "Numbers" 87 | ) 88 | ) 89 | } 90 | 91 | ## drag items between bucket lists 92 | 93 | if(interactive()) { 94 | 95 | ui <- shiny::fluidPage( 96 | shiny::column(4, bucket_list(NULL, 97 | group_name = "foo", 98 | add_rank_list( 99 | text = "Drag from here...", 100 | labels = sample(c(1:3, letters[1:2])) 101 | ) 102 | )), 103 | shiny::column(4, "Some empty space"), 104 | shiny::column(4, bucket_list(NULL, 105 | group_name = "foo", 106 | add_rank_list( 107 | text = "...To here" 108 | ) 109 | )) 110 | ) 111 | 112 | server <- function(input, output, session) {} 113 | 114 | shiny::shinyApp(ui, server) 115 | 116 | 117 | } 118 | 119 | ## Example of a shiny app 120 | if (interactive()) { 121 | app <- system.file( 122 | "shiny/bucket_list/app.R", 123 | package = "sortable" 124 | ) 125 | shiny::runApp(app) 126 | } 127 | } 128 | \seealso{ 129 | \link{rank_list}, \link{update_rank_list} 130 | } 131 | -------------------------------------------------------------------------------- /man/chain_js_events.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/js.R 3 | \name{chain_js_events} 4 | \alias{chain_js_events} 5 | \title{Chain multiple JavaScript events} 6 | \usage{ 7 | chain_js_events(...) 8 | } 9 | \arguments{ 10 | \item{...}{JavaScript functions defined by \link[htmlwidgets:JS]{htmlwidgets::JS}} 11 | } 12 | \value{ 13 | A single JavaScript function that will call all methods provided with 14 | the event 15 | } 16 | \description{ 17 | SortableJS does not have an event based system. To be able to call multiple 18 | JavaScript events under the same event execution, they need to be executed 19 | one after another. 20 | } 21 | \seealso{ 22 | Other JavaScript functions: 23 | \code{\link{sortable_js_capture_input}()} 24 | } 25 | \concept{JavaScript functions} 26 | -------------------------------------------------------------------------------- /man/figures/README-unnamed-chunk-4-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/sortable/2b938859b4c44a2bd4cf99628023590e8cdd4910/man/figures/README-unnamed-chunk-4-1.png -------------------------------------------------------------------------------- /man/figures/README-unnamed-chunk-5-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/sortable/2b938859b4c44a2bd4cf99628023590e8cdd4910/man/figures/README-unnamed-chunk-5-1.png -------------------------------------------------------------------------------- /man/figures/bucket_list_shiny.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/sortable/2b938859b4c44a2bd4cf99628023590e8cdd4910/man/figures/bucket_list_shiny.gif -------------------------------------------------------------------------------- /man/figures/diagrammer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/sortable/2b938859b4c44a2bd4cf99628023590e8cdd4910/man/figures/diagrammer.gif -------------------------------------------------------------------------------- /man/figures/lifecycle-archived.svg: -------------------------------------------------------------------------------- 1 | lifecyclelifecyclearchivedarchived -------------------------------------------------------------------------------- /man/figures/lifecycle-defunct.svg: -------------------------------------------------------------------------------- 1 | lifecyclelifecycledefunctdefunct -------------------------------------------------------------------------------- /man/figures/lifecycle-deprecated.svg: -------------------------------------------------------------------------------- 1 | lifecyclelifecycledeprecateddeprecated -------------------------------------------------------------------------------- /man/figures/lifecycle-experimental.svg: -------------------------------------------------------------------------------- 1 | lifecyclelifecycleexperimentalexperimental -------------------------------------------------------------------------------- /man/figures/lifecycle-maturing.svg: -------------------------------------------------------------------------------- 1 | lifecyclelifecyclematuringmaturing -------------------------------------------------------------------------------- /man/figures/lifecycle-questioning.svg: -------------------------------------------------------------------------------- 1 | lifecyclelifecyclequestioningquestioning -------------------------------------------------------------------------------- /man/figures/lifecycle-retired.svg: -------------------------------------------------------------------------------- 1 | lifecyclelifecycleretiredretired -------------------------------------------------------------------------------- /man/figures/lifecycle-soft-deprecated.svg: -------------------------------------------------------------------------------- 1 | lifecyclelifecyclesoft-deprecatedsoft-deprecated -------------------------------------------------------------------------------- /man/figures/lifecycle-stable.svg: -------------------------------------------------------------------------------- 1 | lifecyclelifecyclestablestable -------------------------------------------------------------------------------- /man/figures/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/sortable/2b938859b4c44a2bd4cf99628023590e8cdd4910/man/figures/logo.png -------------------------------------------------------------------------------- /man/figures/rank_list_shiny.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/sortable/2b938859b4c44a2bd4cf99628023590e8cdd4910/man/figures/rank_list_shiny.gif -------------------------------------------------------------------------------- /man/figures/simple_sortable_shiny.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/sortable/2b938859b4c44a2bd4cf99628023590e8cdd4910/man/figures/simple_sortable_shiny.gif -------------------------------------------------------------------------------- /man/figures/sortable-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/sortable/2b938859b4c44a2bd4cf99628023590e8cdd4910/man/figures/sortable-logo.png -------------------------------------------------------------------------------- /man/is_sortable_options.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/sortable_options.R 3 | \name{is_sortable_options} 4 | \alias{is_sortable_options} 5 | \title{Check if object is sortable options.} 6 | \usage{ 7 | is_sortable_options(x) 8 | } 9 | \arguments{ 10 | \item{x}{Object to test} 11 | } 12 | \value{ 13 | Logical vector. TRUE if the object inherits from \code{sortable_options} 14 | } 15 | \description{ 16 | Check if object is sortable options. 17 | } 18 | \examples{ 19 | is_sortable_options("foo") # returns FALSE 20 | } 21 | -------------------------------------------------------------------------------- /man/question_rank.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/question_rank.R 3 | \name{question_rank} 4 | \alias{question_rank} 5 | \title{Ranking question for learnr tutorials.} 6 | \usage{ 7 | question_rank( 8 | text, 9 | ..., 10 | correct = "Correct!", 11 | incorrect = "Incorrect", 12 | loading = c("**Loading:** ", text, "


"), 13 | submit_button = "Submit Answer", 14 | try_again_button = "Try Again", 15 | allow_retry = FALSE, 16 | random_answer_order = TRUE, 17 | options = sortable_options() 18 | ) 19 | } 20 | \arguments{ 21 | \item{text}{Question or option text} 22 | 23 | \item{...}{parameters passed onto \code{\link[learnr:quiz]{learnr::question()}}.} 24 | 25 | \item{correct}{For \code{question}, text to print for a correct answer (defaults 26 | to "Correct!"). For \code{answer}, a boolean indicating whether this answer is 27 | correct.} 28 | 29 | \item{incorrect}{Text to print for an incorrect answer (defaults to 30 | "Incorrect") when \code{allow_retry} is \code{FALSE}.} 31 | 32 | \item{loading}{Loading text to display as a placeholder while the question is 33 | loaded. If not provided, generic "Loading..." or placeholder elements will 34 | be displayed.} 35 | 36 | \item{submit_button}{Label for the submit button. Defaults to \code{"Submit Answer"}} 37 | 38 | \item{try_again_button}{Label for the try again button. Defaults to \code{"Submit Answer"}} 39 | 40 | \item{allow_retry}{Allow retry for incorrect answers. Defaults to \code{FALSE}.} 41 | 42 | \item{random_answer_order}{Display answers in a random order.} 43 | 44 | \item{options}{Options to be supplied to \link{sortable_js} object. See \link{sortable_options} for more details} 45 | } 46 | \value{ 47 | A custom \code{learnr} question, with \code{type = sortable_rank}. 48 | See \code{\link[learnr:quiz]{learnr::question()}}. 49 | } 50 | \description{ 51 | Add interactive ranking tasks to your \code{learnr} tutorials. The student can 52 | drag-and-drop the answer options into the desired order. 53 | } 54 | \details{ 55 | Each set of answer options must contain the same set of answer options. When 56 | the question is completed, the first correct answer will be displayed. 57 | 58 | Note that, by default, the answer order is randomized. 59 | } 60 | \examples{ 61 | ## Example of rank problem inside a learnr tutorial 62 | if (interactive()) { 63 | learnr::run_tutorial("question_rank", package = "sortable") 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /man/rank_list.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/rank_list.R 3 | \name{rank_list} 4 | \alias{rank_list} 5 | \title{Create a ranking item list.} 6 | \usage{ 7 | rank_list( 8 | text = "", 9 | labels, 10 | input_id, 11 | css_id = input_id, 12 | options = sortable_options(), 13 | orientation = c("vertical", "horizontal"), 14 | class = "default-sortable" 15 | ) 16 | } 17 | \arguments{ 18 | \item{text}{Text to appear at top of list.} 19 | 20 | \item{labels}{A character vector with the text to display inside the widget. 21 | This can also be a list of html tag elements. The text content of each 22 | label or label name will be used to set the shiny \code{input_id} value. 23 | To create an empty \code{rank_list}, use \code{labels = list()}.} 24 | 25 | \item{input_id}{output variable to read the plot/image from.} 26 | 27 | \item{css_id}{This is the css id to use, and must be unique in your shiny 28 | app. This defaults to the value of \code{input_id}, and will be appended to the 29 | value "rank-list-container", to ensure the CSS id is unique for the 30 | container as well as the labels. 31 | If NULL, the function generates an id of the form 32 | \code{rank_list_id_1}, and will automatically increment for every \code{rank_list}.} 33 | 34 | \item{options}{Options to be supplied to \link{sortable_js} object. See \link{sortable_options} for more details} 35 | 36 | \item{orientation}{Set this to "horizontal" to get horizontal orientation of 37 | the items.} 38 | 39 | \item{class}{A css class applied to the rank list. This can be used to 40 | define custom styling.} 41 | } 42 | \description{ 43 | Creates a ranking item list using the \code{SortableJS} framework, 44 | and generates an \code{htmlwidgets} element. The elements of this list can be 45 | dragged and dropped in any order. 46 | 47 | You can embed a ranking question inside a \code{learnr} tutorial, using 48 | \code{\link[=question_rank]{question_rank()}}. 49 | 50 | To embed a \code{rank_list} inside a shiny app, see the Details section. 51 | } 52 | \details{ 53 | You can embed a \code{rank_list} inside a Shiny app, to capture the preferred 54 | ranking order of your user. 55 | 56 | The widget automatically updates a Shiny output, with the matching 57 | \code{input_id}. 58 | } 59 | \examples{ 60 | ## - example-rank-list ------------------------------------------------ 61 | 62 | if (interactive()) { 63 | rank_list( 64 | text = "You can drag, drop and re-order these items:", 65 | labels = c("one", "two", "three", "four", "five"), 66 | input_id = "example_2" 67 | ) 68 | } 69 | ## - example-rank-list-multidrag ------------------------------------------ 70 | 71 | if (interactive()) { 72 | rank_list( 73 | text = "You can select multiple items and drag as a group:", 74 | labels = c("one", "two", "three", "four", "five"), 75 | input_id = "example_2", 76 | options = sortable_options( 77 | multiDrag = TRUE 78 | ) 79 | ) 80 | } 81 | ## - example-rank-list-swap ----------------------------------------------- 82 | 83 | if (interactive()) { 84 | rank_list( 85 | text = "You can re-order these items, and notice the swapping behaviour:", 86 | labels = c("one", "two", "three", "four", "five"), 87 | input_id = "example_2", 88 | options = sortable_options( 89 | swap = TRUE 90 | ) 91 | ) 92 | } 93 | ## Example of a shiny app 94 | if (interactive()) { 95 | app <- system.file("shiny/rank_list/app.R", package = "sortable") 96 | shiny::runApp(app) 97 | } 98 | 99 | } 100 | \seealso{ 101 | \link{update_rank_list}, \link{sortable_js}, \link{bucket_list} and \link{question_rank} 102 | } 103 | -------------------------------------------------------------------------------- /man/render_sortable.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/sortable_js.R 3 | \name{render_sortable} 4 | \alias{render_sortable} 5 | \title{Widget render function for use in Shiny.} 6 | \usage{ 7 | render_sortable(expr, env = parent.frame(), quoted = FALSE) 8 | } 9 | \arguments{ 10 | \item{expr}{An expression} 11 | 12 | \item{env}{The environment in which to evaluate \code{expr}.} 13 | 14 | \item{quoted}{Is \code{expr} a quoted expression (with \code{quote()})? This is useful 15 | if you want to save an expression in a variable.} 16 | } 17 | \description{ 18 | Widget render function for use in Shiny. 19 | } 20 | -------------------------------------------------------------------------------- /man/sortable.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/sortable-package.R 3 | \docType{package} 4 | \name{sortable} 5 | \alias{sortable} 6 | \alias{sortable-package} 7 | \title{sortable: Drag-and-Drop in 'shiny' Apps with 'SortableJS'} 8 | \description{ 9 | \if{html}{\figure{logo.png}{options: style='float: right' alt='logo' width='120'}} 10 | 11 | Enables drag-and-drop behaviour in Shiny apps, by exposing the functionality of the 'SortableJS' \url{https://sortablejs.github.io/Sortable/} JavaScript library as an 'htmlwidget'. You can use this in Shiny apps and widgets, 'learnr' tutorials as well as R Markdown. In addition, provides a custom 'learnr' question type - 'question_rank()' - that allows ranking questions with drag-and-drop. 12 | } 13 | \section{A new html widget}{ 14 | 15 | \itemize{ 16 | \item \code{\link[=sortable_js]{sortable_js()}} is a low-level function that adds the \code{SortableJS} to your widgets. 17 | } 18 | } 19 | 20 | \section{Important functions}{ 21 | 22 | 23 | The important functions in this package are: 24 | \itemize{ 25 | \item \code{\link[=rank_list]{rank_list()}} creates a drag-and-drop, rank list 26 | \item \code{\link[=bucket_list]{bucket_list()}} lets you add multiple \code{rank_list} objects in columns 27 | } 28 | } 29 | 30 | \section{Custom question types for \code{learnr}}{ 31 | 32 | 33 | You can also use new question types in your \code{learnr} tutorials: 34 | \itemize{ 35 | \item \code{\link[=question_rank]{question_rank()}} 36 | } 37 | } 38 | 39 | \seealso{ 40 | Useful links: 41 | \itemize{ 42 | \item \url{https://rstudio.github.io/sortable/} 43 | \item Report bugs at \url{https://github.com/rstudio/sortable/issues} 44 | } 45 | 46 | } 47 | \author{ 48 | \strong{Maintainer}: Andrie de Vries \email{apdevries@gmail.com} 49 | 50 | Authors: 51 | \itemize{ 52 | \item Barret Schloerke \email{barret@rstudio.com} 53 | \item Kenton Russell \email{kent.russell@timelyportfolio.com} (Original author) [conceptor] 54 | } 55 | 56 | Other contributors: 57 | \itemize{ 58 | \item RStudio [copyright holder, funder] 59 | \item Lebedev Konstantin ('SortableJS', https://sortablejs.github.io/Sortable/) [copyright holder] 60 | } 61 | 62 | } 63 | \keyword{internal} 64 | -------------------------------------------------------------------------------- /man/sortable_js.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/sortable_js.R 3 | \name{sortable_js} 4 | \alias{sortable_js} 5 | \title{Creates an htmlwidget with embedded 'SortableJS' library.} 6 | \usage{ 7 | sortable_js( 8 | css_id, 9 | options = sortable_options(), 10 | width = 0, 11 | height = 0, 12 | elementId = NULL, 13 | preRenderHook = NULL 14 | ) 15 | } 16 | \arguments{ 17 | \item{css_id}{\code{String} css_id id on which to apply \code{SortableJS}. Note, 18 | \code{sortable_js} works with any html element, not just \code{ul/li}.} 19 | 20 | \item{options}{Options to be supplied to \link{sortable_js} object. See \link{sortable_options} for more details} 21 | 22 | \item{width}{Fixed width for widget (in css units). The default is 23 | \code{NULL}, which results in intelligent automatic sizing based on the 24 | widget's container.} 25 | 26 | \item{height}{Fixed height for widget (in css units). The default is 27 | \code{NULL}, which results in intelligent automatic sizing based on the 28 | widget's container.} 29 | 30 | \item{elementId}{Use an explicit element ID for the widget (rather than an 31 | automatically generated one). Useful if you have other JavaScript that 32 | needs to explicitly discover and interact with a specific widget instance.} 33 | 34 | \item{preRenderHook}{A function to be run on the widget, just prior to 35 | rendering. It accepts the entire widget object as input, and should return 36 | a modified widget object.} 37 | } 38 | \description{ 39 | Creates an \code{htmlwidget} that provides 40 | \href{https://github.com/SortableJS/Sortable}{SortableJS} to use for 41 | drag-and-drop interactivity in Shiny apps and R Markdown. 42 | } 43 | \examples{ 44 | ## -- example-sortable-js ------------------------------------------------- 45 | # Simple example of sortable_js. 46 | # Important: set the tags CSS `id` equal to the sortable_js `css_id` 47 | 48 | if (interactive()) { 49 | if (require(htmltools)) { 50 | html_print( 51 | tagList( 52 | tags$p("You can drag and reorder the items in this list:"), 53 | tags$ul( 54 | id = "example_1", 55 | tags$li("Move"), 56 | tags$li("Or drag"), 57 | tags$li("Each of the items"), 58 | tags$li("To different positions") 59 | ), 60 | sortable_js(css_id = "example_1") 61 | ) 62 | ) 63 | } 64 | } 65 | } 66 | \seealso{ 67 | \code{\link[=sortable_options]{sortable_options()}} 68 | } 69 | -------------------------------------------------------------------------------- /man/sortable_js_capture_input.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/js.R 3 | \name{sortable_js_capture_input} 4 | \alias{sortable_js_capture_input} 5 | \alias{sortable_js_capture_bucket_input} 6 | \title{Construct JavaScript method to capture Shiny inputs on change.} 7 | \usage{ 8 | sortable_js_capture_input(input_id) 9 | 10 | sortable_js_capture_bucket_input(input_id, input_ids, css_ids) 11 | } 12 | \arguments{ 13 | \item{input_id}{Shiny input name to set} 14 | 15 | \item{input_ids}{Set of Shiny input ids to set corresponding to the provided 16 | \code{css_ids}} 17 | 18 | \item{css_ids}{Set of SortableJS \code{css_id} values to help retrieve all to 19 | set as an object} 20 | } 21 | \value{ 22 | A character vector with class \code{JS_EVAL}. See \code{\link[htmlwidgets:JS]{htmlwidgets::JS()}}. 23 | } 24 | \description{ 25 | This captures the state of a \code{sortable} list. It will look for a \code{data-rank-id} 26 | attribute of the first child for each element. If no? attribute exists for 27 | that particular item's first child, the inner text will be used as an 28 | identifier. 29 | } 30 | \details{ 31 | This method is used with the \code{onSort} option of \code{sortable_js}. See 32 | \code{\link[=sortable_options]{sortable_options()}}. 33 | } 34 | \examples{ 35 | ## -- example-sortable-js-capture ----------------------------------------- 36 | # Simple example of sortable_js_capture. 37 | # Important: set the tags CSS `id` equal to the sortable_js `css_id` 38 | 39 | if(interactive()) { 40 | library(shiny) 41 | library(sortable) 42 | 43 | ui <- fluidPage( 44 | div( 45 | id = "sortable", 46 | div(id = 1, `data-rank-id` = "HELLO", class = "well", "Hello"), 47 | div(id = 2, `data-rank-id` = "WORLD", class = "well", "world") 48 | ), 49 | verbatimTextOutput("chosen"), 50 | sortable_js( 51 | css_id = "sortable", 52 | options = sortable_options( 53 | onSort = sortable_js_capture_input(input_id = "selected") 54 | ) 55 | ) 56 | ) 57 | 58 | server <- function(input, output){ 59 | output$chosen <- renderPrint(input$selected) 60 | } 61 | 62 | shinyApp(ui, server) 63 | } 64 | 65 | 66 | 67 | ## ------------------------------------ 68 | # For an example, see the Shiny app at 69 | system.file("shiny/drag_vars_to_plot/app.R", package = "sortable") 70 | } 71 | \seealso{ 72 | \link{sortable_js} and \link{rank_list}. 73 | 74 | Other JavaScript functions: 75 | \code{\link{chain_js_events}()} 76 | } 77 | \concept{JavaScript functions} 78 | -------------------------------------------------------------------------------- /man/sortable_options.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/sortable_options.R 3 | \name{sortable_options} 4 | \alias{sortable_options} 5 | \title{Define options to pass to a sortable object.} 6 | \usage{ 7 | sortable_options( 8 | ..., 9 | swap = NULL, 10 | multiDrag = NULL, 11 | group = NULL, 12 | sort = NULL, 13 | delay = NULL, 14 | disabled = NULL, 15 | animation = NULL, 16 | handle = NULL, 17 | filter = NULL, 18 | draggable = NULL, 19 | swapThreshold = NULL, 20 | invertSwap = NULL, 21 | direction = NULL, 22 | scrollSensitivity = NULL, 23 | scrollSpeed = NULL, 24 | onStart = NULL, 25 | onEnd = NULL, 26 | onAdd = NULL, 27 | onUpdate = NULL, 28 | onSort = NULL, 29 | onRemove = NULL, 30 | onFilter = NULL, 31 | onMove = NULL, 32 | onLoad = NULL 33 | ) 34 | } 35 | \arguments{ 36 | \item{...}{other arguments passed onto \code{SortableJS}} 37 | 38 | \item{swap}{If \code{TRUE}, modifies the behaviour of \code{sortable} to allow for items to 39 | be swapped with each other rather than sorted. Once dragging starts, the 40 | user can drag over other items and there will be no change in the elements. 41 | However, the item that the user drops on will be swapped with the 42 | originally dragged item. 43 | See also https://github.com/SortableJS/Sortable/tree/master/plugins/Swap} 44 | 45 | \item{multiDrag}{If \code{TRUE}, allows the selection of multiple items within a 46 | \code{sortable} at once, and drag them as one item. Once placed, the items will 47 | unfold into their original order, but all beside each other at the new 48 | position. 49 | See also https://github.com/SortableJS/Sortable/wiki/Dragging-Multiple-Items-in-Sortable} 50 | 51 | \item{group}{To drag elements from one list into another, both lists must 52 | have the same group value. See 53 | \href{https://github.com/sortablejs/Sortable/#group-option}{Sortable#group-option} 54 | for more details. [\code{"name"}]} 55 | 56 | \item{sort}{Boolean that allows sorting inside a list. [\code{TRUE}]} 57 | 58 | \item{delay}{Time in milliseconds to define when the sorting should start. 59 | [\code{0}]} 60 | 61 | \item{disabled}{Boolean that disables the \code{sortable} if set to true. [\code{FALSE}]} 62 | 63 | \item{animation}{Millisecond duration of the animation of items when sorting 64 | [\code{0} (no animation)]} 65 | 66 | \item{handle}{CSS selector used for the drag handle selector within list 67 | items. [\code{".my-handle"}]} 68 | 69 | \item{filter}{CSS selector or JS function used for elements that cannot be 70 | dragged. [\code{".ignore-elements"}]} 71 | 72 | \item{draggable}{CSS selector of which items inside the element should be 73 | draggable. [\code{".item"}]} 74 | 75 | \item{swapThreshold}{Percentage of the target that the swap zone will take 76 | up, as a number between \code{0} and \code{1}. [\code{1}]} 77 | 78 | \item{invertSwap}{Set to \code{TRUE} to set the swap zone to the sides of the 79 | target, for the effect of sorting "in between" items. [\code{FALSE}]} 80 | 81 | \item{direction}{Direction of \code{sortable} [\code{"horizontal"}]} 82 | 83 | \item{scrollSensitivity}{Number of pixels the mouse needs to be to an edge to 84 | start scrolling. [\code{30}]} 85 | 86 | \item{scrollSpeed}{Number of pixels for the speed of scrolling. [\code{10}]} 87 | 88 | \item{onStart, onEnd}{JS function called when an element dragging starts or ends} 89 | 90 | \item{onAdd}{JS function called when an element is dropped into the list from 91 | another list} 92 | 93 | \item{onUpdate}{JS function called when the sorting is changed within a list} 94 | 95 | \item{onSort}{JS function called by any change to the list (add / update / 96 | remove)} 97 | 98 | \item{onRemove}{JS function called when an element is removed from the list 99 | into another list} 100 | 101 | \item{onFilter}{JS function called when an attempt is made to drag a filtered 102 | element} 103 | 104 | \item{onMove}{JS function called when an item is moved in a list or between 105 | lists} 106 | 107 | \item{onLoad}{JS function dispatched on the "next tick" after SortableJS has 108 | initialized} 109 | } 110 | \value{ 111 | A list with class \code{sortable_options} 112 | } 113 | \description{ 114 | Use this function to define the options for \link{sortable_js} and \link{rank_list}, 115 | which will pass these in turn to the \code{SortableJS} JavaScript library. 116 | } 117 | \details{ 118 | Many of the \code{SortableJS} options will accept a JavaScript function. You can 119 | do this using the \code{htmlwidgets::JS} function. 120 | } 121 | \examples{ 122 | sortable_options(sort = FALSE) 123 | } 124 | \references{ 125 | \url{https://github.com/sortablejs/Sortable/} 126 | } 127 | \seealso{ 128 | \link{sortable_js} 129 | } 130 | -------------------------------------------------------------------------------- /man/sortable_output.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/sortable_js.R 3 | \name{sortable_output} 4 | \alias{sortable_output} 5 | \title{Widget output function for use in Shiny.} 6 | \usage{ 7 | sortable_output(input_id, width = "0px", height = "0px") 8 | } 9 | \arguments{ 10 | \item{input_id}{output variable to use for the sortable object} 11 | 12 | \item{width}{Fixed width for widget (in css units). The default is 13 | \code{NULL}, which results in intelligent automatic sizing based on the 14 | widget's container.} 15 | 16 | \item{height}{Fixed height for widget (in css units). The default is 17 | \code{NULL}, which results in intelligent automatic sizing based on the 18 | widget's container.} 19 | } 20 | \description{ 21 | Widget output function for use in Shiny. 22 | } 23 | -------------------------------------------------------------------------------- /man/update_bucket_list.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/rank_list.R 3 | \name{update_bucket_list} 4 | \alias{update_bucket_list} 5 | \title{Change the value of a bucket list.} 6 | \usage{ 7 | update_bucket_list( 8 | css_id, 9 | header = NULL, 10 | session = shiny::getDefaultReactiveDomain() 11 | ) 12 | } 13 | \arguments{ 14 | \item{css_id}{This is the css id to use, and must be unique in your shiny 15 | app. This defaults to the value of \code{group_id}, and will be appended to the 16 | value "bucket-list-container", to ensure the CSS id is unique for the 17 | container as well as the embedded rank lists.} 18 | 19 | \item{header}{Text that appears at the top of the bucket list. (This is 20 | encoded as an HTML \verb{

} tag, so not strictly speaking a header.) Note 21 | that you must explicitly provide \code{header} argument, especially in the case 22 | where you want the header to be empty - to do this use \code{header = NULL} or 23 | \code{header = NA}.} 24 | 25 | \item{session}{The \code{session} object passed to function given to 26 | \code{shinyServer}.} 27 | } 28 | \description{ 29 | You can only update the \code{header} of the \code{bucket_list}. 30 | To update any of the labels or rank list text, use \code{update_rank_list()} 31 | instead. 32 | } 33 | \examples{ 34 | ## Example of a shiny app that updates a bucket list and rank list 35 | if (interactive()) { 36 | app <- system.file( 37 | "shiny/update/app.R", 38 | package = "sortable" 39 | ) 40 | shiny::runApp(app) 41 | } 42 | } 43 | \seealso{ 44 | \link{bucket_list}, \link{update_rank_list} 45 | } 46 | -------------------------------------------------------------------------------- /man/update_rank_list.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/rank_list.R 3 | \name{update_rank_list} 4 | \alias{update_rank_list} 5 | \title{Change the text or labels of a rank list.} 6 | \usage{ 7 | update_rank_list( 8 | css_id, 9 | text = NULL, 10 | labels = NULL, 11 | session = shiny::getDefaultReactiveDomain() 12 | ) 13 | } 14 | \arguments{ 15 | \item{css_id}{This is the css id to use, and must be unique in your shiny 16 | app. This defaults to the value of \code{input_id}, and will be appended to the 17 | value "rank-list-container", to ensure the CSS id is unique for the 18 | container as well as the labels. 19 | If NULL, the function generates an id of the form 20 | \code{rank_list_id_1}, and will automatically increment for every \code{rank_list}.} 21 | 22 | \item{text}{Text to appear at top of list.} 23 | 24 | \item{labels}{A character vector with the text to display inside the widget. 25 | This can also be a list of html tag elements. The text content of each 26 | label or label name will be used to set the shiny \code{input_id} value. 27 | To create an empty \code{rank_list}, use \code{labels = list()}.} 28 | 29 | \item{session}{The \code{session} object passed to function given to 30 | \code{shinyServer}.} 31 | } 32 | \description{ 33 | Change the text or labels of a rank list. 34 | } 35 | \examples{ 36 | ## Example of a shiny app that updates a bucket list and rank list 37 | if (interactive()) { 38 | app <- system.file( 39 | "shiny/update_rank_list/app.R", 40 | package = "sortable" 41 | ) 42 | shiny::runApp(app) 43 | } 44 | } 45 | \seealso{ 46 | \link{rank_list} 47 | } 48 | -------------------------------------------------------------------------------- /pkgdown/favicon/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/sortable/2b938859b4c44a2bd4cf99628023590e8cdd4910/pkgdown/favicon/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /pkgdown/favicon/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/sortable/2b938859b4c44a2bd4cf99628023590e8cdd4910/pkgdown/favicon/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /pkgdown/favicon/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/sortable/2b938859b4c44a2bd4cf99628023590e8cdd4910/pkgdown/favicon/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /pkgdown/favicon/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/sortable/2b938859b4c44a2bd4cf99628023590e8cdd4910/pkgdown/favicon/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /pkgdown/favicon/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/sortable/2b938859b4c44a2bd4cf99628023590e8cdd4910/pkgdown/favicon/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /pkgdown/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/sortable/2b938859b4c44a2bd4cf99628023590e8cdd4910/pkgdown/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /pkgdown/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/sortable/2b938859b4c44a2bd4cf99628023590e8cdd4910/pkgdown/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /pkgdown/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/sortable/2b938859b4c44a2bd4cf99628023590e8cdd4910/pkgdown/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /pkgdown/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/sortable/2b938859b4c44a2bd4cf99628023590e8cdd4910/pkgdown/favicon/favicon.ico -------------------------------------------------------------------------------- /scripts/build_docs.R: -------------------------------------------------------------------------------- 1 | # compile readme 2 | rmarkdown::render("README.Rmd", rmarkdown::github_document(html_preview = FALSE)) 3 | 4 | # build website locally 5 | devtools::install() # files are retrieved from system.file location 6 | pkgdown::build_site() 7 | -------------------------------------------------------------------------------- /scripts/compile_css.R: -------------------------------------------------------------------------------- 1 | 2 | local({ 3 | if (!require(sass)) { 4 | install.packages("sass") 5 | } 6 | library(sass) 7 | 8 | 9 | scss_files <- dir( 10 | file.path("inst", "htmlwidgets", "plugins", "sortable-rstudio"), 11 | pattern = "\\.scss", 12 | full.names = TRUE 13 | ) 14 | 15 | for (scss_file in scss_files) { 16 | if (grepl("^_", basename(scss_file))) { 17 | message("Skipping : ", basename(scss_file)) 18 | NULL 19 | } else { 20 | message("Translating: ", basename(scss_file)) 21 | sass::sass( 22 | input = sass::sass_file(scss_file), 23 | output = sub("\\.scss", ".css", scss_file) 24 | ) 25 | } 26 | } 27 | }) 28 | -------------------------------------------------------------------------------- /scripts/deploy_apps.R: -------------------------------------------------------------------------------- 1 | # install rsconnect and glue 2 | if (!requireNamespace("remotes")) install.packages("remotes") 3 | if (!requireNamespace("rsconnect")) remotes::install_cran("rsconnect") 4 | if (!requireNamespace("glue")) remotes::install_cran("glue") 5 | 6 | # Set the account info for deployment. 7 | rsconnect::setAccountInfo( 8 | name = Sys.getenv("SHINYAPPS_NAME"), 9 | token = Sys.getenv("SHINYAPPS_TOKEN"), 10 | secret = Sys.getenv("SHINYAPPS_SECRET") 11 | ) 12 | 13 | deploy_app <- function( 14 | app_dir, 15 | name = glue::glue("sortable_{basename(app_dir)}_app"), 16 | ... 17 | ) { 18 | server <- "shinyapps.io" 19 | account <- "andrie-de-vries" 20 | 21 | all_apps <- rsconnect::applications(account = account, server = server) 22 | last_deployed <- all_apps[all_apps[["name"]] == name, "updated_time"] 23 | last_updated <- max(file.mtime(list.files(app_dir, recursive = TRUE, full.names = TRUE))) 24 | 25 | last_deployed <- as.POSIXct(last_deployed, format = "%FT%T") 26 | 27 | if (!length(last_deployed) || last_updated > last_deployed) { 28 | message("\n") 29 | message("Deploying: ", name) 30 | message("\n") 31 | rsconnect::deployApp( 32 | appDir = app_dir, 33 | appName = name, 34 | server = server, 35 | account = account, 36 | forceUpdate = TRUE, 37 | ... 38 | ) 39 | } else { 40 | message("Nothing to do for ", name) 41 | } 42 | } 43 | 44 | deploy_tutorial <- function( 45 | app_dir, 46 | doc = dir(app_dir, pattern = "\\.Rmd$")[1], 47 | name = glue::glue("sortable_tutorial_{basename(app_dir)}") 48 | ) { 49 | deploy_app( 50 | app_dir = app_dir, 51 | name = name, 52 | appPrimaryDoc = dir(app_dir, pattern = "\\.Rmd$")[1] 53 | ) 54 | } 55 | 56 | 57 | deploy_folder <- function(path, fn) { 58 | invisible(lapply( 59 | dir(path, full.names = TRUE), 60 | function(path) { 61 | if (dir.exists(path)) { 62 | fn(path) 63 | } 64 | } 65 | )) 66 | } 67 | 68 | 69 | 70 | deploy_folder(system.file("shiny", package = "sortable"), deploy_app) 71 | deploy_folder(system.file("shiny", package = "sortable"), deploy_tutorial) 72 | 73 | message("done") 74 | -------------------------------------------------------------------------------- /scripts/download_sortablejs.R: -------------------------------------------------------------------------------- 1 | 2 | get_new_sortable_version <- function() { 3 | x <- readLines("https://unpkg.com/sortablejs", n = 1) 4 | # sub("^.* Sortable (.*?) - MIT.*$", "\\1", x) 5 | sub(".*(\\d+\\.\\d+\\.\\d+).*", "\\1", x) 6 | } 7 | get_new_sortable_version() 8 | 9 | sortable_version <- "1.15.3" 10 | yaml_version <- sortable_version 11 | 12 | 13 | writeLines( 14 | readLines(paste0("https://unpkg.com/sortablejs@", sortable_version), warn = FALSE), 15 | file.path("inst", "htmlwidgets", "lib", "sortable", "sortable.js") 16 | ) 17 | 18 | sortable_yaml_file <- file.path("inst", "htmlwidgets", "sortable.yaml") 19 | config <- yaml::read_yaml(sortable_yaml_file) 20 | config$dependencies[[1]]$version <- yaml_version 21 | yaml::write_yaml(config, sortable_yaml_file) 22 | 23 | 24 | -------------------------------------------------------------------------------- /scripts/load_all_shim.R: -------------------------------------------------------------------------------- 1 | # Makes it easy to test the package in development by shimming `htmlwidgets` 2 | # and `htmltools`, before `load_all()`. 3 | # 4 | # Solution modified from Winston Chang 5 | # (https://gist.github.com/wch/c942335660dc6c96322f) 6 | 7 | local({ 8 | 9 | shim_system_file <- function(package) { 10 | imports <- parent.env(asNamespace(package)) 11 | pkgload:::unlock_environment(imports) 12 | imports$system.file <- pkgload:::shim_system.file 13 | } 14 | 15 | desc <- here::here("DESCRIPTION") 16 | if (file.exists(desc)) { 17 | 18 | pkgs <- desc::desc_get_deps()$package 19 | 20 | if ("htmlwidgets" %in% pkgs) { 21 | message("shimming htmlwidgets") 22 | shim_system_file("htmlwidgets") 23 | 24 | } 25 | if ("htmltools" %in% pkgs) { 26 | message("shimming htmltools") 27 | shim_system_file("htmltools") 28 | } 29 | } 30 | 31 | # devtools::load_all() 32 | 33 | }) 34 | 35 | -------------------------------------------------------------------------------- /scripts/readme.md: -------------------------------------------------------------------------------- 1 | ## Developer scripts 2 | 3 | All script should be run from the root directory, such as `source("scripts/compile_css.R")`. 4 | 5 | 6 | * `load_all_shim.R` 7 | * Adds a shim to source local htmlwidget files and calls `devtools::load_all()` 8 | 9 | * `compile_css.R` 10 | * Compiles all Sass code into css 11 | 12 | * `deploy_apps.R` 13 | * Deploys all shiny application examples to shinyapps.io 14 | * Deploys all learnr tutorials to shinyapps.io 15 | 16 | * `deploy_apps.R` 17 | * Calls `deploy_apps.R` 18 | * Called within Travis-CI. 19 | 20 | * `download_sortablejs.R` 21 | * Downloads a `sortable.js` file and updates the appropriate versions 22 | 23 | * `build_docs.R` 24 | * Build the README.Rmd 25 | * Installs the pkg to avoid docs issues 26 | * Build the pkgdocs locally (which is `.gitignore`'d) 27 | -------------------------------------------------------------------------------- /sortable.Rproj: -------------------------------------------------------------------------------- 1 | Version: 1.0 2 | 3 | RestoreWorkspace: Default 4 | SaveWorkspace: Default 5 | AlwaysSaveHistory: Default 6 | 7 | EnableCodeIndexing: Yes 8 | UseSpacesForTab: Yes 9 | NumSpacesForTab: 2 10 | Encoding: UTF-8 11 | 12 | RnwWeave: Sweave 13 | LaTeX: XeLaTeX 14 | 15 | AutoAppendNewline: Yes 16 | StripTrailingWhitespace: Yes 17 | 18 | BuildType: Package 19 | PackageUseDevtools: Yes 20 | PackageInstallArgs: --no-multiarch --with-keep.source 21 | PackageCheckArgs: --as-cran 22 | PackageRoxygenize: rd,collate,namespace,vignette 23 | -------------------------------------------------------------------------------- /tests/spelling.R: -------------------------------------------------------------------------------- 1 | if(requireNamespace('spelling', quietly = TRUE)) 2 | spelling::spell_check_test(vignettes = TRUE, error = FALSE, 3 | skip_on_cran = TRUE) 4 | -------------------------------------------------------------------------------- /tests/testthat.R: -------------------------------------------------------------------------------- 1 | library(testthat) 2 | library(sortable) 3 | 4 | test_check("sortable") 5 | -------------------------------------------------------------------------------- /tests/testthat/test-bucket_list.R: -------------------------------------------------------------------------------- 1 | test_that("Can use add_rank_list", { 2 | z <- add_rank_list(text = "missing", labels = NULL, input_id = NULL) 3 | expect_s3_class(z, "add_rank_list") 4 | }) 5 | 6 | 7 | test_that("Can create bucket_list", { 8 | 9 | z <- bucket_list( 10 | header = "This is a bucket list. You can drag items between the lists.", 11 | add_rank_list( 12 | text = "Drag from here", 13 | labels = c("a", "bb", "ccc") 14 | ), 15 | add_rank_list( 16 | text = "to here", 17 | labels = NULL, 18 | input_id = "input_to" 19 | ) 20 | ) 21 | expect_s3_class(z, "bucket_list") 22 | 23 | 24 | expect_error( 25 | bucket_list( 26 | # header = "This is a bucket list. You can drag items between the lists.", 27 | add_rank_list( 28 | text = "Drag from here", 29 | labels = c("a", "bb", "ccc") 30 | ), 31 | add_rank_list( 32 | text = "to here", 33 | labels = NULL 34 | ) 35 | ), 36 | "must be NULL or a string" 37 | ) 38 | 39 | z <- bucket_list( 40 | header = NA, 41 | add_rank_list( 42 | text = "Drag from here", 43 | labels = c("a", "bb", "ccc") 44 | ), 45 | add_rank_list( 46 | text = "to here", 47 | labels = NULL 48 | ) 49 | ) 50 | expect_s3_class(z, "bucket_list") 51 | 52 | }) 53 | 54 | -------------------------------------------------------------------------------- /tests/testthat/test-creation.R: -------------------------------------------------------------------------------- 1 | test_that( "sortable_js makes a htmlwidget ", { 2 | expect_s3_class( sortable_js( "" ), "htmlwidget" ) 3 | expect_s3_class( sortable_js( "" ), "sortable" ) 4 | }) 5 | 6 | test_that( "sortable_js height and width", { 7 | # by default sortable_js should be 0 height and width 8 | # since intended to be used to provide dependencies 9 | # and pass config options 10 | expect_equal( sortable_js( "" )$width, 0 ) 11 | expect_equal( sortable_js( "" )$height, 0 ) 12 | # however, someone might want to override height/width 13 | expect_equal( sortable_js( "", width = 100 )$width, 100 ) 14 | expect_equal( sortable_js( "", height = 100 )$height, 100 ) 15 | }) 16 | 17 | test_that( "css_id and options passed as expected", { 18 | expect_identical( sortable_js( "an_id" )$x$css_id, "an_id" ) 19 | expect_identical( 20 | sortable_js( 21 | "an_id", 22 | options = sortable_options( 23 | group = "name", 24 | sort = FALSE, 25 | disabled = FALSE 26 | ) 27 | )$x, 28 | list( 29 | css_id = "an_id", 30 | options = modifyList( 31 | default_sortable_options(), 32 | sortable_options( 33 | group = "name", 34 | sort = FALSE, 35 | disabled = FALSE 36 | ) 37 | ) 38 | ) 39 | ) 40 | }) 41 | -------------------------------------------------------------------------------- /tests/testthat/test-htmltools.R: -------------------------------------------------------------------------------- 1 | library(htmltools) 2 | test_that( "works with tags ", { 3 | z <- html_print( 4 | viewer = NULL, 5 | tagList( 6 | tags$h1( "Check to make sure items move"), 7 | HTML(" 8 |

13 | "), 14 | sortable_js( "items" ) 15 | ) 16 | ) 17 | expect_type(z, "character") 18 | 19 | }) 20 | 21 | test_that("htmlwidget produces correct output", { 22 | z <- sortable_output("test") 23 | expect_type(z, "list") 24 | expect_s3_class(z, "shiny.tag.list") 25 | 26 | zr <- render_sortable(z) 27 | expect_s3_class(zr, "shiny.render.function") 28 | }) 29 | -------------------------------------------------------------------------------- /tests/testthat/test-js.R: -------------------------------------------------------------------------------- 1 | test_that( "js events can be chained ", { 2 | 3 | fns <- list( 4 | a = htmlwidgets::JS("function(a) { a + 1 }"), 5 | b = htmlwidgets::JS("function(b) { b + 3 }"), 6 | c = htmlwidgets::JS("function(c) { c + 5 }") 7 | ) 8 | 9 | js <- chain_js_events( 10 | NULL, 11 | fns$a, 12 | NULL, 13 | fns$b, 14 | fns$c, 15 | NULL, 16 | NULL 17 | ) 18 | 19 | expect_equal( 20 | js, 21 | htmlwidgets::JS("function() { 22 | try { 23 | (function(a) { a + 1 }).apply(this, arguments); 24 | } catch(e) { 25 | if (window.console && window.console.error) window.console.error(e); 26 | } 27 | 28 | try { 29 | (function(b) { b + 3 }).apply(this, arguments); 30 | } catch(e) { 31 | if (window.console && window.console.error) window.console.error(e); 32 | } 33 | 34 | try { 35 | (function(c) { c + 5 }).apply(this, arguments); 36 | } catch(e) { 37 | if (window.console && window.console.error) window.console.error(e); 38 | } 39 | }") 40 | ) 41 | 42 | 43 | js <- chain_js_events( 44 | NULL, 45 | fns$a, 46 | NULL 47 | ) 48 | expect_equal( 49 | js, fns$a 50 | ) 51 | 52 | }) 53 | -------------------------------------------------------------------------------- /tests/testthat/test-label_ids.R: -------------------------------------------------------------------------------- 1 | test_that("no names are found", { 2 | lets <- letters[1:3] 3 | expect_equal(label_ids(lets), c("", "", "")) 4 | expect_equal(label_ids(as.list(lets)), c("", "", "")) 5 | }) 6 | test_that("no NA names are found", { 7 | lets <- letters[1:3] 8 | names(lets) <- c("A", NA, "B") 9 | expect_equal(label_ids(lets), c("A", "", "B")) 10 | expect_equal(label_ids(as.list(lets)), c("A", "", "B")) 11 | }) 12 | test_that("all names are found", { 13 | lets <- as.list(letters[1:3]) 14 | names(lets) <- c("A", "B", "C") 15 | expect_equal(label_ids(lets), c("A", "B", "C")) 16 | expect_equal(label_ids(as.list(lets)), c("A", "B", "C")) 17 | }) 18 | test_that("missing names do not cause issues", { 19 | expect_equal(label_ids(list(A = 1, 2, 3)), c("A", "", "")) 20 | expect_equal(label_ids(list(1, B = 2, 3)), c("", "B", "")) 21 | expect_equal(label_ids(list(1, 2, C = 3)), c("", "", "C")) 22 | expect_equal(label_ids(list(1, 2, 3)), c("", "", "")) 23 | }) 24 | -------------------------------------------------------------------------------- /tests/testthat/test-learnr-question_rank.R: -------------------------------------------------------------------------------- 1 | test_that( "init display validates", { 2 | 3 | question <- question_rank( 4 | "Sort the first 5 letters", 5 | learnr::answer(LETTERS[1:5], correct = TRUE), 6 | learnr::answer(rev(LETTERS[1:5]), correct = FALSE, "Other direction!") 7 | ) 8 | expect_s3_class(question, "sortable_rank") 9 | 10 | expect_silent({ 11 | learnr::question_ui_initialize(question, "ignored") 12 | }) 13 | 14 | expect_error( 15 | learnr::question_ui_initialize( 16 | question_rank( 17 | "Sort the first 5 letters", 18 | learnr::answer(LETTERS[1:5], correct = TRUE), 19 | learnr::answer(letters[1:5], correct = FALSE, "causes error"), 20 | learnr::answer(rev(LETTERS[1:5]), correct = FALSE, "Other direction!") 21 | ) 22 | ), 23 | "answers MUST have the same set" 24 | ) 25 | 26 | expect_silent( 27 | learnr::question_ui_try_again(question, rev(LETTERS[1:5])) 28 | ) 29 | 30 | expect_s3_class( 31 | learnr::question_ui_try_again(question, rev(LETTERS[1:5])), 32 | "rank_list" 33 | ) 34 | 35 | 36 | expect_silent({ 37 | learnr::question_ui_completed(question, LETTERS[5:1]) 38 | }) 39 | 40 | expect_true( 41 | learnr::question_is_valid(question, letters[1:5]) 42 | ) 43 | expect_false( 44 | learnr::question_is_valid(question, NULL) 45 | ) 46 | 47 | expect_identical( 48 | learnr::question_is_correct(question, LETTERS[1:5]), 49 | mark_as(TRUE, NULL) 50 | ) 51 | 52 | tmp_answer <- learnr::answer("ignored", FALSE, "Other direction!") 53 | expect_identical( 54 | learnr::question_is_correct(question, LETTERS[5:1]), 55 | learnr::mark_as(FALSE, tmp_answer$message) 56 | ) 57 | expect_identical( 58 | learnr::question_is_correct(question, letters[1:5]), 59 | learnr::mark_as(FALSE, NULL) 60 | ) 61 | 62 | }) 63 | -------------------------------------------------------------------------------- /tests/testthat/test-rank_list.R: -------------------------------------------------------------------------------- 1 | test_that("Can create rank_list", { 2 | z <- rank_list( 3 | text = "You can drag, drap and re-order these items:", 4 | labels = c("one", "two", "three", "four", "five"), 5 | input_id = "example_2" 6 | ) 7 | 8 | expect_s3_class(z, "rank_list") 9 | }) 10 | -------------------------------------------------------------------------------- /vignettes/.gitignore: -------------------------------------------------------------------------------- 1 | *.html 2 | *.R 3 | -------------------------------------------------------------------------------- /vignettes/built_in.Rmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Using rank list and bucket lists in Shiny apps" 3 | output: rmarkdown::html_vignette 4 | description: > 5 | An introduction to rank list and bucket list elemens, as provided by the `sortable` package. A rank list allows you to rank items by drag-and-drop, and a bucket list lets you drag items between linked rank lists. 6 | vignette: > 7 | %\VignetteIndexEntry{Using rank list and bucket lists in Shiny apps} 8 | %\VignetteEngine{knitr::rmarkdown} 9 | %\VignetteEncoding{UTF-8} 10 | --- 11 | 12 | ```{r, include = FALSE} 13 | knitr::opts_chunk$set( 14 | collapse = TRUE, 15 | comment = "#>" 16 | ) 17 | ``` 18 | 19 | 20 | ```{css, echo=FALSE} 21 | pre { 22 | max-height: 15em; 23 | overflow-y: auto; 24 | } 25 | 26 | pre[class] { 27 | max-height: 15em; 28 | } 29 | ``` 30 | 31 | 32 | ## Introduction 33 | 34 | Although `sortable` is designed to be a low-level wrapper around the `SortableJS` library, the package also exposes a few higher level functions. 35 | 36 | These functions enable you to easily using drag-and-drop widgets into a Shiny app for specific tasks: 37 | 38 | * To create a ranking task, use `rank_list()` 39 | * To create a bucketing task, use `bucket_list()` 40 | 41 | ## Rank list 42 | 43 | ### Demo 44 | 45 | This is a rank list app, allowing you to change the order of items in a list. The app demonstrates three types of drag-and-drop behaviour: 46 | 47 | * Default 48 | * Multi-drag, to select multiple items and drag as a group 49 | * Swap, to swap two items 50 | 51 | 52 | ```{r, echo=FALSE} 53 | library(htmltools) 54 | tags$div( 55 | class = "shiny-app-frame", 56 | tags$iframe( 57 | src = "https://andrie-de-vries.shinyapps.io/sortable_rank_list_app/", 58 | width = "100%", 59 | height = 550 60 | ) 61 | ) 62 | ``` 63 | 64 | ### Source code 65 | 66 | This is the source code: 67 | 68 | ```{r echo=FALSE, cache=FALSE} 69 | knitr::read_chunk( 70 | system.file("shiny/rank_list/app.R", package = "sortable") 71 | ) 72 | ``` 73 | 74 | 75 | ```{r rank-list-app, eval=FALSE} 76 | ``` 77 | 78 | 79 | ## Bucket list 80 | 81 | ### Demo 82 | 83 | This is a bucket list app, where the bucket list allows you to drag from one bucket to another. 84 | 85 | 86 | ```{r, echo=FALSE} 87 | library(htmltools) 88 | tags$div( 89 | class = "shiny-app-frame", 90 | tags$iframe( 91 | src = "https://andrie-de-vries.shinyapps.io/sortable_bucket_list_app/", 92 | width = "100%", 93 | height = 800 94 | ) 95 | ) 96 | ``` 97 | 98 | 99 | ### Source code 100 | 101 | This is the source code: 102 | 103 | ```{r echo=FALSE, cache=FALSE} 104 | knitr::read_chunk( 105 | system.file("shiny/bucket_list/app.R", package = "sortable") 106 | ) 107 | ``` 108 | 109 | 110 | ```{r bucket-list-app, eval=FALSE} 111 | ``` 112 | 113 | -------------------------------------------------------------------------------- /vignettes/cloning.Rmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Cloning and removing elements" 3 | output: rmarkdown::html_vignette 4 | description: > 5 | You can clone and remove elements in a rank_list, i.e. drag an element multiple times. 6 | vignette: > 7 | %\VignetteIndexEntry{Cloning and removing elements} 8 | %\VignetteEngine{knitr::rmarkdown} 9 | %\VignetteEncoding{UTF-8} 10 | --- 11 | 12 | ```{r, include = FALSE} 13 | knitr::opts_chunk$set( 14 | collapse = TRUE, 15 | comment = "#>" 16 | ) 17 | ``` 18 | 19 | 20 | ## Introduction 21 | 22 | Sometimes you want the ability to drag an item **multiple times** from a list, in other words the ability to **"clone"** the items of the original list. 23 | 24 | And you may also then want the ability to **remove** cloned items, possibly by dragging to a "bin" or "remove item" list. 25 | 26 | 27 | 28 | ### Cloning an element 29 | 30 | To clone an element from a list, you must add the `pull = "clone"` option to the `sortable_options` argument:: 31 | 32 | ```{r setup} 33 | library(sortable) 34 | ``` 35 | 36 | ```{r, eval=FALSE} 37 | sortable_js( 38 | "sort1", 39 | options = sortable_options( 40 | group = list( 41 | pull = "clone", 42 | name = "sortGroup1", 43 | put = FALSE 44 | ), 45 | onSort = sortable_js_capture_input("sort_vars") 46 | ) 47 | ) 48 | ``` 49 | 50 | 51 | ### Removing an element 52 | 53 | To remove an element from the dropped list, one option is to create a "bin" area by using the JavaScript code: 54 | 55 | ```js 56 | this.el.removeChild(evt.item); 57 | ``` 58 | 59 | Then add to this JavaScript to the `onAdd` element of `sortable_options()`. To pass your JavaScript code to R, use the `htmlwidgets::JS()` function: 60 | 61 | ```{r, eval=FALSE} 62 | sortable_js( 63 | "sortable_bin", 64 | options = sortable_options( 65 | group = list( 66 | group = "sortGroup1", 67 | put = TRUE, 68 | pull = TRUE 69 | ), 70 | onAdd = htmlwidgets::JS("function (evt) { this.el.removeChild(evt.item); }") 71 | ) 72 | ) 73 | ``` 74 | 75 | 76 | ## Full example 77 | 78 | And the full code: 79 | 80 | ```{r echo=FALSE, cache=FALSE} 81 | knitr::read_chunk( 82 | system.file("shiny/clone_remove/app.R", package = "sortable") 83 | ) 84 | ``` 85 | 86 | 87 | ```{r shiny-clone-remove, eval=FALSE} 88 | ``` 89 | -------------------------------------------------------------------------------- /vignettes/figures/clone_delete.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/sortable/2b938859b4c44a2bd4cf99628023590e8cdd4910/vignettes/figures/clone_delete.gif -------------------------------------------------------------------------------- /vignettes/figures/drag_vars_to_plot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/sortable/2b938859b4c44a2bd4cf99628023590e8cdd4910/vignettes/figures/drag_vars_to_plot.gif -------------------------------------------------------------------------------- /vignettes/figures/simple_rank_list.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/sortable/2b938859b4c44a2bd4cf99628023590e8cdd4910/vignettes/figures/simple_rank_list.gif -------------------------------------------------------------------------------- /vignettes/figures/sortable_tabs.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio/sortable/2b938859b4c44a2bd4cf99628023590e8cdd4910/vignettes/figures/sortable_tabs.gif -------------------------------------------------------------------------------- /vignettes/novel_solutions.Rmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Novel solutions using sortable in shiny apps" 3 | output: rmarkdown::html_vignette 4 | description: > 5 | Once you understand some of the inner workings of `sortable.js`, you can create sortable widgets using any shiny object. 6 | vignette: > 7 | %\VignetteIndexEntry{Novel solutions using sortable in shiny apps} 8 | %\VignetteEngine{knitr::rmarkdown} 9 | %\VignetteEncoding{UTF-8} 10 | --- 11 | 12 | ```{r, include = FALSE} 13 | knitr::opts_chunk$set( 14 | collapse = TRUE, 15 | comment = "#>" 16 | ) 17 | ``` 18 | 19 | ```{r setup} 20 | library(sortable) 21 | ``` 22 | 23 | ## Creating an app 24 | 25 | This example demonstrates how to use custom sortable widgets with any shiny object. 26 | 27 | 28 | ```{r, echo=FALSE} 29 | library(htmltools) 30 | tags$div( 31 | class = "shiny-app-frame", 32 | tags$iframe( 33 | src = "https://andrie-de-vries.shinyapps.io/sortable_drag_vars_to_plot_app/", 34 | width = 800, 35 | height = 700 36 | ) 37 | ) 38 | ``` 39 | 40 | ## Source code 41 | 42 | ```{r echo=FALSE, cache=FALSE} 43 | knitr::read_chunk( 44 | system.file("shiny/drag_vars_to_plot/app.R", package = "sortable") 45 | ) 46 | ``` 47 | 48 | ```{r shiny-drag-vars-to-plot, eval=FALSE} 49 | ``` 50 | 51 | -------------------------------------------------------------------------------- /vignettes/understanding_sortable_js.Rmd: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Understanding the interface to sortable.js" 3 | author: "Andrie de Vries and Kenton Russell" 4 | date: "`r Sys.Date()`" 5 | output: rmarkdown::html_vignette 6 | description: > 7 | Understanding the key idea of `sortable.js` will help you to write your own custom sortable widgets. 8 | vignette: > 9 | %\VignetteIndexEntry{Understanding the interface to sortable.js} 10 | %\VignetteEngine{knitr::rmarkdown} 11 | %\usepackage[utf8]{inputenc} 12 | --- 13 | 14 | ```{r, echo = FALSE} 15 | ## get knitr just the way we like it 16 | 17 | knitr::opts_chunk$set( 18 | message = FALSE, 19 | warning = FALSE, 20 | error = FALSE, 21 | tidy = FALSE, 22 | cache = FALSE 23 | ) 24 | ``` 25 | 26 | 27 | ```{css, echo=FALSE} 28 | pre { 29 | max-height: 20em; 30 | overflow-y: auto; 31 | } 32 | 33 | pre[class] { 34 | max-height: 20em; 35 | } 36 | ``` 37 | 38 | 39 | With the `sortable` [`htmlwidget`](http://www.htmlwidgets.org) you can use powerful, dependency-free interactivity from [`SortableJS`](https://sortablejs.github.io/Sortable/) in the browser, RStudio Viewer, or Shiny apps. 40 | 41 | ```{r} 42 | library(sortable) 43 | library(htmltools) 44 | ``` 45 | 46 | ## The central idea 47 | 48 | The key idea to understand about `sortable`, and `SortableJS` in particular, is that the JavaScript will manipulate an HTML object based on it's CSS `id`. 49 | 50 | Using `sortable` in markdown is a little tricky since markdown does not provide an easy way to provide an `id` that we'll need. We can overcome this by using bare `HTML` or using `htmltools::tags`. Let's make a simple `ul` list. Note, however, that `sortable` works with nearly any `HTML` element, such as `div`. 51 | 52 | ### An example using raw HTML 53 | 54 | The following example uses HTML to construct an unordered list (`