From 64e9992f61a47c339b9a792380a4d8afc72410f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Mon, 20 Jan 2025 23:48:55 +0100 Subject: [PATCH] Add a standalone Modal layout (#7083) --- examples/reference/layouts/Modal.ipynb | 89 +++++++++++++ panel/__init__.py | 3 +- panel/config.py | 1 + panel/layout/__init__.py | 2 + panel/layout/modal.py | 75 +++++++++++ panel/models/index.ts | 1 + panel/models/modal.py | 54 ++++++++ panel/models/modal.ts | 171 +++++++++++++++++++++++++ panel/styles/models/modal.less | 76 +++++++++++ panel/tests/ui/layout/test_modal.py | 84 ++++++++++++ panel/theme/css/fast.css | 12 ++ 11 files changed, 567 insertions(+), 1 deletion(-) create mode 100644 examples/reference/layouts/Modal.ipynb create mode 100644 panel/layout/modal.py create mode 100644 panel/models/modal.py create mode 100644 panel/models/modal.ts create mode 100644 panel/styles/models/modal.less create mode 100644 panel/tests/ui/layout/test_modal.py diff --git a/examples/reference/layouts/Modal.ipynb b/examples/reference/layouts/Modal.ipynb new file mode 100644 index 0000000000..07c49699d3 --- /dev/null +++ b/examples/reference/layouts/Modal.ipynb @@ -0,0 +1,89 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "5c557435-c052-4622-8023-81ed6f63f231", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "pn.extension('modal')" + ] + }, + { + "cell_type": "markdown", + "id": "a44c5eae-9c73-4626-96ff-019cf959d647", + "metadata": {}, + "source": [ + "The `Modal` layout provides a dialog windows on top of the layout. It is built on-top of [a11y-dialog](https://a11y-dialog.netlify.app/). It has a list-like API with methods to `append`, `extend`, `clear`, `insert`, `pop`, `remove` and `__setitem__`, which make it possible to interactively update and modify the layout. Components inside it are laid out like a `Column`.\n", + "\n", + "#### Parameters:\n", + "\n", + "* **`open`** (boolean): Whether to open the modal.\n", + "* **`show_close_button`** (boolean): Whether to show a close button in the modal.\n", + "* **`background_close`** (boolean): Whether to enable closing the modal when clicking outside the modal.\n", + "\n", + "#### Methods:\n", + "\n", + "* **`show`**: Show the modal.\n", + "* **`hide`**: Hide the modal.\n", + "* **`toggle`**: toggle the modal.\n", + "* **`create_button`**: Create a button which can either show, hide, or toggle the modal.\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "id": "2b3a8784-7e78-4414-a1a5-600b0ff14664", + "metadata": {}, + "source": [ + "A `Modal` layout can either be instantiated as empty and populated after the fact or using a list of objects provided as positional arguments. If the objects are not already panel components they will each be converted to one using the `pn.panel` conversion method." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9fd46bf9-a63e-457c-bcc2-a1578b8f9bda", + "metadata": {}, + "outputs": [], + "source": [ + "w1 = pn.widgets.TextInput(name='Text:')\n", + "w2 = pn.widgets.FloatSlider(name='Slider')\n", + "\n", + "modal = pn.Modal(w1, w2, name='Basic FloatPanel', margin=20)\n", + "toggle_button = modal.create_button(\"toggle\", name=\"Toggle modal\")\n", + "\n", + "pn.Column('**Example: Basic `Modal`**', toggle_button, modal)" + ] + }, + { + "cell_type": "markdown", + "id": "558852dd-9a11-4099-bae3-6da3e38f79f9", + "metadata": {}, + "source": [ + "### Controls\n", + "\n", + "The `Modal` widget exposes a number of options which can be changed from both Python and Javascript. Try out the effect of these parameters interactively:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b49ed4d6-b06b-47e1-a917-88ff3141936b", + "metadata": {}, + "outputs": [], + "source": [ + "modal.controls(jslink=True)" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/panel/__init__.py b/panel/__init__.py index 8b48bb58ec..0b2afe9406 100644 --- a/panel/__init__.py +++ b/panel/__init__.py @@ -64,7 +64,7 @@ ) from .layout import ( # noqa Accordion, Card, Column, Feed, FlexBox, FloatPanel, GridBox, GridSpec, - GridStack, HSpacer, Row, Spacer, Swipe, Tabs, VSpacer, WidgetBox, + GridStack, HSpacer, Modal, Row, Spacer, Swipe, Tabs, VSpacer, WidgetBox, ) from .pane import panel # noqa from .param import Param, ReactiveExpr # noqa @@ -88,6 +88,7 @@ "GridSpec", "GridStack", "HSpacer", + "Modal", "Param", "ReactiveExpr", "Row", diff --git a/panel/config.py b/panel/config.py index e9537ecb21..380529b909 100644 --- a/panel/config.py +++ b/panel/config.py @@ -671,6 +671,7 @@ class panel_extension(_pyviz_extension): 'jsoneditor': 'panel.models.jsoneditor', 'katex': 'panel.models.katex', 'mathjax': 'panel.models.mathjax', + 'modal': 'panel.models.modal', 'perspective': 'panel.models.perspective', 'plotly': 'panel.models.plotly', 'tabulator': 'panel.models.tabulator', diff --git a/panel/layout/__init__.py b/panel/layout/__init__.py index 90fc7e4b48..e5a719611c 100644 --- a/panel/layout/__init__.py +++ b/panel/layout/__init__.py @@ -38,6 +38,7 @@ from .float import FloatPanel # noqa from .grid import GridBox, GridSpec # noqa from .gridstack import GridStack # noqa +from .modal import Modal from .spacer import ( # noqa Divider, HSpacer, Spacer, VSpacer, ) @@ -58,6 +59,7 @@ "HSpacer", "ListLike", "ListPanel", + "Modal", "Panel", "Row", "Spacer", diff --git a/panel/layout/modal.py b/panel/layout/modal.py new file mode 100644 index 0000000000..50e67e127c --- /dev/null +++ b/panel/layout/modal.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from typing import ( + TYPE_CHECKING, ClassVar, Literal, Mapping, Optional, +) + +import param + +from pyviz_comms import JupyterComm + +from ..models.modal import ModalDialogEvent +from ..util import lazy_load +from ..util.warnings import PanelUserWarning, warn +from .base import ListPanel + +if TYPE_CHECKING: + from bokeh.document import Document + from bokeh.model import Model + from pyviz_comms import Comm + + +class Modal(ListPanel): + """Create a modal dialog that can be opened and closed.""" + + open = param.Boolean(default=False, doc="Whether to open the modal.") + + show_close_button = param.Boolean(default=True, doc="Whether to show a close button in the modal.") + + background_close = param.Boolean(default=True, doc="Whether to enable closing the modal when clicking the background.") + + _rename: ClassVar[Mapping[str, str | None]] = {} + + _source_transforms: ClassVar[Mapping[str, str | None]] = {'objects': None} + + def _get_model( + self, doc: Document, root: Optional[Model] = None, + parent: Optional[Model] = None, comm: Optional[Comm] = None + ) -> Model: + Modal._bokeh_model = lazy_load( + 'panel.models.modal', 'Modal', isinstance(comm, JupyterComm), root + ) + return super()._get_model(doc, root, parent, comm) + + def show(self): + self.open = True + + def hide(self): + self.open = False + + def toggle(self): + self.open = not self.open + + @param.depends("open", watch=True) + def _open(self): + if not self._models: + msg = "To use the Modal, you must use '.servable' in a server setting or output the Modal in Jupyter Notebook." + warn(msg, category=PanelUserWarning) + self._send_event(ModalDialogEvent, open=self.open) + + def create_button(self, action: Literal["show", "hide", "toggle"], **kwargs): + """Create a button to show, hide or toggle the modal.""" + from panel.widgets import Button + + button = Button(**kwargs) + match action: + case "show": + button.on_click(lambda *e: self.show()) + case "hide": + button.on_click(lambda *e: self.hide()) + case "toggle": + button.on_click(lambda *e: self.toggle()) + case _: + raise TypeError(f"Invalid action: {action}") + + return button diff --git a/panel/models/index.ts b/panel/models/index.ts index cdcea2da47..220a13be8b 100644 --- a/panel/models/index.ts +++ b/panel/models/index.ts @@ -28,6 +28,7 @@ export {JSONEditor} from "./jsoneditor" export {KaTeX} from "./katex" export {Location} from "./location" export {MathJax} from "./mathjax" +export {Modal} from "./modal" export {PDF} from "./pdf" export {Perspective} from "./perspective" export {Player} from "./player" diff --git a/panel/models/modal.py b/panel/models/modal.py new file mode 100644 index 0000000000..c0f0757e2a --- /dev/null +++ b/panel/models/modal.py @@ -0,0 +1,54 @@ +from typing import Any + +from bokeh.core.properties import Bool +from bokeh.events import ModelEvent +from bokeh.model import Model + +from ..config import config +from ..io.resources import bundled_files +from ..util import classproperty +from .layout import Column + +__all__ = ( + "Modal", + "ModalDialogEvent", +) + + +class Modal(Column): + + __javascript_raw__ = [ + f"{config.npm_cdn}/a11y-dialog@7/dist/a11y-dialog.min.js" + ] + + @classproperty + def __javascript__(cls): + return bundled_files(cls) + + @classproperty + def __js_skip__(cls): + return {'A11yDialog': cls.__javascript__[:1]} + + __js_require__ = { + 'paths': { + 'a11y-dialog': f"{config.npm_cdn}/a11y-dialog@7/dist/a11y-dialog.min", + }, + 'exports': { + 'A11yDialog': 'a11y-dialog', + } + } + + open = Bool(default=False, help="Whether or not the modal is open.") + show_close_button = Bool(True, help="Whether to show a close button in the modal.") + background_close = Bool(True, help="Whether to enable closing the modal when clicking the background.") + + +class ModalDialogEvent(ModelEvent): + event_name = 'modal-dialog-event' + + def __init__(self, model: Model | None, open: bool): + self.open = open + super().__init__(model=model) + + def event_values(self) -> dict[str, Any]: + return dict(super().event_values(), open=self.open) diff --git a/panel/models/modal.ts b/panel/models/modal.ts new file mode 100644 index 0000000000..d782ea14f6 --- /dev/null +++ b/panel/models/modal.ts @@ -0,0 +1,171 @@ +import type * as p from "@bokehjs/core/properties" +import {Column as BkColumn, ColumnView as BkColumnView} from "@bokehjs/models/layouts/column" +import {div, button} from "@bokehjs/core/dom" +import {ModelEvent, server_event} from "@bokehjs/core/bokeh_events" +import type {StyleSheetLike} from "@bokehjs/core/dom" +import type {Attrs} from "@bokehjs/core/types" +import {UIElementView} from "@bokehjs/models/ui/ui_element" +import {isNumber} from "@bokehjs/core/util/types" +import {LayoutDOMView} from "@bokehjs/models/layouts/layout_dom" + +import modal_css from "styles/models/modal.css" + +declare type A11yDialogView = { + on(event: string, listener: () => void): void + show(): void + hide(): void +} + +declare interface A11yDialogInterface { new (container: HTMLElement): A11yDialogView } +declare const A11yDialog: A11yDialogInterface + +@server_event("modal-dialog-event") +export class ModalDialogEvent extends ModelEvent { + open: boolean + + constructor(open: boolean) { + super() + this.open = open + } + + protected override get event_values(): Attrs { + return {open: this.open} + } + + static override from_values(values: object) { + const {open} = values as {open: boolean} + return new ModalDialogEvent(open) + } +} + +export class ModalView extends BkColumnView { + declare model: Modal + + modal: A11yDialogView + close_button: HTMLButtonElement + + override connect_signals(): void { + super.connect_signals() + const {show_close_button} = this.model.properties + this.on_change([show_close_button], this.update_close_button) + this.model.on_event(ModalDialogEvent, (event) => { + event.open ? this.modal.show() : this.modal.hide() + }) + } + + override render(): void { + UIElementView.prototype.render.call(this) + this.class_list.add(...this.css_classes()) + this.create_modal() + } + + override stylesheets(): StyleSheetLike[] { + return [...super.stylesheets(), modal_css] + } + + override async update_children(): Promise { + await LayoutDOMView.prototype.update_children.call(this) + } + + create_modal(): void { + const dialog = div({ + id: "pnx_dialog", + class: "dialog-container bk-root", + style: {display: "none"}, + }) + + const dialog_overlay = div({class: "dialog-overlay"}) + if (this.model.background_close) { + dialog_overlay.setAttribute("data-a11y-dialog-hide", "") + } + + const {height, width, min_height, min_width, max_height, max_width} = this.model + const content = div({ + id: "pnx_dialog_content", + class: "dialog-content", + role: "document", + style: { + height: isNumber(height) ? `${height}px` : height, + width: isNumber(width) ? `${width}px` : width, + min_height: isNumber(min_height) ? `${min_height}px` : min_height, + min_width: isNumber(min_width) ? `${min_width}px` : min_width, + max_height: isNumber(max_height) ? `${max_height}px` : max_height, + max_width: isNumber(max_width) ? `${max_width}px` : max_width, + overflow: "auto", + }, + } as any) + for (const child_view of this.child_views) { + const target = child_view.rendering_target() ?? content + child_view.render_to(target) + } + + this.close_button = button({ + id: "pnx_dialog_close", + "data-a11y-dialog-hide": "", + class: "pnx-dialog-close", + ariaLabel: "Close this dialog window", + } as any) + this.close_button.innerHTML = "✕" + + dialog.append(dialog_overlay) + dialog.append(content) + content.append(this.close_button) + this.shadow_el.append(dialog) + let first_open = false + + this.modal = new A11yDialog(dialog) + this.update_close_button() + this.modal.on("show", () => { + this.model.open = true + dialog.style.display = "" + if (!first_open) { + requestAnimationFrame(() => { this.invalidate_layout() }) + first_open = true + } + }) + this.modal.on("hide", () => { + this.model.open = false + dialog.style.display = "none" + }) + + if (this.model.open) { this.modal.show() } + } + + update_close_button(): void { + if (this.model.show_close_button) { + this.close_button.style.display = "block" + } else { + this.close_button.style.display = "none" + } + } +} + +export namespace Modal { + export type Attrs = p.AttrsOf + + export type Props = BkColumn.Props & { + open: p.Property + show_close_button: p.Property + background_close: p.Property + } +} + +export interface Modal extends Modal.Attrs {} + +export class Modal extends BkColumn { + declare properties: Modal.Props + + constructor(attrs?: Partial) { + super(attrs) + } + + static override __module__ = "panel.models.modal" + static { + this.prototype.default_view = ModalView + this.define(({Bool}) => ({ + open: [Bool, false], + show_close_button: [Bool, true], + background_close: [Bool, true], + })) + } +} diff --git a/panel/styles/models/modal.less b/panel/styles/models/modal.less new file mode 100644 index 0000000000..1dc2bd11e5 --- /dev/null +++ b/panel/styles/models/modal.less @@ -0,0 +1,76 @@ +:host(.bk-panel-models-modal-Modal) { + width: 0; + height: 0; +} + +.dialog-container, +.dialog-overlay { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; +} +.dialog-container { + z-index: 100002; + display: flex; +} +.dialog-overlay { + z-index: 100001; + background-color: rgb(43 46 56 / 0.9); +} +.dialog-content { + margin: auto; + z-index: 100002; + position: relative; + background-color: white; + border-radius: 2px; + padding: 10px; + padding-bottom: 20px; +} +@keyframes fade-in { + from { + opacity: 0; + } +} +@keyframes slide-up { + from { + transform: translateY(10%); + } +} +.dialog-overlay { + animation: fade-in 200ms both; +} +.dialog-content { + animation: + fade-in 400ms 200ms both, + slide-up 400ms 200ms both; +} +@media (prefers-reduced-motion: reduce) { + .dialog-overlay, + .dialog-content { + animation: none; + } +} +.pnx-dialog-close { + position: absolute; + top: 0; + right: 0; + border: 0; + padding: 0; + background-color: transparent; + font-size: 1.5em; + width: 1.5em; + height: 1.5em; + text-align: center; + cursor: pointer; + transition: 0.15s; + border-radius: 50%; + z-index: 100003; +} +.pnx-dialog-close:hover { + background-color: rgb(50 50 0 / 0.15); +} +.lm-Widget.p-Widget.lm-TabBar.p-TabBar.lm-DockPanel-tabBar.jp-Activity { + z-index: -1; +} diff --git a/panel/tests/ui/layout/test_modal.py b/panel/tests/ui/layout/test_modal.py new file mode 100644 index 0000000000..2d3622469f --- /dev/null +++ b/panel/tests/ui/layout/test_modal.py @@ -0,0 +1,84 @@ +import pytest + +from panel.layout import Modal, Spacer +from panel.tests.util import serve_component + +pytest.importorskip("playwright") + +from playwright.sync_api import expect + +pytestmark = pytest.mark.ui + + +def test_modal_close_open(page): + modal = Modal( + Spacer(styles=dict(background="red"), width=200, height=200), + Spacer(styles=dict(background="green"), width=200, height=200), + Spacer(styles=dict(background="blue"), width=200, height=200), + ) + + serve_component(page, modal) + + modal_locator = page.locator("#pnx_dialog_content") + expect(modal_locator).to_be_hidden() + + modal.open = True + expect(modal_locator).to_be_visible() + + +def test_modal_open_close(page): + modal = Modal( + Spacer(styles=dict(background="red"), width=200, height=200), + Spacer(styles=dict(background="green"), width=200, height=200), + Spacer(styles=dict(background="blue"), width=200, height=200), + open=True, + ) + + serve_component(page, modal) + + modal_locator = page.locator("#pnx_dialog_content") + expect(modal_locator).to_be_visible() + + page.mouse.click(0, 0) + expect(modal_locator).to_be_hidden() + + +@pytest.mark.parametrize("show_close_button", [True, False]) +def test_modal_show_close_button(page, show_close_button): + modal = Modal( + Spacer(styles=dict(background="red"), width=200, height=200), + Spacer(styles=dict(background="green"), width=200, height=200), + Spacer(styles=dict(background="blue"), width=200, height=200), + open=True, + show_close_button=show_close_button, + ) + + serve_component(page, modal) + + modal_locator = page.locator("#pnx_dialog_content") + expect(modal_locator).to_be_visible() + + modal_locator = page.locator("#pnx_dialog_close") + if show_close_button: + expect(modal_locator).to_be_visible() + else: + expect(modal_locator).to_be_hidden() + + +def test_modal_background_close(page): + modal = Modal( + Spacer(styles=dict(background="red"), width=200, height=200), + Spacer(styles=dict(background="green"), width=200, height=200), + Spacer(styles=dict(background="blue"), width=200, height=200), + open=True, + background_close=False, + ) + + serve_component(page, modal) + + modal_locator = page.locator("#pnx_dialog_content") + expect(modal_locator).to_be_visible() + + # Should still be visible + page.mouse.click(0, 0) + expect(modal_locator).to_be_visible() diff --git a/panel/theme/css/fast.css b/panel/theme/css/fast.css index b22b5ded1a..2d98b60d60 100644 --- a/panel/theme/css/fast.css +++ b/panel/theme/css/fast.css @@ -1128,3 +1128,15 @@ table.panel-df { background-color: var(--neutral-fill-input-rest); color: var(--neutral-foreground-rest); } + +/* Modal */ +fast-design-system-provider .dialog-content { + background-color: var(--background-color); + border-radius: calc(var(--corner-radius) * 1px); +} +fast-design-system-provider .pnx-dialog-close { + color: var(--neutral-foreground-rest); +} +fast-design-system-provider .pnx-dialog-close:hover { + background-color: var(--neutral-fill-hover); +}