Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### New features

* Added `ui.input_dark_mode()`, a toggle switch that allows users to switch between light and dark mode. By default, when `ui.input_dark_mode()` is added to an app, the app's color mode follows the users's system preferences, unless the app author sets the `mode` argument. When `ui.input_dark_mode(id=)` is set, the color mode is reported to the server, and server-side color mode updating is possible using `ui.update_dark_mode()`. (#1149)

* `ui.sidebar(open=)` now accepts a dictionary with keys `desktop` and `mobile`, allowing you to independently control the initial state of the sidebar at desktop and mobile screen sizes. (#1129)

### Other changes
Expand Down
2 changes: 2 additions & 0 deletions docs/_quartodoc-core.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ quartodoc:
- ui.input_select
- ui.input_selectize
- ui.input_slider
- ui.input_dark_mode
- ui.input_date
- ui.input_date_range
- ui.input_checkbox
Expand Down Expand Up @@ -123,6 +124,7 @@ quartodoc:
dynamic: true
- name: ui.update_slider
dynamic: true
- ui.update_dark_mode
- ui.update_date
- name: ui.update_date_range
dynamic: true
Expand Down
2 changes: 2 additions & 0 deletions docs/_quartodoc-express.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ quartodoc:
- express.ui.input_select
- express.ui.input_selectize
- express.ui.input_slider
- express.ui.input_dark_mode
- express.ui.input_date
- express.ui.input_date_range
- express.ui.input_checkbox
Expand Down Expand Up @@ -104,6 +105,7 @@ quartodoc:
dynamic: true
- name: express.ui.update_slider
dynamic: true
- express.ui.update_dark_mode
- ui.update_date
- name: express.ui.update_date_range
dynamic: true
Expand Down
68 changes: 68 additions & 0 deletions shiny/api-examples/input_dark_mode/app-core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import matplotlib.pyplot as plt
import numpy as np

from shiny import App, Inputs, Outputs, Session, reactive, render, ui

app_ui = ui.page_navbar(
ui.nav_panel(
"One",
ui.layout_sidebar(
ui.sidebar(
ui.input_slider("n", "N", min=0, max=100, value=20),
),
ui.output_plot("plot"),
),
),
ui.nav_panel(
"Two",
ui.layout_column_wrap(
ui.card("Second page content."),
ui.card(
ui.card_header("Server-side color mode setting"),
ui.input_action_button("make_light", "Switch to light mode"),
ui.input_action_button("make_dark", "Switch to dark mode"),
),
),
),
ui.nav_spacer(),
ui.nav_control(ui.input_dark_mode(id="mode")),
title="Shiny Dark Mode",
id="page",
fillable="One",
)


def server(input: Inputs, output: Outputs, session: Session):
@reactive.effect
@reactive.event(input.make_light)
async def _():
await ui.update_dark_mode("light")

@reactive.effect
@reactive.event(input.make_dark)
async def _():
await ui.update_dark_mode("dark")

@render.plot(alt="A histogram")
def plot() -> object:
np.random.seed(19680801)
x = 100 + 15 * np.random.randn(437)

fig, ax = plt.subplots()
ax.hist(x, input.n(), density=True)

# Theme the plot to match light/dark mode
fig.patch.set_facecolor("none")
ax.set_facecolor("none")

color_fg = "black" if input.mode() == "light" else "silver"
ax.tick_params(axis="both", colors=color_fg)
ax.spines["bottom"].set_color(color_fg)
ax.spines["top"].set_color(color_fg)
ax.spines["left"].set_color(color_fg)
ax.spines["right"].set_color(color_fg)

return fig


app = App(app_ui, server)
60 changes: 60 additions & 0 deletions shiny/api-examples/input_dark_mode/app-express.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import matplotlib.pyplot as plt
import numpy as np

from shiny import reactive
from shiny.express import input, render, ui

ui.page_opts(title="Shiny Dark Mode", fillable="One")

with ui.nav_panel("One"):
with ui.layout_sidebar():
with ui.sidebar():
ui.input_slider("n", "N", min=0, max=100, value=20)

@render.plot(alt="A histogram")
def plot() -> object:
np.random.seed(19680801)
x = 100 + 15 * np.random.randn(437)

fig, ax = plt.subplots()
ax.hist(x, input.n(), density=True)

# Theme the plot to match light/dark mode
fig.patch.set_facecolor("none")
ax.set_facecolor("none")

color_fg = "black" if input.mode() == "light" else "silver"
ax.tick_params(axis="both", colors=color_fg)
ax.spines["bottom"].set_color(color_fg)
ax.spines["top"].set_color(color_fg)
ax.spines["left"].set_color(color_fg)
ax.spines["right"].set_color(color_fg)

return fig


with ui.nav_panel("Two"):
with ui.layout_column_wrap():
with ui.card():
"Second page content."

with ui.card():
ui.card_header("More content on the second page.")
ui.input_action_button("make_light", "Switch to light mode")
ui.input_action_button("make_dark", "Switch to dark mode")

ui.nav_spacer()
with ui.nav_control():
ui.input_dark_mode(id="mode")


@reactive.effect
@reactive.event(input.make_light)
async def _():
await ui.update_dark_mode("light")


@reactive.effect
@reactive.event(input.make_dark)
async def _():
await ui.update_dark_mode("dark")
4 changes: 4 additions & 0 deletions shiny/express/ui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
input_checkbox_group,
input_switch,
input_radio_buttons,
input_dark_mode,
input_date,
input_date_range,
input_file,
Expand All @@ -79,6 +80,7 @@
update_switch,
update_checkbox_group,
update_radio_buttons,
update_dark_mode,
update_date,
update_date_range,
update_numeric,
Expand Down Expand Up @@ -197,6 +199,7 @@
"input_checkbox_group",
"input_switch",
"input_radio_buttons",
"input_dark_mode",
"input_date",
"input_date_range",
"input_file",
Expand All @@ -221,6 +224,7 @@
"update_switch",
"update_checkbox_group",
"update_radio_buttons",
"update_dark_mode",
"update_date",
"update_date_range",
"update_numeric",
Expand Down
4 changes: 4 additions & 0 deletions shiny/ui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
input_radio_buttons,
input_switch,
)
from ._input_dark_mode import input_dark_mode, update_dark_mode
from ._input_date import input_date, input_date_range
from ._input_file import input_file
from ._input_numeric import input_numeric
Expand Down Expand Up @@ -219,6 +220,9 @@
"input_checkbox_group",
"input_switch",
"input_radio_buttons",
# _input_dark_mode
"input_dark_mode",
"update_dark_mode",
# _input_date
"input_date",
"input_date_range",
Expand Down
92 changes: 92 additions & 0 deletions shiny/ui/_input_dark_mode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from __future__ import annotations

__all__ = ("input_dark_mode", "update_dark_mode")

from typing import Literal, Optional

from htmltools import Tag, TagAttrValue, css

from .._docstring import add_example, no_example
from .._namespaces import resolve_id
from ..session import Session, require_active_session
from ._web_component import web_component

BootstrapColorMode = Literal["light", "dark"]


@add_example()
def input_dark_mode(
*,
id: Optional[str] = None,
mode: Optional[BootstrapColorMode] = None,
**kwargs: TagAttrValue,
) -> Tag:
"""
Creates a dark mode switch input that toggles the app between dark and light modes.

Parameters
----------
id
An optional ID for the dark mode switch. When included, the current color mode
is reported in the value of the input with this ID.
mode
The initial mode of the dark mode switch. By default or when set to `None`, the
user's system settings for the preferred color scheme will be used. Otherwise,
set to `"light"` or `"dark"` to force the initial mode.
**kwargs
Additional attributes to be added to the dark mode switch, such as `class_` or
`style`.

Returns
-------
:
A dark mode toggle switch UI element.

References
----------
* <https://getbootstrap.com/docs/5.3/customize/color-modes>
"""

if mode is not None:
mode = validate_dark_mode_option(mode)

if id is not None:
id = resolve_id(id)

return web_component(
"bslib-input-dark-mode",
id=id,
attribute="data-bs-theme",
mode=mode,
style=css(
**{
"--text-1": "var(--bs-emphasis-color)",
"--text-2": "var(--bs-tertiary-color)",
# TODO: Fix the vertical correction to work better with Bootstrap
"--vertical-correction": " ",
}
),
**kwargs,
)


def validate_dark_mode_option(mode: BootstrapColorMode) -> BootstrapColorMode:
if mode not in ("light", "dark"):
raise ValueError("`mode` must be either 'light' or 'dark'.")
return mode


@no_example()
async def update_dark_mode(
mode: BootstrapColorMode, *, session: Optional[Session] = None
) -> None:
session = require_active_session(session)

mode = validate_dark_mode_option(mode)

msg: dict[str, object] = {
"method": "toggle",
"value": mode,
}

await session.send_custom_message("bslib.toggle-dark-mode", msg)
34 changes: 34 additions & 0 deletions tests/playwright/controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,40 @@ def __init__(
)


class InputDarkMode(_InputBase):
def __init__(
self,
page: Page,
id: Optional[str] | None,
) -> None:
id_selector = "" if id is None else f"#{id}"

super().__init__(
page,
id="" if id is None else id,
loc=f"bslib-input-dark-mode{id_selector}",
)

def click(self, *, timeout: Timeout = None):
self.loc.click(timeout=timeout)
return self

def expect_mode(self, value: str, *, timeout: Timeout = None):
expect_attr(self.loc, "mode", value=value, timeout=timeout)
self.expect_page_mode(value, timeout=timeout)
return self

def expect_page_mode(self, value: str, *, timeout: Timeout = None):
expect_attr(
self.page.locator("html"), "data-bs-theme", value=value, timeout=timeout
)
return self

def expect_wc_attribute(self, value: str, *, timeout: Timeout = None):
expect_attr(self.loc, "attribute", value=value, timeout=timeout)
return self


class InputTaskButton(
_WidthLocM,
_InputActionBase,
Expand Down
43 changes: 43 additions & 0 deletions tests/playwright/shiny/inputs/test_input_dark_mode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from __future__ import annotations

from conftest import ShinyAppProc, create_doc_example_core_fixture
from controls import InputActionButton, InputDarkMode, LayoutNavSetBar
from playwright.sync_api import Page

app = create_doc_example_core_fixture("input_dark_mode")


def test_input_dark_mode_follows_system_setting(page: Page, app: ShinyAppProc) -> None:
page.emulate_media(color_scheme="light")
page.goto(app.url)

mode_switch = InputDarkMode(page, "mode")
mode_switch.expect_mode("light")
mode_switch.expect_wc_attribute("data-bs-theme")

page.emulate_media(color_scheme="dark")
mode_switch = InputDarkMode(page, "mode")
mode_switch.expect_mode("dark")
mode_switch.expect_wc_attribute("data-bs-theme")


def test_input_dark_mode_switch(page: Page, app: ShinyAppProc) -> None:
page.emulate_media(color_scheme="light")
page.goto(app.url)

mode_switch = InputDarkMode(page, "mode")
navbar = LayoutNavSetBar(page, "page")
make_light = InputActionButton(page, "make_light")
make_dark = InputActionButton(page, "make_dark")

# Test clicking the dark mode switch
mode_switch.expect_mode("light").click().expect_mode("dark")

# Change to nav panel two and trigger server-side changes
navbar.set("Two")

make_light.click()
mode_switch.expect_mode("light")

make_dark.click()
mode_switch.expect_mode("dark")