Skip to content

Commit

Permalink
feat(card_image): Improve card_image() API and usage (#1076)
Browse files Browse the repository at this point in the history
* feat(card_image): Improve API

* tests(card_image): Add snapshot tests

* feat(card_image): Add `alt` parameter

* docs(card_image): src

* chore(card_image): Move `alt` behind `...`

* feat: Add additional error validation to `card_image()`

* docs: Add NEWS item
  • Loading branch information
gadenbuie authored Jul 15, 2024
1 parent 1bd7e03 commit ab9dd5f
Show file tree
Hide file tree
Showing 5 changed files with 291 additions and 30 deletions.
7 changes: 7 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@

* bslib now re-exports `htmltools::css()` to make it easier to specify style declarations. (#1086)

* `card_image()` was improved in a few ways (#1076):
* `alt` is now included in the function inputs and is set to `""` by default. This default value marks images as decorative; please describe the image in the `alt` attribute if it is not decorative.
* `border_radius` now defaults to `"auto"` by default, in which case the image's position in the card will automatically determine whether it should receive the `.card-img-top` (first child), `.card-img-bottom` (last child) or `.card-img` (only child).
* `file` is designed to accept a path to a local (server-side) file, but now recognizes remote files that start with a protocol prefix, e.g. `https://`, or two slashes, e.g. `//`. Local files are base64-encoded and embedded in the HTML output, while remote files are linked directly. To use a relative path for a file that will be served by the Shiny app, use `src` instead of file, e.g. `card_image(src = "cat.jpg")` where `cat.jpg` is stored in `www/`.
* `container` is now `NULL` by default to avoid wrapping the card image in an additional card body container and `fill` is now `FALSE` by default to avoid stretching the image. These changes makes it easier to construct [cards with image caps](https://getbootstrap.com/docs/5.3/components/card/#images).


## Bug fixes

* `toggle_sidebar()` once again correctly closes a sidebar. (@fredericva, #1043)
Expand Down
129 changes: 111 additions & 18 deletions R/card.R
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ card <- function(

attribs <- args[nzchar(argnames)]
children <- as_card_items(args[!nzchar(argnames)], wrapper = wrapper)
children <- card_image_add_classes(children)

is_shiny_input <- !is.null(id)

Expand Down Expand Up @@ -248,32 +249,94 @@ card_footer <- function(..., class = NULL) {
)
}

#' @describeIn card_body Include static (i.e., pre-generated) images.
#' @param file a file path pointing an image. The image will be base64 encoded
#' and provided to the `src` attribute of the `<img>`. Alternatively, you may
#' set this value to `NULL` and provide the `src` yourself.
#' @param href an optional URL to link to.
#' @param border_radius where to apply `border-radius` on the image.
#' @param mime_type the mime type of the `file`.
#' @param container a function to generate an HTML element to contain the image.
#' @param width Any valid [CSS unit][htmltools::validateCssUnit] (e.g., `width="100%"`).
#' @describeIn card_body Include static images in a card, for example as an
#' image cap at the top or bottom of the card.
#'
#' @param file A file path pointing an image. Local images (i.e. not a URI
#' starting with `https://` or similar) will be base64 encoded and provided to
#' the `src` attribute of the `<img>`. Alternatively, you may directly set
#' the image `src`, in which case `file` is ignored.
#' @param alt Alternate text for the image, used by screen readers and assistive
#' devices. Provide alt text with a description of the image for any images
#' with important content. If alt text is not provided, the image will be
#' considered to be decorative and will not be read or announced by screen
#' readers.
#'
#' For more information, the Web Accessibility Initiative (WAI) has a
#' [helpful tutorial on alt text](https://www.w3.org/WAI/tutorials/images/).
#' @param src The `src` attribute of the `<img>` tag. If provided, `file` is
#' ignored entirely. Use `src` to provide a relative path to a file that will
#' be served by the Shiny application and should not be base64 encoded.
#' @param href An optional URL to link to when a user clicks on the image.
#' @param border_radius Which side of the image should have rounded corners,
#' useful when `card_image()` is used as an image cap at the top or bottom of
#' the card.
#'
#' The value of `border_radius` determines whether the `card-img-top`
#' (`"top"`), `card-img-bottom` (`"bottom"`), or `card-img` (`"all"`)
#' [Bootstrap
#' classes](https://getbootstrap.com/docs/5.3/components/card/#images) are
#' applied to the card. The default `"auto"` value will use the image's
#' position within a `card()` to automatically choose the appropriate class.
#' @param mime_type The mime type of the `file` when it is base64 encoded. This
#' argument is available for advanced use cases where [mime::guess_type()] is
#' unable to automatically determine the file type.
#' @param container A function to generate an HTML element to contain the image.
#' Setting this value to `card_body()` places the image inside the card body
#' area, otherwise the image will extend to the edges of the card.
#' @param width Any valid [CSS unit][htmltools::validateCssUnit] (e.g.,
#' `width="100%"`).
#'
#' @export
card_image <- function(
file, ..., href = NULL, border_radius = c("top", "bottom", "all", "none"),
mime_type = NULL, class = NULL, height = NULL, fill = TRUE, width = NULL, container = card_body) {

src <- NULL
if (length(file) > 0) {
src <- base64enc::dataURI(
file = file, mime = mime_type %||% mime::guess_type(file)
)
file,
...,
alt = "",
src = NULL,
href = NULL,
border_radius = c("auto", "top", "bottom", "all", "none"),
mime_type = NULL,
class = NULL,
height = NULL,
fill = FALSE,
width = NULL,
container = NULL
) {
if (any(!nzchar(rlang::names2(list(...))))) {
rlang::abort(c(
"Unnamed arguments were included in `...`.",
i = "All additional arguments to `card_image()` in `...` should be named attributes for the `<img>` tag."
))
}

border_radius <- rlang::arg_match(border_radius)

if (is.null(src)) {
if (grepl("^([[:alnum:]]+:)?//|data:", file)) {
src <- file
} else {
if (!file.exists(file)) {
rlang::abort(c(
sprintf("`file` does not exist: %s", file),
i = sprintf(
"If `file` is a remote file or will be served by the Shiny app, use a URL or set `src = \"%s\"`.",
file
)
))
}
src <- base64enc::dataURI(
file = file,
mime = mime_type %||% mime::guess_type(file)
)
}
}

image <- tags$img(
src = src,
alt = alt,
class = "img-fluid",
class = switch(
match.arg(border_radius),
border_radius,
all = "card-img",
top = "card-img-top",
bottom = "card-img-bottom",
Expand All @@ -295,11 +358,41 @@ card_image <- function(

if (is.function(container)) {
image <- container(image)
} else {
image <- as.card_item(image)
}

class(image) <- c(
if (border_radius == "auto") "card_image_auto",
"card_image",
class(image)
)

image
}

card_image_add_classes <- function(children) {
for (idx_child in seq_along(children)) {
if (inherits(children[[idx_child]], "card_image_auto")) {
card_img_class <-
if (length(children) == 1) {
"card-img"
} else if (idx_child == 1) {
"card-img-top"
} else if (idx_child == length(children)) {
"card-img-bottom"
}

children[[idx_child]] <- tagAppendAttributes(
children[[idx_child]],
class = card_img_class
)
}
}

children
}

#' @describeIn card_body Mark an object as a card item. This will prevent the
#' [card()] from putting the object inside a `wrapper` (i.e., a
#' `card_body()`).
Expand Down
54 changes: 42 additions & 12 deletions man/card_body.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

73 changes: 73 additions & 0 deletions tests/testthat/_snaps/card.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# card_image()

Code
show_raw_html(card(card_image("https://example.com/image.jpg"), card_body(
"image cap on top of card")))
Output
<div class="card bslib-card bslib-mb-spacing html-fill-item html-fill-container" data-bslib-card-init data-require-bs-caller="card()" data-require-bs-version="5">
<img alt="" class="img-fluid card-img-top" src="https://example.com/image.jpg"/>
<div class="card-body bslib-gap-spacing html-fill-item html-fill-container" style="margin-top:auto;margin-bottom:auto;flex:1 1 auto;">image cap on top of card</div>
<script data-bslib-card-init>bslib.Card.initializeAllCards();</script>
</div>

---

Code
show_raw_html(card(card_body("image cap on bottom of card"), card_image(
"https://example.com/image.jpg")))
Output
<div class="card bslib-card bslib-mb-spacing html-fill-item html-fill-container" data-bslib-card-init data-require-bs-caller="card()" data-require-bs-version="5">
<div class="card-body bslib-gap-spacing html-fill-item html-fill-container" style="margin-top:auto;margin-bottom:auto;flex:1 1 auto;">image cap on bottom of card</div>
<img alt="" class="img-fluid card-img-bottom" src="https://example.com/image.jpg"/>
<script data-bslib-card-init>bslib.Card.initializeAllCards();</script>
</div>

---

Code
show_raw_html(card(card_header("header"), card_image(
"https://example.com/image.jpg"), card_body("image not a cap")))
Output
<div class="card bslib-card bslib-mb-spacing html-fill-item html-fill-container" data-bslib-card-init data-require-bs-caller="card()" data-require-bs-version="5">
<div class="card-header">header</div>
<img src="https://example.com/image.jpg" alt="" class="img-fluid"/>
<div class="card-body bslib-gap-spacing html-fill-item html-fill-container" style="margin-top:auto;margin-bottom:auto;flex:1 1 auto;">image not a cap</div>
<script data-bslib-card-init>bslib.Card.initializeAllCards();</script>
</div>

---

Code
show_raw_html(card(card_image("https://example.com/image.jpg", alt = "card-img")))
Output
<div class="card bslib-card bslib-mb-spacing html-fill-item html-fill-container" data-bslib-card-init data-require-bs-caller="card()" data-require-bs-version="5">
<img alt="card-img" class="img-fluid card-img" src="https://example.com/image.jpg"/>
<script data-bslib-card-init>bslib.Card.initializeAllCards();</script>
</div>

# card_image() input validation

Code
card_image("cat.jpg")
Condition
Error in `card_image()`:
! `file` does not exist: cat.jpg
i If `file` is a remote file or will be served by the Shiny app, use a URL or set `src = "cat.jpg"`.

---

Code
card_image("foo", "bar")
Condition
Error in `card_image()`:
! Unnamed arguments were included in `...`.
i All additional arguments to `card_image()` in `...` should be named attributes for the `<img>` tag.

---

Code
card_image("foo", border_radius = "guess")
Condition
Error in `card_image()`:
! `border_radius` must be one of "auto", "top", "bottom", "all", or "none", not "guess".

58 changes: 58 additions & 0 deletions tests/testthat/test-card.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
test_that("card_image()", {
show_raw_html <- function(x) {
cat(format(x))
}

expect_snapshot(
show_raw_html(
card(
card_image("https://example.com/image.jpg"),
card_body("image cap on top of card")
)
)
)

expect_snapshot(
show_raw_html(
card(
card_body("image cap on bottom of card"),
card_image("https://example.com/image.jpg")
)
)
)

expect_snapshot(
show_raw_html(
card(
card_header("header"),
card_image("https://example.com/image.jpg"),
card_body("image not a cap")
)
)
)

expect_snapshot(
show_raw_html(
card(
card_image("https://example.com/image.jpg", alt = "card-img")
)
)
)
})

test_that("card_image() input validation", {
expect_snapshot(
error = TRUE,
card_image("cat.jpg")
)

expect_snapshot(
error = TRUE,
card_image("foo", "bar")
)

expect_snapshot(
error = TRUE,
card_image("foo", border_radius = "guess")
)
})

0 comments on commit ab9dd5f

Please sign in to comment.