fh-matui 0.9.5__tar.gz → 0.9.13__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.
Files changed (24) hide show
  1. {fh_matui-0.9.5/fh_matui.egg-info → fh_matui-0.9.13}/PKG-INFO +1 -1
  2. fh_matui-0.9.13/fh_matui/__init__.py +1 -0
  3. {fh_matui-0.9.5 → fh_matui-0.9.13}/fh_matui/_modidx.py +3 -0
  4. {fh_matui-0.9.5 → fh_matui-0.9.13}/fh_matui/components.py +200 -79
  5. {fh_matui-0.9.5 → fh_matui-0.9.13}/fh_matui/core.py +10 -5
  6. {fh_matui-0.9.5 → fh_matui-0.9.13}/fh_matui/datatable.py +350 -152
  7. {fh_matui-0.9.5 → fh_matui-0.9.13}/fh_matui/web_pages.py +11 -8
  8. {fh_matui-0.9.5 → fh_matui-0.9.13/fh_matui.egg-info}/PKG-INFO +1 -1
  9. {fh_matui-0.9.5 → fh_matui-0.9.13}/settings.ini +1 -1
  10. fh_matui-0.9.5/fh_matui/__init__.py +0 -1
  11. {fh_matui-0.9.5 → fh_matui-0.9.13}/LICENSE +0 -0
  12. {fh_matui-0.9.5 → fh_matui-0.9.13}/MANIFEST.in +0 -0
  13. {fh_matui-0.9.5 → fh_matui-0.9.13}/README.md +0 -0
  14. {fh_matui-0.9.5 → fh_matui-0.9.13}/fh_matui/app_pages.py +0 -0
  15. {fh_matui-0.9.5 → fh_matui-0.9.13}/fh_matui/foundations.py +0 -0
  16. {fh_matui-0.9.5 → fh_matui-0.9.13}/fh_matui.egg-info/SOURCES.txt +0 -0
  17. {fh_matui-0.9.5 → fh_matui-0.9.13}/fh_matui.egg-info/dependency_links.txt +0 -0
  18. {fh_matui-0.9.5 → fh_matui-0.9.13}/fh_matui.egg-info/entry_points.txt +0 -0
  19. {fh_matui-0.9.5 → fh_matui-0.9.13}/fh_matui.egg-info/not-zip-safe +0 -0
  20. {fh_matui-0.9.5 → fh_matui-0.9.13}/fh_matui.egg-info/requires.txt +0 -0
  21. {fh_matui-0.9.5 → fh_matui-0.9.13}/fh_matui.egg-info/top_level.txt +0 -0
  22. {fh_matui-0.9.5 → fh_matui-0.9.13}/pyproject.toml +0 -0
  23. {fh_matui-0.9.5 → fh_matui-0.9.13}/setup.cfg +0 -0
  24. {fh_matui-0.9.5 → fh_matui-0.9.13}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fh-matui
3
- Version: 0.9.5
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 @@
1
+ __version__ = "0.9.12"
@@ -157,7 +157,10 @@ d = { 'settings': { 'branch': 'master',
157
157
  'fh_matui/datatable.py'),
158
158
  'fh_matui.datatable.DataTableResource._wrap_modal': ( 'datatable.html#datatableresource._wrap_modal',
159
159
  'fh_matui/datatable.py'),
160
+ 'fh_matui.datatable.DataTableResource._wrap_response': ( 'datatable.html#datatableresource._wrap_response',
161
+ 'fh_matui/datatable.py'),
160
162
  'fh_matui.datatable._action_menu': ('datatable.html#_action_menu', 'fh_matui/datatable.py'),
163
+ 'fh_matui.datatable._is_htmx_request': ('datatable.html#_is_htmx_request', 'fh_matui/datatable.py'),
161
164
  'fh_matui.datatable._page_size_select': ('datatable.html#_page_size_select', 'fh_matui/datatable.py'),
162
165
  'fh_matui.datatable._safe_int': ('datatable.html#_safe_int', 'fh_matui/datatable.py'),
163
166
  'fh_matui.datatable._to_dict': ('datatable.html#_to_dict', 'fh_matui/datatable.py'),
@@ -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,17 +327,58 @@ 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
- def Modal(*c, id=None, footer=None, active=False, overlay=True, cls=(), **kwargs):
335
- """BeerCSS modal dialog with optional overlay and footer."""
357
+ def Modal(*c, id=None, footer=None, active=False, overlay='default', position=None, cls=(), **kwargs):
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)
377
+
378
+ # Add position class if specified
379
+ if position in ['left', 'right', 'top', 'bottom']:
380
+ modal_cls.append(position)
381
+
337
382
  if active:
