diff --git a/.Rbuildignore b/.Rbuildignore index e33884a..0ceb729 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -1,4 +1,8 @@ ^.*\.Rproj$ ^\.Rproj\.user$ ^.*\.git$ +^\.github$ +^codecov\.yml$ +^LICENSE\.md$ +^README\.Rmd$ README.md \ No newline at end of file diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml new file mode 100644 index 0000000..c2da320 --- /dev/null +++ b/.github/workflows/R-CMD-check.yaml @@ -0,0 +1,52 @@ +# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples +# Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +name: R-CMD-check + +permissions: read-all + +jobs: + R-CMD-check: + runs-on: ${{ matrix.config.os }} + + name: ${{ matrix.config.os }} (${{ matrix.config.r }}) + + strategy: + fail-fast: false + matrix: + config: + - {os: macos-latest, r: 'release'} + - {os: windows-latest, r: 'release'} + - {os: ubuntu-latest, r: 'devel', http-user-agent: 'release'} + - {os: ubuntu-latest, r: 'release'} + - {os: ubuntu-latest, r: 'oldrel-1'} + + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + R_KEEP_PKG_SOURCE: yes + + steps: + - uses: actions/checkout@v4 + + - uses: r-lib/actions/setup-pandoc@v2 + + - uses: r-lib/actions/setup-r@v2 + with: + r-version: ${{ matrix.config.r }} + http-user-agent: ${{ matrix.config.http-user-agent }} + use-public-rspm: true + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::rcmdcheck + needs: check + + - uses: r-lib/actions/check-r-package@v2 + with: + upload-snapshots: true + build_args: 'c("--no-manual","--compact-vignettes=gs+qpdf")' diff --git a/.github/workflows/test-coverage.yaml b/.github/workflows/test-coverage.yaml new file mode 100644 index 0000000..83dc3cb --- /dev/null +++ b/.github/workflows/test-coverage.yaml @@ -0,0 +1,47 @@ +# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples +# Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +name: test-coverage + +permissions: read-all + +jobs: + test-coverage: + runs-on: ubuntu-latest + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + + steps: + - uses: actions/checkout@v4 + + - uses: r-lib/actions/setup-r@v2 + with: + use-public-rspm: true + + - uses: r-lib/actions/setup-r-dependencies@v2 + with: + extra-packages: any::covr, any::xml2 + needs: coverage + + - name: Test coverage + run: | + cov <- covr::package_coverage( + quiet = FALSE, + clean = FALSE, + install_path = file.path(normalizePath(Sys.getenv("RUNNER_TEMP"), winslash = "/"), "package") + ) + covr::to_cobertura(cov) + shell: Rscript {0} + + - uses: codecov/codecov-action@v4 + with: + fail_ci_if_error: ${{ github.event_name != 'pull_request' && true || false }} + file: ./cobertura.xml + plugin: noop + disable_search: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/DESCRIPTION b/DESCRIPTION index 0918e1b..c5b8025 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -10,10 +10,21 @@ Authors@R: c( person("Kevin", "Louie", role = c("ctb", "cph"), comment = "Materialize CSS library") ) Maintainer: Eric Anderson -Description: Allows shiny developers to incorporate UI elements based on Google's Material design. See for more information. +Description: Allows shiny developers to incorporate UI elements based on Google's + Material design. See for more information about + Material Design. URL: https://ericrayanderson.github.io/shinymaterial/ +BugReports: https://github.com/ericrayanderson/shinymaterial/issues License: GPL-3 | file LICENSE -Imports: shiny (>= 0.7.0), jsonlite, sass +Imports: + shiny (>= 1.7.0), + jsonlite, + sass, + rlang (>= 1.0.0), + cli +Suggests: + testthat (>= 3.0.0) +Config/testthat/edition: 3 Encoding: UTF-8 -LazyData: true -RoxygenNote: 7.1.0 +Roxygen: list(markdown = TRUE) +RoxygenNote: 7.3.2 diff --git a/NAMESPACE b/NAMESPACE index 9e6d26a..46ee256 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,4 +1,6 @@ importFrom("utils", "capture.output") +importFrom("cli", "cli_abort", "cli_alert_info") +importFrom("rlang", "abort") export(open_material_modal) export(close_material_modal) export(material_button) diff --git a/R/dir-recursion.R b/R/dir-recursion.R deleted file mode 100644 index f3de602..0000000 --- a/R/dir-recursion.R +++ /dev/null @@ -1,24 +0,0 @@ -dir_recursion <- function(.final_dir){ - - .split_dir <- unlist(strsplit(.final_dir, "/")) - - .dirs <- c() - for(i in 1:length(.split_dir)){ - .dirs <- c(.dirs, paste0(.split_dir[1:i], collapse = "/")) - } - - for(dir.i in .dirs){ - if(!dir.exists(dir.i)){ - if(dir.i == .dirs[length(.dirs)]){ - message( - paste0( - "[shinymaterial] Creating directory: ", - file.path(getwd(), dir.i) - ) - ) - } - dir.create(dir.i) - } - } - -} diff --git a/R/shiny-material-page.R b/R/shiny-material-page.R index c70dbf7..f1183bc 100644 --- a/R/shiny-material-page.R +++ b/R/shiny-material-page.R @@ -95,8 +95,11 @@ material_page <- function(..., title = "", nav_bar_fixed = FALSE, nav_bar_color } if(include_fonts){ - - dir_recursion("www/fonts/roboto") + + if (!dir.exists("www/fonts/roboto")) { + cli::cli_alert_info("Creating directory: {.path www/fonts/roboto}") + dir.create("www/fonts/roboto", recursive = TRUE) + } font_files <- list.files( system.file(paste0("materialize/", materialize_version, "/fonts/roboto"), @@ -123,7 +126,7 @@ material_page <- function(..., title = "", nav_bar_fixed = FALSE, nav_bar_color if (!dir.exists("www/icons/materialicons/")) { - message("[shinymaterial] Creating directory: www/icons/materialicons/") + cli::cli_alert_info("Creating directory: {.path www/icons/materialicons/}") dir.create("www/icons/materialicons/", recursive = TRUE) } @@ -214,11 +217,16 @@ material_page <- function(..., title = "", nav_bar_fixed = FALSE, nav_bar_color package = "shinymaterial") ), shiny::tags$script(" - Shiny.addCustomMessageHandler('shinymaterialJS', - function(code) { - //console.log(code.split('\\\\').join('').trim()); - eval(code.split('\\\\').join('').trim()); - }); - ") + Shiny.addCustomMessageHandler('shinymaterialJS', function(code) { + // Use Function constructor instead of eval for improved security + // Function() doesn't have access to local scope, reducing attack surface + try { + var cleanCode = code.split('\\\\').join('').trim(); + new Function(cleanCode)(); + } catch (e) { + console.error('shinymaterial: Error executing code:', e.message); + } + }); + ") ) } diff --git a/R/update-shiny-material-dropdown.R b/R/update-shiny-material-dropdown.R index a1c24ee..09dc1df 100644 --- a/R/update-shiny-material-dropdown.R +++ b/R/update-shiny-material-dropdown.R @@ -16,21 +16,27 @@ #' } update_material_dropdown <- function(session, input_id, value = NULL, choices = NULL){ if(is.null(value)) { - message("ERROR: Must include 'value' with update_material_dropdown") - return(NULL) + cli::cli_abort( + "Must include {.arg value} with {.fn update_material_dropdown}", + class = "shinymaterial_error_missing_value" + ) } - - + + if(!is.null(choices)){ - + if ( is.null(names(choices)) ){ names(choices) <- choices } - - + + if(!(value %in% choices)) { - message("ERROR: value '", value, "' not found in choices") - return(NULL) + cli::cli_abort( + c("Value not found in choices.", + "x" = "Value {.val {value}} is not in the provided choices.", + "i" = "Available choices: {.val {choices}}"), + class = "shinymaterial_error_invalid_value" + ) } choices_value_js_code <- paste0("$('#", input_id, "').empty(); $('#", input_id, "')") diff --git a/inst/js/shiny-material-button.js b/inst/js/shiny-material-button.js index b23442b..672857b 100644 --- a/inst/js/shiny-material-button.js +++ b/inst/js/shiny-material-button.js @@ -1,28 +1,37 @@ -$(document).ready(function () { - $(".shiny-material-button").on("click", function () { - var el = $(this); - var curVal = parseInt(el.val()); - el.val(curVal + 1); - el.trigger("change"); - }); +/** + * Material Design Button Input Binding for Shiny + * @description Handles button click events and value updates + */ +'use strict'; - var shinyMaterialButton = new Shiny.InputBinding(); - $.extend(shinyMaterialButton, { - find: function (scope) { - return $(scope).find(".shiny-material-button"); - }, - getValue: function (el) { - return parseInt($(el).val()); - }, - subscribe: function (el, callback) { - $(el).on("change.shiny-material-button", function (e) { - callback(); - }); - }, - unsubscribe: function (el) { - $(el).off(".shiny-material-button"); - } +document.addEventListener('DOMContentLoaded', () => { + // Handle button click events + document.querySelectorAll('.shiny-material-button').forEach((button) => { + button.addEventListener('click', function() { + const currentValue = parseInt(this.value, 10) || 0; + this.value = currentValue + 1; + this.dispatchEvent(new Event('change', { bubbles: true })); }); + }); + + // Define the Shiny input binding using ES6 class syntax + class ShinyMaterialButton extends Shiny.InputBinding { + find(scope) { + return $(scope).find('.shiny-material-button'); + } + + getValue(el) { + return parseInt(el.value, 10) || 0; + } + + subscribe(el, callback) { + $(el).on('change.shiny-material-button', () => callback()); + } + + unsubscribe(el) { + $(el).off('.shiny-material-button'); + } + } - Shiny.inputBindings.register(shinyMaterialButton); + Shiny.inputBindings.register(new ShinyMaterialButton()); }); diff --git a/inst/js/shiny-material-checkbox.js b/inst/js/shiny-material-checkbox.js index f6a1cc9..7b46c85 100644 --- a/inst/js/shiny-material-checkbox.js +++ b/inst/js/shiny-material-checkbox.js @@ -1,22 +1,27 @@ -$(document).ready(function () { - - var shinyMaterialCheckbox = new Shiny.InputBinding(); - $.extend(shinyMaterialCheckbox, { - find: function (scope) { - return $(scope).find(".shiny-material-checkbox"); - }, - getValue: function (el) { - return $(el).val(); - }, - subscribe: function (el, callback) { - $(el).on("change.shiny-material-checkbox", function (e) { - callback(); - }); - }, - unsubscribe: function (el) { - $(el).off(".shiny-material-checkbox"); - } - }); - - Shiny.inputBindings.register(shinyMaterialCheckbox); - }); \ No newline at end of file +/** + * Material Design Checkbox Input Binding for Shiny + * @description Handles checkbox state changes + */ +'use strict'; + +document.addEventListener('DOMContentLoaded', () => { + class ShinyMaterialCheckbox extends Shiny.InputBinding { + find(scope) { + return $(scope).find('.shiny-material-checkbox'); + } + + getValue(el) { + return $(el).val(); + } + + subscribe(el, callback) { + $(el).on('change.shiny-material-checkbox', () => callback()); + } + + unsubscribe(el) { + $(el).off('.shiny-material-checkbox'); + } + } + + Shiny.inputBindings.register(new ShinyMaterialCheckbox()); +}); diff --git a/inst/js/shiny-material-date-picker.js b/inst/js/shiny-material-date-picker.js index 8c68c25..0fbea53 100644 --- a/inst/js/shiny-material-date-picker.js +++ b/inst/js/shiny-material-date-picker.js @@ -1,31 +1,34 @@ -$(document).ready(function () { - - function initShinyMaterialDatePicker(callback) { - $('.datepicker').datepicker({}); - callback(); +/** + * Material Design Date Picker Input Binding for Shiny + * @description Handles date picker initialization and value changes + */ +'use strict'; + +document.addEventListener('DOMContentLoaded', () => { + // Initialize Material date pickers + const initDatePickers = () => { + $('.datepicker').datepicker({}); + }; + + initDatePickers(); + + class ShinyMaterialDatePicker extends Shiny.InputBinding { + find(scope) { + return $(scope).find('.shiny-material-date-picker'); + } + + getValue(el) { + return $(el).val(); + } + + subscribe(el, callback) { + $(el).on('change.shiny-material-date-picker', () => callback()); + } + + unsubscribe(el) { + $(el).off('.shiny-material-date-picker'); } - - initShinyMaterialDatePicker(function () { - - var shinyMaterialDatePicker = new Shiny.InputBinding(); - - $.extend(shinyMaterialDatePicker, { - find: function (scope) { - return $(scope).find(".shiny-material-date-picker"); - }, - getValue: function (el) { - return $(el).val(); - }, - subscribe: function (el, callback) { - $(el).on("change.shiny-material-date-picker", function (e) { - callback(); - }); - }, - unsubscribe: function (el) { - $(el).off(".shiny-material-date-picker"); - } - }); + } - Shiny.inputBindings.register(shinyMaterialDatePicker); - }); + Shiny.inputBindings.register(new ShinyMaterialDatePicker()); }); diff --git a/inst/js/shiny-material-dropdown.js b/inst/js/shiny-material-dropdown.js index 601e6ae..fbd81e3 100644 --- a/inst/js/shiny-material-dropdown.js +++ b/inst/js/shiny-material-dropdown.js @@ -1,45 +1,59 @@ -$(document).ready(function () { - function initShinyMaterialDropdown(callback) { - $('.shiny-material-dropdown').formSelect(); - callback(); +/** + * Material Design Dropdown Input Binding for Shiny + * @description Handles dropdown select changes with space replacement handling + */ +'use strict'; + +document.addEventListener('DOMContentLoaded', () => { + const SPACE_PLACEHOLDER = '_shinymaterialdropdownspace_'; + const SPACE_REGEX = new RegExp(SPACE_PLACEHOLDER, 'g'); + + /** + * Replace space placeholders in a value + * @param {string|string[]} value - The value to process + * @returns {string|string[]} - Processed value + */ + const replaceSpacePlaceholder = (value) => { + if (value === null) return null; + + if (typeof value === 'string') { + return value.replace(SPACE_REGEX, ' '); + } + + if (Array.isArray(value)) { + return value.map((item) => + typeof item === 'string' ? item.replace(SPACE_REGEX, ' ') : item + ); + } + + return value; + }; + + // Initialize Material dropdowns + const initDropdowns = () => { + $('.shiny-material-dropdown').formSelect(); + }; + + initDropdowns(); + + class ShinyMaterialDropdown extends Shiny.InputBinding { + find(scope) { + return $(scope).find('select.shiny-material-dropdown'); + } + + getValue(el) { + const value = $(el).val(); + return replaceSpacePlaceholder(value); + } + + subscribe(el, callback) { + $(el).on('change.shiny-material-dropdown', () => callback()); + } + + unsubscribe(el) { + $(el).off('.shiny-material-dropdown'); } + } - initShinyMaterialDropdown(function () { - - var shinyMaterialDropdown = new Shiny.InputBinding(); - $.extend(shinyMaterialDropdown, { - find: function (scope) { - return $(scope).find("select.shiny-material-dropdown"); - }, - getValue: function (el) { - var ans; - ans = $(el).val(); - if(ans === null){ - return ans; - } - if(typeof(ans) == "string"){ - return ans.replace(new RegExp("_shinymaterialdropdownspace_", 'g'), " "); - } else if(typeof(ans) == "object"){ - for (i = 0; i < ans.length; i++) { - if(typeof(ans[i]) == "string"){ - ans[i] = ans[i].replace(new RegExp("_shinymaterialdropdownspace_", 'g'), " "); - } - } - return ans; - } else { - return ans; - } - }, - subscribe: function (el, callback) { - $(el).on("change.shiny-material-dropdown", function (e) { - callback(); - }); - }, - unsubscribe: function (el) { - $(el).off(".shiny-material-dropdown"); - } - }); - - Shiny.inputBindings.register(shinyMaterialDropdown); - }); -}) + Shiny.inputBindings.register(new ShinyMaterialDropdown()); +}); diff --git a/inst/js/shiny-material-file-input.js b/inst/js/shiny-material-file-input.js index 99d3a48..da7c76e 100644 --- a/inst/js/shiny-material-file-input.js +++ b/inst/js/shiny-material-file-input.js @@ -1,30 +1,35 @@ -$(document).ready(function () { +/** + * Material Design File Input Binding for Shiny + * @description Handles file input changes with FileReader support + */ +'use strict'; - var shinyMaterialFileInput = new Shiny.InputBinding(); - $.extend(shinyMaterialFileInput, { - find: function (scope) { - return $(scope).find(".shiny-material-file-input"); - }, - getValue: function (el) { - function () { - var file = el.files[0]; - if (file) { - read = new FileReader(); - read.readAsDataURL(file); - - } - return read.result; - } - }, - subscribe: function (el, callback) { - $(el).on("change.shiny-material-file-input", function (e) { - callback(); - }); - }, - unsubscribe: function (el) { - $(el).off(".shiny-material-file-input"); - } - }); +document.addEventListener('DOMContentLoaded', () => { + class ShinyMaterialFileInput extends Shiny.InputBinding { + find(scope) { + return $(scope).find('.shiny-material-file-input'); + } - Shiny.inputBindings.register(shinyMaterialFileInput); -}); \ No newline at end of file + getValue(el) { + const file = el.files[0]; + if (!file) return null; + + // Return file metadata for Shiny to process + return { + name: file.name, + size: file.size, + type: file.type + }; + } + + subscribe(el, callback) { + $(el).on('change.shiny-material-file-input', () => callback()); + } + + unsubscribe(el) { + $(el).off('.shiny-material-file-input'); + } + } + + Shiny.inputBindings.register(new ShinyMaterialFileInput()); +}); diff --git a/inst/js/shiny-material-floating-button.js b/inst/js/shiny-material-floating-button.js index 6af406a..a7a5cb5 100644 --- a/inst/js/shiny-material-floating-button.js +++ b/inst/js/shiny-material-floating-button.js @@ -1,28 +1,36 @@ -$(document).ready(function(){ -$(".shiny-material-floating-button").on("click", function() { - var el = $(this); - var curVal = parseInt(el.val()); - el.val(curVal + 1); - el.trigger("change"); -}); +/** + * Material Design Floating Button Input Binding for Shiny + * @description Handles floating action button click events + */ +'use strict'; -var shinyMaterialFloatingButton = new Shiny.InputBinding(); -$.extend(shinyMaterialFloatingButton, { - find: function(scope) { - return $(scope).find(".shiny-material-floating-button"); - }, - getValue: function(el) { - return parseInt($(el).val()); - }, - subscribe: function(el, callback) { - $(el).on("change.shiny-material-floating-button", function(e) { - callback(); +document.addEventListener('DOMContentLoaded', () => { + // Handle floating button click events + document.querySelectorAll('.shiny-material-floating-button').forEach((button) => { + button.addEventListener('click', function() { + const currentValue = parseInt(this.value, 10) || 0; + this.value = currentValue + 1; + this.dispatchEvent(new Event('change', { bubbles: true })); }); - }, - unsubscribe: function(el) { - $(el).off(".shiny-material-floating-button"); + }); + + class ShinyMaterialFloatingButton extends Shiny.InputBinding { + find(scope) { + return $(scope).find('.shiny-material-floating-button'); + } + + getValue(el) { + return parseInt(el.value, 10) || 0; + } + + subscribe(el, callback) { + $(el).on('change.shiny-material-floating-button', () => callback()); + } + + unsubscribe(el) { + $(el).off('.shiny-material-floating-button'); + } } -}); -Shiny.inputBindings.register(shinyMaterialFloatingButton); + Shiny.inputBindings.register(new ShinyMaterialFloatingButton()); }); diff --git a/inst/js/shiny-material-modal.js b/inst/js/shiny-material-modal.js index c944124..f5d3442 100644 --- a/inst/js/shiny-material-modal.js +++ b/inst/js/shiny-material-modal.js @@ -1,8 +1,17 @@ -$(document).ready(function () { - $('.modal').modal({ - dismissible: false - }); - $(document).on("click", ".shiny-material-modal-trigger", function () { - $(this).trigger("shown"); - }); +/** + * Material Design Modal Initialization for Shiny + * @description Initializes modal dialogs with Material Design styling + */ +'use strict'; + +document.addEventListener('DOMContentLoaded', () => { + // Initialize Material modals + $('.modal').modal({ + dismissible: false + }); + + // Handle modal trigger events + $(document).on('click', '.shiny-material-modal-trigger', function() { + $(this).trigger('shown'); + }); }); diff --git a/inst/js/shiny-material-number-box.js b/inst/js/shiny-material-number-box.js index 45a2236..e0db174 100644 --- a/inst/js/shiny-material-number-box.js +++ b/inst/js/shiny-material-number-box.js @@ -1,22 +1,27 @@ -$(document).ready(function () { +/** + * Material Design Number Box Input Binding for Shiny + * @description Handles numeric input changes + */ +'use strict'; - var shinyMaterialNumberBox = new Shiny.InputBinding(); - $.extend(shinyMaterialNumberBox, { - find: function (scope) { - return $(scope).find(".shiny-material-number-box"); - }, - getValue: function (el) { - return $(el).val(); - }, - subscribe: function (el, callback) { - $(el).on("change.shiny-material-number-box", function (e) { - callback(); - }); - }, - unsubscribe: function (el) { - $(el).off(".shiny-material-number-box"); - } - }); +document.addEventListener('DOMContentLoaded', () => { + class ShinyMaterialNumberBox extends Shiny.InputBinding { + find(scope) { + return $(scope).find('.shiny-material-number-box'); + } - Shiny.inputBindings.register(shinyMaterialNumberBox); + getValue(el) { + return $(el).val(); + } + + subscribe(el, callback) { + $(el).on('change.shiny-material-number-box', () => callback()); + } + + unsubscribe(el) { + $(el).off('.shiny-material-number-box'); + } + } + + Shiny.inputBindings.register(new ShinyMaterialNumberBox()); }); diff --git a/inst/js/shiny-material-page.js b/inst/js/shiny-material-page.js index 586577d..d7e5be6 100644 --- a/inst/js/shiny-material-page.js +++ b/inst/js/shiny-material-page.js @@ -1,3 +1,12 @@ -$(document).ready(function(){ - M.AutoInit(); -}); \ No newline at end of file +/** + * Material Design Page Initialization for Shiny + * @description Auto-initializes all Material Design components + */ +'use strict'; + +document.addEventListener('DOMContentLoaded', () => { + // Initialize all Material Design components + if (typeof M !== 'undefined' && M.AutoInit) { + M.AutoInit(); + } +}); diff --git a/inst/js/shiny-material-parallax.js b/inst/js/shiny-material-parallax.js index 7ffe173..e34dc55 100644 --- a/inst/js/shiny-material-parallax.js +++ b/inst/js/shiny-material-parallax.js @@ -1,3 +1,10 @@ -$(document).ready(function(){ - $('.parallax').parallax(); +/** + * Material Design Parallax Initialization for Shiny + * @description Initializes parallax scrolling effects + */ +'use strict'; + +document.addEventListener('DOMContentLoaded', () => { + // Initialize Material parallax elements + $('.parallax').parallax(); }); diff --git a/inst/js/shiny-material-password-box.js b/inst/js/shiny-material-password-box.js index ad46737..ce77035 100644 --- a/inst/js/shiny-material-password-box.js +++ b/inst/js/shiny-material-password-box.js @@ -1,22 +1,27 @@ -$(document).ready(function () { +/** + * Material Design Password Box Input Binding for Shiny + * @description Handles password input changes + */ +'use strict'; - var shinyMaterialPasswordBox = new Shiny.InputBinding(); - $.extend(shinyMaterialPasswordBox, { - find: function (scope) { - return $(scope).find(".shiny-material-password-box"); - }, - getValue: function (el) { - return $(el).val(); - }, - subscribe: function (el, callback) { - $(el).on("change.shiny-material-password-box", function (e) { - callback(); - }); - }, - unsubscribe: function (el) { - $(el).off(".shiny-material-password-box"); - } - }); +document.addEventListener('DOMContentLoaded', () => { + class ShinyMaterialPasswordBox extends Shiny.InputBinding { + find(scope) { + return $(scope).find('.shiny-material-password-box'); + } - Shiny.inputBindings.register(shinyMaterialPasswordBox); + getValue(el) { + return $(el).val(); + } + + subscribe(el, callback) { + $(el).on('change.shiny-material-password-box', () => callback()); + } + + unsubscribe(el) { + $(el).off('.shiny-material-password-box'); + } + } + + Shiny.inputBindings.register(new ShinyMaterialPasswordBox()); }); diff --git a/inst/js/shiny-material-radio-button.js b/inst/js/shiny-material-radio-button.js index 3861034..f6effc8 100644 --- a/inst/js/shiny-material-radio-button.js +++ b/inst/js/shiny-material-radio-button.js @@ -1,23 +1,34 @@ -$(document).ready(function () { +/** + * Material Design Radio Button Input Binding for Shiny + * @description Handles radio button selection changes + */ +'use strict'; - var shinyMaterialRadioButton = new Shiny.InputBinding(); - $.extend(shinyMaterialRadioButton, { - find: function (scope) { - return $(scope).find(".shiny-material-radio-button"); - }, - getValue: function (el) { - return $(el).find('input:checked').attr('id').replace(new RegExp("_shinymaterialradioempty_", 'g'), ""); - }, - subscribe: function (el, callback) { - $(el).on("change.shiny-material-radio-button", function (e) { - callback(); - }); - }, - unsubscribe: function (el) { - $(el).off(".shiny-material-radio-button"); - } - }); +document.addEventListener('DOMContentLoaded', () => { + const EMPTY_PLACEHOLDER = '_shinymaterialradioempty_'; + const EMPTY_REGEX = new RegExp(EMPTY_PLACEHOLDER, 'g'); - Shiny.inputBindings.register(shinyMaterialRadioButton); + class ShinyMaterialRadioButton extends Shiny.InputBinding { + find(scope) { + return $(scope).find('.shiny-material-radio-button'); + } + getValue(el) { + const checked = $(el).find('input:checked'); + if (checked.length === 0) return null; + + const id = checked.attr('id'); + return id ? id.replace(EMPTY_REGEX, '') : null; + } + + subscribe(el, callback) { + $(el).on('change.shiny-material-radio-button', () => callback()); + } + + unsubscribe(el) { + $(el).off('.shiny-material-radio-button'); + } + } + + Shiny.inputBindings.register(new ShinyMaterialRadioButton()); }); diff --git a/inst/js/shiny-material-side-nav-fixed.js b/inst/js/shiny-material-side-nav-fixed.js index 1bd697c..447cadc 100644 --- a/inst/js/shiny-material-side-nav-fixed.js +++ b/inst/js/shiny-material-side-nav-fixed.js @@ -1,3 +1,18 @@ - $(document).ready(function () { - $('.nav-wrapper').prepend('menu'); - }) +/** + * Material Design Fixed Side Navigation for Shiny + * @description Initializes fixed side navigation menu trigger + */ +'use strict'; + +document.addEventListener('DOMContentLoaded', () => { + // Add sidenav trigger to navigation wrapper + const navWrapper = document.querySelector('.nav-wrapper'); + if (navWrapper) { + const trigger = document.createElement('a'); + trigger.href = '#'; + trigger.dataset.target = 'slide-out'; + trigger.className = 'sidenav-trigger'; + trigger.innerHTML = 'menu'; + navWrapper.insertBefore(trigger, navWrapper.firstChild); + } +}); diff --git a/inst/js/shiny-material-side-nav-tabs.js b/inst/js/shiny-material-side-nav-tabs.js index 338afc6..7adb245 100644 --- a/inst/js/shiny-material-side-nav-tabs.js +++ b/inst/js/shiny-material-side-nav-tabs.js @@ -1,48 +1,56 @@ -$(document).ready(function () { - $('.shiny-material-side-nav-tab').first().addClass('active'); - - var first_side_nav_tab_id = $('.shiny-material-side-nav-tab').first().attr("id"); - - var first_side_nav_tab_content_id = $('.shiny-material-side-nav-tab') - .first() - .attr("id") - .replace("_tab_id", ""); - - var first_side_nav_tab_content_found = $('.shiny-material-side-nav-tab-content') - .first() - .attr('id'); - - if (first_side_nav_tab_content_id != first_side_nav_tab_content_found) { - alert("SHINYMATERIAL ERROR [side-nav tabs]\n\nTab content code for the first tab must be located prior to tab content code for all other tabs.\n\nFirst tab found (" + first_side_nav_tab_id.replace("_tab_id", "") + ") does not match first tab content found (" + first_side_nav_tab_content_found + ").\n\nPlease rearrange the UI code.") - - $("body").empty(); - } else { - - $(document).on('shiny:sessioninitialized', function() { - - function get_side_nav_tabs_info() { - - var side_nav_tabs_info = []; - - for (var i = 0; i < $('.shiny-material-side-nav-tab').length; i++) { - - side_nav_tabs_info.push({ - id: $('.shiny-material-side-nav-tab')[i].id.slice(0, -7), - active: $('.shiny-material-side-nav-tab').eq(i).hasClass("active") - }); - - } - - Shiny.onInputChange("side_nav_tab_info", JSON.stringify(side_nav_tabs_info)); - } - - get_side_nav_tabs_info(); - - $("#side_nav_tabs_click_info").click(function() { - get_side_nav_tabs_info(); - }); +/** + * Material Design Side Navigation Tabs for Shiny + * @description Handles side navigation tab state and synchronization + */ +'use strict'; + +document.addEventListener('DOMContentLoaded', () => { + const $tabs = $('.shiny-material-side-nav-tab'); + + if ($tabs.length === 0) return; + + // Activate first tab + $tabs.first().addClass('active'); + + const firstTabId = $tabs.first().attr('id'); + const firstTabContentId = firstTabId.replace('_tab_id', ''); + const firstContentFound = $('.shiny-material-side-nav-tab-content').first().attr('id'); + + // Validate tab order + if (firstTabContentId !== firstContentFound) { + console.error( + `SHINYMATERIAL ERROR [side-nav tabs]: Tab content order mismatch.\n` + + `First tab (${firstTabContentId}) does not match first tab content (${firstContentFound}).\n` + + `Please rearrange the UI code.` + ); + document.body.innerHTML = ''; + return; + } + + // Wait for Shiny to initialize + $(document).on('shiny:sessioninitialized', () => { + /** + * Collect and report tab state information + */ + const getSideNavTabsInfo = () => { + const tabsInfo = []; + + $tabs.each((index, tab) => { + tabsInfo.push({ + id: tab.id.slice(0, -7), + active: $(tab).hasClass('active') + }); }); - $('#' + first_side_nav_tab_id).children('a').trigger('click'); - } -}) + Shiny.setInputValue('side_nav_tab_info', JSON.stringify(tabsInfo)); + }; + + getSideNavTabsInfo(); + + // Update tab info on click + $('#side_nav_tabs_click_info').on('click', getSideNavTabsInfo); + }); + + // Trigger click on first tab + $(`#${firstTabId}`).children('a').trigger('click'); +}); diff --git a/inst/js/shiny-material-side-nav.js b/inst/js/shiny-material-side-nav.js index 63d6c1c..b13a947 100644 --- a/inst/js/shiny-material-side-nav.js +++ b/inst/js/shiny-material-side-nav.js @@ -1,4 +1,18 @@ - $(document).ready(function () { +/** + * Material Design Side Navigation for Shiny + * @description Initializes side navigation menu trigger + */ +'use strict'; - $('.nav-wrapper').prepend('menu'); - }) +document.addEventListener('DOMContentLoaded', () => { + // Add sidenav trigger to navigation wrapper + const navWrapper = document.querySelector('.nav-wrapper'); + if (navWrapper) { + const trigger = document.createElement('a'); + trigger.href = '#'; + trigger.dataset.target = 'slide-out'; + trigger.className = 'sidenav-trigger show-on-large'; + trigger.innerHTML = 'menu'; + navWrapper.insertBefore(trigger, navWrapper.firstChild); + } +}); diff --git a/inst/js/shiny-material-slider.js b/inst/js/shiny-material-slider.js index 8e81546..3f992a0 100644 --- a/inst/js/shiny-material-slider.js +++ b/inst/js/shiny-material-slider.js @@ -1,34 +1,35 @@ -$(document).ready(function () { +/** + * Material Design Slider Input Binding for Shiny + * @description Handles slider value changes + */ +'use strict'; - var shinyMaterialSlider = new Shiny.InputBinding(); +document.addEventListener('DOMContentLoaded', () => { + class ShinyMaterialSlider extends Shiny.InputBinding { + find(scope) { + return $(scope).find('.shiny-material-slider'); + } - $.extend(shinyMaterialSlider, { - find: function (scope) { - return $(scope).find(".shiny-material-slider"); - }, - getValue: function (el) { - var classValue = $(el).find(".value").html(); - if (classValue) { - if (classValue.length === 0) { - var inputValue = $(el).find('input').val(); - return Number(inputValue); - } else { - return Number(classValue); - } - } else { - var inputValue = $(el).find('input').val(); - return Number(inputValue); - } - }, - subscribe: function (el, callback) { - $(el).on("change", function (e) { - callback(); - }); - }, - unsubscribe: function (el) { - $(el).off(".shiny-material-slider"); - } - }); + getValue(el) { + const $el = $(el); + const classValue = $el.find('.value').html(); - Shiny.inputBindings.register(shinyMaterialSlider); + if (classValue && classValue.length > 0) { + return Number(classValue); + } + + const inputValue = $el.find('input').val(); + return Number(inputValue); + } + + subscribe(el, callback) { + $(el).on('change.shiny-material-slider', () => callback()); + } + + unsubscribe(el) { + $(el).off('.shiny-material-slider'); + } + } + + Shiny.inputBindings.register(new ShinyMaterialSlider()); }); diff --git a/inst/js/shiny-material-switch.js b/inst/js/shiny-material-switch.js index eb8c5c4..ad4a692 100644 --- a/inst/js/shiny-material-switch.js +++ b/inst/js/shiny-material-switch.js @@ -1,22 +1,27 @@ -$(document).ready(function(){ +/** + * Material Design Switch Input Binding for Shiny + * @description Handles switch toggle state changes + */ +'use strict'; -var shinyMaterialSwitch = new Shiny.InputBinding(); -$.extend(shinyMaterialSwitch, { - find: function(scope) { - return $(scope).find(".shiny-material-switch"); - }, - getValue: function(el) { - return $(el).val(); - }, - subscribe: function(el, callback) { - $(el).on("change.shiny-material-switch", function(e) { - callback(); - }); - }, - unsubscribe: function(el) { - $(el).off(".shiny-material-switch"); +document.addEventListener('DOMContentLoaded', () => { + class ShinyMaterialSwitch extends Shiny.InputBinding { + find(scope) { + return $(scope).find('.shiny-material-switch'); + } + + getValue(el) { + return $(el).val(); + } + + subscribe(el, callback) { + $(el).on('change.shiny-material-switch', () => callback()); + } + + unsubscribe(el) { + $(el).off('.shiny-material-switch'); + } } -}); -Shiny.inputBindings.register(shinyMaterialSwitch); + Shiny.inputBindings.register(new ShinyMaterialSwitch()); }); diff --git a/inst/js/shiny-material-tabs-in-card.js b/inst/js/shiny-material-tabs-in-card.js index d915d7e..d8dd54c 100644 --- a/inst/js/shiny-material-tabs-in-card.js +++ b/inst/js/shiny-material-tabs-in-card.js @@ -1,14 +1,9 @@ -$(document).ready(function(){ - - // $(document).on("click", "li.tab a", function () { -// $(this).trigger("shown"); -//}); - // $(".shiny-material-tab-content").css("visibility", "visible"); - - // $(".card-title").remove(); - - //$(".card-tabs").each(function (){ - // $(this).insertBefore($(this).parent()); -//}); +/** + * Material Design Tabs in Card for Shiny + * @description Placeholder for tabs within card components + */ +'use strict'; -}) +document.addEventListener('DOMContentLoaded', () => { + // Reserved for future tabs-in-card functionality +}); diff --git a/inst/js/shiny-material-tabs.js b/inst/js/shiny-material-tabs.js index 8574fe9..221ccc7 100644 --- a/inst/js/shiny-material-tabs.js +++ b/inst/js/shiny-material-tabs.js @@ -1,9 +1,15 @@ -$(document).ready(function(){ - //$('ul.tabs').tabs(); - $(document).on("click", "li.tab a", function () { - $(this).trigger("shown"); -}); - $(".shiny-material-tab-content").css("visibility", "visible") -}) +/** + * Material Design Tabs for Shiny + * @description Handles tab click events and content visibility + */ +'use strict'; +document.addEventListener('DOMContentLoaded', () => { + // Handle tab click events + $(document).on('click', 'li.tab a', function() { + $(this).trigger('shown'); + }); + // Make tab content visible + $('.shiny-material-tab-content').css('visibility', 'visible'); +}); diff --git a/inst/js/shiny-material-text-box.js b/inst/js/shiny-material-text-box.js index e62da6b..7b68820 100644 --- a/inst/js/shiny-material-text-box.js +++ b/inst/js/shiny-material-text-box.js @@ -1,22 +1,27 @@ -$(document).ready(function () { +/** + * Material Design Text Box Input Binding for Shiny + * @description Handles text input changes + */ +'use strict'; - var shinyMaterialTextBox = new Shiny.InputBinding(); - $.extend(shinyMaterialTextBox, { - find: function (scope) { - return $(scope).find(".shiny-material-text-box"); - }, - getValue: function (el) { - return $(el).val(); - }, - subscribe: function (el, callback) { - $(el).on("change.shiny-material-text-box", function (e) { - callback(); - }); - }, - unsubscribe: function (el) { - $(el).off(".shiny-material-text-box"); - } - }); +document.addEventListener('DOMContentLoaded', () => { + class ShinyMaterialTextBox extends Shiny.InputBinding { + find(scope) { + return $(scope).find('.shiny-material-text-box'); + } - Shiny.inputBindings.register(shinyMaterialTextBox); + getValue(el) { + return $(el).val(); + } + + subscribe(el, callback) { + $(el).on('change.shiny-material-text-box', () => callback()); + } + + unsubscribe(el) { + $(el).off('.shiny-material-text-box'); + } + } + + Shiny.inputBindings.register(new ShinyMaterialTextBox()); }); diff --git a/man/update_material_dropdown.Rd b/man/update_material_dropdown.Rd index b7b6969..bcd412e 100644 --- a/man/update_material_dropdown.Rd +++ b/man/update_material_dropdown.Rd @@ -4,13 +4,7 @@ \alias{update_material_dropdown} \title{Change the value of a material_dropdown on the client} \usage{ -update_material_dropdown( - session, - input_id, - value = NULL, - choices = NULL, - multiple = NULL -) +update_material_dropdown(session, input_id, value = NULL, choices = NULL) } \arguments{ \item{session}{The session object passed to function given to shinyServer.} @@ -20,8 +14,6 @@ update_material_dropdown( \item{value}{The value to set for the material_dropdown.} \item{choices}{The choices to set for the material_dropdown.} - -\item{multiple}{Boolean. Can multiple items be selected?} } \description{ Change the value of a material_dropdown on the client. diff --git a/tests/testthat.R b/tests/testthat.R new file mode 100644 index 0000000..c769848 --- /dev/null +++ b/tests/testthat.R @@ -0,0 +1,12 @@ +# This file is part of the standard setup for testthat. +# It is recommended that you do not modify it. +# +# Where should you do additional test configuration? +# Learn more about the roles of various files in: +# * https://r-pkgs.org/testing-design.html#sec-tests-files-overview +# * https://testthat.r-lib.org/articles/special-files.html + +library(testthat) +library(shinymaterial) + +test_check("shinymaterial") diff --git a/tests/testthat/test-material-button.R b/tests/testthat/test-material-button.R new file mode 100644 index 0000000..f3cb503 --- /dev/null +++ b/tests/testthat/test-material-button.R @@ -0,0 +1,38 @@ +test_that("material_button creates valid shiny tag", { + button <- material_button( + input_id = "test_button", + label = "Test Button" + ) + + expect_s3_class(button, "shiny.tag.list") +}) + +test_that("material_button includes icon when specified", { + button <- material_button( + input_id = "test_button", + label = "Test Button", + icon = "cloud" + ) + + expect_s3_class(button, "shiny.tag.list") +}) + +test_that("material_button includes depth class when specified", { + button <- material_button( + input_id = "test_button", + label = "Test Button", + depth = 3 + ) + + expect_s3_class(button, "shiny.tag.list") +}) + +test_that("material_button includes color class when specified", { + button <- material_button( + input_id = "test_button", + label = "Test Button", + color = "blue lighten-2" + ) + + expect_s3_class(button, "shiny.tag.list") +}) diff --git a/tests/testthat/test-material-page.R b/tests/testthat/test-material-page.R new file mode 100644 index 0000000..f9e64bd --- /dev/null +++ b/tests/testthat/test-material-page.R @@ -0,0 +1,35 @@ +test_that("material_page creates valid HTML structure", { + page <- material_page( + title = "Test Page", + shiny::tags$div("Content") + ) + + expect_s3_class(page, "shiny.tag") + expect_equal(page$name, "html") +}) + +test_that("material_page respects nav_bar_fixed option", { + page_fixed <- material_page( + title = "Test", + nav_bar_fixed = TRUE + ) + + page_normal <- material_page( + title = "Test", + nav_bar_fixed = FALSE + ) + + expect_s3_class(page_fixed, "shiny.tag") + expect_s3_class(page_normal, "shiny.tag") +}) + +test_that("material_page throws error for invalid theme options", { + expect_error( + material_page( + title = "Test", + materialize_in_www = TRUE, + primary_theme_color = "#ff0000" + ), + "cannot be used when setting" + ) +}) diff --git a/tests/testthat/test-update-dropdown.R b/tests/testthat/test-update-dropdown.R new file mode 100644 index 0000000..30e9e36 --- /dev/null +++ b/tests/testthat/test-update-dropdown.R @@ -0,0 +1,31 @@ +test_that("update_material_dropdown throws error for missing value", { + # Create a mock session + mock_session <- list( + sendCustomMessage = function(type, message) {} + ) + + expect_error( + update_material_dropdown( + session = mock_session, + input_id = "test_dropdown", + value = NULL + ), + "Must include" + ) +}) + +test_that("update_material_dropdown throws error when value not in choices", { + mock_session <- list( + sendCustomMessage = function(type, message) {} + ) + + expect_error( + update_material_dropdown( + session = mock_session, + input_id = "test_dropdown", + value = "invalid", + choices = c("a", "b", "c") + ), + "not found in choices|not in the provided choices" + ) +})