fh-matui 0.9.11__py3-none-any.whl → 0.9.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fh_matui/__init__.py +1 -1
- fh_matui/components.py +146 -63
- fh_matui/core.py +10 -5
- fh_matui/datatable.py +49 -6
- fh_matui/web_pages.py +11 -8
- {fh_matui-0.9.11.dist-info → fh_matui-0.9.13.dist-info}/METADATA +1 -1
- fh_matui-0.9.13.dist-info/RECORD +14 -0
- {fh_matui-0.9.11.dist-info → fh_matui-0.9.13.dist-info}/WHEEL +1 -1
- fh_matui-0.9.11.dist-info/RECORD +0 -14
- {fh_matui-0.9.11.dist-info → fh_matui-0.9.13.dist-info}/entry_points.txt +0 -0
- {fh_matui-0.9.11.dist-info → fh_matui-0.9.13.dist-info}/licenses/LICENSE +0 -0
- {fh_matui-0.9.11.dist-info → fh_matui-0.9.13.dist-info}/top_level.txt +0 -0
fh_matui/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.9.
|
|
1
|
+
__version__ = "0.9.12"
|
fh_matui/components.py
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
# %% auto 0
|
|
6
6
|
__all__ = ['BUTTON_SPECIALS', 'ButtonT', 'ANCHOR_SPECIALS', 'AT', 'NavToggleButton', 'SpaceT', 'GridSpanT', 'GridCell', 'Grid',
|
|
7
|
-
'
|
|
7
|
+
'DivHStacked', 'DivLAligned', 'DivVStacked', 'DivRAligned', 'DivCentered', 'DivFullySpaced', 'Icon',
|
|
8
8
|
'NavBar', 'Modal', 'ModalButton', 'ModalCancel', 'ModalConfirm', 'ModalTitle', 'ModalBody', 'ModalFooter',
|
|
9
9
|
'Field', 'LabelInput', 'FormLabel', 'CheckboxX', 'Radio', 'Switch', 'TextArea', 'Range', 'Select',
|
|
10
10
|
'FormGrid', 'Progress', 'LoadingIndicator', 'Table', 'Td', 'Th', 'Thead', 'Tbody', 'Tfoot', 'TableFromLists',
|
|
@@ -29,9 +29,13 @@ from nbdev.showdoc import show_doc
|
|
|
29
29
|
# %% ../nbs/02_components.ipynb 6
|
|
30
30
|
#| code-fold: true
|
|
31
31
|
def NavToggleButton(target, icon='menu', **kwargs):
|
|
32
|
-
"""Create a navigation toggle button that toggles the 'max' class on the target element
|
|
32
|
+
"""Create a navigation toggle button that toggles the 'max' class on the target element.
|
|
33
|
+
|
|
34
|
+
Also toggles icon between 'menu' and 'menu_open' based on nav state.
|
|
35
|
+
"""
|
|
33
36
|
cls = kwargs.get('cls', 'circle transparent')
|
|
34
|
-
|
|
37
|
+
# Inline JS: toggle nav's max class AND change this button's icon
|
|
38
|
+
onclick = f"var nav=document.querySelector('{target}');if(nav){{nav.classList.toggle('max');var i=this.querySelector('i');if(i)i.textContent=nav.classList.contains('max')?'menu_open':'menu'}};return false;"
|
|
35
39
|
kwargs.update({'onclick': onclick, 'cls': cls})
|
|
36
40
|
return Button(I(icon), **kwargs)
|
|
37
41
|
|
|
@@ -225,19 +229,8 @@ def Grid(*cells, space=SpaceT.medium_space,
|
|
|
225
229
|
return Div(*wrapped_cells, cls=stringify(dedupe_preserve_order(grid_cls)), **kwargs)
|
|
226
230
|
|
|
227
231
|
# %% ../nbs/02_components.ipynb 16
|
|
228
|
-
def
|
|
229
|
-
"""
|
|
230
|
-
cls_tokens = normalize_tokens(cls)
|
|
231
|
-
tokens = ['left-align']
|
|
232
|
-
tokens.extend(cls_tokens)
|
|
233
|
-
tokens = [t for t in tokens if t]
|
|
234
|
-
return DivHStacked(*c, cls=stringify(dedupe_preserve_order(tokens)), **kwargs)
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
# %% ../nbs/02_components.ipynb 18
|
|
239
|
-
def DivVStacked(*c, responsive=True, padding=True, cls='', **kwargs):
|
|
240
|
-
"""Responsive vertical stack with padding and mobile compatibility."""
|
|
232
|
+
def DivHStacked(*c, responsive=True, padding=True, cls='', **kwargs):
|
|
233
|
+
"""Responsive horizontal stack with padding and mobile compatibility."""
|
|
241
234
|
cls_tokens = normalize_tokens(cls)
|
|
242
235
|
tokens = []
|
|
243
236
|
if responsive and 'responsive' not in cls_tokens:
|
|
@@ -245,7 +238,7 @@ def DivVStacked(*c, responsive=True, padding=True, cls='', **kwargs):
|
|
|
245
238
|
if padding and 'padding' not in cls_tokens and 'no-padding' not in cls_tokens:
|
|
246
239
|
tokens.append('padding')
|
|
247
240
|
if 'grid' not in cls_tokens:
|
|
248
|
-
tokens.extend(['row', '
|
|
241
|
+
tokens.extend(['row', 'middle-align'])
|
|
249
242
|
if not _has_space_token(cls_tokens):
|
|
250
243
|
tokens.append(SpaceT.medium_space)
|
|
251
244
|
tokens.extend(cls_tokens)
|
|
@@ -253,9 +246,20 @@ def DivVStacked(*c, responsive=True, padding=True, cls='', **kwargs):
|
|
|
253
246
|
return Div(*c, cls=stringify(dedupe_preserve_order(tokens)), **kwargs)
|
|
254
247
|
|
|
255
248
|
|
|
256
|
-
# %% ../nbs/02_components.ipynb
|
|
257
|
-
def
|
|
258
|
-
"""
|
|
249
|
+
# %% ../nbs/02_components.ipynb 17
|
|
250
|
+
def DivLAligned(*c, cls='', **kwargs):
|
|
251
|
+
"""MonsterUI-compatible left-aligned row using BeerCSS tokens."""
|
|
252
|
+
cls_tokens = normalize_tokens(cls)
|
|
253
|
+
tokens = ['left-align']
|
|
254
|
+
tokens.extend(cls_tokens)
|
|
255
|
+
tokens = [t for t in tokens if t]
|
|
256
|
+
return DivHStacked(*c, cls=stringify(dedupe_preserve_order(tokens)), **kwargs)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# %% ../nbs/02_components.ipynb 19
|
|
261
|
+
def DivVStacked(*c, responsive=True, padding=True, cls='', **kwargs):
|
|
262
|
+
"""Responsive vertical stack with padding and mobile compatibility."""
|
|
259
263
|
cls_tokens = normalize_tokens(cls)
|
|
260
264
|
tokens = []
|
|
261
265
|
if responsive and 'responsive' not in cls_tokens:
|
|
@@ -263,7 +267,7 @@ def DivHStacked(*c, responsive=True, padding=True, cls='', **kwargs):
|
|
|
263
267
|
if padding and 'padding' not in cls_tokens and 'no-padding' not in cls_tokens:
|
|
264
268
|
tokens.append('padding')
|
|
265
269
|
if 'grid' not in cls_tokens:
|
|
266
|
-
tokens.extend(['row', '
|
|
270
|
+
tokens.extend(['row', 'vertical'])
|
|
267
271
|
if not _has_space_token(cls_tokens):
|
|
268
272
|
tokens.append(SpaceT.medium_space)
|
|
269
273
|
tokens.extend(cls_tokens)
|
|
@@ -323,16 +327,52 @@ def Icon(icon: str, size: str = None, fill: bool = False, cls = (), **kwargs):
|
|
|
323
327
|
|
|
324
328
|
# %% ../nbs/02_components.ipynb 32
|
|
325
329
|
#| code-fold: true
|
|
326
|
-
def NavBar(*children, brand=None, sticky=False, cls='',
|
|
327
|
-
|
|
328
|
-
|
|
330
|
+
def NavBar(*children, brand=None, sticky=False, cls='', size='small',
|
|
331
|
+
hx_boost=True, hx_target='#main-content', **kwargs):
|
|
332
|
+
"""Horizontal navigation bar with HTMX SPA navigation defaults.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
brand: Brand element (logo/title) positioned on the left
|
|
336
|
+
sticky: Whether navbar sticks to top on scroll
|
|
337
|
+
size: Navbar size - 'small' (default), 'medium', 'large', or None
|
|
338
|
+
hx_boost: Auto-enhance all <a> links for HTMX navigation (default True)
|
|
339
|
+
hx_target: Target element for boosted links (default '#main-content')
|
|
340
|
+
"""
|
|
341
|
+
size_cls = size if size else ''
|
|
342
|
+
nav_cls = f"{'sticky top' if sticky else ''} surface-container {size_cls} {cls}".strip()
|
|
343
|
+
|
|
344
|
+
# HTMX SPA optimizations
|
|
345
|
+
if hx_boost: kwargs['hx_boost'] = 'true'
|
|
346
|
+
if hx_target: kwargs['hx_target'] = hx_target
|
|
347
|
+
kwargs.setdefault('hx_push_url', 'true')
|
|
348
|
+
|
|
349
|
+
# Use small-padding for compact navbar
|
|
350
|
+
padding_cls = 'small-padding' if size == 'small' else 'padding'
|
|
351
|
+
|
|
329
352
|
if brand:
|
|
330
|
-
return Nav(brand, Div(cls='max'), *children, cls=f"row middle-align
|
|
331
|
-
return Nav(*children, cls=f"
|
|
353
|
+
return Nav(brand, Div(cls='max'), *children, cls=f"row middle-align {padding_cls} {nav_cls}", **kwargs)
|
|
354
|
+
return Nav(*children, cls=f"{padding_cls} {nav_cls}", **kwargs)
|
|
332
355
|
|
|
333
356
|
# %% ../nbs/02_components.ipynb 35
|
|
334
357
|
def Modal(*c, id=None, footer=None, active=False, overlay='default', position=None, cls=(), **kwargs):
|
|
335
|
-
"""BeerCSS modal dialog with position and overlay options.
|
|
358
|
+
"""BeerCSS modal dialog with position and overlay options.
|
|
359
|
+
|
|
360
|
+
Always returns a list for consistent unpacking with *Modal(...).
|
|
361
|
+
When overlay is enabled, clicking the overlay closes the modal.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
*c: Modal content (title, body, etc.)
|
|
365
|
+
id: Modal ID for data-ui targeting
|
|
366
|
+
footer: Footer content (auto-wrapped in Nav if not already)
|
|
367
|
+
active: Whether modal starts active/visible
|
|
368
|
+
overlay: Overlay style - 'default' (plain), 'blur', 'small-blur',
|
|
369
|
+
'medium-blur', 'large-blur', or False/None (no overlay)
|
|
370
|
+
position: Position - None (center), 'left', 'right', 'top', 'bottom'
|
|
371
|
+
cls: Additional CSS classes
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
List of elements (overlay + dialog, or just [dialog] if no overlay)
|
|
375
|
+
"""
|
|
336
376
|
modal_cls = normalize_tokens(cls)
|
|
337
377
|
|
|
338
378
|
# Add position class if specified
|
|
@@ -354,7 +394,7 @@ def Modal(*c, id=None, footer=None, active=False, overlay='default', position=No
|
|
|
354
394
|
# Create the dialog
|
|
355
395
|
dialog = Dialog(*children, id=id, cls=cls_str, **kwargs)
|
|
356
396
|
|
|
357
|
-
# Handle overlay
|
|
397
|
+
# Handle overlay - always return a list for consistent *Modal(...) unpacking
|
|
358
398
|
if overlay and overlay not in [False, None]:
|
|
359
399
|
overlay_classes = ['overlay']
|
|
360
400
|
|
|
@@ -372,14 +412,14 @@ def Modal(*c, id=None, footer=None, active=False, overlay='default', position=No
|
|
|
372
412
|
overlay_cls = ' '.join(overlay_classes)
|
|
373
413
|
if active:
|
|
374
414
|
overlay_cls += " active"
|
|
375
|
-
|
|
376
|
-
#
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
]
|
|
415
|
+
|
|
416
|
+
# Overlay with data-ui to close modal on click (click-outside-to-close)
|
|
417
|
+
overlay_el = Div(cls=overlay_cls, data_ui=f"#{id}" if id else None)
|
|
418
|
+
|
|
419
|
+
return [overlay_el, dialog]
|
|
381
420
|
|
|
382
|
-
return
|
|
421
|
+
# No overlay - still return as list for consistent unpacking
|
|
422
|
+
return [dialog]
|
|
383
423
|
|
|
384
424
|
def ModalButton(text: str, id: str, icon: str = None, cls=(), **kwargs):
|
|
385
425
|
"""Button that opens a modal via data-ui attribute."""
|
|
@@ -1055,33 +1095,76 @@ def NavSideBarLinks(*children, as_list=False, cls='', **kwargs):
|
|
|
1055
1095
|
return Ul(*children, cls=list_cls, **kwargs)
|
|
1056
1096
|
return Group(*children) if len(children) > 1 else (children[0] if children else Group())
|
|
1057
1097
|
|
|
1058
|
-
def NavSideBarContainer(*children, position='left', size='m', cls='', active=False,
|
|
1059
|
-
|
|
1098
|
+
def NavSideBarContainer(*children, position='left', size='m', cls='', active=False,
|
|
1099
|
+
hx_boost=True, hx_target='#main-content', **kwargs):
|
|
1100
|
+
"""BeerCSS navigation sidebar with HTMX SPA navigation defaults.
|
|
1101
|
+
|
|
1102
|
+
Args:
|
|
1103
|
+
position: Sidebar position ('left' or 'right')
|
|
1104
|
+
size: Sidebar size ('s', 'm', 'l')
|
|
1105
|
+
active: Whether sidebar starts visible
|
|
1106
|
+
hx_boost: Auto-enhance all <a> links for HTMX navigation (default True)
|
|
1107
|
+
hx_target: Target element for boosted links (default '#main-content')
|
|
1108
|
+
|
|
1109
|
+
Usage:
|
|
1110
|
+
Routes should check `req.headers` for HX-Request and return content only for HTMX requests:
|
|
1111
|
+
|
|
1112
|
+
@rt("/dashboard")
|
|
1113
|
+
def dashboard(req):
|
|
1114
|
+
content = dashboard_content()
|
|
1115
|
+
if 'HX-Request' in req.headers: return content # HTMX swap
|
|
1116
|
+
return Layout(content) # Full page load
|
|
1117
|
+
"""
|
|
1060
1118
|
base_cls = f"{size} {position} surface-container"
|
|
1061
1119
|
if active: base_cls += " active"
|
|
1062
1120
|
nav_cls = f"{base_cls} {cls}".strip()
|
|
1121
|
+
|
|
1122
|
+
# HTMX SPA optimizations
|
|
1123
|
+
if hx_boost: kwargs['hx_boost'] = 'true'
|
|
1124
|
+
if hx_target: kwargs['hx_target'] = hx_target
|
|
1125
|
+
kwargs.setdefault('hx_push_url', 'true')
|
|
1126
|
+
|
|
1063
1127
|
return Nav(*children, cls=nav_cls, **kwargs)
|
|
1064
1128
|
|
|
1065
1129
|
# %% ../nbs/02_components.ipynb 107
|
|
1066
1130
|
#| code-fold: true
|
|
1067
1131
|
def Layout(*content, sidebar=None, sidebar_links=None, nav_bar=None, container_size=ContainerT.expand,
|
|
1068
|
-
main_bg='surface', sidebar_id='app-sidebar', cls='', **kwargs):
|
|
1069
|
-
"""App layout
|
|
1070
|
-
main_content = []
|
|
1071
|
-
|
|
1072
|
-
if nav_bar:
|
|
1073
|
-
if hasattr(nav_bar, 'attrs') and 'cls' in nav_bar.attrs:
|
|
1074
|
-
if 'sticky' not in nav_bar.attrs['cls']: nav_bar.attrs['cls'] += ' sticky top'
|
|
1075
|
-
main_content.append(nav_bar)
|
|
1132
|
+
main_bg='surface', sidebar_id='app-sidebar', main_id='main-content', cls='', **kwargs):
|
|
1133
|
+
"""App layout with HTMX SPA navigation.
|
|
1076
1134
|
|
|
1135
|
+
Args:
|
|
1136
|
+
main_id: ID for main content area (default 'main-content') - use as hx-target
|
|
1137
|
+
|
|
1138
|
+
HTMX SPA features:
|
|
1139
|
+
- hx-boost on sidebar automatically enhances all <a> links
|
|
1140
|
+
- hx-history-elt for back/forward button caching
|
|
1141
|
+
- Routes should check `req.headers` for HX-Request and return content only for HTMX requests
|
|
1142
|
+
|
|
1143
|
+
Usage:
|
|
1144
|
+
@rt("/dashboard")
|
|
1145
|
+
def dashboard(req):
|
|
1146
|
+
content = dashboard_content()
|
|
1147
|
+
if 'HX-Request' in req.headers: return content # HTMX swap
|
|
1148
|
+
return Layout(content) # Full page load
|
|
1149
|
+
"""
|
|
1150
|
+
# Build content wrapper with history caching
|
|
1151
|
+
content_wrapper = None
|
|
1077
1152
|
if content:
|
|
1078
|
-
|
|
1079
|
-
main_content.append(Main(*content, cls=container_cls))
|
|
1153
|
+
content_wrapper = Div(*content, id=main_id, hx_history_elt='true')
|
|
1080
1154
|
|
|
1155
|
+
# No sidebar - simple layout
|
|
1081
1156
|
if not sidebar and not sidebar_links:
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1157
|
+
result = []
|
|
1158
|
+
if nav_bar:
|
|
1159
|
+
if hasattr(nav_bar, 'attrs') and 'cls' in nav_bar.attrs:
|
|
1160
|
+
if 'sticky' not in nav_bar.attrs['cls']: nav_bar.attrs['cls'] += ' sticky top'
|
|
1161
|
+
result.append(nav_bar)
|
|
1162
|
+
if content_wrapper:
|
|
1163
|
+
container_cls = stringify((container_size, 'padding', main_bg))
|
|
1164
|
+
result.append(Main(content_wrapper, cls=container_cls))
|
|
1165
|
+
return Div(*result, cls=cls, **kwargs) if result else Div(cls=cls, **kwargs)
|
|
1166
|
+
|
|
1167
|
+
# Sidebar layout with hx-boost
|
|
1085
1168
|
sidebar_children = [NavSideBarHeader(NavToggleButton(f"#{sidebar_id}"))]
|
|
1086
1169
|
|
|
1087
1170
|
if sidebar_links: sidebar_children.extend(sidebar_links)
|
|
@@ -1091,23 +1174,23 @@ def Layout(*content, sidebar=None, sidebar_links=None, nav_bar=None, container_s
|
|
|
1091
1174
|
|
|
1092
1175
|
nav_rail = NavSideBarContainer(*sidebar_children, position='left', size='l', id=sidebar_id)
|
|
1093
1176
|
|
|
1094
|
-
navbar_elem = None
|
|
1095
|
-
content_items = []
|
|
1096
|
-
for item in main_content:
|
|
1097
|
-
if hasattr(item, 'tag') and item.tag == 'nav': navbar_elem = item
|
|
1098
|
-
else: content_items.append(item)
|
|
1099
|
-
|
|
1100
1177
|
layout_children = []
|
|
1101
|
-
|
|
1178
|
+
|
|
1179
|
+
if nav_bar:
|
|
1180
|
+
if hasattr(nav_bar, 'attrs') and 'cls' in nav_bar.attrs:
|
|
1181
|
+
if 'sticky' not in nav_bar.attrs['cls']: nav_bar.attrs['cls'] += ' sticky top'
|
|
1182
|
+
layout_children.append(nav_bar)
|
|
1183
|
+
|
|
1102
1184
|
layout_children.append(nav_rail)
|
|
1103
|
-
|
|
1185
|
+
|
|
1186
|
+
if content_wrapper:
|
|
1104
1187
|
container_cls = stringify((container_size, 'round', 'elevate', 'margin'))
|
|
1105
|
-
layout_children.append(Main(
|
|
1188
|
+
layout_children.append(Main(content_wrapper, cls=container_cls))
|
|
1106
1189
|
|
|
1107
1190
|
final_cls = f"surface-container {cls}".strip() if cls else "surface-container"
|
|
1108
1191
|
return Div(*layout_children, cls=final_cls, **kwargs)
|
|
1109
1192
|
|
|
1110
|
-
# %% ../nbs/02_components.ipynb
|
|
1193
|
+
# %% ../nbs/02_components.ipynb 113
|
|
1111
1194
|
#| code-fold: true
|
|
1112
1195
|
class TextT(VEnum):
|
|
1113
1196
|
"""Text styles using BeerCSS typography classes."""
|
|
@@ -1139,7 +1222,7 @@ class TextPresets(VEnum):
|
|
|
1139
1222
|
primary_link = 'link primary-text'
|
|
1140
1223
|
muted_link = 'link secondary-text'
|
|
1141
1224
|
|
|
1142
|
-
# %% ../nbs/02_components.ipynb
|
|
1225
|
+
# %% ../nbs/02_components.ipynb 114
|
|
1143
1226
|
#| code-fold: true
|
|
1144
1227
|
def CodeSpan(*c, cls=(), **kwargs):
|
|
1145
1228
|
"""Inline code snippet."""
|
|
@@ -1195,7 +1278,7 @@ def Sup(*c, cls=(), **kwargs):
|
|
|
1195
1278
|
cls_str = stringify(cls) if cls else None
|
|
1196
1279
|
return fc.Sup(*c, cls=cls_str, **kwargs) if cls_str else fc.Sup(*c, **kwargs)
|
|
1197
1280
|
|
|
1198
|
-
# %% ../nbs/02_components.ipynb
|
|
1281
|
+
# %% ../nbs/02_components.ipynb 116
|
|
1199
1282
|
#| code-fold: true
|
|
1200
1283
|
def FAQItem(question: str, answer: str, question_cls: str = '', answer_cls: str = ''):
|
|
1201
1284
|
"""Collapsible FAQ item using details/summary.
|
|
@@ -1213,7 +1296,7 @@ def FAQItem(question: str, answer: str, question_cls: str = '', answer_cls: str
|
|
|
1213
1296
|
Summary(Article(Nav(Div(question, cls=f"max bold {question_cls}".strip()), I("expand_more")), cls="round surface-variant border no-elevate")),
|
|
1214
1297
|
Article(P(answer, cls=f"secondary-text {answer_cls}".strip()), cls="round border padding"))
|
|
1215
1298
|
|
|
1216
|
-
# %% ../nbs/02_components.ipynb
|
|
1299
|
+
# %% ../nbs/02_components.ipynb 120
|
|
1217
1300
|
#| code-fold: true
|
|
1218
1301
|
def CookiesBanner(message='We use cookies to enhance your experience. By continuing to visit this site you agree to our use of cookies.',
|
|
1219
1302
|
accept_text='Accept', decline_text='Decline', settings_text=None, policy_link='/cookies', policy_text='Learn more',
|
fh_matui/core.py
CHANGED
|
@@ -31,7 +31,7 @@ beer_hdrs = (
|
|
|
31
31
|
Script(src=HEADER_URLS["mdc_js"], type='module'),
|
|
32
32
|
)
|
|
33
33
|
|
|
34
|
-
# %% ../nbs/01_core.ipynb
|
|
34
|
+
# %% ../nbs/01_core.ipynb 7
|
|
35
35
|
#| code-fold: true
|
|
36
36
|
# All BeerCSS color names
|
|
37
37
|
COLOR_NAMES = ['amber', 'blue', 'blue_grey', 'brown', 'cyan', 'deep_orange', 'deep_purple',
|
|
@@ -88,7 +88,7 @@ ALL_HELPERS = (SIZES + WIDTH_HEIGHT + ELEVATES + DIRECTIONS + FORMS + MARGINS +
|
|
|
88
88
|
POSITIONS + RESPONSIVE + ALIGNMENTS + BLURS + OPACITIES + SHADOWS + SPACES +
|
|
89
89
|
RIPPLES + SCROLLS + WAVES + ZOOMS + THEME_HELPERS + TYPOGRAPHY + TRIGGERS + COLOR_HELPERS)
|
|
90
90
|
|
|
91
|
-
# %% ../nbs/01_core.ipynb
|
|
91
|
+
# %% ../nbs/01_core.ipynb 10
|
|
92
92
|
#| code-fold: true
|
|
93
93
|
class _ThemeChain:
|
|
94
94
|
"""Internal class for building themed headers"""
|
|
@@ -142,14 +142,19 @@ class _ThemeChain:
|
|
|
142
142
|
}};
|
|
143
143
|
window.toggleNav = function(selector) {{
|
|
144
144
|
const nav = document.querySelector(selector);
|
|
145
|
-
if (nav) {{
|
|
145
|
+
if (nav) {{
|
|
146
|
+
nav.classList.toggle('max');
|
|
147
|
+
const isOpen = nav.classList.contains('max');
|
|
148
|
+
const btn = nav.querySelector('button i, button .icon');
|
|
149
|
+
if (btn) btn.textContent = isOpen ? 'menu_open' : 'menu';
|
|
150
|
+
}}
|
|
146
151
|
}};
|
|
147
152
|
''')
|
|
148
153
|
hdrs.append(theme_script)
|
|
149
154
|
hdrs.append(Title(title))
|
|
150
155
|
return tuple(hdrs)
|
|
151
156
|
|
|
152
|
-
# %% ../nbs/01_core.ipynb
|
|
157
|
+
# %% ../nbs/01_core.ipynb 11
|
|
153
158
|
#| code-fold: true
|
|
154
159
|
class _ThemeNamespace:
|
|
155
160
|
"""Namespace providing color properties that return _ThemeChain instances"""
|
|
@@ -208,7 +213,7 @@ class _ThemeNamespace:
|
|
|
208
213
|
|
|
209
214
|
MatTheme = _ThemeNamespace()
|
|
210
215
|
|
|
211
|
-
# %% ../nbs/01_core.ipynb
|
|
216
|
+
# %% ../nbs/01_core.ipynb 15
|
|
212
217
|
#| code-fold: true
|
|
213
218
|
class BeerCssChain:
|
|
214
219
|
"""Base class for chaining Beer CSS helper classes together"""
|
fh_matui/datatable.py
CHANGED
|
@@ -452,7 +452,7 @@ class CrudContext:
|
|
|
452
452
|
record_id: Optional[Any] = None # ID for update/delete (None for create)
|
|
453
453
|
feedback_id: Optional[str] = None # Target div ID for HTMX swap (for override handlers)
|
|
454
454
|
|
|
455
|
-
# %% ../nbs/05_datatable.ipynb
|
|
455
|
+
# %% ../nbs/05_datatable.ipynb 14
|
|
456
456
|
from typing import Callable, Optional, Any, Union
|
|
457
457
|
from dataclasses import asdict, is_dataclass
|
|
458
458
|
from datetime import datetime
|
|
@@ -488,11 +488,40 @@ class DataTableResource:
|
|
|
488
488
|
- Async/sync hook support for external API integration
|
|
489
489
|
- Auto-refresh table via HX-Trigger after mutations
|
|
490
490
|
- Layout wrapper for full-page (non-HTMX) responses
|
|
491
|
+
- Optional `get_count` for efficient DB-level pagination
|
|
491
492
|
|
|
492
493
|
**Auto-registers 3 routes:**
|
|
493
494
|
- `GET {base_route}` → DataTable list view
|
|
494
495
|
- `GET {base_route}/action` → FormModal for create/edit/view/delete
|
|
495
496
|
- `POST {base_route}/save` → Save handler with hooks
|
|
497
|
+
|
|
498
|
+
**DB-Level Pagination (Recommended for large datasets):**
|
|
499
|
+
|
|
500
|
+
For efficient pagination, provide both `get_all` (returning paginated rows)
|
|
501
|
+
and `get_count` (returning total count):
|
|
502
|
+
|
|
503
|
+
```python
|
|
504
|
+
def get_products(req):
|
|
505
|
+
page = int(req.query_params.get('page', 1))
|
|
506
|
+
page_size = int(req.query_params.get('page_size', 10))
|
|
507
|
+
offset = (page - 1) * page_size
|
|
508
|
+
search = req.query_params.get('search', '')
|
|
509
|
+
# SQL-level pagination
|
|
510
|
+
return list(tbl(limit=page_size, offset=offset))
|
|
511
|
+
|
|
512
|
+
def get_product_count(req):
|
|
513
|
+
search = req.query_params.get('search', '')
|
|
514
|
+
return db.execute("SELECT COUNT(*) FROM products").scalar()
|
|
515
|
+
|
|
516
|
+
DataTableResource(
|
|
517
|
+
get_all=get_products, # Returns page_size rows
|
|
518
|
+
get_count=get_product_count, # Returns total count
|
|
519
|
+
...
|
|
520
|
+
)
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
If `get_count` is not provided, the library filters and paginates in Python
|
|
524
|
+
(requires `get_all` to return ALL rows - inefficient for large datasets).
|
|
496
525
|
"""
|
|
497
526
|
|
|
498
527
|
def __init__(
|
|
@@ -501,8 +530,9 @@ class DataTableResource:
|
|
|
501
530
|
base_route: str,
|
|
502
531
|
columns: list[dict],
|
|
503
532
|
# Data callbacks - ALL receive request as first param
|
|
504
|
-
get_all: Callable[[Any], list], # (req) -> list
|
|
533
|
+
get_all: Callable[[Any], list], # (req) -> list (can be paginated)
|
|
505
534
|
get_by_id: Callable[[Any, Any], Any], # (req, id) -> record
|
|
535
|
+
get_count: Callable[[Any], int] = None, # (req) -> total count for pagination
|
|
506
536
|
create: Callable[[Any, dict], Any] = None, # (req, data) -> record
|
|
507
537
|
update: Callable[[Any, Any, dict], Any] = None, # (req, id, data) -> record
|
|
508
538
|
delete: Callable[[Any, Any], bool] = None, # (req, id) -> bool
|
|
@@ -529,6 +559,7 @@ class DataTableResource:
|
|
|
529
559
|
self.columns = columns
|
|
530
560
|
self.get_all = get_all
|
|
531
561
|
self.get_by_id = get_by_id
|
|
562
|
+
self.get_count = get_count
|
|
532
563
|
self.create_fn = create
|
|
533
564
|
self.update_fn = update
|
|
534
565
|
self.delete_fn = delete
|
|
@@ -704,10 +735,22 @@ class DataTableResource:
|
|
|
704
735
|
"""Handle main table route."""
|
|
705
736
|
state = table_state_from_request(req, page_sizes=self.page_sizes)
|
|
706
737
|
search, page, page_size = state["search"], state["page"], state["page_size"]
|
|
707
|
-
|
|
738
|
+
# Get data from user callback
|
|
708
739
|
data = self._get_filtered_data(req)
|
|
709
|
-
|
|
710
|
-
|
|
740
|
+
|
|
741
|
+
# Determine total count:
|
|
742
|
+
# - If get_count provided: use it (efficient DB-level count)
|
|
743
|
+
# - Otherwise: filter and count in Python (assumes get_all returns all rows)
|
|
744
|
+
if self.get_count:
|
|
745
|
+
# User handles pagination in get_all, we just get count separately
|
|
746
|
+
total = self.get_count(req)
|
|
747
|
+
page_data = data # Already paginated by user
|
|
748
|
+
total_pages = max(1, ceil(total / page_size)) if total > 0 else 1
|
|
749
|
+
page = min(max(1, page), total_pages)
|
|
750
|
+
else:
|
|
751
|
+
# Legacy behavior: filter and paginate in Python
|
|
752
|
+
filtered = self._filter_by_search(data, search)
|
|
753
|
+
page_data, total, page = self._paginate(filtered, page, page_size)
|
|
711
754
|
|
|
712
755
|
table = DataTable(
|
|
713
756
|
data=page_data,
|
|
@@ -828,7 +871,7 @@ class DataTableResource:
|
|
|
828
871
|
# Record required for remaining actions
|
|
829
872
|
if not record:
|
|
830
873
|
return self._error_toast("Record not found.")
|
|
831
|
-
|
|
874
|
+
return self._wrap_modal(modal)
|
|
832
875
|
# Handle VIEW (default behavior)
|
|
833
876
|
if action == "view":
|
|
834
877
|
modal = FormModal(
|
fh_matui/web_pages.py
CHANGED
|
@@ -809,20 +809,20 @@ def LandingPageSimple(
|
|
|
809
809
|
# %% ../nbs/04_web_pages.ipynb 30
|
|
810
810
|
def MarkdownSection(
|
|
811
811
|
content: str, # Markdown text to render
|
|
812
|
-
title: str = None, # Optional section title (rendered as
|
|
812
|
+
title: str = None, # Optional section title (rendered as H3 for appropriate size)
|
|
813
813
|
cls: str = "", # Additional classes
|
|
814
814
|
):
|
|
815
815
|
"""Renders markdown content server-side for SEO compatibility.
|
|
816
816
|
|
|
817
817
|
Uses python-markdown to convert markdown to HTML on the server.
|
|
818
818
|
Search engines see fully rendered HTML (no JavaScript required).
|
|
819
|
-
|
|
819
|
+
Content is centered using BeerCSS large-width + row center-align pattern.
|
|
820
820
|
|
|
821
821
|
Great for text-heavy pages like Privacy Policy, Terms, About, Blog posts, etc.
|
|
822
822
|
|
|
823
823
|
Args:
|
|
824
824
|
content: Markdown text string (can include headers, lists, links, code blocks, tables)
|
|
825
|
-
title: Optional page title (rendered
|
|
825
|
+
title: Optional page title (rendered as H3 for blog-appropriate sizing)
|
|
826
826
|
cls: Additional CSS classes
|
|
827
827
|
|
|
828
828
|
Example:
|
|
@@ -848,12 +848,15 @@ We value your privacy...
|
|
|
848
848
|
|
|
849
849
|
elements = []
|
|
850
850
|
if title:
|
|
851
|
-
|
|
851
|
+
# Use H3 for page title - appropriately sized for content pages
|
|
852
|
+
elements.append(H3(title, cls="bold"))
|
|
852
853
|
|
|
853
854
|
# NotStr tells FastHTML to render raw HTML without escaping
|
|
854
855
|
elements.append(NotStr(html_content))
|
|
855
856
|
|
|
856
|
-
|
|
857
|
+
# large-width constrains content, wrapped in row center-align for centering
|
|
858
|
+
article = Article(*elements, cls=f"large-width large-padding {cls}".strip())
|
|
859
|
+
return Div(article, cls="row center-align")
|
|
857
860
|
|
|
858
861
|
# %% ../nbs/04_web_pages.ipynb 31
|
|
859
862
|
def ContentPage(
|
|
@@ -871,7 +874,7 @@ def ContentPage(
|
|
|
871
874
|
A shell template for text-heavy pages like Privacy Policy, Terms of Service,
|
|
872
875
|
Security, About, Blog posts, etc. Developer passes any number of content sections.
|
|
873
876
|
|
|
874
|
-
Layout: Navbar (sticky) -> Content sections (centered with '
|
|
877
|
+
Layout: Navbar (sticky) -> Content sections (centered with 'responsive') -> Footer
|
|
875
878
|
|
|
876
879
|
Uses STANDARD_FOOTER_COLUMNS by default for consistent footer across all pages.
|
|
877
880
|
|
|
@@ -913,7 +916,7 @@ def ContentPage(
|
|
|
913
916
|
cls="large-padding",
|
|
914
917
|
)
|
|
915
918
|
|
|
916
|
-
# Main content area -
|
|
917
|
-
main_content = Main(*sections, cls="
|
|
919
|
+
# Main content area - sections handle their own centering
|
|
920
|
+
main_content = Main(*sections, cls="large-padding")
|
|
918
921
|
|
|
919
922
|
return Div(navbar, main_content, footer_el, cls=f"column {cls}".strip())
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
fh_matui/__init__.py,sha256=4CbYG_FT4tURr5oUs-LmBkXp278FJwRBFCpWMPcLQog,24
|
|
2
|
+
fh_matui/_modidx.py,sha256=naHwPQ4kCo-5saE_uozmdPA881kp1gnqvKhmaG-Ya-4,23914
|
|
3
|
+
fh_matui/app_pages.py,sha256=Sn9-tgBpaPNbR-0nZtPLoSCmAWLOGB4UQ88IkFvzBRY,10361
|
|
4
|
+
fh_matui/components.py,sha256=Cqgsg2i0dNzMgNpETSu7zf1HYyEwZ1_00bBjeue34Nc,53312
|
|
5
|
+
fh_matui/core.py,sha256=t9Y5e3g6F4ZjlRuNt7SDabbCEdsolT6QHJXMOpEPa3A,10962
|
|
6
|
+
fh_matui/datatable.py,sha256=o7BizE4FMKrfsYT4G6rjMvr2xHo_tvTwL0dI6anDcE4,41228
|
|
7
|
+
fh_matui/foundations.py,sha256=b7PnObJpKN8ZAU9NzCm9xpfnHzFjjAROU7E2YvA_tj4,1820
|
|
8
|
+
fh_matui/web_pages.py,sha256=at_M34Vxc1i9O0ukS41PaRJntkXXDzMqyzlcrgESUcw,35103
|
|
9
|
+
fh_matui-0.9.13.dist-info/licenses/LICENSE,sha256=xV8xoN4VOL0uw9X8RSs2IMuD_Ss_a9yAbtGNeBWZwnw,11337
|
|
10
|
+
fh_matui-0.9.13.dist-info/METADATA,sha256=5dnU_4G5vE1zqb28xwlI2pdXnBB0imjDbUxUW9imcLc,10491
|
|
11
|
+
fh_matui-0.9.13.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
12
|
+
fh_matui-0.9.13.dist-info/entry_points.txt,sha256=zn4CR4gNTiAAxbFsCxHAf2tQhtW29_YOffjbUTgeoWI,38
|
|
13
|
+
fh_matui-0.9.13.dist-info/top_level.txt,sha256=l80d5eoA2ZjqtPYwAorLMS5PiHxUxz3zKzxMJ41Xoso,9
|
|
14
|
+
fh_matui-0.9.13.dist-info/RECORD,,
|
fh_matui-0.9.11.dist-info/RECORD
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
fh_matui/__init__.py,sha256=taelSBN5SjzTPZ994Gxw8NFdSDITeBao31tWpkapnXU,24
|
|
2
|
-
fh_matui/_modidx.py,sha256=naHwPQ4kCo-5saE_uozmdPA881kp1gnqvKhmaG-Ya-4,23914
|
|
3
|
-
fh_matui/app_pages.py,sha256=Sn9-tgBpaPNbR-0nZtPLoSCmAWLOGB4UQ88IkFvzBRY,10361
|
|
4
|
-
fh_matui/components.py,sha256=Q-koxO-BCxG9Dse2QUpP5WrGSrbDyzBSLoqIGatUAg8,49503
|
|
5
|
-
fh_matui/core.py,sha256=xtVBN8CtC50ZJ4Iu7o-mUhaA87tWdnz8gBfKRk63Zhs,10680
|
|
6
|
-
fh_matui/datatable.py,sha256=LsVwuTuoFOh3rIvETXefsqZOqxos4xi9ct2F7YPIIv8,39266
|
|
7
|
-
fh_matui/foundations.py,sha256=b7PnObJpKN8ZAU9NzCm9xpfnHzFjjAROU7E2YvA_tj4,1820
|
|
8
|
-
fh_matui/web_pages.py,sha256=4mF-jpfVcZTVepfQ-aMGgIUp-nBp0YCkvcdsWhUYeaA,34879
|
|
9
|
-
fh_matui-0.9.11.dist-info/licenses/LICENSE,sha256=xV8xoN4VOL0uw9X8RSs2IMuD_Ss_a9yAbtGNeBWZwnw,11337
|
|
10
|
-
fh_matui-0.9.11.dist-info/METADATA,sha256=NIMhExSVx75fEZgaO1WJiaugYb2bVtHSDaX_SandwLI,10491
|
|
11
|
-
fh_matui-0.9.11.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
|
|
12
|
-
fh_matui-0.9.11.dist-info/entry_points.txt,sha256=zn4CR4gNTiAAxbFsCxHAf2tQhtW29_YOffjbUTgeoWI,38
|
|
13
|
-
fh_matui-0.9.11.dist-info/top_level.txt,sha256=l80d5eoA2ZjqtPYwAorLMS5PiHxUxz3zKzxMJ41Xoso,9
|
|
14
|
-
fh_matui-0.9.11.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|