diff --git a/src/myfasthtml/assets/myfasthtml.css b/src/myfasthtml/assets/myfasthtml.css
index e0a1a89..6f515eb 100644
--- a/src/myfasthtml/assets/myfasthtml.css
+++ b/src/myfasthtml/assets/myfasthtml.css
@@ -823,4 +823,343 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
-}
\ No newline at end of file
+}
+
+
+/* ********************************************* */
+/* ************* 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;
+}
+
+.dt2-header,
+.dt2-footer {
+ background-color: var(--color-base-200);
+ border-radius: 10px 10px 0 0;
+ min-width: max-content;
+}
+
+.dt2-body {
+ overflow: hidden; /* You can change this to auto if horizontal scrolling is required */
+ font-size: 14px;
+ min-width: max-content;
+}
+
+.dt2-row {
+ display: flex;
+ width: 100%;
+ height: 22px;
+}
+
+.dt2-cell {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ padding: 2px 8px;
+ position: relative;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ min-width: 100px;
+ flex-grow: 0;
+ flex-shrink: 1;
+ box-sizing: border-box; /* to include the borders in the computations */
+ border-bottom: 1px solid var(--color-border);
+ user-select: none;
+}
+
+.dt2-cell-content-text {
+ text-align: inherit;
+ width: 100%;
+ padding-right: 10px;
+}
+
+.dt2-cell-content-checkbox {
+ display: flex;
+ width: 100%;
+ justify-content: center; /* Horizontally center the icon */
+ align-items: center; /* Vertically center the icon */
+}
+
+.dt2-cell-content-number {
+ text-align: right;
+ width: 100%;
+ padding-right: 10px;
+}
+
+.dt2-footer-cell {
+ 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
+}
+
+.dt2-resize-handle {
+ position: absolute;
+ right: 0;
+ top: 0;
+ width: 8px;
+ height: 100%;
+ cursor: col-resize;
+}
+
+.dt2-resize-handle::after {
+ content: ''; /* This is required */
+ position: absolute; /* Position as needed */
+ z-index: var(--datagrid-resize-zindex);
+ display: block; /* Makes it a block element */
+ width: 3px;
+ height: 60%;
+ top: calc(50% - 60% * 0.5);
+ background-color: var(--color-resize);
+}
+
+.dt2-header-hidden {
+ width: 5px;
+ background: var(--color-neutral-content);
+ border-bottom: 1px solid var(--color-border);
+ cursor: pointer;
+}
+
+.dt2-col-hidden {
+ width: 5px;
+ border-bottom: 1px solid var(--color-border);
+}
+
+.dt2-highlight-1 {
+ color: var(--color-accent);
+}
+
+.dt2-item-handle {
+ background-image: radial-gradient(var(--color-primary-content) 40%, transparent 0);
+ background-repeat: repeat;
+ background-size: 4px 4px;
+ cursor: grab;
+ display: inline-block;
+ height: 16px;
+ margin: auto;
+ position: relative;
+ top: 1px;
+ width: 12px;
+}
+
+/* **************************************************************************** */
+/* COLUMNS SETTINGS */
+/* **************************************************************************** */
+
+.dt2-cs-header {
+ background-color: var(--color-base-200);
+ min-width: max-content;
+}
+
+.dt2-cs-columns {
+ display: grid;
+ grid-template-columns: 20px 1fr 0.5fr 0.5fr 0.5fr 0.5fr;
+}
+
+.dt2-cs-body input {
+ outline: none;
+ border-color: transparent;
+ box-shadow: none;
+}
+
+.dt2-cs-body input[type="checkbox"],
+.dt2-cs-body input.checkbox {
+ outline: initial;
+ border-color: var(--color-border);
+}
+
+
+.dt2-cs-cell {
+ padding: 0 6px 0 6px;
+ margin: auto;
+}
+
+.dt2-cs-checkbox-cell {
+ margin: auto;
+}
+
+.dt2-cs-number-cell {
+ padding: 0 6px 0 6px;
+ text-align: right;
+}
+
+.dt2-cs-select-cell {
+ padding: 0 6px;
+ margin: 3px 0;
+}
+
+.dt2-cs-body input:hover {
+ border: 1px solid #ccc; /* Provide a subtle border on focus */
+}
+
+
+.dt2-views-container-select {
+ width: 170px;
+}
+
+.dt2-views-container-create {
+ width: 300px;
+}
diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py
index 71b2cf3..0a25d79 100644
--- a/src/myfasthtml/controls/DataGrid.py
+++ b/src/myfasthtml/controls/DataGrid.py
@@ -1,18 +1,22 @@
+import html
from typing import Optional
-from fastcore.basics import NotStr
-from fasthtml.components import Div
+import pandas as pd
+from fasthtml.components import *
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
+from myfasthtml.controls.helpers import mk
+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.instances import MultipleInstance
+from myfasthtml.core.utils import make_safe_id
class DatagridState(DbObject):
- def __init__(self, owner):
- super().__init__(owner, name=f"{owner.get_full_id()}-state")
+ def __init__(self, owner, save_state):
+ super().__init__(owner, name=f"{owner.get_full_id()}-state", save_state=save_state)
with self.initializing():
self.sidebar_visible: bool = False
self.selected_view: str = None
@@ -25,12 +29,15 @@ class DatagridState(DbObject):
self.filtered: dict = {}
self.edition: DatagridEditionState = DatagridEditionState()
self.selection: DatagridSelectionState = DatagridSelectionState()
- self.ne_data = None
+ self.ne_df = None
+
+ self.ns_fast_access = None
+ self.ns_total_rows = None
class DatagridSettings(DbObject):
- def __init__(self, owner):
- super().__init__(owner, name=f"{owner.get_full_id()}-settings")
+ def __init__(self, owner, save_state):
+ super().__init__(owner, name=f"{owner.get_full_id()}-settings", save_state=save_state)
with self.initializing():
self.file_name: Optional[str] = None
self.selected_sheet_name: Optional[str] = None
@@ -46,19 +53,271 @@ class Commands(BaseCommands):
class DataGrid(MultipleInstance):
- def __init__(self, parent, settings=None, _id=None):
+ def __init__(self, parent, settings=None, save_state=False, _id=None):
super().__init__(parent, _id=_id)
- self._settings = DatagridSettings(self)
- self._state = DatagridState(self)
+ self._settings = settings or DatagridSettings(self, save_state=save_state)
+ self._state = DatagridState(self, save_state=save_state)
self.commands = Commands(self)
+ self.init_from_dataframe(self._state.ne_df)
+
+ @property
+ def _df(self):
+ return self._state.ne_df
def init_from_dataframe(self, df):
- self._state.ne_data = df
+
+ def _get_column_type(dtype):
+ if pd.api.types.is_integer_dtype(dtype):
+ return ColumnType.Number
+ elif pd.api.types.is_float_dtype(dtype):
+ return ColumnType.Number
+ elif pd.api.types.is_bool_dtype(dtype):
+ return ColumnType.Bool
+ elif pd.api.types.is_datetime64_any_dtype(dtype):
+ return ColumnType.Datetime
+ else:
+ return ColumnType.Text # Default to Text if no match
+
+ def _init_columns(_df):
+ columns = [DataGridColumnState(make_safe_id(col_id),
+ col_index,
+ col_id,
+ _get_column_type(self._df[make_safe_id(col_id)].dtype))
+ for col_index, col_id in enumerate(_df.columns)]
+ if self._state.row_index:
+ columns.insert(0, DataGridColumnState(make_safe_id(ROW_INDEX_ID), -1, " ", ColumnType.RowIndex))
+
+ return columns
+
+ def _init_fast_access(_df):
+ """
+ Generates a fast-access dictionary for a DataFrame.
+
+ This method converts the columns of the provided DataFrame into NumPy arrays
+ and stores them as values in a dictionary, using the column names as keys.
+ This allows for efficient access to the data stored in the DataFrame.
+
+ Args:
+ _df (DataFrame): The input pandas DataFrame whose columns are to be converted
+ into a dictionary of NumPy arrays.
+
+ Returns:
+ dict: A dictionary where the keys are the column names of the input DataFrame
+ and the values are the corresponding column values as NumPy arrays.
+ """
+ if _df is None:
+ return {}
+
+ res = {col: _df[col].to_numpy() for col in _df.columns}
+ res[ROW_INDEX_ID] = _df.index.to_numpy()
+ return res
+
+ if df is not None:
+ self._state.ne_df = df
+ self._df.columns = self._df.columns.map(make_safe_id) # make sure column names are trimmed
+ self._state.rows = [DataGridRowState(row_id) for row_id in self._df.index]
+ self._state.columns = _init_columns(df) # use df not self._df to keep the original title
+ self._state.ns_fast_access = _init_fast_access(self._df)
+ self._state.ns_total_rows = len(self._df) if self._df is not None else 0
+
+ return self
+
+ def mk_headers(self):
+ def _mk_header_name(col_def: DataGridColumnState):
+ return Div(
+ mk.label(col_def.title, name="dt2-header-title"),
+ cls="flex truncate cursor-default",
+ )
+
+ def _mk_header(col_def: DataGridColumnState):
+ return Div(
+ _mk_header_name(col_def),
+ Div(cls="dt2-resize-handle"),
+ style=f"width:{col_def.width}px;",
+ data_col=col_def.col_id,
+ data_tooltip=col_def.title,
+ cls="dt2-cell dt2-resizable flex",
+ )
+
+ header_class = "dt2-row dt2-header" + "" if self._settings.header_visible else " hidden"
+ return Div(
+ *[_mk_header(col_def) for col_def in self._state.columns],
+ cls=header_class,
+ id=f"th_{self._id}"
+ )
+
+ def mk_body_cell_content(self, col_pos, row_index, col_def: DataGridColumnState):
+
+ def mk_bool(_value):
+ return Div(mk.icon(icon_checked if _value else icon_unchecked, can_select=False),
+ cls="dt2-cell-content-checkbox")
+
+ def mk_text(_value):
+ return mk.label(_value, cls="dt2-cell-content-text")
+
+ def mk_number(_value):
+ return mk.label(_value, cls="dt2-cell-content-number")
+
+ def process_cell_content(_value):
+ value_str = html.escape(str(_value))
+
+ if FILTER_INPUT_CID not in self._state.filtered or (
+ keyword := self._state.filtered[FILTER_INPUT_CID]) is None:
+ return value_str
+
+ index = value_str.lower().find(keyword.lower())
+ if index < 0:
+ return value_str
+
+ len_keyword = len(keyword)
+ res = [Span(value_str[:index])] if index > 0 else []
+ res += [Span(value_str[index:index + len_keyword], cls="dt2-highlight-1")]
+ res += [Span(value_str[index + len_keyword:])] if len(value_str) > len_keyword else []
+ return tuple(res)
+
+ column_type = col_def.type
+ value = self._state.ns_fast_access[col_def.col_id][row_index]
+
+ if column_type == ColumnType.Bool:
+ content = mk_bool(value)
+ elif column_type == ColumnType.Number:
+ content = mk_number(process_cell_content(value))
+ elif column_type == ColumnType.RowIndex:
+ content = mk_number(row_index)
+ else:
+ content = mk_text(process_cell_content(value))
+
+ return content
+
+ def mk_body_cell(self, col_pos, row_index, col_def: DataGridColumnState):
+ if not col_def.usable:
+ return None
+
+ if not col_def.visible:
+ return Div(cls="dt2-col-hidden")
+
+ content = self.mk_body_cell_content(col_pos, row_index, col_def)
+
+ return Div(content,
+ data_col=col_def.col_id,
+ style=f"width:{col_def.width}px;",
+ cls="dt2-cell")
+
+ def mk_body_content_page(self, page_index: int):
+ df = self._df # self._get_filtered_df()
+ start = page_index * DATAGRID_PAGE_SIZE
+ end = start + DATAGRID_PAGE_SIZE
+ if self._state.ns_total_rows > end:
+ last_row = df.index[end - 1]
+ else:
+ last_row = None
+
+ rows = [Div(
+ *[self.mk_body_cell(col_pos, row_index, col_def) for col_pos, col_def in enumerate(self._state.columns)],
+ cls="dt2-row",
+ data_row=f"{row_index}",
+ id=f"tr_{self._id}-{row_index}",
+ ) for row_index in df.index[start:end]]
+
+ return rows
+
+ def mk_body(self):
+ return Div(
+ *self.mk_body_content_page(0),
+ cls="dt2-body",
+ id=f"tb_{self._id}",
+ )
+
+ def mk_footers(self):
+ return Div(
+ *[Div(
+ *[self.mk_aggregation_cell(col_def, row_index, footer) for col_def in self._state.columns],
+ id=f"tf_{self._id}",
+ data_row=f"{row_index}",
+ cls="dt2-row dt2-row-footer",
+ ) for row_index, footer in enumerate(self._state.footers)],
+ cls="dt2-footer",
+ id=f"tf_{self._id}"
+ )
+
+ def mk_table(self):
+ return Div(
+ self.mk_headers(),
+ self.mk_body(),
+ self.mk_footers()
+ )
+
+ def mk_aggregation_cell(self, col_def, row_index: int, footer_conf, oob=False):
+ """
+ Generates a footer cell for a data table based on the provided column definition,
+ row index, footer configuration, and optional out-of-bound setting. This method
+ applies appropriate aggregation functions, determines visibility, and structures
+ the cell's elements accordingly.
+
+ :param col_def: Details of the column state, including its usability, visibility,
+ and column ID, which are necessary to determine how the footer
+ cell should be created.
+ :type col_def: DataGridColumnState
+ :param row_index: The specific index of the footer row where this cell will be
+ added. This parameter is used to uniquely identify the cell
+ within the footer.
+ :type row_index: int
+ :param footer_conf: Configuration for the footer that contains mapping of column
+ IDs to their corresponding aggregation functions. This is
+ critical for calculating aggregated values for the cell content.
+ :type footer_conf: DataGridFooterConf
+ :param oob: A boolean flag indicating whether the configuration involves any
+ out-of-bound parameters that must be handled specifically. This
+ parameter is optional and defaults to False.
+ :type oob: bool
+ :return: Returns an instance of `Div`, containing the visually structured footer
+ cell content, including the calculated aggregation if applicable. If
+ the column is not usable, it returns None. For non-visible columns, it
+ returns a hidden cell `Div`. The aggregation value is displayed for valid
+ aggregations. If none is applicable or the configuration is invalid,
+ appropriate default content or styling is applied.
+ :rtype: Div | None
+ """
+ if not col_def.usable:
+ return None
+
+ if not col_def.visible:
+ return Div(cls="dt2-col-hidden")
+
+ if col_def.col_id in footer_conf.conf:
+ agg_function = footer_conf.conf[col_def.col_id]
+ if agg_function == FooterAggregation.Sum.value:
+ value = self._df[col_def.col_id].sum()
+ elif agg_function == FooterAggregation.Min.value:
+ value = self._df[col_def.col_id].min()
+ elif agg_function == FooterAggregation.Max.value:
+ value = self._df[col_def.col_id].max()
+ elif agg_function == FooterAggregation.Mean.value:
+ value = self._df[col_def.col_id].mean()
+ elif agg_function == FooterAggregation.Count.value:
+ value = self._df[col_def.col_id].count()
+ else:
+ value = "** Invalid aggregation function **"
+ else:
+ value = None
+
+ return Div(mk_ellipsis(value, cls="dt2-cell-content-number"),
+ data_col=col_def.col_id,
+ style=f"width:{col_def.width}px;",
+ cls="dt2-cell dt2-footer-cell",
+ id=f"tf_{self._id}-{col_def.col_id}-{row_index}",
+ hx_swap_oob='true' if oob else None,
+ )
def render(self):
- html = self._state.ne_data.to_html(index=False) if self._state.ne_data is not None else "Content lost !"
+ if self._state.ne_df is None:
+ return Div("No data to display !")
+
return Div(
- NotStr(html),
+ Div(
+ self.mk_table(),
+ # Script(f"bindDatagrid('{self._id}', false);"),
+ ),
id=self._id
)
diff --git a/src/myfasthtml/controls/DataGridsManager.py b/src/myfasthtml/controls/DataGridsManager.py
index 9633fee..8cb3ec0 100644
--- a/src/myfasthtml/controls/DataGridsManager.py
+++ b/src/myfasthtml/controls/DataGridsManager.py
@@ -91,7 +91,7 @@ class DataGridsManager(MultipleInstance):
def open_from_excel(self, tab_id, file_upload: FileUpload):
excel_content = file_upload.get_content()
df = pd.read_excel(excel_content, file_upload.get_sheet_name())
- dg = DataGrid(self._tabs_manager)
+ dg = DataGrid(self._tabs_manager, save_state=True)
dg.init_from_dataframe(df)
document = DocumentDefinition(
document_id=str(uuid.uuid4()),
diff --git a/src/myfasthtml/controls/helpers.py b/src/myfasthtml/controls/helpers.py
index e3c982b..eff9651 100644
--- a/src/myfasthtml/controls/helpers.py
+++ b/src/myfasthtml/controls/helpers.py
@@ -95,7 +95,7 @@ class mk:
command: Command | CommandTemplate = None,
binding: Binding = None,
**kwargs):
- merged_cls = merge_classes("flex", cls, kwargs)
+ merged_cls = merge_classes("flex truncate", cls, kwargs)
icon_part = Span(icon, cls=f"mf-icon-{mk.convert_size(size)} mr-1") if icon else None
text_part = Span(text, cls=f"text-{size}")
return mk.mk(Label(icon_part, text_part, cls=merged_cls, **kwargs), command=command, binding=binding)
diff --git a/src/myfasthtml/core/constants.py b/src/myfasthtml/core/constants.py
index c6c2dec..f87b623 100644
--- a/src/myfasthtml/core/constants.py
+++ b/src/myfasthtml/core/constants.py
@@ -4,6 +4,10 @@ DEFAULT_COLUMN_WIDTH = 100
ROUTE_ROOT = "/myfasthtml"
+# Datagrid
+ROW_INDEX_ID = "__row_index__"
+DATAGRID_PAGE_SIZE = 1000
+FILTER_INPUT_CID = "__filter_input__"
class Routes:
Commands = "/commands"
diff --git a/src/myfasthtml/core/dbmanager.py b/src/myfasthtml/core/dbmanager.py
index 6c656f4..7734432 100644
--- a/src/myfasthtml/core/dbmanager.py
+++ b/src/myfasthtml/core/dbmanager.py
@@ -37,10 +37,11 @@ class DbObject:
_initializing = False
_forbidden_attrs = {"_initializing", "_db_manager", "_name", "_owner", "_forbidden_attrs"}
- def __init__(self, owner: BaseInstance, name=None, db_manager=None):
+ def __init__(self, owner: BaseInstance, name=None, db_manager=None, save_state=True):
self._owner = owner
self._name = name or owner.get_full_id()
self._db_manager = db_manager or DbManager(self._owner)
+ self._save_state = save_state
self._finalize_initialization()
@@ -75,6 +76,9 @@ class DbObject:
self._save_self()
def _save_self(self):
+ if not self._save_state:
+ return
+
props = {k: getattr(self, k) for k, v in self._get_properties().items() if
not k.startswith("_") and not k.startswith("ns")}
if props:
diff --git a/src/myfasthtml/core/utils.py b/src/myfasthtml/core/utils.py
index 64bb40c..cc52526 100644
--- a/src/myfasthtml/core/utils.py
+++ b/src/myfasthtml/core/utils.py
@@ -284,6 +284,39 @@ def flatten(*args):
res.append(arg)
return res
+
+def make_html_id(s: str | None) -> str | None:
+ """
+ Creates a valid html id
+ :param s:
+ :return:
+ """
+ if s is None:
+ return None
+
+ s = str(s).strip()
+ # Replace spaces and special characters with hyphens or remove them
+ s = re.sub(r'[^a-zA-Z0-9_-]', '-', s)
+
+ # Ensure the ID starts with a letter or underscore
+ if not re.match(r'^[a-zA-Z_]', s):
+ s = 'id_' + s # Add a prefix if it doesn't
+
+ # Collapse multiple consecutive hyphens into one
+ s = re.sub(r'-+', '-', s)
+
+ # Replace trailing hyphens with underscores
+ s = re.sub(r'-+$', '_', s)
+
+ return s
+
+def make_safe_id(s: str | None):
+ if s is None:
+ return None
+
+ res = re.sub('-', '_', make_html_id(s)) # replace '-' by '_'
+ return res.lower() # no uppercase
+
@utils_rt(Routes.Commands)
def post(session, c_id: str, client_response: dict = None):
"""
diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py
index af2d6ef..ac5db08 100644
--- a/tests/core/test_utils.py
+++ b/tests/core/test_utils.py
@@ -1,6 +1,6 @@
import pytest
-from myfasthtml.core.utils import flatten
+from myfasthtml.core.utils import flatten, make_html_id
@pytest.mark.parametrize("input_args,expected,test_description", [
@@ -56,3 +56,11 @@ def test_i_can_flatten(input_args, expected, test_description):
"""Test that flatten correctly handles various nested structures and arguments."""
result = flatten(*input_args)
assert result == expected, f"Failed for test case: {test_description}"
+
+@pytest.mark.parametrize("string, expected", [
+ ("My Example String!", "My-Example-String_"),
+ ("123 Bad ID", "id_123-Bad-ID"),
+ (None, None)
+])
+def test_i_can_have_valid_html_id(string, expected):
+ assert make_html_id(string) == expected