From 5cb628099ad1d3b9d8bff71fbee073208ca5765c Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Mon, 10 Nov 2025 08:44:59 +0100 Subject: [PATCH] I can toogle the left drawer --- src/app.py | 23 +++ src/myfasthtml/assets/myfasthtml.css | 156 ++++++++++++++++ src/myfasthtml/assets/myfasthtml.js | 119 ++++++++++++ src/myfasthtml/controls/BaseCommands.py | 4 + src/myfasthtml/controls/BaseControl.py | 7 + src/myfasthtml/controls/Layout.py | 236 ++++++++++++++++++++++++ src/myfasthtml/core/commands.py | 11 +- src/myfasthtml/myfastapp.py | 5 +- tests/core/test_commands.py | 31 +++- 9 files changed, 588 insertions(+), 4 deletions(-) create mode 100644 src/app.py create mode 100644 src/myfasthtml/assets/myfasthtml.js create mode 100644 src/myfasthtml/controls/BaseCommands.py create mode 100644 src/myfasthtml/controls/BaseControl.py create mode 100644 src/myfasthtml/controls/Layout.py diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..fb19b99 --- /dev/null +++ b/src/app.py @@ -0,0 +1,23 @@ +import logging + +from fasthtml import serve + +from myfasthtml.controls.Layout import Layout +from myfasthtml.myfastapp import create_app + +logging.basicConfig( + level=logging.DEBUG, # Set logging level to DEBUG + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', # Log format + datefmt='%Y-%m-%d %H:%M:%S', # Timestamp format +) + +app, rt = create_app(protect_routes=False, mount_auth_app=True, pico=False, title="MyFastHtml" ) + +@rt("/") +def index(session): + layout = Layout(session, "Testing Layout", right_drawer=False) + layout.set_footer("Goodbye World") + return layout + +if __name__ == "__main__": + serve(port=5003) diff --git a/src/myfasthtml/assets/myfasthtml.css b/src/myfasthtml/assets/myfasthtml.css index 6d60374..aec4333 100644 --- a/src/myfasthtml/assets/myfasthtml.css +++ b/src/myfasthtml/assets/myfasthtml.css @@ -12,4 +12,160 @@ height: 16px; margin-top: auto; margin-bottom: 4px; +} + +/* + * MF Layout Component - CSS Grid Layout + * Provides fixed header/footer, collapsible drawers, and scrollable main content + * Compatible with DaisyUI 5 + */ + +/* Main layout container using CSS Grid */ +.mf-layout { + display: grid; + grid-template-areas: + "header header header" + "left-drawer main right-drawer" + "footer footer footer"; + grid-template-rows: 32px 1fr 32px; + grid-template-columns: auto 1fr auto; + height: 100vh; + width: 100vw; + overflow: hidden; +} + +/* Header - fixed at top */ +.mf-layout-header { + grid-area: header; + display: flex; + align-items: center; + gap: 1rem; + padding: 0 1rem; + background-color: var(--color-base-300); + border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000); + z-index: 30; +} + +/* Footer - fixed at bottom */ +.mf-layout-footer { + grid-area: footer; + display: flex; + align-items: center; + padding: 0 1rem; + background-color: var(--color-neutral); + color: var(--color-neutral-content); + border-top: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000); + z-index: 30; +} + +/* Main content area - scrollable */ +.mf-layout-main { + grid-area: main; + overflow-y: auto; + overflow-x: auto; + padding: 1rem; + background-color: var(--color-base-100); +} + +/* Drawer base styles */ +.mf-layout-drawer { + overflow-y: auto; + overflow-x: hidden; + background-color: var(--color-base-100); + transition: width 0.3s ease-in-out, margin 0.3s ease-in-out; + width: 250px; + padding: 1rem; +} + +/* Left drawer */ +.mf-layout-left-drawer { + grid-area: left-drawer; + border-right: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000); +} + +/* Right drawer */ +.mf-layout-right-drawer { + grid-area: right-drawer; + border-left: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000); +} + +/* Collapsed drawer states */ +.mf-layout-drawer.collapsed { + width: 0; + padding: 0; + border: none; + overflow: hidden; +} + +/* Toggle buttons positioning */ +.mf-layout-toggle-left { + margin-right: auto; +} + +.mf-layout-toggle-right { + margin-left: auto; +} + +/* Smooth scrollbar styling for webkit browsers */ +.mf-layout-main::-webkit-scrollbar, +.mf-layout-drawer::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.mf-layout-main::-webkit-scrollbar-track, +.mf-layout-drawer::-webkit-scrollbar-track { + background: var(--color-base-200); +} + +.mf-layout-main::-webkit-scrollbar-thumb, +.mf-layout-drawer::-webkit-scrollbar-thumb { + background: color-mix(in oklab, var(--color-base-content) 20%, #0000); + border-radius: 4px; +} + +.mf-layout-main::-webkit-scrollbar-thumb:hover, +.mf-layout-drawer::-webkit-scrollbar-thumb:hover { + background: color-mix(in oklab, var(--color-base-content) 30%, #0000); +} + +/* Responsive adjustments for smaller screens */ +@media (max-width: 768px) { + .mf-layout-drawer { + width: 200px; + } + + .mf-layout-header, + .mf-layout-footer { + padding: 0 0.5rem; + } + + .mf-layout-main { + padding: 0.5rem; + } +} + +/* Handle layouts with no drawers */ +.mf-layout[data-left-drawer="false"] { + grid-template-areas: + "header header" + "main right-drawer" + "footer footer"; + grid-template-columns: 1fr auto; +} + +.mf-layout[data-right-drawer="false"] { + grid-template-areas: + "header header" + "left-drawer main" + "footer footer"; + grid-template-columns: auto 1fr; +} + +.mf-layout[data-left-drawer="false"][data-right-drawer="false"] { + grid-template-areas: + "header" + "main" + "footer"; + grid-template-columns: 1fr; } \ No newline at end of file diff --git a/src/myfasthtml/assets/myfasthtml.js b/src/myfasthtml/assets/myfasthtml.js new file mode 100644 index 0000000..7e97654 --- /dev/null +++ b/src/myfasthtml/assets/myfasthtml.js @@ -0,0 +1,119 @@ +/** + * MF Layout Component - JavaScript Controller + * Manages drawer state and provides programmatic control + */ + +// Global registry for layout instances +if (typeof window.mfLayoutInstances === 'undefined') { + window.mfLayoutInstances = {}; +} + +/** + * Initialize a layout instance with drawer controls + * @param {string} layoutId - The unique ID of the layout (mf-layout-xxx) + */ +function initLayout(layoutId) { + const layoutElement = document.getElementById(layoutId); + + if (!layoutElement) { + console.error(`Layout with id "${layoutId}" not found`); + return; + } + + // Create layout controller object + const layoutController = { + layoutId: layoutId, + element: layoutElement, + + /** + * Get drawer element by side + * @param {string} side - 'left' or 'right' + * @returns {HTMLElement|null} The drawer element + */ + getDrawer: function (side) { + if (side !== 'left' && side !== 'right') { + console.error(`Invalid drawer side: "${side}". Must be "left" or "right".`); + return null; + } + + const drawerClass = side === 'left' ? '.mf-layout-left-drawer' : '.mf-layout-right-drawer'; + return this.element.querySelector(drawerClass); + }, + + /** + * Check if a drawer is currently open + * @param {string} side - 'left' or 'right' + * @returns {boolean} True if drawer is open + */ + isDrawerOpen: function (side) { + const drawer = this.getDrawer(side); + return drawer ? !drawer.classList.contains('collapsed') : false; + }, + + /** + * Open a drawer + * @param {string} side - 'left' or 'right' + */ + openDrawer: function (side) { + const drawer = this.getDrawer(side); + if (drawer) { + drawer.classList.remove('collapsed'); + } + }, + + /** + * Close a drawer + * @param {string} side - 'left' or 'right' + */ + closeDrawer: function (side) { + const drawer = this.getDrawer(side); + if (drawer) { + drawer.classList.add('collapsed'); + } + }, + + /** + * Toggle a drawer between open and closed + * @param {string} side - 'left' or 'right' + */ + toggleDrawer: function (side) { + if (this.isDrawerOpen(side)) { + this.closeDrawer(side); + } else { + this.openDrawer(side); + } + }, + + /** + * Initialize event listeners for toggle buttons + */ + initEventListeners: function () { + // Get all toggle buttons within this layout + const toggleButtons = this.element.querySelectorAll('[class*="mf-layout-toggle"]'); + + toggleButtons.forEach(button => { + button.addEventListener('click', (event) => { + event.preventDefault(); + const side = button.getAttribute('data-side'); + if (side) { + this.toggleDrawer(side); + } + }); + }); + } + }; + + // Initialize event listeners + layoutController.initEventListeners(); + + // Store instance in global registry for programmatic access + window.mfLayoutInstances[layoutId] = layoutController; + + // Log successful initialization + console.log(`Layout "${layoutId}" initialized successfully`); +} + +// Export for module environments if needed +if (typeof module !== 'undefined' && module.exports) { + module.exports = {initLayout}; +} \ No newline at end of file diff --git a/src/myfasthtml/controls/BaseCommands.py b/src/myfasthtml/controls/BaseCommands.py new file mode 100644 index 0000000..ec1d6b8 --- /dev/null +++ b/src/myfasthtml/controls/BaseCommands.py @@ -0,0 +1,4 @@ +class BaseCommands: + def __init__(self, owner): + self._owner = owner + self._id = owner.get_id() \ No newline at end of file diff --git a/src/myfasthtml/controls/BaseControl.py b/src/myfasthtml/controls/BaseControl.py new file mode 100644 index 0000000..185b4bb --- /dev/null +++ b/src/myfasthtml/controls/BaseControl.py @@ -0,0 +1,7 @@ +class BaseControl: + def __init__(self, session, _id): + self.session = session + self._id = _id + + def get_id(self): + return self._id diff --git a/src/myfasthtml/controls/Layout.py b/src/myfasthtml/controls/Layout.py new file mode 100644 index 0000000..c0f8445 --- /dev/null +++ b/src/myfasthtml/controls/Layout.py @@ -0,0 +1,236 @@ +""" +Layout component for FastHTML applications. + +This component provides a responsive layout with fixed header/footer, +optional collapsible left/right drawers, and a scrollable main content area. +""" +import logging +import uuid +from typing import Literal + +from fasthtml.common import * + +from myfasthtml.controls.BaseCommands import BaseCommands +from myfasthtml.controls.BaseControl import BaseControl +from myfasthtml.controls.helpers import mk +from myfasthtml.core.commands import Command +from myfasthtml.icons.fluent import icon_panel_left_expand20_regular as left_drawer_icon +from myfasthtml.icons.fluent import icon_panel_right_expand20_regular as right_drawer_icon + +logger = logging.getLogger("LayoutControl") + +@dataclass +class LayoutState: + left_drawer_open: bool = True + + +class Commands(BaseCommands): + def toggle_left_drawer(self): + return Command("ToggleDrawer", "Toggle main layout drawer", self._owner.toggle_drawer, "left") + + +class Layout(BaseControl): + """ + A responsive layout component with header, footer, main content area, + and optional collapsible side drawers. + + Attributes: + app_name (str): Name of the application + left_drawer (bool): Whether to include a left drawer + right_drawer (bool): Whether to include a right drawer + """ + + def __init__(self, session, app_name, left_drawer=True, right_drawer=True): + """ + Initialize the Layout component. + + Args: + app_name (str): Name of the application + left_drawer (bool): Enable left drawer. Default is True. + right_drawer (bool): Enable right drawer. Default is True. + """ + super().__init__(session, f"mf-layout-{str(uuid.uuid4())}") + self.app_name = app_name + self.left_drawer = left_drawer + self.right_drawer = right_drawer + + # Content storage + self._header_content = None + self._footer_content = None + self._main_content = None + self._left_drawer_content = None + self._right_drawer_content = None + self._state = LayoutState() + self.commands = Commands(self) + + # def set_header(self, content): + # """ + # Set the header content. + # + # Args: + # content: FastHTML component(s) or content for the header + # """ + # self._header_content = content + + def set_footer(self, content): + """ + Set the footer content. + + Args: + content: FastHTML component(s) or content for the footer + """ + self._footer_content = content + + def set_main(self, content): + """ + Set the main content area. + + Args: + content: FastHTML component(s) or content for the main area + """ + self._main_content = content + + def set_left_drawer(self, content): + """ + Set the left drawer content. + + Args: + content: FastHTML component(s) or content for the left drawer + """ + if self.left_drawer: + self._left_drawer_content = content + + def set_right_drawer(self, content): + """ + Set the right drawer content. + + Args: + content: FastHTML component(s) or content for the right drawer + """ + if self.right_drawer: + self._right_drawer_content = content + + def toggle_drawer(self, side: Literal["left", "right"]): + logger.debug(f"Toggle drawer: {side=}, {self._state.left_drawer_open=}") + if side == "left": + self._state.left_drawer_open = not self._state.left_drawer_open + return self._mk_left_drawer_icon(), self._mk_left_drawer() + elif side == "right": + self._state.right_drawer_open = not self._state.right_drawer_open + return Div(), self._mk_right_drawer() + else: + raise ValueError("Invalid drawer side") + + def _mk_header(self): + """ + Render the header component. + + Returns: + Header: FastHTML Header component + """ + return Header( + self._mk_left_drawer_icon(), + cls="mf-layout-header" + ) + + def _mk_footer(self): + """ + Render the footer component. + + Returns: + Footer: FastHTML Footer component + """ + footer_content = self._footer_content if self._footer_content else "" + return Footer( + footer_content, + cls="mf-layout-footer footer sm:footer-horizontal" + ) + + def _mk_main(self): + """ + Render the main content area. + + Returns: + Main: FastHTML Main component + """ + main_content = self._main_content if self._main_content else "" + return Main( + main_content, + cls="mf-layout-main" + ) + + def _mk_left_drawer(self): + """ + Render the left drawer if enabled. + + Returns: + Div or None: FastHTML Div component for left drawer, or None if disabled + """ + if not self.left_drawer: + return None + + print(f"{self._state.left_drawer_open=}") + + drawer_content = self._left_drawer_content if self._left_drawer_content else "" + return Div( + drawer_content, + id=f"{self._id}_ld", + cls=f"mf-layout-drawer mf-layout-left-drawer {'collapsed' if not self._state.left_drawer_open else ''}", + **{"data-side": "left"} + ) + + def _mk_right_drawer(self): + """ + Render the right drawer if enabled. + + Returns: + Div or None: FastHTML Div component for right drawer, or None if disabled + """ + if not self.right_drawer: + return None + + drawer_content = self._right_drawer_content if self._right_drawer_content else "" + return Div( + drawer_content, + cls="mf-layout-drawer mf-layout-right-drawer", + id=f"{self._id}_rd", + **{"data-side": "right"} + ) + + def _mk_left_drawer_icon(self): + return mk.icon(right_drawer_icon if self._state.left_drawer_open else left_drawer_icon, + id=f"{self._id}_ldi", + command=self.commands.toggle_left_drawer()) + + def render(self): + """ + Render the complete layout. + + Returns: + Div: Complete layout as FastHTML Div component + """ + + # Wrap everything in a container div + return Div( + self._mk_header(), + self._mk_left_drawer(), + self._mk_main(), + self._mk_right_drawer(), + self._mk_footer(), + Script(f"initLayout('{self._id}');"), + id=self._id, + cls="mf-layout", + **{ + "data-left-drawer": str(self.left_drawer).lower(), + "data-right-drawer": str(self.right_drawer).lower() + } + ) + + def __ft__(self): + """ + FastHTML magic method for rendering. + + Returns: + Div: The rendered layout + """ + return self.render() diff --git a/src/myfasthtml/core/commands.py b/src/myfasthtml/core/commands.py index 57ec103..b3577f6 100644 --- a/src/myfasthtml/core/commands.py +++ b/src/myfasthtml/core/commands.py @@ -36,6 +36,7 @@ class BaseCommand: def get_htmx_params(self): return self._htmx_extra | { "hx-post": f"{ROUTE_ROOT}{Routes.Commands}", + "hx-swap": "outerHTML", "hx-vals": f'{{"c_id": "{self.id}"}}', } @@ -124,11 +125,17 @@ class Command(BaseCommand): for data in self._bindings: remove_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback) + # Set the hx-swap-oob attribute on all elements returned by the callback + if isinstance(ret, (list, tuple)): + for r in ret[1:]: + if hasattr(r, 'attrs'): + r.attrs["hx-swap-oob"] = "true" + if not ret_from_bindings: return ret - if isinstance(ret, list): - return ret + ret_from_bindings + if isinstance(ret, (list, tuple)): + return list(ret) + ret_from_bindings else: return [ret] + ret_from_bindings diff --git a/src/myfasthtml/myfastapp.py b/src/myfasthtml/myfastapp.py index d76eded..b139c5a 100644 --- a/src/myfasthtml/myfastapp.py +++ b/src/myfasthtml/myfastapp.py @@ -54,7 +54,10 @@ def create_app(daisyui: Optional[bool] = True, :return: A tuple containing the FastHtml application instance and the associated router. :rtype: Any """ - hdrs = [Link(href="/myfasthtml/myfasthtml.css", rel="stylesheet", type="text/css")] + hdrs = [ + Link(href="/myfasthtml/myfasthtml.css", rel="stylesheet", type="text/css"), + Script(src="/myfasthtml/myfasthtml.js"), + ] if daisyui: hdrs += [ diff --git a/tests/core/test_commands.py b/tests/core/test_commands.py index e907a9f..f98ad23 100644 --- a/tests/core/test_commands.py +++ b/tests/core/test_commands.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from typing import Any import pytest -from fasthtml.components import Button +from fasthtml.components import Button, Div from myutils.observable import make_observable, bind from myfasthtml.core.commands import Command, CommandsManager @@ -93,3 +93,32 @@ def test_i_can_bind_a_command_to_an_observable_2(): res = command.execute() assert res == ["another 1", "another 2", ("hello", "new value")] + + +def test_by_default_swap_is_set_to_outer_html(): + command = Command('test', 'Command description', callback) + elt = Button() + updated = command.bind_ft(elt) + + expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}", hx_swap="outerHTML") + + assert matches(updated, expected) + + +@pytest.mark.parametrize("return_values", [ + [Div(), Div(), "hello", Div()], # list + (Div(), Div(), "hello", Div()) # tuple +]) +def test_swap_oob_is_automatically_set_when_multiple_elements_are_returned(return_values): + """Test that hx-swap-oob is automatically set, but not for the first.""" + + def another_callback(): + return return_values + + command = Command('test', 'Command description', another_callback) + + res = command.execute() + + assert "hx_swap_oob" not in res[0].attrs + assert res[1].attrs["hx-swap-oob"] == "true" + assert res[3].attrs["hx-swap-oob"] == "true"