diff --git a/CHANGELOG.md b/CHANGELOG.md index 2846b318f..7e4e7dd46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added support for creating modules using Shiny Express syntax, and using modules in Shiny Express apps. (#1220) +* `ui.page_*()` functions gain a `theme` argument that allows you to replace the Bootstrap CSS file with a new CSS file. `theme` can be a local CSS file, a URL, or a [shinyswatch](https://posit-dev.github.io/py-shinyswatch) theme. In Shiny Express apps, `theme` can be set via `express.ui.page_opts()`. (#1334) + ### Bug fixes ### Other changes diff --git a/shiny/api-examples/theme/_purgecss.py b/shiny/api-examples/theme/_purgecss.py new file mode 100644 index 000000000..1ae84a7d5 --- /dev/null +++ b/shiny/api-examples/theme/_purgecss.py @@ -0,0 +1,63 @@ +# This file is used to create a minimal Bootstrap CSS file based on the Minty +# Bootstwatch theme for use in the example apps. + +# NOTE: This script requires the `purgecss` package to be installed. +# You can install it with `npm install -g purgecss`. + +import shutil +import subprocess +from pathlib import Path + +import shinyswatch + +from shiny import ui + +app_ui = ui.page_sidebar( + ui.sidebar( + ui.input_numeric("n", "N", min=0, max=100, value=20), + title="Parameters", + ), + ui.h2("Output"), + ui.output_text_verbatim("txt"), + ui.markdown( + """ +**AI-generated filler text.** In the world of exotic fruits, the durian stands out with its spiky exterior and strong odor. Despite its divisive smell, many people are drawn to its rich, creamy texture and unique flavor profile. This tropical fruit is often referred to as the "king of fruits" in various Southeast Asian countries. + +Durians are known for their large size and thorn-covered husk, which requires careful handling. The flesh inside can vary in color from pale yellow to deep orange, with a custard-like consistency that melts in your mouth. Some describe its taste as a mix of sweet, savory, and creamy, while others find it overpowering and pungent. +""" + ), + shinyswatch.theme.minty, + title="Theme Example", +) + +# If __file__ is not defined, use the current working directory +if not globals().get("__file__"): + __file__ = Path.cwd() / "_purgecss.py" + +save_dir = Path(__file__).parent / "output" +if save_dir.exists(): + shutil.rmtree(save_dir) +save_dir.mkdir() +app_ui.save_html(save_dir / "index.html", include_version=False) + +purged_dir = Path(__file__).parent / "css" +if purged_dir.exists(): + shutil.rmtree(purged_dir) +purged_dir.mkdir(exist_ok=True) + +args = [ + "purgecss", + "--css", + "output/lib/shinyswatch-css/bootswatch.min.css", + "--content", + "output/index.html", + "--output", + "css", +] + +subprocess.run(args) + +(purged_dir / "bootswatch.min.css").rename(purged_dir / "bootswatch-minty.min.css") + +if True: + shutil.rmtree(save_dir) diff --git a/shiny/api-examples/theme/app-core-remote.py b/shiny/api-examples/theme/app-core-remote.py new file mode 100644 index 000000000..c1ea9b896 --- /dev/null +++ b/shiny/api-examples/theme/app-core-remote.py @@ -0,0 +1,28 @@ +from shiny import App, render, ui + +app_ui = ui.page_sidebar( + ui.sidebar( + ui.input_numeric("n", "N", min=0, max=100, value=20), + title="Parameters", + ), + ui.h2("Output"), + ui.output_text_verbatim("txt"), + ui.markdown( + """ +**AI-generated filler text.** In the world of exotic fruits, the durian stands out with its spiky exterior and strong odor. Despite its divisive smell, many people are drawn to its rich, creamy texture and unique flavor profile. This tropical fruit is often referred to as the "king of fruits" in various Southeast Asian countries. + +Durians are known for their large size and thorn-covered husk, which requires careful handling. The flesh inside can vary in color from pale yellow to deep orange, with a custard-like consistency that melts in your mouth. Some describe its taste as a mix of sweet, savory, and creamy, while others find it overpowering and pungent. +""" + ), + title="Theme Example", + theme="https://cdn.jsdelivr.net/npm/bootswatch@5.3.3/dist/sketchy/bootstrap.min.css", +) + + +def server(input, output, session): + @render.text + def txt(): + return f"n*2 is {input.n() * 2}" + + +app = App(app_ui, server) diff --git a/shiny/api-examples/theme/app-core-shinyswatch.py b/shiny/api-examples/theme/app-core-shinyswatch.py new file mode 100644 index 000000000..5924c07b9 --- /dev/null +++ b/shiny/api-examples/theme/app-core-shinyswatch.py @@ -0,0 +1,30 @@ +import shinyswatch + +from shiny import App, render, ui + +app_ui = ui.page_sidebar( + ui.sidebar( + ui.input_numeric("n", "N", min=0, max=100, value=20), + title="Parameters", + ), + ui.h2("Output"), + ui.output_text_verbatim("txt"), + ui.markdown( + """ +**AI-generated filler text.** In the world of exotic fruits, the durian stands out with its spiky exterior and strong odor. Despite its divisive smell, many people are drawn to its rich, creamy texture and unique flavor profile. This tropical fruit is often referred to as the "king of fruits" in various Southeast Asian countries. + +Durians are known for their large size and thorn-covered husk, which requires careful handling. The flesh inside can vary in color from pale yellow to deep orange, with a custard-like consistency that melts in your mouth. Some describe its taste as a mix of sweet, savory, and creamy, while others find it overpowering and pungent. +""" + ), + title="Theme Example", + theme=shinyswatch.theme.slate(), +) + + +def server(input, output, session): + @render.text + def txt(): + return f"n*2 is {input.n() * 2}" + + +app = App(app_ui, server) diff --git a/shiny/api-examples/theme/app-core.py b/shiny/api-examples/theme/app-core.py new file mode 100644 index 000000000..0b20378b8 --- /dev/null +++ b/shiny/api-examples/theme/app-core.py @@ -0,0 +1,32 @@ +from pathlib import Path + +from shiny import App, render, ui + +app_ui = ui.page_sidebar( + ui.sidebar( + ui.input_numeric("n", "N", min=0, max=100, value=20), + title="Parameters", + ), + ui.h2("Output"), + ui.output_text_verbatim("txt"), + ui.markdown( + """ +**AI-generated filler text.** In the world of exotic fruits, the durian stands out with its spiky exterior and strong odor. Despite its divisive smell, many people are drawn to its rich, creamy texture and unique flavor profile. This tropical fruit is often referred to as the "king of fruits" in various Southeast Asian countries. + +Durians are known for their large size and thorn-covered husk, which requires careful handling. The flesh inside can vary in color from pale yellow to deep orange, with a custard-like consistency that melts in your mouth. Some describe its taste as a mix of sweet, savory, and creamy, while others find it overpowering and pungent. +""" + ), + title="Theme Example", + # theme=shinyswatch.theme.slate, + # theme="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css", + theme=Path(__file__).parent / "css" / "bootswatch-minty.min.css", +) + + +def server(input, output, session): + @render.text + def txt(): + return f"n*2 is {input.n() * 2}" + + +app = App(app_ui, server) diff --git a/shiny/api-examples/theme/app-express-remote.py b/shiny/api-examples/theme/app-express-remote.py new file mode 100644 index 000000000..bc7457922 --- /dev/null +++ b/shiny/api-examples/theme/app-express-remote.py @@ -0,0 +1,25 @@ +from shiny.express import input, render, ui + +ui.page_opts( + title="Theme Example", + theme="https://cdn.jsdelivr.net/npm/bootswatch@5.3.3/dist/sketchy/bootstrap.min.css", +) + +with ui.sidebar(title="Parameters"): + ui.input_numeric("n", "N", min=0, max=100, value=20) + +ui.h2("Output") + + +@render.code +def txt(): + return f"n*2 is {input.n() * 2}" + + +ui.markdown( + """ +**AI-generated filler text.** In the world of exotic fruits, the durian stands out with its spiky exterior and strong odor. Despite its divisive smell, many people are drawn to its rich, creamy texture and unique flavor profile. This tropical fruit is often referred to as the "king of fruits" in various Southeast Asian countries. + +Durians are known for their large size and thorn-covered husk, which requires careful handling. The flesh inside can vary in color from pale yellow to deep orange, with a custard-like consistency that melts in your mouth. Some describe its taste as a mix of sweet, savory, and creamy, while others find it overpowering and pungent. +""" +) diff --git a/shiny/api-examples/theme/app-express-shinyswatch.py b/shiny/api-examples/theme/app-express-shinyswatch.py new file mode 100644 index 000000000..57bce768f --- /dev/null +++ b/shiny/api-examples/theme/app-express-shinyswatch.py @@ -0,0 +1,27 @@ +import shinyswatch + +from shiny.express import input, render, ui + +ui.page_opts( + title="Theme Example", + theme=shinyswatch.theme.slate, +) + +with ui.sidebar(title="Parameters"): + ui.input_numeric("n", "N", min=0, max=100, value=20) + +ui.h2("Output") + + +@render.code +def txt(): + return f"n*2 is {input.n() * 2}" + + +ui.markdown( + """ +**AI-generated filler text.** In the world of exotic fruits, the durian stands out with its spiky exterior and strong odor. Despite its divisive smell, many people are drawn to its rich, creamy texture and unique flavor profile. This tropical fruit is often referred to as the "king of fruits" in various Southeast Asian countries. + +Durians are known for their large size and thorn-covered husk, which requires careful handling. The flesh inside can vary in color from pale yellow to deep orange, with a custard-like consistency that melts in your mouth. Some describe its taste as a mix of sweet, savory, and creamy, while others find it overpowering and pungent. +""" +) diff --git a/shiny/api-examples/theme/app-express.py b/shiny/api-examples/theme/app-express.py new file mode 100644 index 000000000..ff777fcd5 --- /dev/null +++ b/shiny/api-examples/theme/app-express.py @@ -0,0 +1,27 @@ +from pathlib import Path + +from shiny.express import input, render, ui + +ui.page_opts( + title="Theme Example", + theme=Path(__file__).parent / "css" / "bootswatch-minty.min.css", +) + +with ui.sidebar(title="Parameters"): + ui.input_numeric("n", "N", min=0, max=100, value=20) + +ui.h2("Output") + + +@render.code +def txt(): + return f"n*2 is {input.n() * 2}" + + +ui.markdown( + """ +**AI-generated filler text.** In the world of exotic fruits, the durian stands out with its spiky exterior and strong odor. Despite its divisive smell, many people are drawn to its rich, creamy texture and unique flavor profile. This tropical fruit is often referred to as the "king of fruits" in various Southeast Asian countries. + +Durians are known for their large size and thorn-covered husk, which requires careful handling. The flesh inside can vary in color from pale yellow to deep orange, with a custard-like consistency that melts in your mouth. Some describe its taste as a mix of sweet, savory, and creamy, while others find it overpowering and pungent. +""" +) diff --git a/shiny/api-examples/theme/css/bootswatch-minty.min.css b/shiny/api-examples/theme/css/bootswatch-minty.min.css new file mode 100644 index 000000000..297eb5f78 --- /dev/null +++ b/shiny/api-examples/theme/css/bootswatch-minty.min.css @@ -0,0 +1,8 @@ +/* {bslib} version: Github (rstudio/bslib@2c87d0c8753d003fef66858235ead8c9206aefa9) */ +/* bw: 5: 5 */ +/* bsw5 theme: minty */ +@import url("https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;700&display=swap");:root{--bslib-bootstrap-version: 5;--bslib-preset-name: minty;--bslib-preset-type: bootswatch}/*! + * Bootstrap v5.3.1 (https://getbootstrap.com/) + * Copyright 2011-2023 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root{--bs-blue: #007bff;--bs-indigo: #6610f2;--bs-purple: #6f42c1;--bs-pink: #e83e8c;--bs-red: #ff7851;--bs-orange: #fd7e14;--bs-yellow: #ffce67;--bs-green: #56cc9d;--bs-teal: #20c997;--bs-cyan: #6cc3d5;--bs-black: #000;--bs-white: #fff;--bs-gray: #888;--bs-gray-dark: #343a40;--bs-gray-100: #f8f9fa;--bs-gray-200: #f7f7f9;--bs-gray-300: #eceeef;--bs-gray-400: #ced4da;--bs-gray-500: #aaa;--bs-gray-600: #888;--bs-gray-700: #5a5a5a;--bs-gray-800: #343a40;--bs-gray-900: #212529;--bs-default: #f3969a;--bs-primary: #78c2ad;--bs-secondary: #f3969a;--bs-success: #56cc9d;--bs-info: #6cc3d5;--bs-warning: #ffce67;--bs-danger: #ff7851;--bs-light: #f8f9fa;--bs-dark: #343a40;--bs-default-rgb: 243,150,154;--bs-primary-rgb: 120,194,173;--bs-secondary-rgb: 243,150,154;--bs-success-rgb: 86,204,157;--bs-info-rgb: 108,195,213;--bs-warning-rgb: 255,206,103;--bs-danger-rgb: 255,120,81;--bs-light-rgb: 248,249,250;--bs-dark-rgb: 52,58,64;--bs-primary-text-emphasis: #304e45;--bs-secondary-text-emphasis: #613c3e;--bs-success-text-emphasis: #22523f;--bs-info-text-emphasis: #2b4e55;--bs-warning-text-emphasis: #665229;--bs-danger-text-emphasis: #663020;--bs-light-text-emphasis: #5a5a5a;--bs-dark-text-emphasis: #5a5a5a;--bs-primary-bg-subtle: #e4f3ef;--bs-secondary-bg-subtle: #fdeaeb;--bs-success-bg-subtle: #ddf5eb;--bs-info-bg-subtle: #e2f3f7;--bs-warning-bg-subtle: #fff5e1;--bs-danger-bg-subtle: #ffe4dc;--bs-light-bg-subtle: #fcfcfd;--bs-dark-bg-subtle: #ced4da;--bs-primary-border-subtle: #c9e7de;--bs-secondary-border-subtle: #fad5d7;--bs-success-border-subtle: #bbebd8;--bs-info-border-subtle: #c4e7ee;--bs-warning-border-subtle: #ffebc2;--bs-danger-border-subtle: #ffc9b9;--bs-light-border-subtle: #f7f7f9;--bs-dark-border-subtle: #aaa;--bs-white-rgb: 255,255,255;--bs-black-rgb: 0,0,0;--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--bs-gradient: linear-gradient(180deg, rgba(255,255,255,0.15), rgba(255,255,255,0));--bs-body-font-family: var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight: 400;--bs-body-line-height: 1.5;--bs-body-color: #5a5a5a;--bs-body-color-rgb: 90,90,90;--bs-body-bg: #fff;--bs-body-bg-rgb: 255,255,255;--bs-emphasis-color: #000;--bs-emphasis-color-rgb: 0,0,0;--bs-secondary-color: rgba(90,90,90,0.75);--bs-secondary-color-rgb: 90,90,90;--bs-secondary-bg: #f7f7f9;--bs-secondary-bg-rgb: 247,247,249;--bs-tertiary-color: rgba(90,90,90,0.5);--bs-tertiary-color-rgb: 90,90,90;--bs-tertiary-bg: #f8f9fa;--bs-tertiary-bg-rgb: 248,249,250;--bs-heading-color: #5a5a5a;--bs-link-color: #78c2ad;--bs-link-color-rgb: 120,194,173;--bs-link-decoration: underline;--bs-link-hover-color: #609b8a;--bs-link-hover-color-rgb: 96,155,138;--bs-code-color: RGB(var(--bs-emphasis-color-rgb, 0, 0, 0));--bs-highlight-bg: #fff5e1;--bs-border-width: 1px;--bs-border-style: solid;--bs-border-color: #eceeef;--bs-border-color-translucent: rgba(0,0,0,0.175);--bs-border-radius: .4rem;--bs-border-radius-sm: .3rem;--bs-border-radius-lg: .6rem;--bs-border-radius-xl: 1rem;--bs-border-radius-xxl: 2rem;--bs-border-radius-2xl: var(--bs-border-radius-xxl);--bs-border-radius-pill: 50rem;--bs-box-shadow: 0 0.5rem 1rem rgba(0,0,0,0.15);--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0,0,0,0.075);--bs-box-shadow-lg: 0 1rem 3rem rgba(0,0,0,0.175);--bs-box-shadow-inset: inset 0 1px 2px rgba(0,0,0,0.075);--bs-focus-ring-width: .25rem;--bs-focus-ring-opacity: .25;--bs-focus-ring-color: rgba(120,194,173,0.25);--bs-form-valid-color: #56cc9d;--bs-form-valid-border-color: #56cc9d;--bs-form-invalid-color: #ff7851;--bs-form-invalid-border-color: #ff7851}*,*::before,*::after{box-sizing:border-box}@media (prefers-reduced-motion: no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:rgba(0,0,0,0)}h2,.h2,h1,.h1{margin-top:0;margin-bottom:.5rem;font-family:Montserrat,-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;font-weight:500;line-height:1.2;color:var(--bs-heading-color)}h1,.h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width: 1200px){h1,.h1{font-size:2.5rem}}h2,.h2{font-size:calc(1.325rem + .9vw)}@media (min-width: 1200px){h2,.h2{font-size:2rem}}p{margin-top:0;margin-bottom:1rem}strong{font-weight:bolder}a{color:rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));text-decoration:underline;-webkit-text-decoration:underline;-moz-text-decoration:underline;-ms-text-decoration:underline;-o-text-decoration:underline}a:hover{--bs-link-color-rgb: var(--bs-link-hover-color-rgb)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}pre{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em;color:RGB(var(--bs-emphasis-color-rgb, 0, 0, 0));background-color:RGBA(var(--bs-emphasis-color-rgb, 0, 0, 0), 0.04);padding:.5rem;border:1px solid var(--bs-border-color, #eceeef);border-radius:.4rem}img,svg{vertical-align:middle}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}input,button{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button{text-transform:none}[role="button"]{cursor:pointer}button,[type="button"]{-webkit-appearance:button}button:not(:disabled),[type="button"]:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-text,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit;-webkit-appearance:button}.container-fluid{--bs-gutter-x: 1.5rem;--bs-gutter-y: 0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}:root{--bs-breakpoint-xs: 0;--bs-breakpoint-sm: 576px;--bs-breakpoint-md: 768px;--bs-breakpoint-lg: 992px;--bs-breakpoint-xl: 1200px;--bs-breakpoint-xxl: 1400px}.shiny-input-container .control-label{margin-bottom:.5rem}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);appearance:none;-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;background-color:var(--bs-body-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.form-control{transition:none}}.form-control:focus{color:var(--bs-body-color);background-color:var(--bs-body-bg);border-color:#bce1d6;outline:0;box-shadow:0 0 0 .25rem rgba(120,194,173,0.25)}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::placeholder{color:var(--bs-secondary-color);opacity:1}.form-control:disabled{background-color:var(--bs-secondary-bg);opacity:1}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out}@media (prefers-reduced-motion: reduce){.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--bs-secondary-bg)}.navbar{--bs-navbar-padding-x: 0;--bs-navbar-padding-y: .5rem;--bs-navbar-color: rgba(var(--bs-emphasis-color-rgb), 0.65);--bs-navbar-hover-color: rgba(var(--bs-emphasis-color-rgb), 0.8);--bs-navbar-disabled-color: rgba(var(--bs-emphasis-color-rgb), 0.3);--bs-navbar-active-color: rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-padding-y: .3125rem;--bs-navbar-brand-margin-end: 1rem;--bs-navbar-brand-font-size: 1.25rem;--bs-navbar-brand-color: rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-hover-color: rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-nav-link-padding-x: .5rem;--bs-navbar-toggler-padding-y: .25rem;--bs-navbar-toggler-padding-x: .75rem;--bs-navbar-toggler-font-size: 1.25rem;--bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2890,90,90,0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color: rgba(var(--bs-emphasis-color-rgb), 0.15);--bs-navbar-toggler-border-radius: var(--bs-border-radius);--bs-navbar-toggler-focus-width: .25rem;--bs-navbar-toggler-transition: box-shadow 0.15s ease-in-out;position:relative;display:flex;display:-webkit-flex;flex-wrap:wrap;-webkit-flex-wrap:wrap;align-items:center;-webkit-align-items:center;justify-content:space-between;-webkit-justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container-fluid{display:flex;display:-webkit-flex;flex-wrap:inherit;-webkit-flex-wrap:inherit;align-items:center;-webkit-align-items:center;justify-content:space-between;-webkit-justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;-webkit-text-decoration:none;-moz-text-decoration:none;-ms-text-decoration:none;-o-text-decoration:none;white-space:nowrap}.navbar-brand:hover,.navbar-brand:focus{color:var(--bs-navbar-brand-hover-color)}@media (min-width: 992px){.navbar:not(.navbar-expand):not(.navbar-expand-sm):not(.navbar-expand-md):not(.navbar-expand-lg):not(.navbar-expand-xl){flex-wrap:nowrap;-webkit-flex-wrap:nowrap;justify-content:flex-start;-webkit-justify-content:flex-start}}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}@keyframes spinner-border{to{transform:rotate(360deg) /* rtl:ignore */}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes placeholder-glow{50%{opacity:.2}}@keyframes placeholder-wave{100%{mask-position:-200% 0%;-webkit-mask-position:-200% 0%}}@font-face{font-family:'Glyphicons Halflings';src:url("fonts/bootstrap/glyphicons-halflings-regular.eot");src:url("fonts/bootstrap/glyphicons-halflings-regular.eot?#iefix") format("embedded-opentype"),url("fonts/bootstrap/glyphicons-halflings-regular.woff2") format("woff2"),url("fonts/bootstrap/glyphicons-halflings-regular.woff") format("woff"),url("fonts/bootstrap/glyphicons-halflings-regular.ttf") format("truetype"),url("fonts/bootstrap/glyphicons-halflings-regular.svg#glyphicons_halflingsregular") format("svg")}.form-group{margin-bottom:1rem}:root{--bslib-spacer: 1rem;--bslib-mb-spacer: var(--bslib-spacer, 1rem)}.bslib-mb-spacing{margin-bottom:var(--bslib-mb-spacer)}.bslib-gap-spacing{gap:var(--bslib-mb-spacer)}.bslib-gap-spacing>.bslib-mb-spacing,.bslib-gap-spacing>.form-group,.bslib-gap-spacing>p,.bslib-gap-spacing>pre{margin-bottom:0}.html-fill-container>.html-fill-item.bslib-mb-spacing{margin-bottom:0}.navbar{font-family:Montserrat,-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif}.bslib-page-sidebar{--bslib-page-sidebar-title-bg: #f8f9fa;--bslib-page-sidebar-title-color: #5a5a5a} diff --git a/shiny/experimental/ui/_deprecated.py b/shiny/experimental/ui/_deprecated.py index e6c98b051..64c68c5dd 100644 --- a/shiny/experimental/ui/_deprecated.py +++ b/shiny/experimental/ui/_deprecated.py @@ -1434,6 +1434,7 @@ def page_sidebar( fillable_mobile=fillable_mobile, window_title=window_title, lang=lang, + theme=None, **kwargs, ) @@ -1510,5 +1511,6 @@ def page_fillable( fillable_mobile=fillable_mobile, title=title, lang=lang, + theme=None, **kwargs, ) diff --git a/shiny/express/ui/_page.py b/shiny/express/ui/_page.py index 6dd5b4ac5..99e03469e 100644 --- a/shiny/express/ui/_page.py +++ b/shiny/express/ui/_page.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import Path from typing import Callable from htmltools import Tag @@ -7,6 +8,7 @@ from ... import ui from ..._docstring import add_example from ...types import MISSING, MISSING_TYPE +from ...ui._html_deps_external import ThemeProvider from .._recall_context import RecallContextManager from .._run import get_top_level_recall_context_manager @@ -23,6 +25,7 @@ def page_opts( title: str | MISSING_TYPE = MISSING, window_title: str | MISSING_TYPE = MISSING, lang: str | MISSING_TYPE = MISSING, + theme: str | Path | ThemeProvider | MISSING_TYPE = MISSING, page_fn: Callable[..., Tag] | None | MISSING_TYPE = MISSING, fillable: bool | MISSING_TYPE = MISSING, full_width: bool | MISSING_TYPE = MISSING, @@ -57,6 +60,16 @@ def page_opts( ISO 639-1 language code for the HTML page, such as ``"en"`` or ``"ko"``. This will be used as the lang in the ```` tag, as in ````. The default, `None`, results in an empty string. + theme + A path to a local or online CSS file that will replace the Bootstrap CSS + bundled by default with a Shiny app. This file should be a complete + `bootstrap.css` or `bootstrap.min.css` file. + + For advanced uses, you can also pass a :class:`~htmltools.Tagifiable` object. + In this case, Shiny will suppress the default Bootstrap CSS. + + To modify the theme of an app without replacing the Bootstrap CSS entirely, use + :func:`~shiny.ui.include_css` to add custom CSS. fillable If there is a top-level sidebar or nav, then the value is passed through to the :func:`~shiny.ui.page_sidebar` or :func:`~shiny.ui.page_navbar` function. @@ -90,6 +103,8 @@ def page_opts( cm.kwargs["window_title"] = window_title if not isinstance(lang, MISSING_TYPE): cm.kwargs["lang"] = lang + if not isinstance(theme, MISSING_TYPE): + cm.kwargs["theme"] = theme if not isinstance(page_fn, MISSING_TYPE): cm.kwargs["page_fn"] = page_fn if not isinstance(fillable, MISSING_TYPE): diff --git a/shiny/ui/_html_deps_external.py b/shiny/ui/_html_deps_external.py index c4f1ea8f5..dbd21baa0 100644 --- a/shiny/ui/_html_deps_external.py +++ b/shiny/ui/_html_deps_external.py @@ -1,10 +1,15 @@ from __future__ import annotations -from htmltools import HTML, HTMLDependency +from pathlib import Path +from typing import List, Union + +from htmltools import HTML, HTMLDependency, Tagifiable, TagList, head_content +from htmltools.tags import link from .._versions import bootstrap as bootstrap_version from .._versions import shiny_html_deps from ..html_dependencies import jquery_deps +from ._include_helpers import check_path, include_css """ HTML dependencies for external dependencies Bootstrap, ionrangeslider, datepicker, selectize, and jQuery UI. @@ -15,14 +20,43 @@ * shinyverse dependencies (e.g. bslib, htmltools), see `shiny.ui._html_deps_shinyverse` """ +ThemeProvider = Union[Tagifiable, HTMLDependency, List[HTMLDependency]] + + +def bootstrap_theme_deps(theme: str | Path | ThemeProvider | None) -> TagList: + deps_bootstrap = bootstrap_deps(include_css=theme is None) + + if theme is None: + deps_theme = None + elif isinstance(theme, str) and theme.startswith(("http", "//")): + deps_theme = head_content(link(rel="stylesheet", href=theme, type="text/css")) + elif isinstance(theme, (str, Path)): + check_path(theme) + deps_theme = head_content(include_css(theme)) + elif isinstance(theme, Tagifiable) or isinstance(theme, HTMLDependency): + deps_theme = theme + elif isinstance(theme, list) and all( + [isinstance(dep, HTMLDependency) for dep in theme] + ): + deps_theme = theme + else: + raise ValueError( + "Invalid `theme`. " + + "Expected a URL or path to a full Bootstrap CSS file, " + + "or a theme provider, " + + f"but received `theme` with type {type(theme)}." + ) + + return TagList(deps_bootstrap, deps_theme) + -def bootstrap_deps() -> list[HTMLDependency]: +def bootstrap_deps(include_css: bool = True) -> list[HTMLDependency]: dep = HTMLDependency( name="bootstrap", version=bootstrap_version, source={"package": "shiny", "subdir": "www/shared/bootstrap/"}, script={"src": "bootstrap.bundle.min.js"}, - stylesheet={"href": "bootstrap.min.css"}, + stylesheet={"href": "bootstrap.min.css"} if include_css else None, meta={"name": "viewport", "content": "width=device-width, initial-scale=1"}, ) deps = [jquery_deps(), dep] diff --git a/shiny/ui/_page.py b/shiny/ui/_page.py index 3f4d98a54..12fc2cf22 100644 --- a/shiny/ui/_page.py +++ b/shiny/ui/_page.py @@ -1,5 +1,7 @@ from __future__ import annotations +from pathlib import Path + __all__ = ( "page_sidebar", "page_navbar", @@ -31,7 +33,7 @@ from .._namespaces import resolve_id_or_none from ..types import MISSING, MISSING_TYPE, NavSetArg from ._bootstrap import panel_title -from ._html_deps_external import bootstrap_deps +from ._html_deps_external import ThemeProvider, bootstrap_theme_deps from ._html_deps_py_shiny import page_output_dependency from ._html_deps_shinyverse import components_dependencies from ._navs import NavMenu, NavPanel, navset_bar @@ -53,6 +55,7 @@ def page_sidebar( fillable_mobile: bool = False, window_title: str | MISSING_TYPE = MISSING, lang: Optional[str] = None, + theme: Optional[str | Path | ThemeProvider] = None, **kwargs: TagAttrValue, ) -> Tag: """ @@ -78,6 +81,16 @@ def page_sidebar( ISO 639-1 language code for the HTML page, such as ``"en"`` or ``"ko"``. This will be used as the lang in the ```` tag, as in ````. The default, `None`, results in an empty string. + theme + A path to a local or online CSS file that will replace the Bootstrap CSS + bundled by default with a Shiny app. This file should be a complete + `bootstrap.css` or `bootstrap.min.css` file. + + For advanced uses, you can also pass a :class:`~htmltools.Tagifiable` object. + In this case, Shiny will suppress the default Bootstrap CSS. + + To modify the theme of an app without replacing the Bootstrap CSS entirely, use + :func:`~shiny.ui.include_css` to add custom CSS. **kwargs Additional attributes passed to :func:`~shiny.ui.layout_sidebar`. @@ -124,6 +137,7 @@ def page_sidebar( padding=0, gap=0, lang=lang, + theme=theme, fillable_mobile=fillable_mobile, ) @@ -151,6 +165,7 @@ def page_navbar( fluid: bool = True, window_title: str | MISSING_TYPE = MISSING, lang: Optional[str] = None, + theme: Optional[str | Path | ThemeProvider] = None, ) -> Tag: """ Create a page with a navbar and a title. @@ -200,6 +215,16 @@ def page_navbar( ISO 639-1 language code for the HTML page, such as ``"en"`` or ``"ko"``. This will be used as the lang in the ```` tag, as in ````. The default, `None`, results in an empty string. + theme + A path to a local or online CSS file that will replace the Bootstrap CSS + bundled by default with a Shiny app. This file should be a complete + `bootstrap.css` or `bootstrap.min.css` file. + + For advanced uses, you can also pass a :class:`~htmltools.Tagifiable` object. + In this case, Shiny will suppress the default Bootstrap CSS. + + To modify the theme of an app without replacing the Bootstrap CSS entirely, use + :func:`~shiny.ui.include_css` to add custom CSS. Returns ------- @@ -264,6 +289,7 @@ def page_navbar( if fillable is False and sidebar is None: return page_bootstrap( *page_args, + theme=theme, **page_kwargs, ) @@ -273,6 +299,7 @@ def page_navbar( fillable_mobile=fillable_mobile, padding=0, gap=0, + theme=theme, **page_kwargs, ) @@ -285,6 +312,7 @@ def page_fillable( fillable_mobile: bool = False, title: Optional[str] = None, lang: Optional[str] = None, + theme: Optional[str | Path | ThemeProvider] = None, **kwargs: TagAttrValue, ) -> Tag: """ @@ -310,6 +338,16 @@ def page_fillable( ISO 639-1 language code for the HTML page, such as ``"en"`` or ``"ko"``. This will be used as the lang in the ```` tag, as in ````. The default, `None`, results in an empty string. + theme + A path to a local or online CSS file that will replace the Bootstrap CSS + bundled by default with a Shiny app. This file should be a complete + `bootstrap.css` or `bootstrap.min.css` file. + + For advanced uses, you can also pass a :class:`~htmltools.Tagifiable` object. + In this case, Shiny will suppress the default Bootstrap CSS. + + To modify the theme of an app without replacing the Bootstrap CSS entirely, use + :func:`~shiny.ui.include_css` to add custom CSS. Returns ------- @@ -334,6 +372,7 @@ def page_fillable( components_dependencies(), title=title, lang=lang, + theme=theme, ) # page returns a tag, but we need to make the fillable @@ -351,6 +390,7 @@ def page_fluid( *args: TagChild | TagAttrs, title: Optional[str] = None, lang: Optional[str] = None, + theme: Optional[str | Path | ThemeProvider] = None, **kwargs: str, ) -> Tag: """ @@ -367,6 +407,16 @@ def page_fluid( ISO 639-1 language code for the HTML page, such as ``"en"`` or ``"ko"``. This will be used as the lang in the ```` tag, as in ````. The default, `None`, results in an empty string. + theme + A path to a local or online CSS file that will replace the Bootstrap CSS + bundled by default with a Shiny app. This file should be a complete + `bootstrap.css` or `bootstrap.min.css` file. + + For advanced uses, you can also pass a :class:`~htmltools.Tagifiable` object. + In this case, Shiny will suppress the default Bootstrap CSS. + + To modify the theme of an app without replacing the Bootstrap CSS entirely, use + :func:`~shiny.ui.include_css` to add custom CSS. **kwargs Attributes on the page level container. @@ -383,7 +433,10 @@ def page_fluid( """ return page_bootstrap( - div({"class": "container-fluid"}, *args, **kwargs), title=title, lang=lang + div({"class": "container-fluid"}, *args, **kwargs), + title=title, + lang=lang, + theme=theme, ) @@ -392,6 +445,7 @@ def page_fixed( *args: TagChild | TagAttrs, title: Optional[str] = None, lang: Optional[str] = None, + theme: Optional[str | Path | ThemeProvider] = None, **kwargs: str, ) -> Tag: """ @@ -408,6 +462,16 @@ def page_fixed( ISO 639-1 language code for the HTML page, such as ``"en"`` or ``"ko"``. This will be used as the lang in the ```` tag, as in ````. The default, `None`, results in an empty string. + theme + A path to a local or online CSS file that will replace the Bootstrap CSS + bundled by default with a Shiny app. This file should be a complete + `bootstrap.css` or `bootstrap.min.css` file. + + For advanced uses, you can also pass a :class:`~htmltools.Tagifiable` object. + In this case, Shiny will suppress the default Bootstrap CSS. + + To modify the theme of an app without replacing the Bootstrap CSS entirely, use + :func:`~shiny.ui.include_css` to add custom CSS. **kwargs Attributes on the page level container. @@ -424,7 +488,10 @@ def page_fixed( """ return page_bootstrap( - div({"class": "container"}, *args, **kwargs), title=title, lang=lang + div({"class": "container"}, *args, **kwargs), + title=title, + lang=lang, + theme=theme, ) @@ -434,6 +501,7 @@ def page_bootstrap( *args: TagChild | TagAttrs, title: Optional[str] = None, lang: Optional[str] = None, + theme: Optional[str | Path | ThemeProvider] = None, **kwargs: TagAttrValue, ) -> Tag: """ @@ -450,6 +518,16 @@ def page_bootstrap( ISO 639-1 language code for the HTML page, such as ``"en"`` or ``"ko"``. This will be used as the lang in the ```` tag, as in ````. The default, `None`, results in an empty string. + theme + A path to a local or online CSS file that will replace the Bootstrap CSS + bundled by default with a Shiny app. This file should be a complete + `bootstrap.css` or `bootstrap.min.css` file. + + For advanced uses, you can also pass a :class:`~htmltools.Tagifiable` object. + In this case, Shiny will suppress the default Bootstrap CSS. + + To modify the theme of an app without replacing the Bootstrap CSS entirely, use + :func:`~shiny.ui.include_css` to add custom CSS. **kwargs Attributes on the the `` tag. @@ -466,7 +544,7 @@ def page_bootstrap( head = tags.title(title) if title else None return tags.html( tags.head(head), - tags.body(*bootstrap_deps(), *args, **kwargs), + tags.body(bootstrap_theme_deps(theme), *args, **kwargs), lang=lang, ) @@ -477,6 +555,7 @@ def page_auto( title: str | MISSING_TYPE = MISSING, window_title: str | MISSING_TYPE = MISSING, lang: str | MISSING_TYPE = MISSING, + theme: str | Path | ThemeProvider | MISSING_TYPE = MISSING, fillable: bool | MISSING_TYPE = MISSING, full_width: bool = False, page_fn: Callable[..., Tag] | None = None, @@ -510,6 +589,16 @@ def page_auto( ISO 639-1 language code for the HTML page, such as ``"en"`` or ``"ko"``. This will be used as the lang in the ```` tag, as in ````. The default, `None`, results in an empty string. + theme + A path to a local or online CSS file that will replace the Bootstrap CSS + bundled by default with a Shiny app. This file should be a complete + `bootstrap.css` or `bootstrap.min.css` file. + + For advanced uses, you can also pass a :class:`~htmltools.Tagifiable` object. + In this case, Shiny will suppress the default Bootstrap CSS. + + To modify the theme of an app without replacing the Bootstrap CSS entirely, use + :func:`~shiny.ui.include_css` to add custom CSS. fillable If there is a top-level sidebar or nav, then the value is passed through to the :func:`~shiny.ui.page_sidebar` or :func:`~shiny.ui.page_navbar` function. @@ -538,6 +627,8 @@ def page_auto( kwargs["window_title"] = window_title if not isinstance(lang, MISSING_TYPE): kwargs["lang"] = lang + if not isinstance(theme, MISSING_TYPE): + kwargs["theme"] = theme # Presence of a top-level nav items and/or sidebar determines the page function navs = [x for x in args if isinstance(x, (NavPanel, NavMenu))]