338
383
  modal_cls.append('active')
339
384
 
@@ -349,19 +394,32 @@ def Modal(*c, id=None, footer=None, active=False, overlay=True, cls=(), **kwargs
349
394
  # Create the dialog
350
395
  dialog = Dialog(*children, id=id, cls=cls_str, **kwargs)
351
396
 
352
- if overlay:
353
- # Return overlay + dialog as separate elements that BeerCSS can manage
354
- overlay_cls = "overlay blur"
397
+ # Handle overlay - always return a list for consistent *Modal(...) unpacking
398
+ if overlay and overlay not in [False, None]:
399
+ overlay_classes = ['overlay']
400
+
401
+ # Map overlay parameter to BeerCSS classes
402
+ if overlay == 'blur':
403
+ overlay_classes.append('blur')
404
+ elif overlay == 'small-blur':
405
+ overlay_classes.append('small-blur')
406
+ elif overlay == 'medium-blur':
407
+ overlay_classes.append('medium-blur')
408
+ elif overlay == 'large-blur':
409
+ overlay_classes.append('large-blur')
410
+ # 'default' means plain overlay with no blur class
411
+
412
+ overlay_cls = ' '.join(overlay_classes)
355
413
  if active:
356
414
  overlay_cls += " active"
357
-
358
- # Return as a list - both elements need to be at same DOM level
359
- return [
360
- Div(cls=overlay_cls),
361
- dialog
362
- ]
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]
363
420
 
364
- return dialog
421
+ # No overlay - still return as list for consistent unpacking
422
+ return [dialog]
365
423
 
366
424
  def ModalButton(text: str, id: str, icon: str = None, cls=(), **kwargs):
367
425
  """Button that opens a modal via data-ui attribute."""
@@ -717,15 +775,14 @@ def Toolbar(*items, cls='', elevate='large', fill=True, **kwargs):
717
775
  if cls: classes.append(cls)
718
776
  return Nav(*items, cls=' '.join(classes), **kwargs)
719
777
 
720
- # %% ../nbs/02_components.ipynb 92
778
+ # %% ../nbs/02_components.ipynb 93
721
779
  #| code-fold: true
722
- def Toast(*c, cls='', position='top', variant='', action=None, dur=5.0, active=False, **kwargs):
723
- """BeerCSS snackbar/toast notification with position and variant options."""
780
+ def Toast(*c, cls='', position='top', variant='', action=None, active=False, dur=None, **kwargs):
781
+ """BeerCSS snackbar/toast notification with position ('top' or 'bottom') and variant options."""
724
782
  classes = ['snackbar']
725
783
  if variant: classes.append(variant)
726
- if position:
727
- position_map = {'top': 'bottom', 'bottom': 'top', 'left': 'right', 'right': 'left'}
728
- classes.append(position_map.get(position, position))
784
+ if position in ['top', 'bottom']:
785
+ classes.append(position)
729
786
  if active: classes.append('active')
730
787
  if cls: classes.append(cls)
731
788
 
