I can toogle the left drawer

This commit is contained in:
2025-11-10 08:44:59 +01:00
parent 459c89bae2
commit 5cb628099a
9 changed files with 588 additions and 4 deletions

23
src/app.py Normal file
View File

@@ -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)

View File

@@ -12,4 +12,160 @@
height: 16px; height: 16px;
margin-top: auto; margin-top: auto;
margin-bottom: 4px; 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;
} }

View File

@@ -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};
}

View File

@@ -0,0 +1,4 @@
class BaseCommands:
def __init__(self, owner):
self._owner = owner
self._id = owner.get_id()

View File

@@ -0,0 +1,7 @@
class BaseControl:
def __init__(self, session, _id):
self.session = session
self._id = _id
def get_id(self):
return self._id

View File

@@ -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()

View File

@@ -36,6 +36,7 @@ class BaseCommand:
def get_htmx_params(self): def get_htmx_params(self):
return self._htmx_extra | { return self._htmx_extra | {
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}", "hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
"hx-swap": "outerHTML",
"hx-vals": f'{{"c_id": "{self.id}"}}', "hx-vals": f'{{"c_id": "{self.id}"}}',
} }
@@ -124,11 +125,17 @@ class Command(BaseCommand):
for data in self._bindings: for data in self._bindings:
remove_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback) 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: if not ret_from_bindings:
return ret return ret
if isinstance(ret, list): if isinstance(ret, (list, tuple)):
return ret + ret_from_bindings return list(ret) + ret_from_bindings
else: else:
return [ret] + ret_from_bindings return [ret] + ret_from_bindings

View File

@@ -54,7 +54,10 @@ def create_app(daisyui: Optional[bool] = True,
:return: A tuple containing the FastHtml application instance and the associated router. :return: A tuple containing the FastHtml application instance and the associated router.
:rtype: Any :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: if daisyui:
hdrs += [ hdrs += [

View File

@@ -2,7 +2,7 @@ from dataclasses import dataclass
from typing import Any from typing import Any
import pytest import pytest
from fasthtml.components import Button from fasthtml.components import Button, Div
from myutils.observable import make_observable, bind from myutils.observable import make_observable, bind
from myfasthtml.core.commands import Command, CommandsManager 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() res = command.execute()
assert res == ["another 1", "another 2", ("hello", "new value")] 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"