diff --git a/examples/demo_new_theme_changer/demo_new_theme_changer.py b/examples/demo_new_theme_changer/demo_new_theme_changer.py new file mode 100644 index 0000000..160bb89 --- /dev/null +++ b/examples/demo_new_theme_changer/demo_new_theme_changer.py @@ -0,0 +1,131 @@ +from dash import Dash, dcc, html, Input, Output, dash_table, callback +import pandas as pd +import plotly.express as px +import dash_bootstrap_components as dbc +import dash_ag_grid as dag + +from dash_bootstrap_templates import ThemeChangerAIO, template_from_url + +app = Dash( + __name__, + external_stylesheets=["https://cdn.jsdelivr.net/gh/AnnMarieW/dash-bootstrap-templates/dbc.min.css"], + ########## test custom server/client assets folders ########## + assets_folder='server_side_assets', + assets_url_path='client_side_assets' +) + +df = pd.DataFrame({ + "Fruit": ["Apples", "Oranges", "Bananas", "Apples", "Oranges", "Bananas"], + "Amount": [4, 1, 2, 2, 4, 5], + "City": ["SF", "SF", "SF", "Montreal", "Montreal", "Montreal"], +}) + +app.layout = html.Div( + [ + # header + html.Div( + [ + html.H3("ThemeChangerAIO Demo"), + ThemeChangerAIO( + aio_id="theme", + button_props={'outline': False}, + ########## test custom themes ########## + custom_themes={ + 'custom-light': 'custom_light_theme.css', + 'custom-dark': 'custom_dark_theme.css' + }, + custom_dark_themes=['custom-dark'], + ########## test custom RadioItems list ########## + ## Note: the value must match the name of the theme + # radio_props={ + # "options": [ + # {"label": "Cyborg", "value": dbc.themes.CYBORG}, + # {"label": "My Theme", "value": "custom_light_theme.css"}, + # {"label": "My Dark Theme", "value": "custom_dark_theme.css"}, + # {"label": "Spacelab", "value": dbc.themes.SPACELAB}, + # # test setting label styling (here unset the style) + # {"label": "Vapor", "value": dbc.themes.VAPOR, "label_id": ""} + # ], + # "value": dbc.themes.VAPOR, + # }, + ########## test persistence ########## + # radio_props={"persistence": True}, + ), + ], className="sticky-top bg-primary p-2" + ), + + # test DBC components + html.H4('Dash Bootstrap Components:'), + html.Div([ + dbc.Button(f"{color}", color=f"{color}", size="sm") + for color in ["primary", "secondary", "success", "warning", "danger", "info", "light", "dark", "link"] + ]), + dbc.Checklist(['New York City', 'Montréal', 'San Francisco'], ['New York City', 'Montréal'], inline=True), + dbc.RadioItems(['New York City', 'Montreal', 'San Francisco'], 'Montreal', inline=True), + html.Hr(), + + # test DCC components + html.H4('Dash Core Components:'), + dcc.Checklist(['New York City', 'Montréal', 'San Francisco'], ['New York City', 'Montréal'], inline=True), + dcc.RadioItems(['New York City', 'Montreal', 'San Francisco'], 'Montreal', inline=True), + dcc.Dropdown(["Apple", "Carrots", "Chips", "Cookies"], ["Cookies", "Carrots"], multi=True), + dcc.Slider(min=0, max=20, step=5, value=10), + html.Hr(), + + # test DataTable + html.H4('Dash DataTable:'), + dash_table.DataTable( + columns=[{"name": i, "id": i} for i in df.columns], + data=df.to_dict("records"), + row_selectable="single", + row_deletable=True, + editable=True, + filter_action="native", + sort_action="native", + style_table={"overflowX": "auto"}, + ), + html.Hr(), + + # test DAG + html.H4('Dash AG Grid:'), + dag.AgGrid( + columnDefs=[{"field": i} for i in df.columns], + rowData=df.to_dict("records"), + defaultColDef={ + "flex": 1, "filter": True, + "checkboxSelection": { + "function": 'params.column == params.api.getAllDisplayedColumns()[0]' + }, + "headerCheckboxSelection": { + "function": 'params.column == params.api.getAllDisplayedColumns()[0]' + } + }, + dashGridOptions={"rowSelection": "multiple", "domLayout": "autoHeight"}, + className='ag-theme-quartz dbc-ag-grid' + ), + html.Hr(), + + # test plotly fig + html.H4('Plotly Figure:'), + dcc.Graph( + id='theme_changer-graph', + figure=px.bar(df, x="Fruit", y="Amount", color="City", barmode="group") + ) + ], className='dbc' +) + + +# Switch figure themes +@callback( + Output("theme_changer-graph", "figure"), + Input(ThemeChangerAIO.ids.radio("theme"), "value"), +) +def update_figure_template(theme): + return px.bar( + df, x="Fruit", y="Amount", color="City", barmode="group", + template=template_from_url(theme) + ) + + +if __name__ == "__main__": + app.run(debug=True) diff --git a/examples/demo_new_theme_changer/server_side_assets/custom_dark_theme.css b/examples/demo_new_theme_changer/server_side_assets/custom_dark_theme.css new file mode 100644 index 0000000..fe72ddb --- /dev/null +++ b/examples/demo_new_theme_changer/server_side_assets/custom_dark_theme.css @@ -0,0 +1,65 @@ +body { + background-color: black; + color: white; +} + +.offcanvas { + position: fixed; + bottom: 0; + top: 0; + left: 0; + z-index: 1045; + display: flex; + flex-direction: column; + max-width: 100%; + visibility: hidden; + background-color: black; + transition: transform 0.3s ease-in-out; +} + +.offcanvas-backdrop { + position: fixed; + top: 0; + left: 0; + z-index: 1040; + width: 100vw; + height: 100vh; + background-color: white +} + +.offcanvas-backdrop.fade { + opacity: 0 +} + +.offcanvas-backdrop.show { + opacity: .5 +} + +.offcanvas-header { + display: flex; +} + +.btn-close { + --bs-btn-close-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e"); + box-sizing: content-box; + width: 1em; + height: 1em; + padding: .25em .25em; + background: transparent var(--bs-btn-close-bg) center/1em auto no-repeat; + border: 0; + opacity: 0.5 +} + +.btn-close:hover { + opacity: 0.75 +} + +.form-check { + display: block; + padding-left: 1.5em; +} + +.form-check .form-check-input { + float: left; + margin-left: -1.5em +} \ No newline at end of file diff --git a/examples/demo_new_theme_changer/server_side_assets/custom_light_theme.css b/examples/demo_new_theme_changer/server_side_assets/custom_light_theme.css new file mode 100644 index 0000000..1b9aac6 --- /dev/null +++ b/examples/demo_new_theme_changer/server_side_assets/custom_light_theme.css @@ -0,0 +1,65 @@ +body { + background-color: white; + color: black; +} + +.offcanvas { + position: fixed; + bottom: 0; + top: 0; + left: 0; + z-index: 1045; + display: flex; + flex-direction: column; + max-width: 100%; + visibility: hidden; + background-color: white; + transition: transform 0.3s ease-in-out; +} + +.offcanvas-backdrop { + position: fixed; + top: 0; + left: 0; + z-index: 1040; + width: 100vw; + height: 100vh; + background-color: black +} + +.offcanvas-backdrop.fade { + opacity: 0 +} + +.offcanvas-backdrop.show { + opacity: .5 +} + +.offcanvas-header { + display: flex; +} + +.btn-close { + --bs-btn-close-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e"); + box-sizing: content-box; + width: 1em; + height: 1em; + padding: .25em .25em; + background: transparent var(--bs-btn-close-bg) center/1em auto no-repeat; + border: 0; + opacity: 0.5 +} + +.btn-close:hover { + opacity: 0.75 +} + +.form-check { + display: block; + padding-left: 1.5em; +} + +.form-check .form-check-input { + float: left; + margin-left: -1.5em +} \ No newline at end of file diff --git a/src/aio/__init__.py b/src/aio/__init__.py index 13b4a6d..b509893 100644 --- a/src/aio/__init__.py +++ b/src/aio/__init__.py @@ -1,15 +1,2 @@ -import os - from .aio_theme_switch import ThemeSwitchAIO from .aio_theme_changer import ThemeChangerAIO, template_from_url - -# needed for Dash for _js_dist -from dash_bootstrap_templates import __version__ - -_js_dist = [ - { - "namespace": "aio", - "relative_package_path": "clientsideCallbacks.js", - "external_url": f'{os.path.dirname(os.path.realpath(__file__))}\\clientsideCallbacks.js', - } -] diff --git a/src/aio/aio_theme_changer.py b/src/aio/aio_theme_changer.py index bc318dc..daa761e 100644 --- a/src/aio/aio_theme_changer.py +++ b/src/aio/aio_theme_changer.py @@ -1,4 +1,7 @@ -from dash import html, dcc, Input, Output, State, callback, clientside_callback, MATCH +from dash import html, dcc, Input, Output, State, callback, clientside_callback, MATCH, ClientsideFunction, get_app +from typing import Dict, List + +from dash_bootstrap_templates import load_figure_template import dash_bootstrap_components as dbc import uuid @@ -9,7 +12,7 @@ } url_dbc_themes = dict(map(reversed, dbc_themes_url.items())) dbc_themes_lowercase = [t.lower() for t in dbc_themes_url.keys()] -dbc_dark_themes = ["cyborg", "darkly", "slate", "solar", "superhero", "vapor"] +dbc_dark_themes = ["CYBORG", "DARKLY", "SLATE", "SOLAR", "SUPERHERO", "VAPOR"] def template_from_url(url): @@ -34,16 +37,27 @@ class ids: "subcomponent": "radio", "aio_id": aio_id, } - dummy_div = lambda aio_id: { + store = lambda aio_id: { "component": "ThemeChangerAIO", - "subcomponent": "dummy_div", + "subcomponent": "store", + "aio_id": aio_id, + } + assetsPath = lambda aio_id: { + "component": "ThemeSwitchAIO", + "subcomponent": "assetsPath", "aio_id": aio_id, } ids = ids def __init__( - self, radio_props={}, button_props={}, offcanvas_props={}, aio_id=None, + self, + aio_id: str = str(uuid.uuid4()), + custom_themes: Dict[str, str] = None, + custom_dark_themes: List[str] = None, + radio_props: Dict[str, any] = None, + button_props: Dict[str, any] = None, + offcanvas_props: Dict[str, any] = None, ): """ThemeChangerAIO is an All-in-One component composed of a parent `html.Div` with @@ -53,7 +67,7 @@ def __init__( - `dbc.Offcanvas` ("`offcanvas`") - `dbc.RadioItems` ("`radio`"). The themes are displayed as RadioItems inside the `dbc.Offcanvas` component. The `value` is a url for the theme - - `html.Div` is used as the `Output` of the clientside callbacks. + - Two `dcc.Store` used as `Input` of the clientside callbacks to provide the theme list and the assets path. The ThemeChangerAIO component updates the stylesheet when the `value` of radio changes. (ie the user selects a new theme) @@ -61,6 +75,9 @@ def __init__( - param: `button_props` A dictionary of properties passed into the dbc.Button component. - param: `offcanvas_props`. A dictionary of properties passed into the dbc.Offcanvas component - param: `aio_id` The All-in-One component ID used to generate components' dictionary IDs. + - param: `custom_themes` A dictionary of local .css files or external url + with the keys being the theme name and the value being the theme path (file name in assets folder or url). + - param: `custom_dark_themes` List of custom dark theme name, so that they appear with a black background in the offcanvas list. The All-in-One component dictionary IDs are available as: @@ -68,127 +85,133 @@ def __init__( - ThemeChangerAIO.ids.offcanvas(aio_id) - ThemeChangerAIO.ids.button(aio_id) """ - from dash_bootstrap_templates import load_figure_template - + # make all dash_bootstrap_templates templates available to plotly figures load_figure_template("all") - if aio_id is None: - aio_id = str(uuid.uuid4()) - - radio_props = radio_props.copy() - if "value" not in radio_props: - radio_props["value"] = dbc_themes_url["BOOTSTRAP"] - if "options" not in radio_props: - radio_props["options"] = [ - { - "label": str(i), - "label_id": "theme-switch-label", - "value": dbc_themes_url[i], - } - for i in dbc_themes_url - ] - # assign id to dark themes in order to apply css - for option in radio_props["options"]: - if option["label"].lower() in dbc_dark_themes: - option["label_id"] = "theme-switch-label-dark" - - button_props = button_props.copy() - if "children" not in button_props: - button_props["children"] = "Change Theme" - if "color" not in button_props: - button_props["color"] = "secondary" - if "outline" not in button_props: - button_props["outline"] = True - if "size" not in button_props: - button_props["size"] = "sm" - - offcanvas_props = offcanvas_props.copy() - if "children" not in offcanvas_props: - offcanvas_props["children"] = [ - dbc.RadioItems(id=self.ids.radio(aio_id), **radio_props), - ] - if "title" not in offcanvas_props: - offcanvas_props["title"] = "Select a Theme" - if "is_open" not in offcanvas_props: - offcanvas_props["is_open"] = False - if "style" not in offcanvas_props: - offcanvas_props["style"] = {"width": 235} - - super().__init__( - [ - dbc.Button(id=self.ids.button(aio_id), **button_props), - dbc.Offcanvas(id=self.ids.offcanvas(aio_id), **offcanvas_props), - html.Div( - id=self.ids.dummy_div(aio_id), - children=radio_props["value"], - hidden=True, - ), - ] - ) + # concat custom themes and bootstrap themes + themes_url = {**custom_themes, **dbc_themes_url} if custom_themes else dbc_themes_url + # concat custom dark themes and bootstrap dark themes + dark_themes = (dbc_dark_themes + custom_dark_themes) if custom_dark_themes else dbc_dark_themes + dark_themes_url = [url for theme, url in themes_url.items() if theme in dark_themes] + + # init button_props + if button_props is None: + button_props = {} + # set default params if they don't exist + button_props.setdefault("children", "Change Theme") + button_props.setdefault("color", "secondary") + button_props.setdefault("outline", True) + button_props.setdefault("size", "sm") + + # init radio_props + if radio_props is None: + radio_props = {} + # set default params if they don't exist + radio_props.setdefault("options", [{'label': k, 'value': v} for k, v in themes_url.items()]) + radio_props.setdefault("value", dbc.themes.BOOTSTRAP) + # add label styling to make the difference between light/dark themes + for option in radio_props['options']: + option.setdefault( + "label_id", "theme-switch-label-dark" if option["value"] in dark_themes_url else "theme-switch-label" + ) + # init offcanvas_props + if offcanvas_props is None: + offcanvas_props = {} + # set default params if they don't exist + offcanvas_props.setdefault("title", "Select a Theme") + offcanvas_props.setdefault("is_open", False) + offcanvas_props.setdefault("style", { + "width": 'unset', # so that it can grow if there is a long theme label + "min-width": 230, + }) + offcanvas_props.setdefault("children", [ + dbc.RadioItems(id=self.ids.radio(aio_id), **radio_props), + ]) + + super().__init__([ + dbc.Button(id=self.ids.button(aio_id), **button_props), + dbc.Offcanvas(id=self.ids.offcanvas(aio_id), **offcanvas_props), + dcc.Store(id=self.ids.store(aio_id), data=themes_url), + dcc.Store(id=self.ids.assetsPath(aio_id), data=get_app().config.assets_url_path) + ]) @callback( Output(ids.offcanvas(MATCH), "is_open"), Input(ids.button(MATCH), "n_clicks"), - [State(ids.offcanvas(MATCH), "is_open")], + State(ids.offcanvas(MATCH), "is_open"), ) def toggle_theme_offcanvas(n1, is_open): - if n1: - return not is_open - return is_open + return not is_open if n1 else is_open clientside_callback( """ - function switcher(url) { - var stylesheets = document.querySelectorAll( - `link[rel=stylesheet][href^="https://cdn.jsdelivr.net/npm/bootswatch@5"], - link[rel=stylesheet][href^="https://cdn.jsdelivr.net/npm/bootstrap@5"]` - ); - // The delay in updating the stylesheet reduces the flash when changing themes - stylesheets[stylesheets.length - 1].href = url - setTimeout(function() { - for (let i = 0; i < stylesheets.length -1; i++) { - stylesheets[i].href = url; + function (selected_theme, themes, assetsUrlPath) { + + // function to test if the theme is an external or a local theme + const isValidHttpUrl = (theme) => { + try { + new URL(theme); + return true; + } catch (error) { + return false; + } } - }, 500); + + // Find the existing theme stylesheets + let stylesheets = [] + Object.values(themes).forEach( + + url => stylesheets.push(...document.querySelectorAll(`link[rel='stylesheet'][href*='${url}']`)) + ); + + // Create a new stylesheet link element + let newStylesheet = document.createElement("link"); + newStylesheet.rel = "stylesheet"; + newStylesheet.href = isValidHttpUrl(selected_theme) + ? selected_theme + : `/${assetsUrlPath}/${selected_theme.split('/').at(-1)}`; + + + // When the new stylesheet is loaded, remove the old ones + newStylesheet.onload = function () { + + stylesheets.forEach(s => s.remove()); + } + + // Append the new stylesheet to the document head + document.head.appendChild(newStylesheet); + + return window.dash_clientside.no_update; } """, - Output(ids.dummy_div(MATCH), "key"), + Output(ids.store(MATCH), "id"), Input(ids.radio(MATCH), "value"), + Input(ids.store(MATCH), "data"), + State(ids.assetsPath(MATCH), "data") ) - # This callback is used to bundle custom CSS with the AIO component - # and to add a stylesheet so that the theme switcher will work even if there is a - # Bootstrap stylesheet in the assets folder. - # This only runs once when the app starts. The clientside function adds the css to a