@@ -735,13 +792,34 @@ def Toast(*c, cls='', position='top', variant='', action=None, dur=5.0, active=F
735
792
  if isinstance(action, str): content.append(A(action, cls='inverse-link'))
736
793
  else: content.append(action)
737
794
  else: content.extend(c)
738
- return Div(*content, cls=' '.join(classes), **kwargs)
795
+
796
+ snackbar = Div(*content, cls=' '.join(classes), **kwargs)
797
+
798
+ # If duration is specified and there's an id, generate auto-hide script
799
+ if dur and 'id' in kwargs:
800
+ timeout_ms = int(dur * 1000)
801
+ toast_id = kwargs['id']
802
+ # Wait for Beer CSS module to load before calling ui()
803
+ script = Script(f"""
804
+ window.addEventListener('load', function() {{
805
+ function showToast() {{
806
+ if (typeof ui !== 'undefined') {{
807
+ try {{ ui('#{toast_id}', {timeout_ms}); }}
808
+ catch (e) {{ console.error('Toast error:', e.message); }}
809
+ }}
810
+ }}
811
+ setTimeout(showToast, 50);
812
+ }});
813
+ """)
814
+ return (snackbar, script)
815
+
816
+ return snackbar
739
817
 
740
818
  def Snackbar(*c, **kwargs):
741
819
  """Alias for Toast component."""
742
820
  return Toast(*c, **kwargs)
743
821
 
744
- # %% ../nbs/02_components.ipynb 94
822
+ # %% ../nbs/02_components.ipynb 96
745
823
  #| code-fold: true
746
824
  class ContainerT(VEnum):
747
825
  """Container size options (BeerCSS). Most alias to 'responsive'; use 'expand' for full-width."""
@@ -752,7 +830,7 @@ class ContainerT(VEnum):
752
830
  xl = 'responsive'
753
831
  expand = 'responsive max'
754
832
 
755
- # %% ../nbs/02_components.ipynb 96
833
+ # %% ../nbs/02_components.ipynb 98
756
834
  #| code-fold: true
757
835
  def _get_form_config(col: dict) -> dict:
758
836
  """Extract form config from column, with sensible defaults."""
@@ -828,7 +906,7 @@ def FormField(
828
906
  **attrs
829
907
  )
830
908
 
831
- # %% ../nbs/02_components.ipynb 98
909
+ # %% ../nbs/02_components.ipynb 100
832
910
  #| code-fold: true
833
911
  from typing import Callable, Any
834
912
 
@@ -947,7 +1025,7 @@ def FormModal(
947
1025
  cls="large-width"
948
1026
  )
949
1027
 
950
- # %% ../nbs/02_components.ipynb 100
1028
+ # %% ../nbs/02_components.ipynb 102
951
1029
  #| code-fold: true
952
1030
  def NavContainer(*li, title=None, brand=None, position='left', close_button=True, cls='active', id=None, **kwargs):
953
1031
  """Slide-out navigation drawer with header and close button."""
@@ -1004,7 +1082,7 @@ def BottomNav(*c, cls='bottom', size='s', **kwargs):
1004
1082
  final_cls = f"{cls} {size_cls}".strip()
1005
1083
  return Nav(*c, cls=final_cls, **kwargs)
1006
1084
 
1007
- # %% ../nbs/02_components.ipynb 103
1085
+ # %% ../nbs/02_components.ipynb 105
1008
1086
  #| code-fold: true
1009
1087
  def NavSideBarHeader(*c, cls='', **kwargs):
1010
1088
  """Sidebar header section for menu buttons and branding."""
@@ -1017,33 +1095,76 @@ def NavSideBarLinks(*children, as_list=False, cls='', **kwargs):
1017
1095
  return Ul(*children, cls=list_cls, **kwargs)
1018
1096
  return Group(*children) if len(children) > 1 else (children[0] if children else Group())
1019
1097
 
1020
- def NavSideBarContainer(*children, position='left', size='m', cls='', active=False, **kwargs):
1021
- """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
+ """
1022
1118
  base_cls = f"{size} {position} surface-container"
1023
1119
  if active: base_cls += " active"
1024
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
+
1025
1127
  return Nav(*children, cls=nav_cls, **kwargs)
1026
1128
 
1027
- # %% ../nbs/02_components.ipynb 105
1129
+ # %% ../nbs/02_components.ipynb 107
1028
1130
  #| code-fold: true
1029
1131
  def Layout(*content, sidebar=None, sidebar_links=None, nav_bar=None, container_size=ContainerT.expand,
1030
- main_bg='surface', sidebar_id='app-sidebar', cls='', **kwargs):
1031
- """App layout wrapper with auto-toggle sidebar and sensible defaults."""
1032
- main_content = []
1033
-
1034
- if nav_bar:
1035
- if hasattr(nav_bar, 'attrs') and 'cls' in nav_bar.attrs:
1036
- if 'sticky' not in nav_bar.attrs['cls']: nav_bar.attrs['cls'] += ' sticky top'
1037
- 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.
1038
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
1039
1152
  if content:
1040
- container_cls = stringify((container_size, 'padding', main_bg))
1041
- main_content.append(Main(*content, cls=container_cls))
1153
+ content_wrapper = Div(*content, id=main_id, hx_history_elt='true')
1042
1154
 
1155
+ # No sidebar - simple layout
1043
1156
  if not sidebar and not sidebar_links:
1044
- if main_content: return Div(*main_content, cls=cls, **kwargs)
1045
- else: return Div(cls=cls, **kwargs)
1046
-
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
1047
1168
  sidebar_children = [NavSideBarHeader(NavToggleButton(f"#{sidebar_id}"))]
1048
1169
 
1049
1170
  if sidebar_links: sidebar_children.extend(sidebar_links)
@@ -1053,23 +1174,23 @@ def Layout(*content, sidebar=None, sidebar_links=None, nav_bar=None, container_s
1053
1174
 
1054
1175
  nav_rail = NavSideBarContainer(*sidebar_children, position='left', size='l', id=sidebar_id)
1055
1176
 
1056
- navbar_elem = None
1057
- content_items = []
1058
- for item in main_content:
1059
- if hasattr(item, 'tag') and item.tag == 'nav': navbar_elem = item
1060
- else: content_items.append(item)
1061
-
1062
1177
  layout_children = []
1063
- 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
+
1064
1184
  layout_children.append(nav_rail)
1065
- if content_items:
1185
+
1186
+ if content_wrapper:
1066
1187
  container_cls = stringify((container_size, 'round', 'elevate', 'margin'))
1067
- layout_children.append(Main(*content_items, cls=container_cls))
1188
+ layout_children.append(Main(content_wrapper, cls=container_cls))
1068
1189
 
1069
1190
  final_cls = f"surface-container {cls}".strip() if cls else "surface-container"
1070
1191
  return Div(*layout_children, cls=final_cls, **kwargs)
1071
1192
 
1072
- # %% ../nbs/02_components.ipynb 109
1193
+ # %% ../nbs/02_components.ipynb 113
1073
1194
  #| code-fold: true
1074
1195
  class TextT(VEnum):
1075
1196
  """Text styles using BeerCSS typography classes."""
@@ -1101,7 +1222,7 @@ class TextPresets(VEnum):
1101
1222
  primary_link = 'link primary-text'
1102
1223
  muted_link = 'link secondary-text'
1103
1224
 
1104
- # %% ../nbs/02_components.ipynb 110
1225
+ # %% ../nbs/02_components.ipynb 114
1105
1226
  #| code-fold: true
1106
1227
  def CodeSpan(*c, cls=(), **kwargs):
1107
1228
  """Inline code snippet."""
@@ -1157,7 +1278,7 @@ def Sup(*c, cls=(), **kwargs):
1157
1278
  cls_str = stringify(cls) if cls else None
1158
1279
  return fc.Sup(*c, cls=cls_str, **kwargs) if cls_str else fc.Sup(*c, **kwargs)
1159
1280
 
1160
- # %% ../nbs/02_components.ipynb 112
1281
+ # %% ../nbs/02_components.ipynb 116
1161
1282
  #| code-fold: true
1162
1283
  def FAQItem(question: str, answer: str, question_cls: str = '', answer_cls: str = ''):
1163
1284
  """Collapsible FAQ item using details/summary.
@@ -1175,7 +1296,7 @@ def FAQItem(question: str, answer: str, question_cls: str = '', answer_cls: str
1175
1296
  Summary(Article(Nav(Div(question, cls=f"max bold {question_cls}".strip()), I("expand_more")), cls="round surface-variant border no-elevate")),
1176
1297
  Article(P(answer, cls=f"secondary-text {answer_cls}".strip()), cls="round border padding"))
1177
1298
 
1178
- # %% ../nbs/02_components.ipynb 116
1299
+ # %% ../nbs/02_components.ipynb 120
1179
1300
  #| code-fold: true
1180
1301
  def CookiesBanner(message='We use cookies to enhance your experience. By continuing to visit this site you agree to our use of cookies.',
1181
1302
  accept_text='Accept', decline_text='Decline', settings_text=None, policy_link='/cookies', policy_text='Learn more',
@@ -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"""