2 Commits

Author SHA1 Message Date
d909f2125d Added scrollbars 2026-01-11 23:25:55 +01:00
5d6c02001e Implemented lazy loading 2026-01-11 15:49:20 +01:00
9 changed files with 592 additions and 359 deletions

View File

@@ -690,7 +690,7 @@
.mf-panel-main { .mf-panel-main {
flex: 1; flex: 1;
height: 100%; height: 100%;
overflow: auto; overflow: hidden;
min-width: 0; /* Important to allow the shrinking of flexbox */ min-width: 0; /* Important to allow the shrinking of flexbox */
} }
@@ -829,169 +829,33 @@
/* ********************************************* */ /* ********************************************* */
/* ************* Datagrid Component ************ */ /* ************* Datagrid Component ************ */
/* ********************************************* */ /* ********************************************* */
input:focus {
outline: none;
}
.dt2-drag-drop {
display: none;
position: absolute;
top: 100%;
z-index: var(--datagrid-drag-drop-zindex);
width: 100px;
border: 1px solid var(--color-base-300);
border-radius: 10px;
padding: 10px;
box-shadow: 0 0 40px rgba(0, 0, 0, 0.3);
background: var(--color-base-100);
box-sizing: border-box;
overflow-x: auto;
pointer-events: none; /* Prevent interfering with mouse events */
}
.dt2-main {
height: 100%;
position: relative;
}
.dt2-sidebar {
opacity: 0; /* Default to invisible */
visibility: hidden; /* Prevent interaction when invisible */
transition: opacity 0.3s ease, visibility 0s linear 0.3s; /* Delay visibility removal */
position: absolute;
top: 0;
right: 0;
width: 75%;
max-height: 710px;
overflow-y: auto;
background-color: var(--color-base-100);
z-index: var(--datagrid-sidebar-zindex);
box-shadow: -5px 0 15px rgba(0, 0, 0, 0.5); /* Stronger shadow */
border-radius: 10px;
}
.dt2-sidebar.active {
opacity: 1;
visibility: visible;
transition: opacity 0.3s ease;
}
.dt2-container {
position: relative;
}
.dt2-scrollbars {
position: absolute;
top: 24px;
bottom: 0px;
left: 0;
right: 0;
pointer-events: none; /* Ensures parents don't intercept pointer events */
z-index: var(--datagrid-scrollbars-zindex);
}
/* Scrollbar Wrappers common attributes*/
.dt2-scrollbars-vertical-wrapper,
.dt2-scrollbars-horizontal-wrapper {
position: absolute;
background-color: var(--color-base-200);
opacity: 1;
transition: opacity 0.2s ease-in-out; /* Smooth fade in/out */
pointer-events: auto; /* Allow interaction */
}
/* Scrollbar Wrappers */
.dt2-scrollbars-vertical-wrapper {
left: auto;
right: 3px;
top: 3px;
bottom: 3px;
width: 8px;
}
.dt2-scrollbars-horizontal-wrapper {
left: 3px;
right: 3px;
top: auto;
bottom: -12px;
height: 8px;
}
/* Scrollbars */
.dt2-scrollbars-vertical,
.dt2-scrollbars-horizontal {
background-color: var(--color-base-300);
border-radius: 3px;
pointer-events: auto; /* Allow interaction with the scrollbar */
cursor: pointer;
position: absolute;
border-radius: 3px; /* Rounded corners */
pointer-events: auto; /* Enable interaction */
cursor: pointer;
}
/* Vertical Scrollbar */
.dt2-scrollbars-vertical {
left: 0;
right: 0;
top: auto;
bottom: auto;
width: 100%; /* Fits inside its wrapper */
}
/* Horizontal Scrollbar */
.dt2-scrollbars-horizontal {
left: auto;
right: auto;
top: 0;
bottom: 0;
height: 100%; /* Fits inside its wrapper */
}
/* Scrollbar hover effects */
.dt2-scrollbars-vertical:hover,
.dt2-scrollbars-horizontal:hover,
.dt2-scrollbars-vertical.dt2-dragging,
.dt2-scrollbars-horizontal.dt2-dragging {
background-color: var(--color-base-content);
}
.dt2-table {
--color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000);
--color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000);
display: flex;
flex-direction: column;
border: 1px solid var(--color-border);
border-radius: 10px;
overflow: hidden;
}
.dt2-table:focus {
outline: none;
}
/* Header and Footer */
.dt2-header, .dt2-header,
.dt2-footer { .dt2-footer {
background-color: var(--color-base-200); background-color: var(--color-base-200);
border-radius: 10px 10px 0 0; border-radius: 10px 10px 0 0;
min-width: max-content; min-width: max-content; /* Content width propagates to scrollable parent */
} }
/* Body */
.dt2-body { .dt2-body {
overflow: hidden; /* You can change this to auto if horizontal scrolling is required */ overflow: hidden;
font-size: 14px; font-size: 14px;
min-width: max-content; min-width: max-content; /* Content width propagates to scrollable parent */
} }
/* Row */
.dt2-row { .dt2-row {
display: flex; display: flex;
width: 100%; width: 100%;
height: 22px; height: 22px;
} }
/* Cell */
.dt2-cell { .dt2-cell {
--color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000);
--color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
@@ -1002,11 +866,12 @@ input:focus {
min-width: 100px; min-width: 100px;
flex-grow: 0; flex-grow: 0;
flex-shrink: 1; flex-shrink: 1;
box-sizing: border-box; /* to include the borders in the computations */ box-sizing: border-box;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
user-select: none; user-select: none;
} }
/* Cell content types */
.dt2-cell-content-text { .dt2-cell-content-text {
text-align: inherit; text-align: inherit;
width: 100%; width: 100%;
@@ -1016,8 +881,8 @@ input:focus {
.dt2-cell-content-checkbox { .dt2-cell-content-checkbox {
display: flex; display: flex;
width: 100%; width: 100%;
justify-content: center; /* Horizontally center the icon */ justify-content: center;
align-items: center; /* Vertically center the icon */ align-items: center;
} }
.dt2-cell-content-number { .dt2-cell-content-number {
@@ -1026,36 +891,12 @@ input:focus {
padding-right: 10px; padding-right: 10px;
} }
/* Footer cell */
.dt2-footer-cell { .dt2-footer-cell {
cursor: pointer cursor: pointer;
}
.dt2-footer-menu {
position: absolute;
display: None;
z-index: var(--datagrid-menu-zindex);
border: 1px solid oklch(var(--b3));
box-sizing: border-box;
width: 80px;
background-color: var(--color-base-100); /* Add background color */
opacity: 1; /* Ensure full opacity */
}
.dt2-footer-menu.show {
display: block;
}
.dt2-footer-menu-item {
padding: 0 8px;
border-radius: 4px;
background-color: var(--color-base-100); /* Add background color */
}
.dt2-footer-menu-item:hover {
background: color-mix(in oklab, var(--color-base-100, var(--color-base-200)), #000 7%);
cursor: pointer
} }
/* Resize handle */
.dt2-resize-handle { .dt2-resize-handle {
position: absolute; position: absolute;
right: 0; right: 0;
@@ -1066,100 +907,149 @@ input:focus {
} }
.dt2-resize-handle::after { .dt2-resize-handle::after {
content: ''; /* This is required */ content: '';
position: absolute; /* Position as needed */ position: absolute;
z-index: var(--datagrid-resize-zindex); z-index: var(--datagrid-resize-zindex);
display: block; /* Makes it a block element */ display: block;
width: 3px; width: 3px;
height: 60%; height: 60%;
top: calc(50% - 60% * 0.5); top: calc(50% - 60% * 0.5);
background-color: var(--color-resize); background-color: var(--color-resize);
} }
.dt2-header-hidden { /* Hidden column */
width: 5px;
background: var(--color-neutral-content);
border-bottom: 1px solid var(--color-border);
cursor: pointer;
}
.dt2-col-hidden { .dt2-col-hidden {
width: 5px; width: 5px;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
/* Highlight */
.dt2-highlight-1 { .dt2-highlight-1 {
color: var(--color-accent); color: var(--color-accent);
} }
.dt2-item-handle { /* *********************************************** */
background-image: radial-gradient(var(--color-primary-content) 40%, transparent 0); /* ******** DataGrid Fixed Header/Footer ******** */
background-repeat: repeat; /* *********************************************** */
background-size: 4px 4px;
cursor: grab; /*
display: inline-block; * DataGrid with CSS Grid + Custom Scrollbars
height: 16px; * - Wrapper takes 100% of parent height
margin: auto; * - Table uses Grid: header (auto) + body (1fr) + footer (auto)
* - Native scrollbars hidden, custom scrollbars overlaid
* - Vertical scrollbar: right side of entire table
* - Horizontal scrollbar: bottom, under footer
*/
/* Main wrapper - takes full parent height, contains table + scrollbars */
.dt2-table-wrapper {
height: 100%;
overflow: hidden;
position: relative; position: relative;
top: 1px;
width: 12px;
} }
/* **************************************************************************** */ /* Table with Grid layout - horizontal scroll enabled, scrollbars hidden */
/* COLUMNS SETTINGS */ .dt2-table {
/* **************************************************************************** */ height: 100%;
.dt2-cs-header {
background-color: var(--color-base-200);
min-width: max-content;
}
.dt2-cs-columns {
display: grid; display: grid;
grid-template-columns: 20px 1fr 0.5fr 0.5fr 0.5fr 0.5fr; grid-template-rows: auto 1fr auto; /* header, body, footer */
overflow-x: auto; /* Enable horizontal scroll */
overflow-y: hidden; /* No vertical scroll on table */
scrollbar-width: none; /* Firefox: hide scrollbar */
-ms-overflow-style: none; /* IE/Edge: hide scrollbar */
} }
.dt2-cs-body input { /* Chrome/Safari: hide scrollbar */
outline: none; .dt2-table::-webkit-scrollbar {
border-color: transparent; display: none;
box-shadow: none;
} }
.dt2-cs-body input[type="checkbox"], /* Header - no scroll, takes natural height */
.dt2-cs-body input.checkbox { .dt2-header-container {
outline: initial; overflow: hidden;
border-color: var(--color-border); min-width: max-content; /* Force table to be as wide as content */
} }
/* Body - scrollable vertically via JS, scrollbars hidden */
.dt2-cs-cell { .dt2-body-container {
padding: 0 6px 0 6px; overflow: hidden; /* Scrollbars hidden, scroll via JS */
margin: auto; min-height: 0; /* Important for Grid to allow shrinking */
min-width: max-content; /* Force table to be as wide as content */
} }
.dt2-cs-checkbox-cell { /* Footer - no scroll, takes natural height */
margin: auto; .dt2-footer-container {
overflow: hidden;
min-width: max-content; /* Force table to be as wide as content */
} }
.dt2-cs-number-cell { /* Custom scrollbars container - overlaid on table */
padding: 0 6px 0 6px; .dt2-scrollbars {
text-align: right; position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
pointer-events: none; /* Let clicks pass through */
z-index: 10;
} }
.dt2-cs-select-cell { /* Scrollbar wrappers - clickable/draggable */
padding: 0 6px; .dt2-scrollbars-vertical-wrapper,
margin: 3px 0; .dt2-scrollbars-horizontal-wrapper {
position: absolute;
background-color: var(--color-base-200);
opacity: 1;
transition: opacity 0.2s ease-in-out;
pointer-events: auto; /* Enable interaction */
} }
.dt2-cs-body input:hover { /* Vertical scrollbar wrapper - right side, full table height */
border: 1px solid #ccc; /* Provide a subtle border on focus */ .dt2-scrollbars-vertical-wrapper {
right: 0;
top: 0;
bottom: 0;
width: 8px;
} }
/* Horizontal scrollbar wrapper - bottom, full width minus vertical scrollbar */
.dt2-views-container-select { .dt2-scrollbars-horizontal-wrapper {
width: 170px; left: 0;
right: 8px; /* Leave space for vertical scrollbar */
bottom: 0;
height: 8px;
} }
.dt2-views-container-create { /* Scrollbar thumbs */
width: 300px; .dt2-scrollbars-vertical,
.dt2-scrollbars-horizontal {
background-color: color-mix(in oklab, var(--color-base-content) 20%, #0000);
border-radius: 3px;
position: absolute;
cursor: pointer;
transition: background-color 0.2s ease;
}
/* Vertical scrollbar thumb */
.dt2-scrollbars-vertical {
left: 0;
right: 0;
top: 0;
width: 100%;
}
/* Horizontal scrollbar thumb */
.dt2-scrollbars-horizontal {
top: 0;
bottom: 0;
left: 0;
height: 100%;
}
/* Hover and dragging states */
.dt2-scrollbars-vertical:hover,
.dt2-scrollbars-horizontal:hover,
.dt2-scrollbars-vertical.dt2-dragging,
.dt2-scrollbars-horizontal.dt2-dragging {
background-color: color-mix(in oklab, var(--color-base-content) 30%, #0000);
} }

View File

@@ -1466,3 +1466,280 @@ function updateTabs(controllerId) {
}; };
})(); })();
/**
* Initialize DataGrid with CSS Grid layout + Custom Scrollbars
*
* Adapted from previous custom scrollbar implementation to work with CSS Grid.
* - Grid handles layout (no height calculations needed)
* - Custom scrollbars for visual consistency and positioning control
* - Vertical scroll: on body container (.dt2-body-container)
* - Horizontal scroll: on table (.dt2-table) to scroll header, body, footer together
*
* @param {string} gridId - The ID of the DataGrid instance
*/
function initDataGridScrollbars(gridId) {
console.debug("initDataGridScrollbars on element " + gridId);
const wrapper = document.getElementById(`tw_${gridId}`);
if (!wrapper) {
console.error(`DataGrid wrapper "tw_${gridId}" not found.`);
return;
}
const verticalScrollbar = wrapper.querySelector(".dt2-scrollbars-vertical");
const verticalWrapper = wrapper.querySelector(".dt2-scrollbars-vertical-wrapper");
const horizontalScrollbar = wrapper.querySelector(".dt2-scrollbars-horizontal");
const horizontalWrapper = wrapper.querySelector(".dt2-scrollbars-horizontal-wrapper");
const bodyContainer = wrapper.querySelector(".dt2-body-container");
const table = wrapper.querySelector(".dt2-table");
if (!verticalScrollbar || !verticalWrapper || !horizontalScrollbar || !horizontalWrapper || !bodyContainer || !table) {
console.error("Essential scrollbar or content elements are missing in the datagrid.");
return;
}
// OPTIMIZATION: Cache element references to avoid repeated querySelector calls
const header = table.querySelector(".dt2-header");
const body = table.querySelector(".dt2-body");
// OPTIMIZATION: RequestAnimationFrame flags to throttle visual updates
let rafScheduledVertical = false;
let rafScheduledHorizontal = false;
const computeScrollbarVisibility = () => {
// Vertical: check if body content exceeds body container height
const isVerticalRequired = bodyContainer.scrollHeight > bodyContainer.clientHeight;
// Horizontal: check if content width exceeds table width (use cached references)
const contentWidth = Math.max(
header ? header.scrollWidth : 0,
body ? body.scrollWidth : 0
);
const isHorizontalRequired = contentWidth > table.clientWidth;
requestAnimationFrame(() => {
verticalWrapper.style.display = isVerticalRequired ? "block" : "none";
horizontalWrapper.style.display = isHorizontalRequired ? "block" : "none";
});
};
const computeScrollbarSize = () => {
// Vertical scrollbar height
const visibleHeight = bodyContainer.clientHeight;
const totalHeight = bodyContainer.scrollHeight;
const wrapperHeight = verticalWrapper.offsetHeight;
let scrollbarHeight = 0;
if (totalHeight > 0) {
scrollbarHeight = (visibleHeight / totalHeight) * wrapperHeight;
}
// Horizontal scrollbar width (use cached references)
const visibleWidth = table.clientWidth;
const totalWidth = Math.max(
header ? header.scrollWidth : 0,
body ? body.scrollWidth : 0
);
const wrapperWidth = horizontalWrapper.offsetWidth;
let scrollbarWidth = 0;
if (totalWidth > 0) {
scrollbarWidth = (visibleWidth / totalWidth) * wrapperWidth;
}
requestAnimationFrame(() => {
verticalScrollbar.style.height = `${scrollbarHeight}px`;
horizontalScrollbar.style.width = `${scrollbarWidth}px`;
});
};
const updateVerticalScrollbarPosition = () => {
const maxScrollTop = bodyContainer.scrollHeight - bodyContainer.clientHeight;
const wrapperHeight = verticalWrapper.offsetHeight;
if (maxScrollTop > 0) {
const scrollRatio = wrapperHeight / bodyContainer.scrollHeight;
verticalScrollbar.style.top = `${bodyContainer.scrollTop * scrollRatio}px`;
}
};
const updateHorizontalScrollbarPosition = () => {
// Use cached references
const totalWidth = Math.max(
header ? header.scrollWidth : 0,
body ? body.scrollWidth : 0
);
const maxScrollLeft = totalWidth - table.clientWidth;
const wrapperWidth = horizontalWrapper.offsetWidth;
if (maxScrollLeft > 0 && totalWidth > 0) {
const scrollRatio = wrapperWidth / totalWidth;
horizontalScrollbar.style.left = `${table.scrollLeft * scrollRatio}px`;
}
};
// Drag management for vertical scrollbar
let isDraggingVertical = false;
let startYVertical = 0;
let pendingVerticalScroll = null;
verticalScrollbar.addEventListener("mousedown", (e) => {
isDraggingVertical = true;
startYVertical = e.clientY;
document.body.style.userSelect = "none";
verticalScrollbar.classList.add("dt2-dragging");
});
document.addEventListener("mousemove", (e) => {
if (isDraggingVertical) {
const deltaY = e.clientY - startYVertical;
startYVertical = e.clientY;
// OPTIMIZATION: Store the scroll delta, update visual in RAF
if (pendingVerticalScroll === null) {
pendingVerticalScroll = 0;
}
pendingVerticalScroll += deltaY;
if (!rafScheduledVertical) {
rafScheduledVertical = true;
requestAnimationFrame(() => {
rafScheduledVertical = false;
const wrapperHeight = verticalWrapper.offsetHeight;
const scrollbarHeight = verticalScrollbar.offsetHeight;
const maxScrollTop = bodyContainer.scrollHeight - bodyContainer.clientHeight;
const scrollRatio = maxScrollTop / (wrapperHeight - scrollbarHeight);
let newTop = parseFloat(verticalScrollbar.style.top || "0") + pendingVerticalScroll;
newTop = Math.max(0, Math.min(newTop, wrapperHeight - scrollbarHeight));
verticalScrollbar.style.top = `${newTop}px`;
bodyContainer.scrollTop = newTop * scrollRatio;
pendingVerticalScroll = null;
});
}
}
});
document.addEventListener("mouseup", () => {
if (isDraggingVertical) {
isDraggingVertical = false;
document.body.style.userSelect = "";
verticalScrollbar.classList.remove("dt2-dragging");
}
});
// Drag management for horizontal scrollbar
let isDraggingHorizontal = false;
let startXHorizontal = 0;
let pendingHorizontalScroll = null;
horizontalScrollbar.addEventListener("mousedown", (e) => {
isDraggingHorizontal = true;
startXHorizontal = e.clientX;
document.body.style.userSelect = "none";
horizontalScrollbar.classList.add("dt2-dragging");
});
document.addEventListener("mousemove", (e) => {
if (isDraggingHorizontal) {
const deltaX = e.clientX - startXHorizontal;
startXHorizontal = e.clientX;
// OPTIMIZATION: Store the scroll delta, update visual in RAF
if (pendingHorizontalScroll === null) {
pendingHorizontalScroll = 0;
}
pendingHorizontalScroll += deltaX;
if (!rafScheduledHorizontal) {
rafScheduledHorizontal = true;
requestAnimationFrame(() => {
rafScheduledHorizontal = false;
const wrapperWidth = horizontalWrapper.offsetWidth;
const scrollbarWidth = horizontalScrollbar.offsetWidth;
// Use cached references
const totalWidth = Math.max(
header ? header.scrollWidth : 0,
body ? body.scrollWidth : 0
);
const maxScrollLeft = totalWidth - table.clientWidth;
const scrollRatio = maxScrollLeft / (wrapperWidth - scrollbarWidth);
let newLeft = parseFloat(horizontalScrollbar.style.left || "0") + pendingHorizontalScroll;
newLeft = Math.max(0, Math.min(newLeft, wrapperWidth - scrollbarWidth));
horizontalScrollbar.style.left = `${newLeft}px`;
table.scrollLeft = newLeft * scrollRatio;
pendingHorizontalScroll = null;
});
}
}
});
document.addEventListener("mouseup", () => {
if (isDraggingHorizontal) {
isDraggingHorizontal = false;
document.body.style.userSelect = "";
horizontalScrollbar.classList.remove("dt2-dragging");
}
});
// Wheel scrolling - OPTIMIZED with RAF throttling
let rafScheduledWheel = false;
let pendingWheelDeltaX = 0;
let pendingWheelDeltaY = 0;
const handleWheelScrolling = (event) => {
// Accumulate wheel deltas
pendingWheelDeltaX += event.deltaX;
pendingWheelDeltaY += event.deltaY;
// Schedule update in next animation frame (throttle)
if (!rafScheduledWheel) {
rafScheduledWheel = true;
requestAnimationFrame(() => {
rafScheduledWheel = false;
// Apply accumulated scroll
bodyContainer.scrollTop += pendingWheelDeltaY;
table.scrollLeft += pendingWheelDeltaX;
// Update scrollbar positions
updateVerticalScrollbarPosition();
updateHorizontalScrollbarPosition();
// Reset pending deltas
pendingWheelDeltaX = 0;
pendingWheelDeltaY = 0;
});
}
event.preventDefault();
};
wrapper.addEventListener("wheel", handleWheelScrolling, { passive: false });
// Initialize scrollbars
computeScrollbarVisibility();
computeScrollbarSize();
updateVerticalScrollbarPosition();
updateHorizontalScrollbarPosition();
// Recompute on window resize
window.addEventListener("resize", () => {
computeScrollbarVisibility();
computeScrollbarSize();
updateVerticalScrollbarPosition();
updateHorizontalScrollbarPosition();
});
console.info(`DataGrid "${gridId}" initialized with custom scrollbars`);
}

View File

@@ -11,6 +11,7 @@ from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \ from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
from myfasthtml.controls.helpers import mk from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.constants import ColumnType, ROW_INDEX_ID, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID from myfasthtml.core.constants import ColumnType, ROW_INDEX_ID, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID
from myfasthtml.core.dbmanager import DbObject from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance from myfasthtml.core.instances import MultipleInstance
@@ -25,14 +26,14 @@ _HTML_SPECIAL_CHARS_REGEX = re.compile(r'[<>&"\']')
@lru_cache(maxsize=2) @lru_cache(maxsize=2)
def _mk_bool_cached(_value): def _mk_bool_cached(_value):
""" """
OPTIMIZED: Cached boolean checkbox HTML generator. OPTIMIZED: Cached boolean checkbox HTML generator.
Since there are only 2 possible values (True/False), this will only generate HTML twice. Since there are only 2 possible values (True/False), this will only generate HTML twice.
""" """
return NotStr(str( return NotStr(str(
Div(mk.icon(checkbox_checked16_regular if _value else checkbox_unchecked16_regular, can_select=False), Div(mk.icon(checkbox_checked16_regular if _value else checkbox_unchecked16_regular, can_select=False),
cls="dt2-cell-content-checkbox") cls="dt2-cell-content-checkbox")
)) ))
class DatagridState(DbObject): class DatagridState(DbObject):
@@ -41,7 +42,7 @@ class DatagridState(DbObject):
with self.initializing(): with self.initializing():
self.sidebar_visible: bool = False self.sidebar_visible: bool = False
self.selected_view: str = None self.selected_view: str = None
self.row_index: bool = False self.row_index: bool = True
self.columns: list[DataGridColumnState] = [] self.columns: list[DataGridColumnState] = []
self.rows: list[DataGridRowState] = [] # only the rows that have a specific state self.rows: list[DataGridRowState] = [] # only the rows that have a specific state
self.headers: list[DataGridHeaderFooterConf] = [] self.headers: list[DataGridHeaderFooterConf] = []
@@ -70,7 +71,17 @@ class DatagridSettings(DbObject):
class Commands(BaseCommands): class Commands(BaseCommands):
pass def get_page(self, page_index: int):
return Command("GetPage",
"Get a specific page of data",
self._owner,
self._owner.mk_body_content_page,
kwargs={"page_index": page_index}
).htmx(target=f"#tb_{self._id}",
swap="beforeend",
trigger=f"intersect root:#tb_{self._id} once",
auto_swap_oob=False
)
class DataGrid(MultipleInstance): class DataGrid(MultipleInstance):
@@ -175,18 +186,18 @@ class DataGrid(MultipleInstance):
- Avoids html.escape when not necessary - Avoids html.escape when not necessary
- Uses cached boolean HTML (_mk_bool_cached) - Uses cached boolean HTML (_mk_bool_cached)
""" """
def mk_highlighted_text(value_str, css_class): def mk_highlighted_text(value_str, css_class):
"""Return highlighted text as raw HTML string or tuple of Spans.""" """Return highlighted text as raw HTML string or tuple of Spans."""
if not filter_keyword_lower: if not filter_keyword_lower:
# OPTIMIZATION: Return plain HTML string instead of Label object # OPTIMIZATION: Return plain HTML string instead of Label object
# Include "truncate text-sm" to match mk.label() behavior (ellipsis + font size) # Include "truncate text-sm" to match mk.label() behavior (ellipsis + font size)
return NotStr(f'<span class="{css_class} truncate text-sm">{value_str}</span>') return NotStr(f'<span class="{css_class} truncate text-sm">{value_str}</span>')
index = value_str.lower().find(filter_keyword_lower) index = value_str.lower().find(filter_keyword_lower)
if index < 0: if index < 0:
return NotStr(f'<span class="{css_class} truncate text-sm">{value_str}</span>') return NotStr(f'<span class="{css_class} truncate text-sm">{value_str}</span>')
# Has highlighting - need to use Span objects # Has highlighting - need to use Span objects
# Add "truncate text-sm" to match mk.label() behavior # Add "truncate text-sm" to match mk.label() behavior
len_keyword = len(filter_keyword_lower) len_keyword = len(filter_keyword_lower)
@@ -197,25 +208,25 @@ class DataGrid(MultipleInstance):
if index + len_keyword < len(value_str): if index + len_keyword < len(value_str):
res.append(Span(value_str[index + len_keyword:], cls=f"{css_class} text-sm")) res.append(Span(value_str[index + len_keyword:], cls=f"{css_class} text-sm"))
return Span(*res, cls=f"{css_class} truncate") if len(res) > 1 else res[0] return Span(*res, cls=f"{css_class} truncate") if len(res) > 1 else res[0]
column_type = col_def.type column_type = col_def.type
value = self._state.ns_fast_access[col_def.col_id][row_index] value = self._state.ns_fast_access[col_def.col_id][row_index]
# Boolean type - uses cached HTML (only 2 possible values) # Boolean type - uses cached HTML (only 2 possible values)
if column_type == ColumnType.Bool: if column_type == ColumnType.Bool:
return _mk_bool_cached(value) return _mk_bool_cached(value)
# RowIndex - simplest case, just return the number as plain HTML # RowIndex - simplest case, just return the number as plain HTML
if column_type == ColumnType.RowIndex: if column_type == ColumnType.RowIndex:
return NotStr(f'<span class="dt2-cell-content-number truncate text-sm">{row_index}</span>') return NotStr(f'<span class="dt2-cell-content-number truncate text-sm">{row_index}</span>')
# Convert value to string # Convert value to string
value_str = str(value) value_str = str(value)
# OPTIMIZATION: Only escape if necessary (check for HTML special chars with pre-compiled regex) # OPTIMIZATION: Only escape if necessary (check for HTML special chars with pre-compiled regex)
if _HTML_SPECIAL_CHARS_REGEX.search(value_str): if _HTML_SPECIAL_CHARS_REGEX.search(value_str):
value_str = html.escape(value_str) value_str = html.escape(value_str)
# Number or Text type # Number or Text type
if column_type == ColumnType.Number: if column_type == ColumnType.Number:
return mk_highlighted_text(value_str, "dt2-cell-content-number") return mk_highlighted_text(value_str, "dt2-cell-content-number")
@@ -229,12 +240,12 @@ class DataGrid(MultipleInstance):
""" """
if not col_def.usable: if not col_def.usable:
return None return None
if not col_def.visible: if not col_def.visible:
return OptimizedDiv(cls="dt2-col-hidden") return OptimizedDiv(cls="dt2-col-hidden")
content = self.mk_body_cell_content(col_pos, row_index, col_def, filter_keyword_lower) content = self.mk_body_cell_content(col_pos, row_index, col_def, filter_keyword_lower)
return OptimizedDiv(content, return OptimizedDiv(content,
data_col=col_def.col_id, data_col=col_def.col_id,
style=f"width:{col_def.width}px;", style=f"width:{col_def.width}px;",
@@ -252,29 +263,29 @@ class DataGrid(MultipleInstance):
last_row = df.index[end - 1] last_row = df.index[end - 1]
else: else:
last_row = None last_row = None
# OPTIMIZATION: Extract filter keyword once (was being checked 10,000 times)
filter_keyword = self._state.filtered.get(FILTER_INPUT_CID) filter_keyword = self._state.filtered.get(FILTER_INPUT_CID)
filter_keyword_lower = filter_keyword.lower() if filter_keyword else None filter_keyword_lower = filter_keyword.lower() if filter_keyword else None
rows = [OptimizedDiv( rows = [OptimizedDiv(
*[self.mk_body_cell(col_pos, row_index, col_def, filter_keyword_lower) *[self.mk_body_cell(col_pos, row_index, col_def, filter_keyword_lower)
for col_pos, col_def in enumerate(self._state.columns)], for col_pos, col_def in enumerate(self._state.columns)],
cls="dt2-row", cls="dt2-row",
data_row=f"{row_index}", data_row=f"{row_index}",
_id=f"tr_{self._id}-{row_index}", id=f"tr_{self._id}-{row_index}",
**self.commands.get_page(page_index + 1).get_htmx_params(escaped=True) if row_index == last_row else {}
) for row_index in df.index[start:end]] ) for row_index in df.index[start:end]]
return rows return rows
def mk_body(self): def mk_body(self):
return Div( return Div(
*self.mk_body_content_page(0), *self.mk_body_content_page(0),
cls="dt2-body", cls="dt2-body"
id=f"tb_{self._id}",
) )
def mk_footers(self): def mk_footers(self):
return self.mk_headers()
return Div( return Div(
*[Div( *[Div(
*[self.mk_aggregation_cell(col_def, row_index, footer) for col_def in self._state.columns], *[self.mk_aggregation_cell(col_def, row_index, footer) for col_def in self._state.columns],
@@ -288,9 +299,43 @@ class DataGrid(MultipleInstance):
def mk_table(self): def mk_table(self):
return Div( return Div(
self.mk_headers(), # Grid table with header, body, footer
self.mk_body(), Div(
self.mk_footers() # Header container - no scroll
Div(
self.mk_headers(),
cls="dt2-header-container"
),
# Body container - scroll via JS, scrollbars hidden
Div(
self.mk_body(),
cls="dt2-body-container",
id=f"tb_{self._id}"
),
# Footer container - no scroll
Div(
self.mk_footers(),
cls="dt2-footer-container"
),
cls="dt2-table",
id=f"t_{self._id}"
),
# Custom scrollbars overlaid
Div(
# Vertical scrollbar wrapper (right side)
Div(
Div(cls="dt2-scrollbars-vertical"),
cls="dt2-scrollbars-vertical-wrapper"
),
# Horizontal scrollbar wrapper (bottom)
Div(
Div(cls="dt2-scrollbars-horizontal"),
cls="dt2-scrollbars-horizontal-wrapper"
),
cls="dt2-scrollbars"
),
cls="dt2-table-wrapper",
id=f"tw_{self._id}"
) )
def mk_aggregation_cell(self, col_def, row_index: int, footer_conf, oob=False): def mk_aggregation_cell(self, col_def, row_index: int, footer_conf, oob=False):
@@ -358,13 +403,12 @@ class DataGrid(MultipleInstance):
def render(self): def render(self):
if self._state.ne_df is None: if self._state.ne_df is None:
return Div("No data to display !") return Div("No data to display !")
return Div( return Div(
Div( self.mk_table(),
self.mk_table(), Script(f"initDataGridScrollbars('{self._id}');"),
# Script(f"bindDatagrid('{self._id}', false);"), id=self._id,
), style="height: 100%;"
id=self._id
) )
def __ft__(self): def __ft__(self):

View File

@@ -1,5 +1,6 @@
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from io import BytesIO
import pandas as pd import pandas as pd
from fasthtml.components import Div from fasthtml.components import Div
@@ -90,7 +91,7 @@ class DataGridsManager(MultipleInstance):
def open_from_excel(self, tab_id, file_upload: FileUpload): def open_from_excel(self, tab_id, file_upload: FileUpload):
excel_content = file_upload.get_content() excel_content = file_upload.get_content()
df = pd.read_excel(excel_content, file_upload.get_sheet_name()) df = pd.read_excel(BytesIO(excel_content), file_upload.get_sheet_name())
dg = DataGrid(self._tabs_manager, save_state=True) dg = DataGrid(self._tabs_manager, save_state=True)
dg.init_from_dataframe(df) dg.init_from_dataframe(df)
document = DocumentDefinition( document = DocumentDefinition(

View File

@@ -1,6 +1,6 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from myfasthtml.core.constants import ColumnType, DEFAULT_COLUMN_WIDTH, ViewType from myfasthtml.core.constants import ColumnType, DATAGRID_DEFAULT_COLUMN_WIDTH, ViewType
@dataclass @dataclass
@@ -17,7 +17,7 @@ class DataGridColumnState:
type: ColumnType = ColumnType.Text type: ColumnType = ColumnType.Text
visible: bool = True visible: bool = True
usable: bool = True usable: bool = True
width: int = DEFAULT_COLUMN_WIDTH width: int = DATAGRID_DEFAULT_COLUMN_WIDTH
@dataclass @dataclass

View File

@@ -1,3 +1,4 @@
import html
import inspect import inspect
import json import json
import logging import logging
@@ -11,6 +12,7 @@ from myfasthtml.core.utils import flatten
logger = logging.getLogger("Commands") logger = logging.getLogger("Commands")
AUTO_SWAP_OOB = "__auto_swap_oob__"
class Command: class Command:
""" """
@@ -71,7 +73,7 @@ class Command:
self.callback = callback self.callback = callback
self.default_args = args or [] self.default_args = args or []
self.default_kwargs = kwargs or {} self.default_kwargs = kwargs or {}
self._htmx_extra = {} self._htmx_extra = {AUTO_SWAP_OOB: True}
self._bindings = [] self._bindings = []
self._ft = None self._ft = None
self._callback_parameters = dict(inspect.signature(callback).parameters) if callback else {} self._callback_parameters = dict(inspect.signature(callback).parameters) if callback else {}
@@ -97,7 +99,7 @@ class Command:
def get_key(self): def get_key(self):
return self._key return self._key
def get_htmx_params(self): def get_htmx_params(self, escaped=False):
res = { res = {
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}", "hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
"hx-swap": "outerHTML", "hx-swap": "outerHTML",
@@ -115,10 +117,13 @@ class Command:
# kwarg are given to the callback as values # kwarg are given to the callback as values
res["hx-vals"] |= self.default_kwargs res["hx-vals"] |= self.default_kwargs
if escaped:
res["hx-vals"] = html.escape(json.dumps(res["hx-vals"]))
return res return res
def execute(self, client_response: dict = None): def execute(self, client_response: dict = None):
logger.debug(f"Executing command {self.name}") logger.debug(f"Executing command {self.name} with arguments {client_response=}")
with ObservableResultCollector(self._bindings) as collector: with ObservableResultCollector(self._bindings) as collector:
kwargs = self._create_kwargs(self.default_kwargs, kwargs = self._create_kwargs(self.default_kwargs,
client_response, client_response,
@@ -135,15 +140,18 @@ class Command:
all_ret = flatten(ret, ret_from_bound_commands, collector.results) all_ret = flatten(ret, ret_from_bound_commands, collector.results)
# Set the hx-swap-oob attribute on all elements returned by the callback # Set the hx-swap-oob attribute on all elements returned by the callback
for r in all_ret[1:]: if self._htmx_extra[AUTO_SWAP_OOB]:
if (hasattr(r, 'attrs') for r in all_ret[1:]:
and "hx-swap-oob" not in r.attrs if (hasattr(r, 'attrs')
and r.get("id", None) is not None): and "hx-swap-oob" not in r.attrs
r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true") and r.get("id", None) is not None):
r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true")
return all_ret[0] if len(all_ret) == 1 else all_ret return all_ret[0] if len(all_ret) == 1 else all_ret
def htmx(self, target: Optional[str] = "this", swap="outerHTML", trigger=None): def htmx(self, target: Optional[str] = "this", swap="outerHTML", trigger=None, auto_swap_oob=True):
self._htmx_extra[AUTO_SWAP_OOB] = auto_swap_oob
# Note that the default value is the same than in get_htmx_params() # Note that the default value is the same than in get_htmx_params()
if target is None: if target is None:
self._htmx_extra["hx-swap"] = "none" self._htmx_extra["hx-swap"] = "none"

View File

@@ -1,14 +1,16 @@
from enum import Enum from enum import Enum
DEFAULT_COLUMN_WIDTH = 100 NO_DEFAULT_VALUE = object()
ROUTE_ROOT = "/myfasthtml" ROUTE_ROOT = "/myfasthtml"
# Datagrid # Datagrid
ROW_INDEX_ID = "__row_index__" ROW_INDEX_ID = "__row_index__"
DATAGRID_DEFAULT_COLUMN_WIDTH = 100
DATAGRID_PAGE_SIZE = 1000 DATAGRID_PAGE_SIZE = 1000
FILTER_INPUT_CID = "__filter_input__" FILTER_INPUT_CID = "__filter_input__"
class Routes: class Routes:
Commands = "/commands" Commands = "/commands"
Bindings = "/bindings" Bindings = "/bindings"

View File

@@ -3,6 +3,7 @@ import uuid
from typing import Optional from typing import Optional
from myfasthtml.controls.helpers import Ids from myfasthtml.controls.helpers import Ids
from myfasthtml.core.constants import NO_DEFAULT_VALUE
from myfasthtml.core.utils import pascal_to_snake, get_class, snake_to_pascal from myfasthtml.core.utils import pascal_to_snake, get_class, snake_to_pascal
logger = logging.getLogger("InstancesManager") logger = logging.getLogger("InstancesManager")
@@ -183,7 +184,7 @@ class InstancesManager:
return instance return instance
@staticmethod @staticmethod
def get(session: dict, instance_id: str, default="**__no_default__**"): def get(session: dict, instance_id: str, default=NO_DEFAULT_VALUE):
""" """
Get or create an instance of the given type (from its id) Get or create an instance of the given type (from its id)
:param session: :param session:
@@ -196,9 +197,9 @@ class InstancesManager:
key = (session_id, instance_id) key = (session_id, instance_id)
return InstancesManager.instances[key] return InstancesManager.instances[key]
except KeyError: except KeyError:
if default != "**__non__**": if default is NO_DEFAULT_VALUE:
return default raise
raise return default
@staticmethod @staticmethod
def get_by_type(session: dict, cls: type): def get_by_type(session: dict, cls: type):
@@ -211,19 +212,19 @@ class InstancesManager:
@staticmethod @staticmethod
def dynamic_get(session, component_parent: tuple, component: tuple): def dynamic_get(session, component_parent: tuple, component: tuple):
component_type, component_id = component component_type, component_id = component
# 1. Check if component already exists # 1. Check if component already exists
existing = InstancesManager.get(session, component_id, None) existing = InstancesManager.get(session, component_id, None)
if existing is not None: if existing is not None:
logger.debug(f"Component {component_id} already exists, returning existing instance") logger.debug(f"Component {component_id} already exists, returning existing instance")
return existing return existing
# 2. Component doesn't exist, create it # 2. Component doesn't exist, create it
parent_type, parent_id = component_parent parent_type, parent_id = component_parent
# parent should always exist # parent should always exist
parent = InstancesManager.get(session, parent_id) parent = InstancesManager.get(session, parent_id)
real_component_type = snake_to_pascal(component_type.removeprefix("mf-")) real_component_type = snake_to_pascal(component_type.removeprefix("mf-"))
component_full_type = f"myfasthtml.controls.{real_component_type}.{real_component_type}" component_full_type = f"myfasthtml.controls.{real_component_type}.{real_component_type}"
cls = get_class(component_full_type) cls = get_class(component_full_type)

View File

@@ -9,74 +9,84 @@ from functools import lru_cache
from fasthtml.common import NotStr from fasthtml.common import NotStr
from myfasthtml.core.constants import NO_DEFAULT_VALUE
class OptimizedFt: class OptimizedFt:
"""Lightweight FastHTML-compatible element that generates HTML directly.""" """Lightweight FastHTML-compatible element that generates HTML directly."""
ATTR_MAP = { ATTR_MAP = {
"cls": "class", "cls": "class",
"_id": "id", "_id": "id",
} }
def __init__(self, tag, *args, **kwargs): def __init__(self, tag, *args, **kwargs):
self.tag = tag self.tag = tag
self.children = args self.children = args
self.attrs = {self.safe_attr(k): v for k, v in kwargs.items() if v is not None} self.attrs = {self.safe_attr(k): v for k, v in kwargs.items() if v is not None}
@staticmethod @staticmethod
@lru_cache(maxsize=128) @lru_cache(maxsize=128)
def safe_attr(attr_name): def safe_attr(attr_name):
"""Convert Python attribute names to HTML attribute names.""" """Convert Python attribute names to HTML attribute names."""
attr_name = attr_name.replace("hx_", "hx-") attr_name = attr_name.replace("hx_", "hx-")
attr_name = attr_name.replace("data_", "data-") attr_name = attr_name.replace("data_", "data-")
return OptimizedFt.ATTR_MAP.get(attr_name, attr_name) return OptimizedFt.ATTR_MAP.get(attr_name, attr_name)
@staticmethod @staticmethod
def to_html_helper(item): def to_html_helper(item):
"""Convert any item to HTML string.""" """Convert any item to HTML string."""
if item is None: if item is None:
return "" return ""
elif isinstance(item, str): elif isinstance(item, str):
return item return item
elif isinstance(item, (int, float, bool)): elif isinstance(item, (int, float, bool)):
return str(item) return str(item)
elif isinstance(item, OptimizedFt): elif isinstance(item, OptimizedFt):
return item.to_html() return item.to_html()
elif isinstance(item, NotStr): elif isinstance(item, NotStr):
return str(item) return str(item)
else: else:
raise Exception(f"Unsupported type: {type(item)}, {item=}") raise Exception(f"Unsupported type: {type(item)}, {item=}")
def to_html(self): def to_html(self):
"""Generate HTML string.""" """Generate HTML string."""
# Build attributes # Build attributes
attrs_list = [] attrs_list = []
for k, v in self.attrs.items(): for k, v in self.attrs.items():
if v is False: if v is False:
continue # Skip False attributes continue # Skip False attributes
if v is True: if v is True:
attrs_list.append(k) # Boolean attribute attrs_list.append(k) # Boolean attribute
else: else:
# No need to escape v since we control the values (width, IDs, etc.) # No need to escape v since we control the values (width, IDs, etc.)
attrs_list.append(f'{k}="{v}"') attrs_list.append(f'{k}="{v}"')
attrs_str = ' ' + ' '.join(attrs_list) if attrs_list else '' attrs_str = ' ' + ' '.join(attrs_list) if attrs_list else ''
# Build children HTML # Build children HTML
children_html = ''.join(self.to_html_helper(child) for child in self.children) children_html = ''.join(self.to_html_helper(child) for child in self.children)
return f'<{self.tag}{attrs_str}>{children_html}</{self.tag}>' return f'<{self.tag}{attrs_str}>{children_html}</{self.tag}>'
def __ft__(self): def __ft__(self):
"""FastHTML compatibility - returns NotStr to avoid double escaping.""" """FastHTML compatibility - returns NotStr to avoid double escaping."""
return NotStr(self.to_html()) return NotStr(self.to_html())
def __str__(self): def __str__(self):
return self.to_html() return self.to_html()
def get(self, attr_name, default=NO_DEFAULT_VALUE):
try:
return self.attrs[self.safe_attr(attr_name)]
except KeyError:
if default is NO_DEFAULT_VALUE:
raise
return default
class OptimizedDiv(OptimizedFt): class OptimizedDiv(OptimizedFt):
"""Optimized Div element.""" """Optimized Div element."""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__("div", *args, **kwargs) super().__init__("div", *args, **kwargs)