Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(card_image): Improve card_image() API and usage #1076

Merged
merged 12 commits into from
Jul 15, 2024
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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's investigate what this would mean for navset_card_*()

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Turns out this works perfectly with navset_card_*().

Kapture.2024-07-15.at.10.30.52.mp4


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")
)
})
Loading