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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.9.10"
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
- 'DivLAligned', 'DivVStacked', 'DivHStacked', 'DivRAligned', 'DivCentered', 'DivFullySpaced', 'Icon',
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
- onclick = f"toggleNav('{target}'); return false;"
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 DivLAligned(*c, cls='', **kwargs):
229
- """MonsterUI-compatible left-aligned row using BeerCSS tokens."""
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', 'vertical'])
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 20
257
- def DivHStacked(*c, responsive=True, padding=True, cls='', **kwargs):
258
- """Responsive horizontal stack with padding and mobile compatibility."""
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', 'middle-align'])
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='', **kwargs):
327
- """Horizontal navigation bar with optional brand and sticky positioning"""
328
- nav_cls = f"{'sticky top' if sticky else ''} surface-container {cls}".strip()
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 padding {nav_cls}", **kwargs)
331
- return Nav(*children, cls=f"padding {nav_cls}", **kwargs)
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
- # Return as a list - both elements need to be at same DOM level
377
- return [
378
- Div(cls=overlay_cls),
379
- dialog
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 dialog
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, **kwargs):
1059
- """BeerCSS navigation sidebar/drawer component."""
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 wrapper with auto-toggle sidebar and sensible defaults."""
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
- container_cls = stringify((container_size, 'padding', main_bg))
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
- if main_content: return Div(*main_content, cls=cls, **kwargs)
1083
- else: return Div(cls=cls, **kwargs)
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
- if navbar_elem: layout_children.append(navbar_elem)
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
- if content_items:
1185
+
1186
+ if content_wrapper:
1104
1187
  container_cls = stringify((container_size, 'round', 'elevate', 'margin'))
1105
- layout_children.append(Main(*content_items, cls=container_cls))
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 111
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 112
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 114
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 118
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 8
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 11
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) {{ nav.classList.toggle('max'); }}
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 12
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 16
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 13
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
- filtered = self._filter_by_search(data, search)
710
- page_data, total, page = self._paginate(filtered, page, page_size)
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 H5)
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
- Parent ContentPage uses 'min' class to center the page layout.
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 before markdown content)
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
- elements.append(H5(title, cls="center-align"))
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
- return Article(*elements, cls=f"large-padding {cls}".strip())
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 'min') -> Footer
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 - use "min" to center content (unlike landing page which uses "max")
917
- main_content = Main(*sections, cls="min large-padding")
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fh-matui
3
- Version: 0.9.11
3
+ Version: 0.9.13
4
4
  Summary: material-ui for fasthtml
5
5
  Home-page: https://github.com/abhisheksreesaila/fh-matui
6
6
  Author: abhishek sreesaila
@@ -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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.10.1)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -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,,