From c26be524eac0ad68e4f64d889c0d44de792c9a0d Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 24 Jan 2023 11:03:12 -0600 Subject: [PATCH] Add sidebar(), layout_sidebar(), card_sidebar(), and container() --- DESCRIPTION | 1 + NAMESPACE | 6 ++ R/bs-theme.R | 3 +- R/card.R | 11 ++ R/page.R | 27 +++++ R/sidebar.R | 155 +++++++++++++++++++++++++++ _pkgdown.yml | 1 + inst/components/accordion.min.js.map | 2 +- inst/components/card.scss | 4 + inst/components/sidebar.min.js | 3 + inst/components/sidebar.min.js.map | 7 ++ inst/components/sidebar.scss | 134 +++++++++++++++++++++++ man/card_body.Rd | 7 ++ man/container.Rd | 28 +++++ man/sidebar.Rd | 75 +++++++++++++ man/value_box.Rd | 2 +- srcts/build/index.ts | 6 ++ srcts/src/components/_utils.ts | 21 +++- srcts/src/components/sidebar.ts | 66 ++++++++++++ 19 files changed, 555 insertions(+), 4 deletions(-) create mode 100644 R/sidebar.R create mode 100644 inst/components/sidebar.min.js create mode 100644 inst/components/sidebar.min.js.map create mode 100644 inst/components/sidebar.scss create mode 100644 man/container.Rd create mode 100644 man/sidebar.Rd create mode 100644 srcts/src/components/sidebar.ts diff --git a/DESCRIPTION b/DESCRIPTION index 99424402d..ab6165003 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -71,6 +71,7 @@ Collate: 'precompiled.R' 'print.R' 'shiny-devmode.R' + 'sidebar.R' 'staticimports.R' 'utils-shiny.R' 'utils-tags.R' diff --git a/NAMESPACE b/NAMESPACE index 4d6bc01d1..dd334e347 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -56,7 +56,9 @@ export(card_body_fill) export(card_footer) export(card_header) export(card_image) +export(card_sidebar) export(card_title) +export(container) export(font_collection) export(font_face) export(font_google) @@ -64,6 +66,7 @@ export(font_link) export(is.card_item) export(is_bs_theme) export(layout_column_wrap) +export(layout_sidebar) export(nav) export(nav_append) export(nav_content) @@ -92,6 +95,9 @@ export(precompiled_css_path) export(run_with_themer) export(showcase_left_center) export(showcase_top_right) +export(sidebar) +export(sidebar_close) +export(sidebar_open) export(theme_bootswatch) export(theme_version) export(value_box) diff --git a/R/bs-theme.R b/R/bs-theme.R index 08dbf86e4..62da27cd9 100644 --- a/R/bs-theme.R +++ b/R/bs-theme.R @@ -277,7 +277,8 @@ bootstrap_bundle <- function(version) { system_file("components", "accordion.scss", package = "bslib"), system_file("components", "card.scss", package = "bslib"), system_file("components", "value_box.scss", package = "bslib"), - system_file("components", "layout_column_wrap.scss", package = "bslib") + system_file("components", "layout_column_wrap.scss", package = "bslib"), + system_file("components", "sidebar.scss", package = "bslib") )) ), four = sass_bundle( diff --git a/R/card.R b/R/card.R index 70fc85ccb..46cde701e 100644 --- a/R/card.R +++ b/R/card.R @@ -210,6 +210,17 @@ card_footer <- function(..., class = NULL) { ) } +#' @describeIn card_body A [card_body_fill()] with a [layout_sidebar()] inside +#' of it. All arguments to this function are passed along to +#' [layout_sidebar()]. +#' @export +card_sidebar <- function(sidebar = sidebar(), ..., border = FALSE) { + card_body_fill( + class = "p-0", + layout_sidebar(sidebar = sidebar, ..., border = border) + ) +} + #' @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 ``. Alternatively, you may diff --git a/R/page.R b/R/page.R index 78ff9b64d..ec36b1d05 100644 --- a/R/page.R +++ b/R/page.R @@ -88,6 +88,33 @@ page_navbar <- function(..., title = NULL, id = NULL, selected = NULL, ) } +#' Contain, pad, and align content +#' +#' @param ... A collection of [htmltools::tag()] children. +#' @param size A size (i.e., max-width policy) for the container. +#' @param bg A background color. +#' @param class Additional CSS classes for the container. +#' +#' @references +#' +#' @export +container <- function(..., size = c("sm", "md", "lg", "xl", "xxl", "fluid"), bg = NULL, class = NULL) { + + size <- match.arg(size) + + res <- div( + class = paste0("container-", size), + class = class, + # TODO: parseCssColors(), once it supports var() and !important + style = css(background_color = bg), + ... + ) + + as_fragment( + tag_require(res, version = 5, caller = "container()") + ) +} + #> unlist(find_characters(div(h1("foo"), h2("bar")))) #> [1] "foo" "bar" find_characters <- function(x) { diff --git a/R/sidebar.R b/R/sidebar.R new file mode 100644 index 000000000..8d9d98bee --- /dev/null +++ b/R/sidebar.R @@ -0,0 +1,155 @@ +#' Create various sidebar-based layouts +#' +#' @param ... A collection of [htmltools::tag()] children to place in the main +#' content area. +#' @param width A valid [CSS unit][htmltools::validateCssUnit] used for the +#' width of the sidebar. +#' @param collapsible Whether or not the sidebar should be collapsible. +#' @param id A character string. Required if wanting to re-actively read (or +#' update) the `collapsible` state in a Shiny app. +#' @param bg A background color. +#' @param class Additional CSS classes for the top-level HTML element. +#' +#' @export +#' @seealso [card_sidebar()], [container()], [page_navbar()] +sidebar <- function(..., width = 250, collapsible = TRUE, id = NULL, bg = NULL, class = NULL) { + + # For accessiblity reasons, always provide id (when collapsible), + # but only create input binding when id is provided + if (is.null(id) && collapsible) { + id <- paste0("bslib-sidebar-", p_randomInt(1000, 10000)) + } else { + class <- c("bslib-sidebar-input", class) + } + + res <- list2( + tag = tags$form( + id = id, + role = "complementary", + class = c("sidebar", class), + # TODO: parseCssColors(), once it supports var() and !important + style = css(background_color = bg), + ... + ), + collapse_tag = tags$a( + class = "collapse-toggle", + role = "button", + "aria-expanded" = "true", + "aria-controls" = id + ), + width = validateCssUnit(width) + ) + + class(res) <- c("sidebar", class(res)) + res +} + + +#' @describeIn sidebar A 'low-level' sidebar layout +#' +#' @param sidebar A [sidebar()] object. +#' @param full_bleed whether or not to clip the layout container the entire viewport. +#' @param fill whether or not the `main` content area should be considered a +#' fill (i.e., flexbox) container. +#' @param border whether or not to add a border. +#' @param border_radius whether or not to add a border radius. +#' +#' @export +layout_sidebar <- function(sidebar = sidebar(), ..., full_bleed = FALSE, fill = FALSE, bg = "var(--bs-body-bg)", border = !full_bleed, border_radius = !full_bleed, class = NULL) { + if (!inherits(sidebar, "sidebar")) { + abort("`sidebar` argument must contain a `bslib::sidebar()` component.") + } + + main <- div( + role = "main", + class = "main", + # TODO: parseCssColors(), once it supports var() and !important + style = css(background_color = bg), + ... + ) + + border_css <- if (border) { + "var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)" + } else { + "none" + } + + border_radius_css <- if (border_radius) "var(--bs-border-radius)" else "initial" + + res <- div( + class = c("bslib-sidebar-layout", class), + style = css( + "--bslib-sidebar-width" = sidebar$width, + "--bslib-sidebar-border" = border_css, + "--bslib-sidebar-border-radius" = border_radius_css + ), + sidebar$tag, + sidebar$collapse_tag, + bindFillRole(main, container = fill), + sidebar_dependency() + ) + + if (full_bleed) { + res <- tagAppendAttributes(res, style = css(position = "fixed", inset = 0)) + res <- tagAppendChild(res, adjust_full_bleed_inset()) + } + + res <- bindFillRole(res, item = TRUE) + + as_fragment( + tag_require(res, version = 5, caller = "layout_sidebar()") + ) +} + + +#' @describeIn sidebar Close a (`collapsible`) [sidebar()]. +#' @export +sidebar_open <- function(id, session = get_current_session()) { + callback <- function() { + session$sendInputMessage(id, list(method = "open")) + } + session$onFlush(callback, once = TRUE) +} + +#' @describeIn sidebar Close a (`collapsible`) [sidebar()]. +#' @export +sidebar_close <- function(id, session = get_current_session()) { + callback <- function() { + session$sendInputMessage(id, list(method = "close")) + } + session$onFlush(callback, once = TRUE) +} + + +adjust_full_bleed_inset <- function() { + tags$script("data-bslib-sidebar-full-bleed-inset" = NA, HTML( + " + var thisScript = document.querySelector('script[data-bslib-sidebar-full-bleed-inset]'); + thisScript.removeAttribute('data-bslib-sidebar-full-bleed-inset'); + + var navbar = $('.navbar:visible'); + // TODO: actually handle the multiple navbar case. + if (navbar.length > 1) { + console.warning('More than one navbar is visible. Will only adjust full_bleed layout for the first navbar.') + navbar = navbar.first(); + } + if (navbar.length == 1) { + var height = navbar.outerHeight() + 'px'; + var $el = $(thisScript.parentElement); + navbar.hasClass('navbar-fixed-bottom') ? + $el.css('bottom', height) : + $el.css('top', height); + } + " + )) +} + +sidebar_dependency <- function() { + htmlDependency( + name = "bslib-sidebar", + version = get_package_version("bslib"), + package = "bslib", + src = "components", + script = "sidebar.min.js" + ) +} \ No newline at end of file diff --git a/_pkgdown.yml b/_pkgdown.yml index 3f314824c..ee1df9aae 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -98,6 +98,7 @@ reference: description: | Useful layout templates contents: + - layout_sidebar - layout_column_wrap - title: Page layouts contents: diff --git a/inst/components/accordion.min.js.map b/inst/components/accordion.min.js.map index 2fc945c35..75234a522 100644 --- a/inst/components/accordion.min.js.map +++ b/inst/components/accordion.min.js.map @@ -1,7 +1,7 @@ { "version": 3, "sources": ["../../srcts/src/components/_utils.ts", "../../srcts/src/components/accordion.ts"], - "sourcesContent": ["import type { HtmlDep } from \"rstudio-shiny/srcts/types/src/shiny/render\";\n\nimport type { InputBinding as InputBindingType } from \"rstudio-shiny/srcts/types/src/bindings/input\";\n\n// Exclude undefined from T\ntype NotUndefined = T extends undefined ? never : T;\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nconst InputBinding = (\n window.Shiny ? Shiny.InputBinding : class {}\n) as typeof InputBindingType;\n\nfunction registerBinding(\n inputBindingClass: new () => InputBindingType,\n name: string\n): void {\n if (window.Shiny) {\n Shiny.inputBindings.register(new inputBindingClass(), \"bslib.\" + name);\n }\n}\n\n// Return true if the key exists on the object and the value is not undefined.\n//\n// This method is mainly used in input bindings' `receiveMessage` method.\n// Since we know that the values are sent by Shiny via `{jsonlite}`,\n// then we know that there are no `undefined` values. `null` is possible, but not `undefined`.\nfunction hasDefinedProperty<\n Prop extends keyof X,\n X extends { [key: string]: any }\n>(\n obj: X,\n prop: Prop\n): obj is X & { [key in NonNullable]: NotUndefined } {\n return (\n Object.prototype.hasOwnProperty.call(obj, prop) && obj[prop] !== undefined\n );\n}\n\nexport { InputBinding, registerBinding, hasDefinedProperty };\nexport type { HtmlDep };\n", "import type { HtmlDep } from \"./_utils\";\nimport { InputBinding, registerBinding, hasDefinedProperty } from \"./_utils\";\n\ntype AccordionItem = {\n item: Element;\n value: string;\n isOpen: () => boolean;\n show: () => void;\n hide: () => void;\n};\n\ntype HTMLContent = {\n html: string;\n deps?: HtmlDep[];\n};\n\ntype SetMessage = {\n method: \"set\";\n values: string[];\n};\n\ntype OpenMessage = {\n method: \"open\";\n values: string[] | true;\n};\n\ntype CloseMessage = {\n method: \"close\";\n values: string[] | true;\n};\n\ntype InsertMessage = {\n method: \"insert\";\n panel: HTMLContent;\n target: string;\n position: \"after\" | \"before\";\n};\n\ntype RemoveMessage = {\n method: \"remove\";\n target: string[];\n};\n\ntype UpdateMessage = {\n method: \"update\";\n target: string;\n value: string;\n body: HTMLContent;\n title: HTMLContent;\n icon: HTMLContent;\n};\n\ntype MessageData =\n | CloseMessage\n | InsertMessage\n | OpenMessage\n | RemoveMessage\n | SetMessage\n | UpdateMessage;\n\nclass AccordionInputBinding extends InputBinding {\n find(scope: HTMLElement) {\n return $(scope).find(\".accordion.bslib-accordion-input\");\n }\n\n getValue(el: HTMLElement): string[] | null {\n const items = this._getItemInfo(el);\n const selected = items.filter((x) => x.isOpen()).map((x) => x.value);\n return selected.length === 0 ? null : selected;\n }\n\n subscribe(el: HTMLElement, callback: (x: boolean) => void) {\n $(el).on(\n \"shown.bs.collapse.accordionInputBinding hidden.bs.collapse.accordionInputBinding\",\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n function (event) {\n callback(true);\n }\n );\n }\n\n unsubscribe(el: HTMLElement) {\n $(el).off(\".accordionInputBinding\");\n }\n\n receiveMessage(el: HTMLElement, data: MessageData) {\n const method = data.method;\n if (method === \"set\") {\n this._setItems(el, data);\n } else if (method === \"open\") {\n this._openItems(el, data);\n } else if (method === \"close\") {\n this._closeItems(el, data);\n } else if (method === \"remove\") {\n this._removeItem(el, data);\n } else if (method === \"insert\") {\n this._insertItem(el, data);\n } else if (method === \"update\") {\n this._updateItem(el, data);\n } else {\n throw new Error(`Method not yet implemented: ${method}`);\n }\n }\n\n protected _setItems(el: HTMLElement, data: SetMessage) {\n const items = this._getItemInfo(el);\n const vals = this._getValues(el, items, data.values);\n items.forEach((x) => {\n vals.indexOf(x.value) > -1 ? x.show() : x.hide();\n });\n }\n\n protected _openItems(el: HTMLElement, data: OpenMessage) {\n const items = this._getItemInfo(el);\n const vals = this._getValues(el, items, data.values);\n items.forEach((x) => {\n if (vals.indexOf(x.value) > -1) x.show();\n });\n }\n\n protected _closeItems(el: HTMLElement, data: CloseMessage) {\n const items = this._getItemInfo(el);\n const vals = this._getValues(el, items, data.values);\n items.forEach((x) => {\n if (vals.indexOf(x.value) > -1) x.hide();\n });\n }\n\n protected _insertItem(el: HTMLElement, data: InsertMessage) {\n let targetItem = this._findItem(el, data.target);\n\n // If no target was specified, or the target was not found, then default\n // to the first or last item, depending on the position\n if (!targetItem) {\n targetItem = (\n data.position === \"before\" ? el.firstElementChild : el.lastElementChild\n ) as HTMLElement;\n }\n\n const panel = data.panel;\n\n // If there is still no targetItem, then there are no items in the accordion\n if (targetItem) {\n Shiny.renderContent(\n targetItem,\n panel,\n data.position === \"before\" ? \"beforeBegin\" : \"afterEnd\"\n );\n } else {\n Shiny.renderContent(el, panel);\n }\n\n // Need to add a reference to the parent id that makes autoclose to work\n if (this._isAutoClosing(el)) {\n const val = $(panel.html).attr(\"data-value\");\n $(el)\n .find(`[data-value=\"${val}\"] .accordion-collapse`)\n .attr(\"data-bs-parent\", \"#\" + el.id);\n }\n }\n\n protected _removeItem(el: HTMLElement, data: RemoveMessage) {\n const targetItems = this._getItemInfo(el).filter(\n (x) => data.target.indexOf(x.value) > -1\n );\n\n targetItems.forEach((x) => x.item.remove());\n }\n\n protected _updateItem(el: HTMLElement, data: UpdateMessage) {\n const target = this._findItem(el, data.target);\n\n if (!target) {\n throw new Error(\n `Unable to find an accordion_panel() with a value of ${data.target}`\n );\n }\n\n if (hasDefinedProperty(data, \"value\")) {\n target.dataset.value = data.value;\n }\n\n if (hasDefinedProperty(data, \"body\")) {\n const body = target.querySelector(\".accordion-body\") as HTMLElement; // always exists\n Shiny.renderContent(body, data.body);\n }\n\n const header = target.querySelector(\".accordion-header\") as HTMLElement; // always exists\n\n if (hasDefinedProperty(data, \"title\")) {\n const title = header.querySelector(\".accordion-title\") as HTMLElement; // always exists\n Shiny.renderContent(title, data.title);\n }\n\n if (hasDefinedProperty(data, \"icon\")) {\n const icon = header.querySelector(\n \".accordion-button > .accordion-icon\"\n ) as HTMLElement; // always exists\n Shiny.renderContent(icon, data.icon);\n }\n }\n\n protected _getItemInfo(el: HTMLElement): AccordionItem[] {\n const items = Array.from(\n el.querySelectorAll(\":scope > .accordion-item\")\n ) as HTMLElement[];\n return items.map((x) => this._getSingleItemInfo(x));\n }\n\n protected _getSingleItemInfo(x: HTMLElement): AccordionItem {\n const collapse = x.querySelector(\".accordion-collapse\") as HTMLElement;\n const isOpen = () => $(collapse).hasClass(\"show\");\n return {\n item: x,\n value: x.dataset.value as string,\n isOpen: isOpen,\n show: () => {\n if (!isOpen()) $(collapse).collapse(\"show\");\n },\n hide: () => {\n if (isOpen()) $(collapse).collapse(\"hide\");\n },\n };\n }\n\n protected _getValues(\n el: HTMLElement,\n items: AccordionItem[],\n values: string[] | true\n ): string[] {\n let vals = values !== true ? values : items.map((x) => x.value);\n const autoclose = this._isAutoClosing(el);\n if (autoclose) {\n vals = vals.slice(vals.length - 1, vals.length);\n }\n return vals;\n }\n\n protected _findItem(el: HTMLElement, value: string): HTMLElement | null {\n return el.querySelector(`[data-value=\"${value}\"]`);\n }\n\n protected _isAutoClosing(el: HTMLElement): boolean {\n return el.classList.contains(\"autoclose\");\n }\n}\n\nregisterBinding(AccordionInputBinding, \"accordion\");\n"], + "sourcesContent": ["import type { HtmlDep } from \"rstudio-shiny/srcts/types/src/shiny/render\";\n\nimport type { InputBinding as InputBindingType } from \"rstudio-shiny/srcts/types/src/bindings/input\";\n\n// Exclude undefined from T\ntype NotUndefined = T extends undefined ? never : T;\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nconst InputBinding = (\n window.Shiny ? Shiny.InputBinding : class {}\n) as typeof InputBindingType;\n\nfunction registerBinding(\n inputBindingClass: new () => InputBindingType,\n name: string\n): void {\n if (window.Shiny) {\n Shiny.inputBindings.register(new inputBindingClass(), \"bslib.\" + name);\n }\n}\n\n// Return true if the key exists on the object and the value is not undefined.\n//\n// This method is mainly used in input bindings' `receiveMessage` method.\n// Since we know that the values are sent by Shiny via `{jsonlite}`,\n// then we know that there are no `undefined` values. `null` is possible, but not `undefined`.\nfunction hasDefinedProperty<\n Prop extends keyof X,\n X extends { [key: string]: any }\n>(\n obj: X,\n prop: Prop\n): obj is X & { [key in NonNullable]: NotUndefined } {\n return (\n Object.prototype.hasOwnProperty.call(obj, prop) && obj[prop] !== undefined\n );\n}\n\n// TODO: Shiny should trigger resize events when the output\n// https://github.com/rstudio/shiny/pull/3682\nfunction doWindowResizeOnElementResize(el: HTMLElement): void {\n if ($(el).data(\"window-resize-observer\")) {\n return;\n }\n const resizeEvent = new Event(\"resize\");\n const ro = new ResizeObserver(() => {\n window.dispatchEvent(resizeEvent);\n });\n ro.observe(el);\n $(el).data(\"window-resize-observer\", ro);\n}\n\nexport {\n InputBinding,\n registerBinding,\n hasDefinedProperty,\n doWindowResizeOnElementResize,\n};\nexport type { HtmlDep };\n", "import type { HtmlDep } from \"./_utils\";\nimport { InputBinding, registerBinding, hasDefinedProperty } from \"./_utils\";\n\ntype AccordionItem = {\n item: Element;\n value: string;\n isOpen: () => boolean;\n show: () => void;\n hide: () => void;\n};\n\ntype HTMLContent = {\n html: string;\n deps?: HtmlDep[];\n};\n\ntype SetMessage = {\n method: \"set\";\n values: string[];\n};\n\ntype OpenMessage = {\n method: \"open\";\n values: string[] | true;\n};\n\ntype CloseMessage = {\n method: \"close\";\n values: string[] | true;\n};\n\ntype InsertMessage = {\n method: \"insert\";\n panel: HTMLContent;\n target: string;\n position: \"after\" | \"before\";\n};\n\ntype RemoveMessage = {\n method: \"remove\";\n target: string[];\n};\n\ntype UpdateMessage = {\n method: \"update\";\n target: string;\n value: string;\n body: HTMLContent;\n title: HTMLContent;\n icon: HTMLContent;\n};\n\ntype MessageData =\n | CloseMessage\n | InsertMessage\n | OpenMessage\n | RemoveMessage\n | SetMessage\n | UpdateMessage;\n\nclass AccordionInputBinding extends InputBinding {\n find(scope: HTMLElement) {\n return $(scope).find(\".accordion.bslib-accordion-input\");\n }\n\n getValue(el: HTMLElement): string[] | null {\n const items = this._getItemInfo(el);\n const selected = items.filter((x) => x.isOpen()).map((x) => x.value);\n return selected.length === 0 ? null : selected;\n }\n\n subscribe(el: HTMLElement, callback: (x: boolean) => void) {\n $(el).on(\n \"shown.bs.collapse.accordionInputBinding hidden.bs.collapse.accordionInputBinding\",\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n function (event) {\n callback(true);\n }\n );\n }\n\n unsubscribe(el: HTMLElement) {\n $(el).off(\".accordionInputBinding\");\n }\n\n receiveMessage(el: HTMLElement, data: MessageData) {\n const method = data.method;\n if (method === \"set\") {\n this._setItems(el, data);\n } else if (method === \"open\") {\n this._openItems(el, data);\n } else if (method === \"close\") {\n this._closeItems(el, data);\n } else if (method === \"remove\") {\n this._removeItem(el, data);\n } else if (method === \"insert\") {\n this._insertItem(el, data);\n } else if (method === \"update\") {\n this._updateItem(el, data);\n } else {\n throw new Error(`Method not yet implemented: ${method}`);\n }\n }\n\n protected _setItems(el: HTMLElement, data: SetMessage) {\n const items = this._getItemInfo(el);\n const vals = this._getValues(el, items, data.values);\n items.forEach((x) => {\n vals.indexOf(x.value) > -1 ? x.show() : x.hide();\n });\n }\n\n protected _openItems(el: HTMLElement, data: OpenMessage) {\n const items = this._getItemInfo(el);\n const vals = this._getValues(el, items, data.values);\n items.forEach((x) => {\n if (vals.indexOf(x.value) > -1) x.show();\n });\n }\n\n protected _closeItems(el: HTMLElement, data: CloseMessage) {\n const items = this._getItemInfo(el);\n const vals = this._getValues(el, items, data.values);\n items.forEach((x) => {\n if (vals.indexOf(x.value) > -1) x.hide();\n });\n }\n\n protected _insertItem(el: HTMLElement, data: InsertMessage) {\n let targetItem = this._findItem(el, data.target);\n\n // If no target was specified, or the target was not found, then default\n // to the first or last item, depending on the position\n if (!targetItem) {\n targetItem = (\n data.position === \"before\" ? el.firstElementChild : el.lastElementChild\n ) as HTMLElement;\n }\n\n const panel = data.panel;\n\n // If there is still no targetItem, then there are no items in the accordion\n if (targetItem) {\n Shiny.renderContent(\n targetItem,\n panel,\n data.position === \"before\" ? \"beforeBegin\" : \"afterEnd\"\n );\n } else {\n Shiny.renderContent(el, panel);\n }\n\n // Need to add a reference to the parent id that makes autoclose to work\n if (this._isAutoClosing(el)) {\n const val = $(panel.html).attr(\"data-value\");\n $(el)\n .find(`[data-value=\"${val}\"] .accordion-collapse`)\n .attr(\"data-bs-parent\", \"#\" + el.id);\n }\n }\n\n protected _removeItem(el: HTMLElement, data: RemoveMessage) {\n const targetItems = this._getItemInfo(el).filter(\n (x) => data.target.indexOf(x.value) > -1\n );\n\n targetItems.forEach((x) => x.item.remove());\n }\n\n protected _updateItem(el: HTMLElement, data: UpdateMessage) {\n const target = this._findItem(el, data.target);\n\n if (!target) {\n throw new Error(\n `Unable to find an accordion_panel() with a value of ${data.target}`\n );\n }\n\n if (hasDefinedProperty(data, \"value\")) {\n target.dataset.value = data.value;\n }\n\n if (hasDefinedProperty(data, \"body\")) {\n const body = target.querySelector(\".accordion-body\") as HTMLElement; // always exists\n Shiny.renderContent(body, data.body);\n }\n\n const header = target.querySelector(\".accordion-header\") as HTMLElement; // always exists\n\n if (hasDefinedProperty(data, \"title\")) {\n const title = header.querySelector(\".accordion-title\") as HTMLElement; // always exists\n Shiny.renderContent(title, data.title);\n }\n\n if (hasDefinedProperty(data, \"icon\")) {\n const icon = header.querySelector(\n \".accordion-button > .accordion-icon\"\n ) as HTMLElement; // always exists\n Shiny.renderContent(icon, data.icon);\n }\n }\n\n protected _getItemInfo(el: HTMLElement): AccordionItem[] {\n const items = Array.from(\n el.querySelectorAll(\":scope > .accordion-item\")\n ) as HTMLElement[];\n return items.map((x) => this._getSingleItemInfo(x));\n }\n\n protected _getSingleItemInfo(x: HTMLElement): AccordionItem {\n const collapse = x.querySelector(\".accordion-collapse\") as HTMLElement;\n const isOpen = () => $(collapse).hasClass(\"show\");\n return {\n item: x,\n value: x.dataset.value as string,\n isOpen: isOpen,\n show: () => {\n if (!isOpen()) $(collapse).collapse(\"show\");\n },\n hide: () => {\n if (isOpen()) $(collapse).collapse(\"hide\");\n },\n };\n }\n\n protected _getValues(\n el: HTMLElement,\n items: AccordionItem[],\n values: string[] | true\n ): string[] {\n let vals = values !== true ? values : items.map((x) => x.value);\n const autoclose = this._isAutoClosing(el);\n if (autoclose) {\n vals = vals.slice(vals.length - 1, vals.length);\n }\n return vals;\n }\n\n protected _findItem(el: HTMLElement, value: string): HTMLElement | null {\n return el.querySelector(`[data-value=\"${value}\"]`);\n }\n\n protected _isAutoClosing(el: HTMLElement): boolean {\n return el.classList.contains(\"autoclose\");\n }\n}\n\nregisterBinding(AccordionInputBinding, \"accordion\");\n"], "mappings": "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", "names": ["InputBinding", "registerBinding", "inputBindingClass", "name", "hasDefinedProperty", "obj", "prop", "AccordionInputBinding", "InputBinding", "scope", "el", "selected", "x", "callback", "event", "data", "method", "items", "vals", "targetItem", "panel", "val", "target", "hasDefinedProperty", "body", "header", "title", "icon", "collapse", "isOpen", "values", "value", "registerBinding"] } diff --git a/inst/components/card.scss b/inst/components/card.scss index 893338061..ec7ae2817 100644 --- a/inst/components/card.scss +++ b/inst/components/card.scss @@ -15,6 +15,10 @@ margin-bottom: 0; } } + .bslib-sidebar-layout { + border-top-left-radius: 0; + border-top-right-radius: 0; + } } .card-body { diff --git a/inst/components/sidebar.min.js b/inst/components/sidebar.min.js new file mode 100644 index 000000000..2b1b8a622 --- /dev/null +++ b/inst/components/sidebar.min.js @@ -0,0 +1,3 @@ +/*! bslib 0.4.2.9000 | (c) 2012-2023 RStudio, PBC. | License: MIT + file LICENSE */ +"use strict";(()=>{var d=window.Shiny?Shiny.InputBinding:class{};function a(n,e){window.Shiny&&Shiny.inputBindings.register(new n,"bslib."+e)}function l(n){if($(n).data("window-resize-observer"))return;let e=new Event("resize"),i=new ResizeObserver(()=>{window.dispatchEvent(e)});i.observe(n),$(n).data("window-resize-observer",i)}var s="sidebar-collapsed",o=class extends d{find(e){return $(e).find(".bslib-sidebar-layout > .bslib-sidebar-input")}getValue(e){return!$(e).parent().hasClass(s)}subscribe(e,i){$(e).on("toggleCollapse.sidebarInputBinding",function(t){i(!0)})}unsubscribe(e){$(e).off(".sidebarInputBinding")}receiveMessage(e,i){let t=i.method,r=$(e).parent();if(t==="open")r.removeClass(s);else if(t==="close")r.addClass(s);else throw new Error(`Unknown method ${t}`);$(e).trigger("toggleCollapse.sidebarInputBinding")}};a(o,"sidebar");$(document).on("click",".bslib-sidebar-layout .collapse-toggle",n=>{n.preventDefault();let e=$(n.target).closest(".bslib-sidebar-layout"),i=e.find(".sidebar");l(i[0]),e.toggleClass(s),i.trigger("toggleCollapse.sidebarInputBinding")});})(); +//# sourceMappingURL=sidebar.min.js.map diff --git a/inst/components/sidebar.min.js.map b/inst/components/sidebar.min.js.map new file mode 100644 index 000000000..cb0c138e5 --- /dev/null +++ b/inst/components/sidebar.min.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../../srcts/src/components/_utils.ts", "../../srcts/src/components/sidebar.ts"], + "sourcesContent": ["import type { HtmlDep } from \"rstudio-shiny/srcts/types/src/shiny/render\";\n\nimport type { InputBinding as InputBindingType } from \"rstudio-shiny/srcts/types/src/bindings/input\";\n\n// Exclude undefined from T\ntype NotUndefined = T extends undefined ? never : T;\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nconst InputBinding = (\n window.Shiny ? Shiny.InputBinding : class {}\n) as typeof InputBindingType;\n\nfunction registerBinding(\n inputBindingClass: new () => InputBindingType,\n name: string\n): void {\n if (window.Shiny) {\n Shiny.inputBindings.register(new inputBindingClass(), \"bslib.\" + name);\n }\n}\n\n// Return true if the key exists on the object and the value is not undefined.\n//\n// This method is mainly used in input bindings' `receiveMessage` method.\n// Since we know that the values are sent by Shiny via `{jsonlite}`,\n// then we know that there are no `undefined` values. `null` is possible, but not `undefined`.\nfunction hasDefinedProperty<\n Prop extends keyof X,\n X extends { [key: string]: any }\n>(\n obj: X,\n prop: Prop\n): obj is X & { [key in NonNullable]: NotUndefined } {\n return (\n Object.prototype.hasOwnProperty.call(obj, prop) && obj[prop] !== undefined\n );\n}\n\n// TODO: Shiny should trigger resize events when the output\n// https://github.com/rstudio/shiny/pull/3682\nfunction doWindowResizeOnElementResize(el: HTMLElement): void {\n if ($(el).data(\"window-resize-observer\")) {\n return;\n }\n const resizeEvent = new Event(\"resize\");\n const ro = new ResizeObserver(() => {\n window.dispatchEvent(resizeEvent);\n });\n ro.observe(el);\n $(el).data(\"window-resize-observer\", ro);\n}\n\nexport {\n InputBinding,\n registerBinding,\n hasDefinedProperty,\n doWindowResizeOnElementResize,\n};\nexport type { HtmlDep };\n", "import {\n InputBinding,\n registerBinding,\n doWindowResizeOnElementResize,\n} from \"./_utils\";\n\ntype MessageData = {\n method: \"close\" | \"open\";\n};\n\n// eslint-disable-next-line @typescript-eslint/naming-convention\nconst COLLAPSE_CLASS = \"sidebar-collapsed\";\n\nclass SidebarInputBinding extends InputBinding {\n find(scope: HTMLElement) {\n return $(scope).find(\".bslib-sidebar-layout > .bslib-sidebar-input\");\n }\n\n getValue(el: HTMLElement): boolean {\n return !$(el).parent().hasClass(COLLAPSE_CLASS);\n }\n\n subscribe(el: HTMLElement, callback: (x: boolean) => void) {\n $(el).on(\n \"toggleCollapse.sidebarInputBinding\",\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n function (event) {\n callback(true);\n }\n );\n }\n\n unsubscribe(el: HTMLElement) {\n $(el).off(\".sidebarInputBinding\");\n }\n\n receiveMessage(el: HTMLElement, data: MessageData) {\n const method = data.method;\n const $parent = $(el).parent();\n\n if (method === \"open\") {\n $parent.removeClass(COLLAPSE_CLASS);\n } else if (method === \"close\") {\n $parent.addClass(COLLAPSE_CLASS);\n } else {\n throw new Error(`Unknown method ${method}`);\n }\n\n $(el).trigger(\"toggleCollapse.sidebarInputBinding\");\n }\n}\n\nregisterBinding(SidebarInputBinding, \"sidebar\");\n\n$(document).on(\"click\", \".bslib-sidebar-layout .collapse-toggle\", (e) => {\n e.preventDefault();\n\n const $container = $(e.target).closest(\".bslib-sidebar-layout\"),\n $side = $container.find(\".sidebar\");\n\n // Make sure outputs resize properly when the sidebar is opened/closed\n doWindowResizeOnElementResize($side[0]);\n\n $container.toggleClass(COLLAPSE_CLASS);\n $side.trigger(\"toggleCollapse.sidebarInputBinding\");\n});\n"], + "mappings": ";mBAQA,IAAMA,EACJ,OAAO,MAAQ,MAAM,aAAe,KAAM,CAAC,EAG7C,SAASC,EACPC,EACAC,EACM,CACF,OAAO,OACT,MAAM,cAAc,SAAS,IAAID,EAAqB,SAAWC,CAAI,CAEzE,CAqBA,SAASC,EAA8BC,EAAuB,CAC5D,GAAI,EAAEA,CAAE,EAAE,KAAK,wBAAwB,EACrC,OAEF,IAAMC,EAAc,IAAI,MAAM,QAAQ,EAChCC,EAAK,IAAI,eAAe,IAAM,CAClC,OAAO,cAAcD,CAAW,CAClC,CAAC,EACDC,EAAG,QAAQF,CAAE,EACb,EAAEA,CAAE,EAAE,KAAK,yBAA0BE,CAAE,CACzC,CCvCA,IAAMC,EAAiB,oBAEjBC,EAAN,cAAkCC,CAAa,CAC7C,KAAKC,EAAoB,CACvB,OAAO,EAAEA,CAAK,EAAE,KAAK,8CAA8C,CACrE,CAEA,SAASC,EAA0B,CACjC,MAAO,CAAC,EAAEA,CAAE,EAAE,OAAO,EAAE,SAASJ,CAAc,CAChD,CAEA,UAAUI,EAAiBC,EAAgC,CACzD,EAAED,CAAE,EAAE,GACJ,qCAEA,SAAUE,EAAO,CACfD,EAAS,EAAI,CACf,CACF,CACF,CAEA,YAAYD,EAAiB,CAC3B,EAAEA,CAAE,EAAE,IAAI,sBAAsB,CAClC,CAEA,eAAeA,EAAiBG,EAAmB,CACjD,IAAMC,EAASD,EAAK,OACdE,EAAU,EAAEL,CAAE,EAAE,OAAO,EAE7B,GAAII,IAAW,OACbC,EAAQ,YAAYT,CAAc,UACzBQ,IAAW,QACpBC,EAAQ,SAAST,CAAc,MAE/B,OAAM,IAAI,MAAM,kBAAkBQ,GAAQ,EAG5C,EAAEJ,CAAE,EAAE,QAAQ,oCAAoC,CACpD,CACF,EAEAM,EAAgBT,EAAqB,SAAS,EAE9C,EAAE,QAAQ,EAAE,GAAG,QAAS,yCAA2CU,GAAM,CACvEA,EAAE,eAAe,EAEjB,IAAMC,EAAa,EAAED,EAAE,MAAM,EAAE,QAAQ,uBAAuB,EAC5DE,EAAQD,EAAW,KAAK,UAAU,EAGpCE,EAA8BD,EAAM,CAAC,CAAC,EAEtCD,EAAW,YAAYZ,CAAc,EACrCa,EAAM,QAAQ,oCAAoC,CACpD,CAAC", + "names": ["InputBinding", "registerBinding", "inputBindingClass", "name", "doWindowResizeOnElementResize", "el", "resizeEvent", "ro", "COLLAPSE_CLASS", "SidebarInputBinding", "InputBinding", "scope", "el", "callback", "event", "data", "method", "$parent", "registerBinding", "e", "$container", "$side", "doWindowResizeOnElementResize"] +} diff --git a/inst/components/sidebar.scss b/inst/components/sidebar.scss new file mode 100644 index 000000000..57f09f6c6 --- /dev/null +++ b/inst/components/sidebar.scss @@ -0,0 +1,134 @@ +$bslib-sidebar-padding: $spacer * 1.5 !default; +$bslib-sidebar-icon-size: $spacer * 1.25 !default; +$bslib-sidebar-border: $border-width $border-style $border-color !default; +$bslib-sidebar-transition: grid-template-columns ease-in-out 0.5s !default; + +.bslib-sidebar-layout { + --bslib-collapse-toggle-transform: 90deg; + + display: grid; + grid-template-columns: var(--bslib-sidebar-width) minmax(0, 1fr); + @include transition($bslib-sidebar-transition); + position: relative; + + border: var(--bslib-sidebar-border); + border-radius: var(--bslib-sidebar-border-radius); + + > .main, .sidebar { + overflow: auto; + } + + > .sidebar { + padding: $bslib-sidebar-padding; + width: var(--bslib-sidebar-width); + border-right: $bslib-sidebar-border; + border-top-left-radius: inherit; + border-bottom-left-radius: inherit; + + > .accordion { + margin: - $bslib-sidebar-padding; + @extend .accordion-flush; + .accordion-body { + display: flex; + flex-direction: column; + } + } + } + + > .main { + padding: $bslib-sidebar-padding; + z-index: 1; // Make sure main content is on top of sidebar during transition + border-top-right-radius: inherit; + border-bottom-right-radius: inherit; + } + + > .collapse-toggle { + grid-row: 1 / 2; + grid-column: 1 / 2; + position: absolute; + right: -$bslib-sidebar-icon-size; + bottom: $bslib-sidebar-icon-size; + display: inline-flex; + align-items: center; + border: $bslib-sidebar-border; + border-left: none; + border-radius: 0 $border-radius $border-radius 0; + z-index: $zindex-tooltip; + &::after { + width: $bslib-sidebar-icon-size; + height: $bslib-sidebar-icon-size; + content: ""; + background-image: #{escape-svg($accordion-button-icon)}; + background-repeat: no-repeat; + background-size: $bslib-sidebar-icon-size; + transform: rotate(var(--bslib-collapse-toggle-transform)); + } + } + + &.sidebar-collapsed { + grid-template-columns: 0px minmax(0, 1fr); + // Putting display: none on .sidebar would change the number of columns + // in the grid, and I don't think we can transition between those states + > .sidebar { + padding: 0; + border: none !important; + > * { + display: none; + } + } + + > .main { + border-radius: var(--bslib-sidebar-border-radius); + } + + > .collapse-toggle { + --bslib-collapse-toggle-transform: -90deg; + } + } + +} + +// TODO: transition between collapse states on mobile? +@include media-breakpoint-down(sm) { + .bslib-sidebar-layout { + grid-template-columns: 1fr; + --bslib-collapse-toggle-transform: -180deg; + + // Override `full_bleed=T`'s position:fixed, so that sidebar can be scrolled sensibly + position: relative !important; + inset: 0 !important; + + > .sidebar { + width: 100%; + border-right: none; + border-top-right-radius: var(--bslib-sidebar-border-radius); + border-bottom-left-radius: 0; + } + + > .main { + border-top: $bslib-sidebar-border; + border-top-right-radius: 0; + border-bottom-left-radius: var(--bslib-sidebar-border-radius); + border-bottom-right-radius: var(--bslib-sidebar-border-radius); + } + + > .collapse-toggle { + bottom: - $bslib-sidebar-icon-size; + left: $bslib-sidebar-icon-size; + right: initial; + border-top: none; + border-left: $bslib-sidebar-border; + border-radius: 0 0 $border-radius $border-radius; + } + + &.sidebar-collapsed { + > .collapse-toggle { + --bslib-collapse-toggle-transform: 0deg; + // Assuming the grid-template-columns from non-mobile .collapsed has precedence + grid-column: 2 / 3; + top: 0; + bottom: initial; + } + } + } +} diff --git a/man/card_body.Rd b/man/card_body.Rd index cd3bce3a1..c75704386 100644 --- a/man/card_body.Rd +++ b/man/card_body.Rd @@ -6,6 +6,7 @@ \alias{card_title} \alias{card_header} \alias{card_footer} +\alias{card_sidebar} \alias{card_image} \alias{as.card_item} \alias{is.card_item} @@ -28,6 +29,8 @@ card_header(..., class = NULL, container = htmltools::div) card_footer(..., class = NULL) +card_sidebar(sidebar = sidebar(), ..., border = FALSE) + card_image( file, ..., @@ -98,6 +101,10 @@ that its immediate children are allowed to grow and shrink to fit. \item \code{card_footer()}: A header (with border and background color) for the \code{card()}. Typically appears after a \code{card_body()}. +\item \code{card_sidebar()}: A \code{\link[=card_body_fill]{card_body_fill()}} with a \code{\link[=layout_sidebar]{layout_sidebar()}} inside +of it. All arguments to this function are passed along to +\code{\link[=layout_sidebar]{layout_sidebar()}}. + \item \code{card_image()}: Include static (i.e., pre-generated) images. \item \code{as.card_item()}: Mark an object as a card item. This will prevent the diff --git a/man/container.Rd b/man/container.Rd new file mode 100644 index 000000000..8a0d7c10c --- /dev/null +++ b/man/container.Rd @@ -0,0 +1,28 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/page.R +\name{container} +\alias{container} +\title{Contain, pad, and align content} +\usage{ +container( + ..., + size = c("sm", "md", "lg", "xl", "xxl", "fluid"), + bg = NULL, + class = NULL +) +} +\arguments{ +\item{...}{A collection of \code{\link[htmltools:builder]{htmltools::tag()}} children.} + +\item{size}{A size (i.e., max-width policy) for the container.} + +\item{bg}{A background color.} + +\item{class}{Additional CSS classes for the container.} +} +\description{ +Contain, pad, and align content +} +\references{ +\url{https://getbootstrap.com/docs/5.3/layout/containers/} +} diff --git a/man/sidebar.Rd b/man/sidebar.Rd new file mode 100644 index 000000000..366e00082 --- /dev/null +++ b/man/sidebar.Rd @@ -0,0 +1,75 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/sidebar.R +\name{sidebar} +\alias{sidebar} +\alias{layout_sidebar} +\alias{sidebar_open} +\alias{sidebar_close} +\title{Create various sidebar-based layouts} +\usage{ +sidebar( + ..., + width = 250, + collapsible = TRUE, + id = NULL, + bg = NULL, + class = NULL +) + +layout_sidebar( + sidebar = sidebar(), + ..., + full_bleed = FALSE, + fill = FALSE, + bg = "var(--bs-body-bg)", + border = !full_bleed, + border_radius = !full_bleed, + class = NULL +) + +sidebar_open(id, session = get_current_session()) + +sidebar_close(id, session = get_current_session()) +} +\arguments{ +\item{...}{A collection of \code{\link[htmltools:builder]{htmltools::tag()}} children to place in the main +content area.} + +\item{width}{A valid \link[htmltools:validateCssUnit]{CSS unit} used for the +width of the sidebar.} + +\item{collapsible}{Whether or not the sidebar should be collapsible.} + +\item{id}{A character string. Required if wanting to re-actively read (or +update) the \code{collapsible} state in a Shiny app.} + +\item{bg}{A background color.} + +\item{class}{Additional CSS classes for the top-level HTML element.} + +\item{sidebar}{A \code{\link[=sidebar]{sidebar()}} object.} + +\item{full_bleed}{whether or not to clip the layout container the entire viewport.} + +\item{fill}{whether or not the \code{main} content area should be considered a +fill (i.e., flexbox) container.} + +\item{border}{whether or not to add a border.} + +\item{border_radius}{whether or not to add a border radius.} +} +\description{ +Create various sidebar-based layouts +} +\section{Functions}{ +\itemize{ +\item \code{layout_sidebar()}: A 'low-level' sidebar layout + +\item \code{sidebar_open()}: Close a (\code{collapsible}) \code{\link[=sidebar]{sidebar()}}. + +\item \code{sidebar_close()}: Close a (\code{collapsible}) \code{\link[=sidebar]{sidebar()}}. + +}} +\seealso{ +\code{\link[=card_sidebar]{card_sidebar()}}, \code{\link[=container]{container()}}, \code{\link[=page_navbar]{page_navbar()}} +} diff --git a/man/value_box.Rd b/man/value_box.Rd index 6d3cf772c..b6e259e05 100644 --- a/man/value_box.Rd +++ b/man/value_box.Rd @@ -39,7 +39,7 @@ display below \code{value}.. Named arguments become attributes on the containing element.} \item{showcase}{a \code{\link[htmltools:builder]{htmltools::tag()}} child to showcase (e.g., a -\code{\link[bsicons:bs_icon]{bsicons::bs_icon()}}, a \code{\link[plotly:plotlyOutput]{plotly::plotlyOutput()}}, etc).} +\code{\link[bsicons:bs_icon]{bsicons::bs_icon()}}, a \code{\link[plotly:plotly-shiny]{plotly::plotlyOutput()}}, etc).} \item{showcase_layout}{either \code{showcase_left_center()} or \code{showcase_top_right()}.} diff --git a/srcts/build/index.ts b/srcts/build/index.ts index 9361b7e09..6ea535f21 100644 --- a/srcts/build/index.ts +++ b/srcts/build/index.ts @@ -19,3 +19,9 @@ build({ entryPoints: ["srcts/src/components/accordion.ts"], outfile: "inst/components/accordion.min.js", }); + +build({ + ...opts, + entryPoints: ["srcts/src/components/sidebar.ts"], + outfile: "inst/components/sidebar.min.js", +}); diff --git a/srcts/src/components/_utils.ts b/srcts/src/components/_utils.ts index 4731e3736..0d061d66d 100644 --- a/srcts/src/components/_utils.ts +++ b/srcts/src/components/_utils.ts @@ -36,5 +36,24 @@ function hasDefinedProperty< ); } -export { InputBinding, registerBinding, hasDefinedProperty }; +// TODO: Shiny should trigger resize events when the output +// https://github.com/rstudio/shiny/pull/3682 +function doWindowResizeOnElementResize(el: HTMLElement): void { + if ($(el).data("window-resize-observer")) { + return; + } + const resizeEvent = new Event("resize"); + const ro = new ResizeObserver(() => { + window.dispatchEvent(resizeEvent); + }); + ro.observe(el); + $(el).data("window-resize-observer", ro); +} + +export { + InputBinding, + registerBinding, + hasDefinedProperty, + doWindowResizeOnElementResize, +}; export type { HtmlDep }; diff --git a/srcts/src/components/sidebar.ts b/srcts/src/components/sidebar.ts new file mode 100644 index 000000000..f6e1ed097 --- /dev/null +++ b/srcts/src/components/sidebar.ts @@ -0,0 +1,66 @@ +import { + InputBinding, + registerBinding, + doWindowResizeOnElementResize, +} from "./_utils"; + +type MessageData = { + method: "close" | "open"; +}; + +// eslint-disable-next-line @typescript-eslint/naming-convention +const COLLAPSE_CLASS = "sidebar-collapsed"; + +class SidebarInputBinding extends InputBinding { + find(scope: HTMLElement) { + return $(scope).find(".bslib-sidebar-layout > .bslib-sidebar-input"); + } + + getValue(el: HTMLElement): boolean { + return !$(el).parent().hasClass(COLLAPSE_CLASS); + } + + subscribe(el: HTMLElement, callback: (x: boolean) => void) { + $(el).on( + "toggleCollapse.sidebarInputBinding", + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function (event) { + callback(true); + } + ); + } + + unsubscribe(el: HTMLElement) { + $(el).off(".sidebarInputBinding"); + } + + receiveMessage(el: HTMLElement, data: MessageData) { + const method = data.method; + const $parent = $(el).parent(); + + if (method === "open") { + $parent.removeClass(COLLAPSE_CLASS); + } else if (method === "close") { + $parent.addClass(COLLAPSE_CLASS); + } else { + throw new Error(`Unknown method ${method}`); + } + + $(el).trigger("toggleCollapse.sidebarInputBinding"); + } +} + +registerBinding(SidebarInputBinding, "sidebar"); + +$(document).on("click", ".bslib-sidebar-layout .collapse-toggle", (e) => { + e.preventDefault(); + + const $container = $(e.target).closest(".bslib-sidebar-layout"), + $side = $container.find(".sidebar"); + + // Make sure outputs resize properly when the sidebar is opened/closed + doWindowResizeOnElementResize($side[0]); + + $container.toggleClass(COLLAPSE_CLASS); + $side.trigger("toggleCollapse.sidebarInputBinding"); +});