diff --git a/.Rbuildignore b/.Rbuildignore index d681f82..f4e8e2b 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -7,3 +7,4 @@ ^docs$ ^pkgdown$ ^.lintr$ +^data-raw$ diff --git a/DESCRIPTION b/DESCRIPTION index 3f41a2b..d7c167a 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: dfeshiny Title: DfE R-Shiny Standards -Version: 0.3.0 +Version: 0.4.0 Authors@R: c( person("Rich", "Bielby", , "richard.bielby@education.gov.uk", role = c("aut", "cre"), comment = c(ORCID = "0000-0001-9070-9969")), @@ -35,3 +35,6 @@ RoxygenNote: 7.3.2 URL: https://dfe-analytical-services.github.io/dfeshiny/ https://www.github.com/dfe-analytical-services/dfeshiny/ VignetteBuilder: knitr +Depends: + R (>= 2.10) +LazyData: true diff --git a/NAMESPACE b/NAMESPACE index e8a8dcd..b0e5b76 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -6,6 +6,7 @@ export(cookies_panel_server) export(cookies_panel_ui) export(custom_disconnect_message) export(dfe_cookies_script) +export(external_link) export(init_analytics) export(init_cookies) export(support_panel) diff --git a/NEWS.md b/NEWS.md index a46f81e..5d6c4a0 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,7 @@ +# dfeshiny 0.4.0 + +* Add new `external_link()` function and look up data for `bad_link_text`. + # dfeshiny 0.3.0 ## New features diff --git a/R/data-bad_link_text.R b/R/data-bad_link_text.R new file mode 100644 index 0000000..88de1ce --- /dev/null +++ b/R/data-bad_link_text.R @@ -0,0 +1,17 @@ +#' Lookup for bad link text +#' +#' A single column data frame, listing out known examples of bad link text that +#' check for in the `external_link()` function. +#' +#' We've started curating this list so we can create automated checks to help +#' all link text to be as descriptive as possible in line with +#' [WCAG 2.2 success criteria 2.4.4: Link Purpose (In Context)]( +#' https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context). +#' +#' @format ## `bad_link_text` +#' A data frame with 52 rows and 1 columns: +#' \describe{ +#' \item{bad_link_text}{Lower cased examples of non-descriptive link text} +#' } +#' @source Curated by explore.statistics@@education.gov.uk +"bad_link_text" diff --git a/R/external_link.R b/R/external_link.R new file mode 100644 index 0000000..acaaf43 --- /dev/null +++ b/R/external_link.R @@ -0,0 +1,129 @@ +#' External link +#' +#' Intentionally basic wrapper for html anchor elements making it easier to +#' create safe external links with standard and accessible behaviour. For more +#' information on how the tag is generated, see \code{\link[htmltools]{tags}}. +#' +#' @description +#' It is commonplace for external links to open in a new tab, and when we do +#' this we should be careful... +#' +#' This function automatically adds the following to your link: +#' * `target="_blank"` to open in new tab +#' * `rel="noopener noreferrer"` to prevent [reverse tabnabbing]( +#' https://owasp.org/www-community/attacks/Reverse_Tabnabbing) +#' +#' By default this function also adds "(opens in new tab)" to your link text +#' to warn users of the behaviour. +#' +#' This also adds "This link opens in a new tab" as a visually hidden span +#' element within the html outputted to warn non-visual users of the behaviour. +#' +#' The function will error if you end with a full stop, give a warning for +#' particularly short link text and will automatically trim any leading or +#' trailing white space inputted into link_text. +#' +#' Related links and guidance: +#' +#' * [Government digital services guidelines on the use of links]( +#' https://design-system.service.gov.uk/styles/links/) +#' +#' * [Anchor tag html element and its properties]( +#' https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a) +#' +#' * [WCAG 2.2 success criteria 2.4.4: Link Purpose (In Context)]( +#' https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context) +#' +#' * [Web Accessibility standards link text behaviour]( +#' https://www.w3.org/TR/WCAG20-TECHS/G200.html) +#' +#' @param href URL that you want the link to point to +#' @param link_text Text that will appear describing your link, must be +#' descriptive of the page you are linking to. Vague text like 'click here' or +#' 'here' will cause an error, as will ending in a full stop. Leading and +#' trailing white space will be automatically trimmed. If the string is shorter +#' than 7 characters a console warning will be thrown. There is no way to hush +#' this other than providing more detail. +#' @param add_warning Boolean for adding "(opens in new tab)" at the end of the +#' link text to warn users of the behaviour. Be careful and consider +#' accessibility before removing the visual warning. +#' @return shiny.tag object +#' @export +#' +#' @examples +#' external_link("https://shiny.posit.co/", "R Shiny") +#' +#' external_link( +#' "https://shiny.posit.co/", +#' "R Shiny", +#' add_warning = FALSE +#' ) +external_link <- function(href, link_text, add_warning = TRUE) { + if (!is.logical(add_warning)) { + stop("add_warning must be a TRUE or FALSE value") + } + + # Trim whitespace as I don't trust humans not to accidentally include + link_text <- stringr::str_trim(link_text) + + # Create a basic check for raw URLs + is_url <- function(text) { + url_pattern <- "^(https://|http://|www\\.)" + grepl(url_pattern, text) + } + + # Check for vague link text on our list + if (is_url(link_text)) { + stop(paste0( + link_text, + " has been recognised as a raw URL, please change the link_text value", + "to a description of the page being linked to instead" + )) + } + + # Check against curated data set for link text we should banish into room 101 + if (tolower(link_text) %in% dfeshiny::bad_link_text$bad_link_text) { + stop( + paste0( + link_text, + " is not descriptive enough and has has been recognised as bad link", + " text, please replace the link_text argument with more descriptive", + " text." + ) + ) + } + + # Check if link text ends in a full stop + if (grepl("\\.$", link_text)) { + stop("link_text should not end with a full stop") + } + + # Give a console warning if link text is under 7 characters + # Arbritary number that allows for R Shiny to be link text without a warning + if (nchar(link_text) < 7) { + warning(paste0( + "the link_text: ", link_text, ", is shorter than 7 characters, this is", + " unlikely to be descriptive for users, consider having more detailed", + " link text" + )) + } + + # Assuming all else has passed, make the link text a nice accessible link + if (add_warning) { + link_text <- paste(link_text, "(opens in new tab)") + hidden_span <- NULL # don't have extra hidden text if clear in main text + } else { + hidden_span <- + htmltools::span(class = "visually-hidden", "This link opens in a new tab") + } + + # Create link using htmltools::tags$a + htmltools::tags$a( + hidden_span, + href = href, + link_text, + target = "_blank", + rel = "noopener noreferrer", + .noWS = "after" + ) +} diff --git a/_pkgdown.yml b/_pkgdown.yml index ffe107a..195f9c9 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -6,15 +6,9 @@ template: pkgdown-nav-height: 81.4468px reference: -- title: Maintenance - contents: - - tidy_code - title: Cookies contents: - has_concept("cookies") -- title: Standard panels - contents: - - support_panel - title: Connectivity contents: - custom_disconnect_message @@ -22,3 +16,13 @@ reference: desc: One time functions used to set up or update standardised scripts and workflows needed for your dashboard contents: - starts_with("init") +- title: Links + contents: + - external_link + - bad_link_text +- title: Maintenance + contents: + - tidy_code +- title: Standard panels + contents: + - support_panel diff --git a/data-raw/bad_link_text.R b/data-raw/bad_link_text.R new file mode 100644 index 0000000..7248a72 --- /dev/null +++ b/data-raw/bad_link_text.R @@ -0,0 +1,17 @@ +bad_link_text <- data.frame( + bad_link_text = c( + # one word examples + "click", "csv", "continue", "dashboard", "document", "download", "file", + "form", "guidance", "here", "info", "information", "jpeg", "jpg", "learn", + "link", "more", "next", "page", "pdf", "previous", "read", "site", "svg", + "this", "web", "webpage", "website", "word", "xslx", + # two word examples + "click here", "click this link", "download csv", "download document", + "download file", "download here", "download jpg", "download jpeg", + "download pdf", "download png", "download svg", "download word", + "download xslx", "further information", "go here", "learn more", + "link to", "read more", "this page", "visit this", "web page", "web site" + ) +) + +usethis::use_data(bad_link_text, overwrite = TRUE) diff --git a/data/bad_link_text.rda b/data/bad_link_text.rda new file mode 100644 index 0000000..90ac9e1 Binary files /dev/null and b/data/bad_link_text.rda differ diff --git a/man/bad_link_text.Rd b/man/bad_link_text.Rd new file mode 100644 index 0000000..d24d43f --- /dev/null +++ b/man/bad_link_text.Rd @@ -0,0 +1,31 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/data-bad_link_text.R +\docType{data} +\name{bad_link_text} +\alias{bad_link_text} +\title{Lookup for bad link text} +\format{ +\subsection{\code{bad_link_text}}{ + +A data frame with 52 rows and 1 columns: +\describe{ +\item{bad_link_text}{Lower cased examples of non-descriptive link text} +} +} +} +\source{ +Curated by explore.statistics@education.gov.uk +} +\usage{ +bad_link_text +} +\description{ +A single column data frame, listing out known examples of bad link text that +check for in the \code{external_link()} function. +} +\details{ +We've started curating this list so we can create automated checks to help +all link text to be as descriptive as possible in line with +\href{https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context}{WCAG 2.2 success criteria 2.4.4: Link Purpose (In Context)}. +} +\keyword{datasets} diff --git a/man/external_link.Rd b/man/external_link.Rd new file mode 100644 index 0000000..d5f0641 --- /dev/null +++ b/man/external_link.Rd @@ -0,0 +1,67 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/external_link.R +\name{external_link} +\alias{external_link} +\title{External link} +\usage{ +external_link(href, link_text, add_warning = TRUE) +} +\arguments{ +\item{href}{URL that you want the link to point to} + +\item{link_text}{Text that will appear describing your link, must be +descriptive of the page you are linking to. Vague text like 'click here' or +'here' will cause an error, as will ending in a full stop. Leading and +trailing white space will be automatically trimmed. If the string is shorter +than 7 characters a console warning will be thrown. There is no way to hush +this other than providing more detail.} + +\item{add_warning}{Boolean for adding "(opens in new tab)" at the end of the +link text to warn users of the behaviour. Be careful and consider +accessibility before removing the visual warning.} +} +\value{ +shiny.tag object +} +\description{ +It is commonplace for external links to open in a new tab, and when we do +this we should be careful... + +This function automatically adds the following to your link: +\itemize{ +\item \code{target="_blank"} to open in new tab +\item \code{rel="noopener noreferrer"} to prevent \href{https://owasp.org/www-community/attacks/Reverse_Tabnabbing}{reverse tabnabbing} +} + +By default this function also adds "(opens in new tab)" to your link text +to warn users of the behaviour. + +This also adds "This link opens in a new tab" as a visually hidden span +element within the html outputted to warn non-visual users of the behaviour. + +The function will error if you end with a full stop, give a warning for +particularly short link text and will automatically trim any leading or +trailing white space inputted into link_text. + +Related links and guidance: +\itemize{ +\item \href{https://design-system.service.gov.uk/styles/links/}{Government digital services guidelines on the use of links} +\item \href{https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a}{Anchor tag html element and its properties} +\item \href{https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context}{WCAG 2.2 success criteria 2.4.4: Link Purpose (In Context)} +\item \href{https://www.w3.org/TR/WCAG20-TECHS/G200.html}{Web Accessibility standards link text behaviour} +} +} +\details{ +Intentionally basic wrapper for html anchor elements making it easier to +create safe external links with standard and accessible behaviour. For more +information on how the tag is generated, see \code{\link[htmltools]{tags}}. +} +\examples{ +external_link("https://shiny.posit.co/", "R Shiny") + +external_link( + "https://shiny.posit.co/", + "R Shiny", + add_warning = FALSE +) +} diff --git a/tests/testthat/test-data-bad_link_text.R b/tests/testthat/test-data-bad_link_text.R new file mode 100644 index 0000000..c208dca --- /dev/null +++ b/tests/testthat/test-data-bad_link_text.R @@ -0,0 +1,24 @@ +test_that("Returns data frame", { + expect_true(is.data.frame(dfeshiny::bad_link_text)) +}) + +test_that("Matches description", { + # If this test fails, update the notes in R/data-bad_link_text.R + expect_equal(nrow(dfeshiny::bad_link_text), 52) + expect_equal(names(dfeshiny::bad_link_text), "bad_link_text") +}) + +test_that("All are string values", { + expect_true(all(sapply(dfeshiny::bad_link_text$bad_link_text, is.character))) +}) + +test_that("Is all lower case", { + expect_true(all( + dfeshiny::bad_link_text$bad_link_text == + tolower(dfeshiny::bad_link_text$bad_link_text) + )) +}) + +test_that("There are no duplicates", { + expect_true(!anyDuplicated(dfeshiny::bad_link_text)) +}) diff --git a/tests/testthat/test-external_link.R b/tests/testthat/test-external_link.R new file mode 100644 index 0000000..dc8de5e --- /dev/null +++ b/tests/testthat/test-external_link.R @@ -0,0 +1,86 @@ +# Create a test link ========================================================== +test_link <- external_link("https://shiny.posit.co/", "R Shiny") + +# Run rest of tests against the test link ------------------------------------- +test_that("Returns shiny.tag object", { + expect_s3_class(test_link, "shiny.tag") +}) + +test_that("content and URL are correctly formatted", { + expect_equal(test_link$attribs$href, "https://shiny.posit.co/") + expect_true(grepl("R Shiny", test_link$children[[2]])) +}) + +test_that("New tab warning appends", { + expect_true(grepl("\\(opens in new tab\\)", test_link$children[[2]])) +}) + +test_that("attributes are attached properly", { + expect_equal(test_link$attribs$rel, "noopener noreferrer") + expect_equal(test_link$attribs$target, "_blank") +}) + +test_that("hidden text is skipped", { + expect_true(is.null(test_link$children[[1]])) +}) + +# Rest of tests against the function ========================================== +test_that("Rejects dodgy link text", { + expect_error(external_link("https://shiny.posit.co/", "Click here")) + expect_error(external_link("https://shiny.posit.co/", "here")) + expect_error(external_link("https://shiny.posit.co/", "PDF")) + expect_error(external_link("https://shiny.posit.co/", "Full stop.")) + expect_error(external_link("https://shiny.posit.co/", "https://shiny.posit.co/")) + expect_error(external_link("https://shiny.posit.co/", "http://shiny.posit.co/")) + expect_error(external_link("https://shiny.posit.co/", "www.google.com")) +}) + +test_that("Rejects non-boolean for add_warning", { + expect_error( + external_link( + "https://shiny.posit.co/", + "R Shiny", + add_warning = "Funky non-boolean" + ), + "add_warning must be a TRUE or FALSE value" + ) +}) + +test_that("New tab warning always stays for non-visual users", { + test_link_hidden <- + external_link("https://shiny.posit.co/", "R Shiny", add_warning = FALSE) + + expect_true( + grepl("This link opens in a new tab", test_link_hidden$children[[1]]) + ) +}) + +test_that("Surrounding whitespace shrubbery is trimmed", { + expect_equal( + external_link("https://shiny.posit.co/", " R Shiny")$children[[2]], + "R Shiny (opens in new tab)" + ) + + expect_equal( + external_link("https://shiny.posit.co/", "R Shiny ")$children[[2]], + "R Shiny (opens in new tab)" + ) + + expect_equal( + external_link("https://shiny.posit.co/", " R Shiny ")$children[[2]], + "R Shiny (opens in new tab)" + ) +}) + +test_that("Warning appears for short link text and not for long text", { + expect_warning( + external_link("https://shiny.posit.co/", "R"), + paste0( + "the link_text: R, is shorter than 7 characters, this is", + " unlikely to be descriptive for users, consider having more detailed", + " link text" + ) + ) + + expect_no_warning(external_link("https://shiny.posit.co/", "R Shiny")) +})