fh-matui 0.9.12__tar.gz → 0.9.14__tar.gz
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-0.9.12/fh_matui.egg-info → fh_matui-0.9.14}/PKG-INFO +1 -1
- fh_matui-0.9.14/fh_matui/__init__.py +1 -0
- {fh_matui-0.9.12 → fh_matui-0.9.14}/fh_matui/_modidx.py +1 -0
- {fh_matui-0.9.12 → fh_matui-0.9.14}/fh_matui/components.py +130 -41
- {fh_matui-0.9.12 → fh_matui-0.9.14}/fh_matui/core.py +15 -5
- {fh_matui-0.9.12 → fh_matui-0.9.14}/fh_matui/datatable.py +50 -6
- {fh_matui-0.9.12 → fh_matui-0.9.14}/fh_matui/foundations.py +3 -0
- {fh_matui-0.9.12 → fh_matui-0.9.14}/fh_matui/web_pages.py +11 -8
- {fh_matui-0.9.12 → fh_matui-0.9.14/fh_matui.egg-info}/PKG-INFO +1 -1
- {fh_matui-0.9.12 → fh_matui-0.9.14}/settings.ini +1 -1
- fh_matui-0.9.12/fh_matui/__init__.py +0 -1
- {fh_matui-0.9.12 → fh_matui-0.9.14}/LICENSE +0 -0
- {fh_matui-0.9.12 → fh_matui-0.9.14}/MANIFEST.in +0 -0
- {fh_matui-0.9.12 → fh_matui-0.9.14}/README.md +0 -0
- {fh_matui-0.9.12 → fh_matui-0.9.14}/fh_matui/app_pages.py +0 -0
- {fh_matui-0.9.12 → fh_matui-0.9.14}/fh_matui.egg-info/SOURCES.txt +0 -0
- {fh_matui-0.9.12 → fh_matui-0.9.14}/fh_matui.egg-info/dependency_links.txt +0 -0
- {fh_matui-0.9.12 → fh_matui-0.9.14}/fh_matui.egg-info/entry_points.txt +0 -0
- {fh_matui-0.9.12 → fh_matui-0.9.14}/fh_matui.egg-info/not-zip-safe +0 -0
- {fh_matui-0.9.12 → fh_matui-0.9.14}/fh_matui.egg-info/requires.txt +0 -0
- {fh_matui-0.9.12 → fh_matui-0.9.14}/fh_matui.egg-info/top_level.txt +0 -0
- {fh_matui-0.9.12 → fh_matui-0.9.14}/pyproject.toml +0 -0
- {fh_matui-0.9.12 → fh_matui-0.9.14}/setup.cfg +0 -0
- {fh_matui-0.9.12 → fh_matui-0.9.14}/setup.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.9.13"
|
|
@@ -54,6 +54,7 @@ d = { 'settings': { 'branch': 'master',
|
|
|
54
54
|
'fh_matui.components.NavSideBarLinks': ('components.html#navsidebarlinks', 'fh_matui/components.py'),
|
|
55
55
|
'fh_matui.components.NavSubtitle': ('components.html#navsubtitle', 'fh_matui/components.py'),
|
|
56
56
|
'fh_matui.components.NavToggleButton': ('components.html#navtogglebutton', 'fh_matui/components.py'),
|
|
57
|
+
'fh_matui.components.Page': ('components.html#page', 'fh_matui/components.py'),
|
|
57
58
|
'fh_matui.components.Pagination': ('components.html#pagination', 'fh_matui/components.py'),
|
|
58
59
|
'fh_matui.components.Progress': ('components.html#progress', 'fh_matui/components.py'),
|
|
59
60
|
'fh_matui.components.Q': ('components.html#q', 'fh_matui/components.py'),
|
|
@@ -10,9 +10,9 @@ __all__ = ['BUTTON_SPECIALS', 'ButtonT', 'ANCHOR_SPECIALS', 'AT', 'NavToggleButt
|
|
|
10
10
|
'FormGrid', 'Progress', 'LoadingIndicator', 'Table', 'Td', 'Th', 'Thead', 'Tbody', 'Tfoot', 'TableFromLists',
|
|
11
11
|
'TableFromDicts', 'TableControls', 'Pagination', 'Card', 'Toolbar', 'Toast', 'Snackbar', 'ContainerT',
|
|
12
12
|
'FormField', 'FormModal', 'NavContainer', 'NavHeaderLi', 'NavDividerLi', 'NavCloseLi', 'NavSubtitle',
|
|
13
|
-
'BottomNav', 'NavSideBarHeader', 'NavSideBarLinks', 'NavSideBarContainer', '
|
|
14
|
-
'CodeSpan', 'CodeBlock', 'Blockquote', 'Q', 'Em', 'Strong', 'Small', 'Mark', 'Abbr', 'Sub',
|
|
15
|
-
'CookiesBanner']
|
|
13
|
+
'BottomNav', 'NavSideBarHeader', 'NavSideBarLinks', 'NavSideBarContainer', 'Page', 'Layout', 'TextT',
|
|
14
|
+
'TextPresets', 'CodeSpan', 'CodeBlock', 'Blockquote', 'Q', 'Em', 'Strong', 'Small', 'Mark', 'Abbr', 'Sub',
|
|
15
|
+
'Sup', 'FAQItem', 'CookiesBanner']
|
|
16
16
|
|
|
17
17
|
# %% ../nbs/02_components.ipynb 2
|
|
18
18
|
from fastcore.utils import *
|
|
@@ -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
|
|
|
@@ -323,12 +327,31 @@ 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 {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):
|
|
@@ -1072,33 +1095,97 @@ def NavSideBarLinks(*children, as_list=False, cls='', **kwargs):
|
|
|
1072
1095
|
return Ul(*children, cls=list_cls, **kwargs)
|
|
1073
1096
|
return Group(*children) if len(children) > 1 else (children[0] if children else Group())
|
|
1074
1097
|
|
|
1075
|
-
def NavSideBarContainer(*children, position='left', size='m', cls='', active=False,
|
|
1076
|
-
|
|
1077
|
-
|
|
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
|
+
"""
|
|
1118
|
+
base_cls = f"{size} {position} surface"
|
|
1078
1119
|
if active: base_cls += " active"
|
|
1079
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
|
+
|
|
1080
1127
|
return Nav(*children, cls=nav_cls, **kwargs)
|
|
1081
1128
|
|
|
1082
1129
|
# %% ../nbs/02_components.ipynb 107
|
|
1083
1130
|
#| code-fold: true
|
|
1084
|
-
def
|
|
1085
|
-
|
|
1086
|
-
"""App layout wrapper with auto-toggle sidebar and sensible defaults."""
|
|
1087
|
-
main_content = []
|
|
1131
|
+
def Page(*c, active=True, position=None, cls='', **kwargs):
|
|
1132
|
+
"""BeerCSS animated page container.
|
|
1088
1133
|
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1134
|
+
Pages are containers that can be a main page, multiple pages, or animated elements.
|
|
1135
|
+
|
|
1136
|
+
Args:
|
|
1137
|
+
active: Show page (default True)
|
|
1138
|
+
position: Animation direction - 'left', 'right', 'top', 'bottom' (optional)
|
|
1139
|
+
cls: Additional classes
|
|
1093
1140
|
|
|
1141
|
+
Example:
|
|
1142
|
+
Page(H1("Dashboard"), P("Content here")) # Default active page
|
|
1143
|
+
Page(content, position='right') # Slide from right animation
|
|
1144
|
+
"""
|
|
1145
|
+
page_cls = ['page']
|
|
1146
|
+
if active: page_cls.append('active')
|
|
1147
|
+
if position: page_cls.append(position)
|
|
1148
|
+
if cls: page_cls.extend(normalize_tokens(cls))
|
|
1149
|
+
return Div(*c, cls=stringify(page_cls), **kwargs)
|
|
1150
|
+
|
|
1151
|
+
def Layout(*content, sidebar=None, sidebar_links=None, nav_bar=None, container_size=ContainerT.expand,
|
|
1152
|
+
main_bg='surface', sidebar_id='app-sidebar', main_id='main-content', cls='', **kwargs):
|
|
1153
|
+
"""App layout with HTMX SPA navigation.
|
|
1154
|
+
|
|
1155
|
+
Args:
|
|
1156
|
+
main_id: ID for main content area (default 'main-content') - use as hx-target
|
|
1157
|
+
|
|
1158
|
+
HTMX SPA features:
|
|
1159
|
+
- hx-boost on sidebar automatically enhances all <a> links
|
|
1160
|
+
- hx-history-elt for back/forward button caching
|
|
1161
|
+
- Routes should check `req.headers` for HX-Request and return content only for HTMX requests
|
|
1162
|
+
|
|
1163
|
+
Usage:
|
|
1164
|
+
@rt("/dashboard")
|
|
1165
|
+
def dashboard(req):
|
|
1166
|
+
content = dashboard_content()
|
|
1167
|
+
if 'HX-Request' in req.headers: return content # HTMX swap
|
|
1168
|
+
return Layout(content) # Full page load
|
|
1169
|
+
"""
|
|
1170
|
+
# Build content wrapper with history caching
|
|
1171
|
+
content_wrapper = None
|
|
1094
1172
|
if content:
|
|
1095
|
-
|
|
1096
|
-
main_content.append(Main(*content, cls=container_cls))
|
|
1173
|
+
content_wrapper = Div(*content, hx_history_elt='true')
|
|
1097
1174
|
|
|
1175
|
+
# No sidebar - simple layout
|
|
1098
1176
|
if not sidebar and not sidebar_links:
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1177
|
+
result = []
|
|
1178
|
+
if nav_bar:
|
|
1179
|
+
if hasattr(nav_bar, 'attrs') and 'cls' in nav_bar.attrs:
|
|
1180
|
+
if 'sticky' not in nav_bar.attrs['cls']: nav_bar.attrs['cls'] += ' sticky top'
|
|
1181
|
+
result.append(nav_bar)
|
|
1182
|
+
if content_wrapper:
|
|
1183
|
+
container_cls = stringify((container_size, 'padding', main_bg))
|
|
1184
|
+
page_content = Page(content_wrapper, id=main_id)
|
|
1185
|
+
result.append(Main(page_content, cls=container_cls))
|
|
1186
|
+
return Div(*result, cls=cls, **kwargs) if result else Div(cls=cls, **kwargs)
|
|
1187
|
+
|
|
1188
|
+
# Sidebar layout with hx-boost
|
|
1102
1189
|
sidebar_children = [NavSideBarHeader(NavToggleButton(f"#{sidebar_id}"))]
|
|
1103
1190
|
|
|
1104
1191
|
if sidebar_links: sidebar_children.extend(sidebar_links)
|
|
@@ -1108,23 +1195,25 @@ def Layout(*content, sidebar=None, sidebar_links=None, nav_bar=None, container_s
|
|
|
1108
1195
|
|
|
1109
1196
|
nav_rail = NavSideBarContainer(*sidebar_children, position='left', size='l', id=sidebar_id)
|
|
1110
1197
|
|
|
1111
|
-
navbar_elem = None
|
|
1112
|
-
content_items = []
|
|
1113
|
-
for item in main_content:
|
|
1114
|
-
if hasattr(item, 'tag') and item.tag == 'nav': navbar_elem = item
|
|
1115
|
-
else: content_items.append(item)
|
|
1116
|
-
|
|
1117
1198
|
layout_children = []
|
|
1118
|
-
|
|
1199
|
+
|
|
1200
|
+
if nav_bar:
|
|
1201
|
+
if hasattr(nav_bar, 'attrs') and 'cls' in nav_bar.attrs:
|
|
1202
|
+
if 'sticky' not in nav_bar.attrs['cls']: nav_bar.attrs['cls'] += ' sticky top'
|
|
1203
|
+
layout_children.append(nav_bar)
|
|
1204
|
+
|
|
1119
1205
|
layout_children.append(nav_rail)
|
|
1120
|
-
if content_items:
|
|
1121
|
-
container_cls = stringify((container_size, 'round', 'elevate', 'margin'))
|
|
1122
|
-
layout_children.append(Main(*content_items, cls=container_cls))
|
|
1123
1206
|
|
|
1124
|
-
|
|
1207
|
+
if content_wrapper:
|
|
1208
|
+
container_cls = stringify((container_size, 'surface-container', 'round', 'padding', 'bottom-margin'))
|
|
1209
|
+
page_content = Page(content_wrapper, id=main_id)
|
|
1210
|
+
layout_children.append(Main(page_content, cls=container_cls))
|
|
1211
|
+
|
|
1212
|
+
final_cls = f"surface {cls}".strip() if cls else "surface"
|
|
1125
1213
|
return Div(*layout_children, cls=final_cls, **kwargs)
|
|
1126
1214
|
|
|
1127
|
-
|
|
1215
|
+
|
|
1216
|
+
# %% ../nbs/02_components.ipynb 113
|
|
1128
1217
|
#| code-fold: true
|
|
1129
1218
|
class TextT(VEnum):
|
|
1130
1219
|
"""Text styles using BeerCSS typography classes."""
|
|
@@ -1156,7 +1245,7 @@ class TextPresets(VEnum):
|
|
|
1156
1245
|
primary_link = 'link primary-text'
|
|
1157
1246
|
muted_link = 'link secondary-text'
|
|
1158
1247
|
|
|
1159
|
-
# %% ../nbs/02_components.ipynb
|
|
1248
|
+
# %% ../nbs/02_components.ipynb 114
|
|
1160
1249
|
#| code-fold: true
|
|
1161
1250
|
def CodeSpan(*c, cls=(), **kwargs):
|
|
1162
1251
|
"""Inline code snippet."""
|
|
@@ -1212,7 +1301,7 @@ def Sup(*c, cls=(), **kwargs):
|
|
|
1212
1301
|
cls_str = stringify(cls) if cls else None
|
|
1213
1302
|
return fc.Sup(*c, cls=cls_str, **kwargs) if cls_str else fc.Sup(*c, **kwargs)
|
|
1214
1303
|
|
|
1215
|
-
# %% ../nbs/02_components.ipynb
|
|
1304
|
+
# %% ../nbs/02_components.ipynb 116
|
|
1216
1305
|
#| code-fold: true
|
|
1217
1306
|
def FAQItem(question: str, answer: str, question_cls: str = '', answer_cls: str = ''):
|
|
1218
1307
|
"""Collapsible FAQ item using details/summary.
|
|
@@ -1230,7 +1319,7 @@ def FAQItem(question: str, answer: str, question_cls: str = '', answer_cls: str
|
|
|
1230
1319
|
Summary(Article(Nav(Div(question, cls=f"max bold {question_cls}".strip()), I("expand_more")), cls="round surface-variant border no-elevate")),
|
|
1231
1320
|
Article(P(answer, cls=f"secondary-text {answer_cls}".strip()), cls="round border padding"))
|
|
1232
1321
|
|
|
1233
|
-
# %% ../nbs/02_components.ipynb
|
|
1322
|
+
# %% ../nbs/02_components.ipynb 120
|
|
1234
1323
|
#| code-fold: true
|
|
1235
1324
|
def CookiesBanner(message='We use cookies to enhance your experience. By continuing to visit this site you agree to our use of cookies.',
|
|
1236
1325
|
accept_text='Accept', decline_text='Decline', settings_text=None, policy_link='/cookies', policy_text='Learn more',
|
|
@@ -19,6 +19,7 @@ from nbdev.showdoc import show_doc
|
|
|
19
19
|
|
|
20
20
|
# %% ../nbs/01_core.ipynb 5
|
|
21
21
|
#| code-fold: true
|
|
22
|
+
#| code-fold: true
|
|
22
23
|
HEADER_URLS = {
|
|
23
24
|
"beercss_css": "https://cdn.jsdelivr.net/npm/beercss@3.13.1/dist/cdn/beer.min.css",
|
|
24
25
|
"beercss_js": "https://cdn.jsdelivr.net/npm/beercss@3.13.1/dist/cdn/beer.min.js",
|
|
@@ -31,7 +32,8 @@ beer_hdrs = (
|
|
|
31
32
|
Script(src=HEADER_URLS["mdc_js"], type='module'),
|
|
32
33
|
)
|
|
33
34
|
|
|
34
|
-
# %% ../nbs/01_core.ipynb
|
|
35
|
+
# %% ../nbs/01_core.ipynb 7
|
|
36
|
+
#| code-fold: true
|
|
35
37
|
#| code-fold: true
|
|
36
38
|
# All BeerCSS color names
|
|
37
39
|
COLOR_NAMES = ['amber', 'blue', 'blue_grey', 'brown', 'cyan', 'deep_orange', 'deep_purple',
|
|
@@ -88,7 +90,8 @@ ALL_HELPERS = (SIZES + WIDTH_HEIGHT + ELEVATES + DIRECTIONS + FORMS + MARGINS +
|
|
|
88
90
|
POSITIONS + RESPONSIVE + ALIGNMENTS + BLURS + OPACITIES + SHADOWS + SPACES +
|
|
89
91
|
RIPPLES + SCROLLS + WAVES + ZOOMS + THEME_HELPERS + TYPOGRAPHY + TRIGGERS + COLOR_HELPERS)
|
|
90
92
|
|
|
91
|
-
# %% ../nbs/01_core.ipynb
|
|
93
|
+
# %% ../nbs/01_core.ipynb 10
|
|
94
|
+
#| code-fold: true
|
|
92
95
|
#| code-fold: true
|
|
93
96
|
class _ThemeChain:
|
|
94
97
|
"""Internal class for building themed headers"""
|
|
@@ -142,14 +145,20 @@ class _ThemeChain:
|
|
|
142
145
|
}};
|
|
143
146
|
window.toggleNav = function(selector) {{
|
|
144
147
|
const nav = document.querySelector(selector);
|
|
145
|
-
if (nav) {{
|
|
148
|
+
if (nav) {{
|
|
149
|
+
nav.classList.toggle('max');
|
|
150
|
+
const isOpen = nav.classList.contains('max');
|
|
151
|
+
const btn = nav.querySelector('button i, button .icon');
|
|
152
|
+
if (btn) btn.textContent = isOpen ? 'menu_open' : 'menu';
|
|
153
|
+
}}
|
|
146
154
|
}};
|
|
147
155
|
''')
|
|
148
156
|
hdrs.append(theme_script)
|
|
149
157
|
hdrs.append(Title(title))
|
|
150
158
|
return tuple(hdrs)
|
|
151
159
|
|
|
152
|
-
# %% ../nbs/01_core.ipynb
|
|
160
|
+
# %% ../nbs/01_core.ipynb 11
|
|
161
|
+
#| code-fold: true
|
|
153
162
|
#| code-fold: true
|
|
154
163
|
class _ThemeNamespace:
|
|
155
164
|
"""Namespace providing color properties that return _ThemeChain instances"""
|
|
@@ -208,7 +217,8 @@ class _ThemeNamespace:
|
|
|
208
217
|
|
|
209
218
|
MatTheme = _ThemeNamespace()
|
|
210
219
|
|
|
211
|
-
# %% ../nbs/01_core.ipynb
|
|
220
|
+
# %% ../nbs/01_core.ipynb 15
|
|
221
|
+
#| code-fold: true
|
|
212
222
|
#| code-fold: true
|
|
213
223
|
class BeerCssChain:
|
|
214
224
|
"""Base class for chaining Beer CSS helper classes together"""
|
|
@@ -19,6 +19,7 @@ from .components import *
|
|
|
19
19
|
|
|
20
20
|
# %% ../nbs/05_datatable.ipynb 7
|
|
21
21
|
#| code-fold: true
|
|
22
|
+
#| code-fold: true
|
|
22
23
|
from math import ceil
|
|
23
24
|
from urllib.parse import urlencode
|
|
24
25
|
from typing import Callable, Optional, Any
|
|
@@ -452,7 +453,7 @@ class CrudContext:
|
|
|
452
453
|
record_id: Optional[Any] = None # ID for update/delete (None for create)
|
|
453
454
|
feedback_id: Optional[str] = None # Target div ID for HTMX swap (for override handlers)
|
|
454
455
|
|
|
455
|
-
# %% ../nbs/05_datatable.ipynb
|
|
456
|
+
# %% ../nbs/05_datatable.ipynb 14
|
|
456
457
|
from typing import Callable, Optional, Any, Union
|
|
457
458
|
from dataclasses import asdict, is_dataclass
|
|
458
459
|
from datetime import datetime
|
|
@@ -488,11 +489,40 @@ class DataTableResource:
|
|
|
488
489
|
- Async/sync hook support for external API integration
|
|
489
490
|
- Auto-refresh table via HX-Trigger after mutations
|
|
490
491
|
- Layout wrapper for full-page (non-HTMX) responses
|
|
492
|
+
- Optional `get_count` for efficient DB-level pagination
|
|
491
493
|
|
|
492
494
|
**Auto-registers 3 routes:**
|
|
493
495
|
- `GET {base_route}` → DataTable list view
|
|
494
496
|
- `GET {base_route}/action` → FormModal for create/edit/view/delete
|
|
495
497
|
- `POST {base_route}/save` → Save handler with hooks
|
|
498
|
+
|
|
499
|
+
**DB-Level Pagination (Recommended for large datasets):**
|
|
500
|
+
|
|
501
|
+
For efficient pagination, provide both `get_all` (returning paginated rows)
|
|
502
|
+
and `get_count` (returning total count):
|
|
503
|
+
|
|
504
|
+
```python
|
|
505
|
+
def get_products(req):
|
|
506
|
+
page = int(req.query_params.get('page', 1))
|
|
507
|
+
page_size = int(req.query_params.get('page_size', 10))
|
|
508
|
+
offset = (page - 1) * page_size
|
|
509
|
+
search = req.query_params.get('search', '')
|
|
510
|
+
# SQL-level pagination
|
|
511
|
+
return list(tbl(limit=page_size, offset=offset))
|
|
512
|
+
|
|
513
|
+
def get_product_count(req):
|
|
514
|
+
search = req.query_params.get('search', '')
|
|
515
|
+
return db.execute("SELECT COUNT(*) FROM products").scalar()
|
|
516
|
+
|
|
517
|
+
DataTableResource(
|
|
518
|
+
get_all=get_products, # Returns page_size rows
|
|
519
|
+
get_count=get_product_count, # Returns total count
|
|
520
|
+
...
|
|
521
|
+
)
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
If `get_count` is not provided, the library filters and paginates in Python
|
|
525
|
+
(requires `get_all` to return ALL rows - inefficient for large datasets).
|
|
496
526
|
"""
|
|
497
527
|
|
|
498
528
|
def __init__(
|
|
@@ -501,8 +531,9 @@ class DataTableResource:
|
|
|
501
531
|
base_route: str,
|
|
502
532
|
columns: list[dict],
|
|
503
533
|
# Data callbacks - ALL receive request as first param
|
|
504
|
-
get_all: Callable[[Any], list], # (req) -> list
|
|
534
|
+
get_all: Callable[[Any], list], # (req) -> list (can be paginated)
|
|
505
535
|
get_by_id: Callable[[Any, Any], Any], # (req, id) -> record
|
|
536
|
+
get_count: Callable[[Any], int] = None, # (req) -> total count for pagination
|
|
506
537
|
create: Callable[[Any, dict], Any] = None, # (req, data) -> record
|
|
507
538
|
update: Callable[[Any, Any, dict], Any] = None, # (req, id, data) -> record
|
|
508
539
|
delete: Callable[[Any, Any], bool] = None, # (req, id) -> bool
|
|
@@ -529,6 +560,7 @@ class DataTableResource:
|
|
|
529
560
|
self.columns = columns
|
|
530
561
|
self.get_all = get_all
|
|
531
562
|
self.get_by_id = get_by_id
|
|
563
|
+
self.get_count = get_count
|
|
532
564
|
self.create_fn = create
|
|
533
565
|
self.update_fn = update
|
|
534
566
|
self.delete_fn = delete
|
|
@@ -704,10 +736,22 @@ class DataTableResource:
|
|
|
704
736
|
"""Handle main table route."""
|
|
705
737
|
state = table_state_from_request(req, page_sizes=self.page_sizes)
|
|
706
738
|
search, page, page_size = state["search"], state["page"], state["page_size"]
|
|
707
|
-
|
|
739
|
+
# Get data from user callback
|
|
708
740
|
data = self._get_filtered_data(req)
|
|
709
|
-
|
|
710
|
-
|
|
741
|
+
|
|
742
|
+
# Determine total count:
|
|
743
|
+
# - If get_count provided: use it (efficient DB-level count)
|
|
744
|
+
# - Otherwise: filter and count in Python (assumes get_all returns all rows)
|
|
745
|
+
if self.get_count:
|
|
746
|
+
# User handles pagination in get_all, we just get count separately
|
|
747
|
+
total = self.get_count(req)
|
|
748
|
+
page_data = data # Already paginated by user
|
|
749
|
+
total_pages = max(1, ceil(total / page_size)) if total > 0 else 1
|
|
750
|
+
page = min(max(1, page), total_pages)
|
|
751
|
+
else:
|
|
752
|
+
# Legacy behavior: filter and paginate in Python
|
|
753
|
+
filtered = self._filter_by_search(data, search)
|
|
754
|
+
page_data, total, page = self._paginate(filtered, page, page_size)
|
|
711
755
|
|
|
712
756
|
table = DataTable(
|
|
713
757
|
data=page_data,
|
|
@@ -828,7 +872,7 @@ class DataTableResource:
|
|
|
828
872
|
# Record required for remaining actions
|
|
829
873
|
if not record:
|
|
830
874
|
return self._error_toast("Record not found.")
|
|
831
|
-
|
|
875
|
+
return self._wrap_modal(modal)
|
|
832
876
|
# Handle VIEW (default behavior)
|
|
833
877
|
if action == "view":
|
|
834
878
|
modal = FormModal(
|
|
@@ -13,6 +13,7 @@ from nbdev.showdoc import show_doc
|
|
|
13
13
|
|
|
14
14
|
# %% ../nbs/00_foundations.ipynb 4
|
|
15
15
|
#| code-fold: true
|
|
16
|
+
#| code-fold: true
|
|
16
17
|
class VEnum(Enum):
|
|
17
18
|
"""Enum with string conversion and concatenation support"""
|
|
18
19
|
def __str__(self): return self.value
|
|
@@ -21,6 +22,7 @@ class VEnum(Enum):
|
|
|
21
22
|
|
|
22
23
|
# %% ../nbs/00_foundations.ipynb 8
|
|
23
24
|
#| code-fold: true
|
|
25
|
+
#| code-fold: true
|
|
24
26
|
def stringify(o):
|
|
25
27
|
"""Converts input types into strings that can be passed to FT components"""
|
|
26
28
|
if is_listy(o):
|
|
@@ -29,6 +31,7 @@ def stringify(o):
|
|
|
29
31
|
|
|
30
32
|
# %% ../nbs/00_foundations.ipynb 12
|
|
31
33
|
#| code-fold: true
|
|
34
|
+
#| code-fold: true
|
|
32
35
|
def normalize_tokens(cls):
|
|
33
36
|
"""Normalize class input to list of string tokens"""
|
|
34
37
|
if cls is None:
|
|
@@ -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())
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.9.11"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|