fh-matui 0.9.7__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/components.py ADDED
@@ -0,0 +1,1200 @@
1
+ """Material Design components for FastHTML using BeerCSS"""
2
+
3
+ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/02_components.ipynb.
4
+
5
+ # %% auto 0
6
+ __all__ = ['BUTTON_SPECIALS', 'ButtonT', 'ANCHOR_SPECIALS', 'AT', 'NavToggleButton', 'SpaceT', 'GridSpanT', 'GridCell', 'Grid',
7
+ 'DivLAligned', 'DivVStacked', 'DivHStacked', 'DivRAligned', 'DivCentered', 'DivFullySpaced', 'Icon',
8
+ 'NavBar', 'Modal', 'ModalButton', 'ModalCancel', 'ModalConfirm', 'ModalTitle', 'ModalBody', 'ModalFooter',
9
+ 'Field', 'LabelInput', 'FormLabel', 'CheckboxX', 'Radio', 'Switch', 'TextArea', 'Range', 'Select',
10
+ 'FormGrid', 'Progress', 'LoadingIndicator', 'Table', 'Td', 'Th', 'Thead', 'Tbody', 'Tfoot', 'TableFromLists',
11
+ 'TableFromDicts', 'TableControls', 'Pagination', 'Card', 'Toolbar', 'Toast', 'Snackbar', 'ContainerT',
12
+ 'FormField', 'FormModal', 'NavContainer', 'NavHeaderLi', 'NavDividerLi', 'NavCloseLi', 'NavSubtitle',
13
+ 'BottomNav', 'NavSideBarHeader', 'NavSideBarLinks', 'NavSideBarContainer', 'Layout', 'TextT', 'TextPresets',
14
+ 'CodeSpan', 'CodeBlock', 'Blockquote', 'Q', 'Em', 'Strong', 'Small', 'Mark', 'Abbr', 'Sub', 'Sup', 'FAQItem',
15
+ 'CookiesBanner']
16
+
17
+ # %% ../nbs/02_components.ipynb 2
18
+ from fastcore.utils import *
19
+ from fasthtml.common import *
20
+ from fasthtml.jupyter import *
21
+ from fastlite import *
22
+ import fasthtml.components as fc
23
+ from fasthtml.common import A, Button as FhButton, I, Span
24
+ from .foundations import normalize_tokens, stringify, VEnum, dedupe_preserve_order
25
+ from .core import *
26
+ from nbdev.showdoc import show_doc
27
+
28
+
29
+ # %% ../nbs/02_components.ipynb 6
30
+ #| code-fold: true
31
+ def NavToggleButton(target, icon='menu', **kwargs):
32
+ """Create a navigation toggle button that toggles the 'max' class on the target element"""
33
+ cls = kwargs.get('cls', 'circle transparent')
34
+ onclick = f"toggleNav('{target}'); return false;"
35
+ kwargs.update({'onclick': onclick, 'cls': cls})
36
+ return Button(I(icon), **kwargs)
37
+
38
+ # %% ../nbs/02_components.ipynb 7
39
+ #| code-fold: true
40
+ BUTTON_SPECIALS = {
41
+ 'primary': 'primary',
42
+ 'secondary': 'secondary',
43
+ 'destructive': 'tertiary',
44
+ 'ghost': 'transparent border',
45
+ 'text': 'transparent',
46
+ 'link': '__link__',
47
+ 'default': 'primary'
48
+ }
49
+
50
+ def _make_button_property(tokens):
51
+ return property(lambda self: _ButtonChain(self._tokens + tokens))
52
+
53
+ class _ButtonChain(BeerCssChain):
54
+ """Chainable button style helper"""
55
+ pass
56
+
57
+ for name, css in BUTTON_SPECIALS.items():
58
+ tokens = css.split()
59
+ setattr(_ButtonChain, name, _make_button_property(tokens))
60
+
61
+ ButtonT = _ButtonChain()
62
+
63
+ # %% ../nbs/02_components.ipynb 10
64
+ #| code-fold: true
65
+ class _AnchorChain(BeerCssChain):
66
+ """Chainable anchor style helper"""
67
+ pass
68
+
69
+ ANCHOR_SPECIALS = {
70
+ 'muted': 'grey-text',
71
+ 'text': '',
72
+ 'reset': 'no-underline',
73
+ 'primary': 'primary-text link',
74
+ 'classic': 'link',
75
+ 'inverse': 'inverse-link'
76
+ }
77
+
78
+ def _make_anchor_property(tokens):
79
+ return property(lambda self: _AnchorChain(self._tokens + tokens))
80
+
81
+ for name, css in ANCHOR_SPECIALS.items():
82
+ tokens = css.split() if css else []
83
+ setattr(_AnchorChain, name, _make_anchor_property(tokens))
84
+
85
+ AT = _AnchorChain()
86
+
87
+ # %% ../nbs/02_components.ipynb 13
88
+ class SpaceT(VEnum):
89
+ """Space types using BeerCSS spacing classes"""
90
+ no_space = 'no-space'
91
+ small_space = 'small-space'
92
+ medium_space = 'medium-space'
93
+ large_space = 'large-space'
94
+
95
+
96
+ class GridSpanT(VEnum):
97
+ """Grid span classes for responsive layouts (BeerCSS)"""
98
+ s1 = 's1'
99
+ s2 = 's2'
100
+ s3 = 's3'
101
+ s4 = 's4'
102
+ s5 = 's5'
103
+ s6 = 's6'
104
+ s7 = 's7'
105
+ s8 = 's8'
106
+ s9 = 's9'
107
+ s10 = 's10'
108
+ s11 = 's11'
109
+ s12 = 's12'
110
+ m1 = 'm1'
111
+ m2 = 'm2'
112
+ m3 = 'm3'
113
+ m4 = 'm4'
114
+ m5 = 'm5'
115
+ m6 = 'm6'
116
+ m7 = 'm7'
117
+ m8 = 'm8'
118
+ m9 = 'm9'
119
+ m10 = 'm10'
120
+ m11 = 'm11'
121
+ m12 = 'm12'
122
+ l1 = 'l1'
123
+ l2 = 'l2'
124
+ l3 = 'l3'
125
+ l4 = 'l4'
126
+ l5 = 'l5'
127
+ l6 = 'l6'
128
+ l7 = 'l7'
129
+ l8 = 'l8'
130
+ l9 = 'l9'
131
+ l10 = 'l10'
132
+ l11 = 'l11'
133
+ l12 = 'l12'
134
+
135
+
136
+ def _has_space_token(tokens):
137
+ space_tokens = {'space', 'no-space', 'small-space', 'medium-space', 'large-space'}
138
+ return any(t in space_tokens for t in tokens)
139
+
140
+
141
+ def GridCell(*c, span=(), cls='', **kwargs):
142
+ """Wrap content as a BeerCSS grid cell with responsive span control."""
143
+ cell_cls = []
144
+ cell_cls.extend(normalize_tokens(span))
145
+ cell_cls.extend(normalize_tokens(cls))
146
+ cell_cls = [t for t in cell_cls if t]
147
+ return Div(*c, cls=stringify(dedupe_preserve_order(cell_cls)), **kwargs)
148
+
149
+
150
+ # Valid BeerCSS 12-column grid divisors (cols that divide evenly into 12)
151
+ _VALID_GRID_COLS = (1, 2, 3, 4, 6, 12)
152
+
153
+ def _snap_to_valid_cols(n: int) -> int:
154
+ """Snap column count to nearest valid 12-column grid divisor."""
155
+ if n <= 0: return 1
156
+ if n >= 12: return 12
157
+ for valid in _VALID_GRID_COLS:
158
+ if n <= valid: return valid
159
+ return 12
160
+
161
+
162
+ def _wrap_grid_children(cells, cols=None, cols_sm=None, cols_md=None, cols_lg=None):
163
+ """Wrap grid children with span classes based on column counts."""
164
+ # If no column counts specified, return cells as-is
165
+ if not any([cols, cols_sm, cols_md, cols_lg]):
166
+ return cells
167
+
168
+ # Build span string from column counts
169
+ spans = []
170
+ if cols_sm:
171
+ spans.append(f's{12 // cols_sm}')
172
+ elif cols:
173
+ spans.append(f's{12 // cols}')
174
+
175
+ if cols_md:
176
+ spans.append(f'm{12 // cols_md}')
177
+ elif cols:
178
+ spans.append(f'm{12 // cols}')
179
+
180
+ if cols_lg:
181
+ spans.append(f'l{12 // cols_lg}')
182
+ elif cols:
183
+ spans.append(f'l{12 // cols}')
184
+
185
+ span_str = ' '.join(spans) if spans else 's12'
186
+
187
+ # Wrap each child in a GridCell with the calculated spans
188
+ return [GridCell(cell, span=span_str) for cell in cells]
189
+
190
+
191
+ def Grid(*cells, space=SpaceT.medium_space,
192
+ cols_min: int = 1, cols_max: int = 4,
193
+ cols: int = None, cols_sm: int = None, cols_md: int = None, cols_lg: int = None,
194
+ responsive: bool = True, padding: bool = True, cls: str = '', **kwargs):
195
+ """BeerCSS responsive grid with smart column defaults and mobile-first design."""
196
+ # Smart defaults based on content count (MonsterUI pattern)
197
+ if cols:
198
+ # Fixed cols for all breakpoints
199
+ cols_sm = cols_md = cols_lg = _snap_to_valid_cols(cols)
200
+ elif not any([cols_sm, cols_md, cols_lg]):
201
+ # Auto-calculate responsive columns based on item count
202
+ n = len(cells)
203
+ cols_max = min(n, cols_max)
204
+ cols_sm = _snap_to_valid_cols(min(n, cols_min, cols_max))
205
+ cols_md = _snap_to_valid_cols(min(n, cols_min + 1, cols_max))
206
+ cols_lg = _snap_to_valid_cols(min(n, cols_max))
207
+ else:
208
+ # Explicit breakpoint values - snap to valid grid cols
209
+ if cols_sm: cols_sm = _snap_to_valid_cols(cols_sm)
210
+ if cols_md: cols_md = _snap_to_valid_cols(cols_md)
211
+ if cols_lg: cols_lg = _snap_to_valid_cols(cols_lg)
212
+
213
+ wrapped_cells = _wrap_grid_children(cells, cols, cols_sm, cols_md, cols_lg)
214
+
215
+ cls_tokens = normalize_tokens(cls)
216
+ grid_cls = ['grid']
217
+ if responsive and 'responsive' not in cls_tokens:
218
+ grid_cls.append('responsive')
219
+ if padding and 'padding' not in cls_tokens and 'no-padding' not in cls_tokens:
220
+ grid_cls.append('padding')
221
+ if space and not _has_space_token(cls_tokens):
222
+ grid_cls.extend(normalize_tokens(space))
223
+ grid_cls.extend(cls_tokens)
224
+ grid_cls = [t for t in grid_cls if t]
225
+ return Div(*wrapped_cells, cls=stringify(dedupe_preserve_order(grid_cls)), **kwargs)
226
+
227
+ # %% ../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."""
241
+ cls_tokens = normalize_tokens(cls)
242
+ tokens = []
243
+ if responsive and 'responsive' not in cls_tokens:
244
+ tokens.append('responsive')
245
+ if padding and 'padding' not in cls_tokens and 'no-padding' not in cls_tokens:
246
+ tokens.append('padding')
247
+ if 'grid' not in cls_tokens:
248
+ tokens.extend(['row', 'vertical'])
249
+ if not _has_space_token(cls_tokens):
250
+ tokens.append(SpaceT.medium_space)
251
+ tokens.extend(cls_tokens)
252
+ tokens = [t for t in tokens if t]
253
+ return Div(*c, cls=stringify(dedupe_preserve_order(tokens)), **kwargs)
254
+
255
+
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."""
259
+ cls_tokens = normalize_tokens(cls)
260
+ tokens = []
261
+ if responsive and 'responsive' not in cls_tokens:
262
+ tokens.append('responsive')
263
+ if padding and 'padding' not in cls_tokens and 'no-padding' not in cls_tokens:
264
+ tokens.append('padding')
265
+ if 'grid' not in cls_tokens:
266
+ tokens.extend(['row', 'middle-align'])
267
+ if not _has_space_token(cls_tokens):
268
+ tokens.append(SpaceT.medium_space)
269
+ tokens.extend(cls_tokens)
270
+ tokens = [t for t in tokens if t]
271
+ return Div(*c, cls=stringify(dedupe_preserve_order(tokens)), **kwargs)
272
+
273
+
274
+ # %% ../nbs/02_components.ipynb 22
275
+ def DivRAligned(*c, cls='', **kwargs):
276
+ """MonsterUI-compatible right-aligned row using BeerCSS tokens."""
277
+ cls_tokens = normalize_tokens(cls)
278
+ tokens = ['right-align']
279
+ tokens.extend(cls_tokens)
280
+ tokens = [t for t in tokens if t]
281
+ return DivHStacked(*c, cls=stringify(dedupe_preserve_order(tokens)), **kwargs)
282
+
283
+ # %% ../nbs/02_components.ipynb 24
284
+ def DivCentered(*c, cls='', **kwargs):
285
+ """Center-aligned container using BeerCSS tokens."""
286
+ cls_tokens = normalize_tokens(cls)
287
+ tokens = ['center-align']
288
+ tokens.extend(cls_tokens)
289
+ tokens = [t for t in tokens if t]
290
+ return Div(*c, cls=stringify(dedupe_preserve_order(tokens)), **kwargs)
291
+
292
+ # %% ../nbs/02_components.ipynb 26
293
+ def DivFullySpaced(*c, cls='', **kwargs):
294
+ """Row with children stretched to far ends using BeerCSS `max` spacers."""
295
+ cls_tokens = normalize_tokens(cls)
296
+ tokens = []
297
+ if 'grid' not in cls_tokens:
298
+ tokens.extend(['row', 'middle-align'])
299
+ if not _has_space_token(cls_tokens):
300
+ tokens.append(SpaceT.no_space)
301
+ tokens.extend(cls_tokens)
302
+ tokens = [t for t in tokens if t]
303
+ base = list(c)
304
+ if 'grid' not in cls_tokens and len(base) > 1:
305
+ spaced_children = []
306
+ for i, child in enumerate(base):
307
+ spaced_children.append(child)
308
+ if i != len(base) - 1:
309
+ spaced_children.append(Div(cls='max'))
310
+ base = spaced_children
311
+ return Div(*base, cls=stringify(dedupe_preserve_order(tokens)), **kwargs)
312
+
313
+ # %% ../nbs/02_components.ipynb 29
314
+ #| code-fold: true
315
+ def Icon(icon: str, size: str = None, fill: bool = False, cls = (), **kwargs):
316
+ """Material Design icon with optional size and fill"""
317
+ icon_cls = []
318
+ if size: icon_cls.append(size)
319
+ if fill: icon_cls.append('fill')
320
+ if cls: icon_cls.extend(normalize_tokens(cls))
321
+ cls_str = ' '.join(icon_cls) if icon_cls else None
322
+ return I(icon, cls=cls_str, **kwargs) if cls_str else I(icon, **kwargs)
323
+
324
+ # %% ../nbs/02_components.ipynb 32
325
+ #| 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()
329
+ 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)
332
+
333
+ # %% ../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."""
336
+ modal_cls = normalize_tokens(cls)
337
+ if active:
338
+ modal_cls.append('active')
339
+
340
+ children = list(c)
341
+ if footer:
342
+ if hasattr(footer, 'tag') and footer.tag == 'nav':
343
+ children.append(footer)
344
+ else:
345
+ children.append(Nav(*(footer if is_listy(footer) else [footer])))
346
+
347
+ cls_str = ' '.join(modal_cls) if modal_cls else None
348
+
349
+ # Create the dialog
350
+ dialog = Dialog(*children, id=id, cls=cls_str, **kwargs)
351
+
352
+ if overlay:
353
+ # Return overlay + dialog as separate elements that BeerCSS can manage
354
+ overlay_cls = "overlay blur"
355
+ if active:
356
+ 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
+ ]
363
+
364
+ return dialog
365
+
366
+ def ModalButton(text: str, id: str, icon: str = None, cls=(), **kwargs):
367
+ """Button that opens a modal via data-ui attribute."""
368
+ kwargs["data_ui"] = f"#{id}"
369
+ return Button(text, icon=icon, cls=cls, **kwargs)
370
+
371
+ def ModalCancel(text="Cancel", modal_id=None, cls=(), **kwargs):
372
+ """Cancel button that closes modal via data-ui."""
373
+ cancel_cls = normalize_tokens(cls)
374
+ cancel_cls.extend(['transparent', 'link'])
375
+ if modal_id:
376
+ kwargs["data_ui"] = f"#{modal_id}"
377
+ return Button(text, cls=' '.join(cancel_cls), **kwargs)
378
+
379
+ def ModalConfirm(text="Confirm", modal_id=None, cls=(), **kwargs):
380
+ """Confirm button that closes modal via data-ui."""
381
+ confirm_cls = normalize_tokens(cls)
382
+ confirm_cls.extend(['transparent', 'link'])
383
+ if modal_id:
384
+ kwargs["data_ui"] = f"#{modal_id}"
385
+ return Button(text, cls=' '.join(confirm_cls), **kwargs)
386
+
387
+ def ModalTitle(*c, cls=(), **kwargs):
388
+ """Modal title using H5 element."""
389
+ return H5(*c, cls=stringify(cls), **kwargs)
390
+
391
+ def ModalBody(*c, cls=(), **kwargs):
392
+ """Modal body wrapper for content layout."""
393
+ return Div(*c, cls=stringify(cls), **kwargs)
394
+
395
+ def ModalFooter(*c, cls=(), **kwargs):
396
+ """Modal footer with right-aligned action buttons."""
397
+ footer_cls = normalize_tokens(cls)
398
+ footer_cls.extend(['right-align', 'no-space'])
399
+ return Nav(*c, cls=' '.join(footer_cls), **kwargs)
400
+
401
+ # %% ../nbs/02_components.ipynb 38
402
+ #| code-fold: true
403
+ def Field(*c, label: bool = False, prefix: bool = False, suffix: bool = False, cls = '', **kwargs):
404
+ """BeerCSS field wrapper for inputs with border/label/prefix/suffix styling."""
405
+ field_cls = ['field', 'small', 'round', 'border']
406
+ if label: field_cls.append('label')
407
+ if prefix: field_cls.append('prefix')
408
+ if suffix: field_cls.append('suffix')
409
+ cls_str = f"{' '.join(field_cls)} {cls}".strip()
410
+ return Div(*c, cls=cls_str, **kwargs)
411
+
412
+ def LabelInput(label: str, id: str = None, placeholder: str = None, input_type: str = 'text',
413
+ prefix_icon: str = None, suffix_icon: str = None, value: str = None, cls = '', **kwargs):
414
+ """Labeled input field with floating label and optional icons."""
415
+ if not id: id = label.lower().replace(' ', '-')
416
+ children = []
417
+ if prefix_icon: children.append(I(prefix_icon))
418
+ input_attrs = {'type': input_type, 'id': id, 'name': id, 'placeholder': placeholder if placeholder is not None else " "}
419
+ if value is not None: input_attrs['value'] = value
420
+ input_attrs.update(kwargs)
421
+ children.append(Input(**input_attrs))
422
+ children.append(Label(label, fr=id))
423
+ if suffix_icon: children.append(I(suffix_icon))
424
+ return Field(*children, label=True, prefix=bool(prefix_icon), suffix=bool(suffix_icon), cls=cls)
425
+
426
+ # %% ../nbs/02_components.ipynb 41
427
+ #| code-fold: true
428
+ def LabelInput(label: str, id: str = None, placeholder: str = None, input_type: str = 'text',
429
+ prefix_icon: str = None, suffix_icon: str = None, value: str = None, cls = '', **kwargs):
430
+ """Labeled input field with floating label and optional icons."""
431
+ if not id: id = label.lower().replace(' ', '-')
432
+ children = []
433
+ if prefix_icon: children.append(I(prefix_icon))
434
+ input_attrs = {'type': input_type, 'id': id, 'name': id, 'placeholder': placeholder if placeholder is not None else " "}
435
+ if value is not None: input_attrs['value'] = value
436
+ input_attrs.update(kwargs)
437
+ children.append(Input(**input_attrs))
438
+ children.append(Label(label, fr=id))
439
+ if suffix_icon: children.append(I(suffix_icon))
440
+ return Field(*children, label=True, prefix=bool(prefix_icon), suffix=bool(suffix_icon), cls=cls)
441
+
442
+ def FormLabel(*c, cls=(), **kwargs):
443
+ """Standalone form label element with MonsterUI-compatible signature."""
444
+ cls_str = stringify(cls) if cls else None
445
+ if cls_str: return Label(*c, cls=cls_str, **kwargs)
446
+ return Label(*c, **kwargs)
447
+
448
+ # %% ../nbs/02_components.ipynb 44
449
+ #| code-fold: true
450
+ def CheckboxX(*c, cls=(), **kwargs):
451
+ """BeerCSS checkbox with label support."""
452
+ label_text = stringify(c) if c else ""
453
+ checkbox_cls = ['checkbox']
454
+ if cls: checkbox_cls.extend(normalize_tokens(cls))
455
+ cls_str = stringify(checkbox_cls)
456
+ return Label(Input(type='checkbox', **kwargs), Span(label_text) if label_text else Span(), cls=cls_str)
457
+
458
+ # %% ../nbs/02_components.ipynb 47
459
+ #| code-fold: true
460
+ def Radio(*c, cls=(), **kwargs):
461
+ """BeerCSS radio button with label."""
462
+ label_text = stringify(c) if c else ""
463
+ radio_cls = ['radio']
464
+ if cls: radio_cls.extend(normalize_tokens(cls))
465
+ cls_str = stringify(radio_cls)
466
+ return Label(Input(type='radio', **kwargs), Span(label_text) if label_text else Span(), cls=cls_str)
467
+
468
+ # %% ../nbs/02_components.ipynb 50
469
+ #| code-fold: true
470
+ def Switch(*c, cls=(), **kwargs):
471
+ """BeerCSS toggle switch for on/off states.
472
+
473
+ If label text is provided, wraps in a nav with the label on the left
474
+ and the switch on the right (standard BeerCSS pattern).
475
+ """
476
+ label_text = stringify(c) if c else ""
477
+ switch_cls = ['switch']
478
+ if cls: switch_cls.extend(normalize_tokens(cls))
479
+ cls_str = stringify(switch_cls)
480
+
481
+ # Core switch element: input + span (for the toggle visual)
482
+ switch_label = Label(Input(type='checkbox', **kwargs), Span(), cls=cls_str)
483
+
484
+ # If no label text, return just the switch
485
+ if not label_text:
486
+ return switch_label
487
+
488
+ # With label text, use BeerCSS nav pattern for proper layout
489
+ return Nav(
490
+ Div(Span(label_text), cls='max'),
491
+ switch_label,
492
+ cls='middle-align'
493
+ )
494
+
495
+ # %% ../nbs/02_components.ipynb 53
496
+ #| code-fold: true
497
+ def TextArea(*c, cls=(), **kwargs):
498
+ """BeerCSS textarea with field wrapper for consistent styling."""
499
+ content = stringify(c) if c else ""
500
+ textarea = Textarea(content, **kwargs) if content else Textarea(**kwargs)
501
+ return Field(textarea, cls=cls)
502
+
503
+ # %% ../nbs/02_components.ipynb 56
504
+ #| code-fold: true
505
+ def Range(*c, min=None, max=None, step=None, cls=(), **kwargs):
506
+ """BeerCSS range slider with two-tone fill effect."""
507
+ min_val = min if min is not None else 0
508
+ max_val = max if max is not None else 100
509
+ value = kwargs.get('value', min_val)
510
+ percentage = ((float(value) - float(min_val)) / (float(max_val) - float(min_val))) * 100 if max_val != min_val else 0
511
+
512
+ input_attrs = {'type': 'range'}
513
+ if min is not None: input_attrs['min'] = min
514
+ if max is not None: input_attrs['max'] = max
515
+ if step is not None: input_attrs['step'] = step
516
+ input_attrs['oninput'] = """
517
+ const val = this.value;
518
+ const min = this.min || 0;
519
+ const max = this.max || 100;
520
+ const percentage = ((val - min) / (max - min)) * 100;
521
+ this.parentElement.style.setProperty('--_start', '0%');
522
+ this.parentElement.style.setProperty('--_end', percentage + '%');
523
+ """.strip()
524
+ input_attrs.update(kwargs)
525
+
526
+ slider_cls = ['slider']
527
+ if cls: slider_cls.extend(normalize_tokens(cls))
528
+ cls_str = stringify(slider_cls)
529
+ style = f"--_start: 0%; --_end: {percentage:.1f}%;"
530
+ return Label(Input(**input_attrs), Span(), cls=cls_str, style=style)
531
+
532
+ # %% ../nbs/02_components.ipynb 59
533
+ #| code-fold: true
534
+ def Select(*items, value='', placeholder='Select...', prefix_icon=None, name='', cls=(), **kwargs):
535
+ """BeerCSS menu-based select dropdown with rich styling."""
536
+ menu_items = []
537
+ for item in items:
538
+ if isinstance(item, str): menu_items.append(Li(item))
539
+ else: menu_items.append(item)
540
+
541
+ children = []
542
+ if prefix_icon: children.append(I(prefix_icon))
543
+ input_attrs = {'value': value, 'readonly': True, 'placeholder': placeholder if placeholder else ' '}
544
+ if name: input_attrs['name'] = name
545
+ input_attrs.update(kwargs)
546
+ children.append(Input(**input_attrs))
547
+ children.append(I('arrow_drop_down'))
548
+ children.append(Menu(*menu_items))
549
+
550
+ field_cls = ['field', 'fill', 'round']
551
+ if prefix_icon: field_cls.append('prefix')
552
+ field_cls.append('suffix')
553
+ if cls: field_cls.extend(normalize_tokens(cls))
554
+ cls_str = stringify(field_cls)
555
+ return Div(*children, cls=cls_str)
556
+
557
+ # %% ../nbs/02_components.ipynb 68
558
+ #| code-fold: true
559
+ def FormGrid(*c, cols: int = 1):
560
+ """Responsive grid layout for form fields that stacks on mobile."""
561
+ col_width = 12 // max(1, min(cols, 4))
562
+ col_cls = f"s12 m{col_width}"
563
+ wrapped = [Div(child, cls=col_cls) for child in c]
564
+ return Div(*wrapped, cls="grid")
565
+
566
+ # %% ../nbs/02_components.ipynb 71
567
+ #| code-fold: true
568
+ def Progress(*c, value='', max='100', cls=(), **kwargs):
569
+ """Linear progress bar with value/max support."""
570
+ progress_attrs = {}
571
+ if value: progress_attrs['value'] = value
572
+ if max: progress_attrs['max'] = max
573
+ progress_attrs.update(kwargs)
574
+ cls_str = stringify(cls) if cls else None
575
+ if cls_str: return fc.Progress(*c, cls=cls_str, **progress_attrs)
576
+ return fc.Progress(*c, **progress_attrs)
577
+
578
+ # %% ../nbs/02_components.ipynb 74
579
+ #| code-fold: true
580
+ def LoadingIndicator(size='medium', cls='', **kwargs):
581
+ """BeerCSS circular spinner for async operations."""
582
+ size_cls = size if size else 'medium'
583
+ progress_cls = f"circle {size_cls} {cls}".strip()
584
+ return fc.Progress(cls=progress_cls, **kwargs)
585
+
586
+ # %% ../nbs/02_components.ipynb 77
587
+ #| code-fold: true
588
+ def Table(*c, cls = 'border', **kwargs):
589
+ """BeerCSS table with optional border/stripes classes."""
590
+ cls_str = stringify(cls) if cls else 'border'
591
+ return fc.Table(*c, cls=cls_str, **kwargs)
592
+
593
+ def Td(*c, shrink = False, expand = False, cls = (), **kwargs):
594
+ """Table data cell with shrink/expand width options."""
595
+ cls_str = stringify(cls) if cls else ''
596
+ if shrink:
597
+ cls_str += ' no-wrap'
598
+ existing_style = kwargs.get('style', '').strip()
599
+ kwargs['style'] = f"{existing_style}; width: 1%;".strip().lstrip(';')
600
+ if expand:
601
+ cls_str += ' no-wrap'
602
+ existing_style = kwargs.get('style', '').strip()
603
+ kwargs['style'] = f"{existing_style}; width: 99%;".strip().lstrip(';')
604
+ return fc.Td(*c, cls=cls_str.strip(), **kwargs) if cls_str.strip() else fc.Td(*c, **kwargs)
605
+
606
+ def Th(*c, shrink = False, expand = False, cls = (), **kwargs):
607
+ """Table header cell with shrink/expand width options."""
608
+ cls_str = stringify(cls) if cls else ''
609
+ if shrink:
610
+ cls_str += ' no-wrap'
611
+ existing_style = kwargs.get('style', '').strip()
612
+ kwargs['style'] = f"{existing_style}; width: 1%;".strip().lstrip(';')
613
+ if expand:
614
+ cls_str += ' no-wrap'
615
+ existing_style = kwargs.get('style', '').strip()
616
+ kwargs['style'] = f"{existing_style}; width: 99%;".strip().lstrip(';')
617
+ return fc.Th(*c, cls=cls_str.strip(), **kwargs) if cls_str.strip() else fc.Th(*c, **kwargs)
618
+
619
+ def Thead(*c, cls=(), **kwargs):
620
+ """Table header section."""
621
+ cls_str = stringify(cls) if cls else None
622
+ return fc.Thead(*c, cls=cls_str, **kwargs) if cls_str else fc.Thead(*c, **kwargs)
623
+
624
+ def Tbody(*c, cls=(), sortable=False, **kwargs):
625
+ """Table body section with optional SortableJS support."""
626
+ cls_str = stringify(cls) if cls else ''
627
+ if sortable: cls_str = f"{cls_str} sortable".strip()
628
+ return fc.Tbody(*c, cls=cls_str, **kwargs) if cls_str else fc.Tbody(*c, **kwargs)
629
+
630
+ def Tfoot(*c, cls=(), **kwargs):
631
+ """Table footer section."""
632
+ cls_str = stringify(cls) if cls else None
633
+ return fc.Tfoot(*c, cls=cls_str, **kwargs) if cls_str else fc.Tfoot(*c, **kwargs)
634
+
635
+ def TableFromLists(header_data, body_data, footer_data = None, header_cell_render = Th,
636
+ body_cell_render = Td, footer_cell_render = Td, cls = 'border', sortable = False, **kwargs):
637
+ """Create table from header list and body list of lists."""
638
+ return Table(
639
+ Thead(Tr(*map(header_cell_render, header_data))),
640
+ Tbody(*[Tr(*map(body_cell_render, row)) for row in body_data], sortable=sortable),
641
+ Tfoot(Tr(*map(footer_cell_render, footer_data))) if footer_data else None,
642
+ cls=cls, **kwargs)
643
+
644
+ def TableFromDicts(header_data, body_data, footer_data = None, header_cell_render = Th,
645
+ body_cell_render = lambda k, v: Td(v), footer_cell_render = lambda k, v: Td(v),
646
+ cls = 'border', sortable = False, **kwargs):
647
+ """Create table from header keys and body list of dicts."""
648
+ return Table(
649
+ Thead(Tr(*[header_cell_render(h) for h in header_data])),
650
+ Tbody(*[Tr(*[body_cell_render(k, row.get(k, '')) for k in header_data]) for row in body_data], sortable=sortable),
651
+ Tfoot(Tr(*[footer_cell_render(k, footer_data.get(k, '')) for k in header_data])) if footer_data else None,
652
+ cls=cls, **kwargs)
653
+
654
+ # %% ../nbs/02_components.ipynb 80
655
+ #| code-fold: true
656
+ def TableControls(*controls, cls='', **kwargs):
657
+ """Toolbar container for table filters, search, and actions."""
658
+ controls_cls = f"padding middle-align space {cls}".strip()
659
+ return Div(*controls, cls=controls_cls, **kwargs)
660
+
661
+ # %% ../nbs/02_components.ipynb 83
662
+ #| code-fold: true
663
+ def Pagination(current_page: int, total_pages: int, hx_get: str, hx_target: str = '#table-container',
664
+ show_first_last: bool = True, cls='', **kwargs):
665
+ """HTMX-integrated pagination controls with first/prev/next/last buttons."""
666
+ buttons = []
667
+ separator = '&' if '?' in hx_get else '?'
668
+
669
+ if show_first_last:
670
+ first_disabled = current_page == 1
671
+ buttons.append(FhButton(Icon('first_page'), cls='circle transparent' if first_disabled else 'circle',
672
+ disabled=first_disabled, hx_get=f"{hx_get}{separator}page=1" if not first_disabled else None,
673
+ hx_target=hx_target, hx_push_url='true' if not first_disabled else None))
674
+
675
+ prev_disabled = current_page == 1
676
+ buttons.append(FhButton(Icon('chevron_left'), cls='circle transparent' if prev_disabled else 'circle',
677
+ disabled=prev_disabled, hx_get=f"{hx_get}{separator}page={current_page - 1}" if not prev_disabled else None,
678
+ hx_target=hx_target, hx_push_url='true' if not prev_disabled else None))
679
+
680
+ buttons.append(Span(f"Page {current_page} of {total_pages}", cls='small-text'))
681
+
682
+ next_disabled = current_page >= total_pages
683
+ buttons.append(FhButton(Icon('chevron_right'), cls='circle transparent' if next_disabled else 'circle',
684
+ disabled=next_disabled, hx_get=f"{hx_get}{separator}page={current_page + 1}" if not next_disabled else None,
685
+ hx_target=hx_target, hx_push_url='true' if not next_disabled else None))
686
+
687
+ if show_first_last:
688
+ last_disabled = current_page >= total_pages
689
+ buttons.append(FhButton(Icon('last_page'), cls='circle transparent' if last_disabled else 'circle',
690
+ disabled=last_disabled, hx_get=f"{hx_get}{separator}page={total_pages}" if not last_disabled else None,
691
+ hx_target=hx_target, hx_push_url='true' if not last_disabled else None))
692
+
693
+ nav_cls = f"center-align middle-align {cls}".strip()
694
+ return Nav(*buttons, cls=nav_cls, **kwargs)
695
+
696
+ # %% ../nbs/02_components.ipynb 86
697
+ #| code-fold: true
698
+ def Card(*c, header = None, footer = None, body_cls = 'padding', header_cls = (), footer_cls = (), cls = (), **kwargs):
699
+ """BeerCSS card with optional header/footer sections."""
700
+ cls = normalize_tokens(cls)
701
+ header_cls = normalize_tokens(header_cls)
702
+ footer_cls = normalize_tokens(footer_cls)
703
+ body_cls = normalize_tokens(body_cls)
704
+ sections = []
705
+ if header is not None: sections.append(Header(header, cls=header_cls) if header_cls else Header(header))
706
+ if c: sections.append(Div(*c, cls=body_cls) if body_cls else Div(*c))
707
+ if footer is not None: sections.append(Nav(footer, cls=footer_cls) if footer_cls else Nav(footer))
708
+ return Article(*sections, cls=cls, **kwargs)
709
+
710
+ # %% ../nbs/02_components.ipynb 89
711
+ #| code-fold: true
712
+ def Toolbar(*items, cls='', elevate='large', fill=True, **kwargs):
713
+ """BeerCSS toolbar for action bars with elevation options."""
714
+ classes = ['toolbar']
715
+ if elevate: classes.append(f'{elevate}-elevate')
716
+ if fill: classes.append('fill')
717
+ if cls: classes.append(cls)
718
+ return Nav(*items, cls=' '.join(classes), **kwargs)
719
+
720
+ # %% ../nbs/02_components.ipynb 92
721
+ #| 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."""
724
+ classes = ['snackbar']
725
+ 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))
729
+ if active: classes.append('active')
730
+ if cls: classes.append(cls)
731
+
732
+ content = []
733
+ if action:
734
+ if c: content.append(Div(*c, cls='max'))
735
+ if isinstance(action, str): content.append(A(action, cls='inverse-link'))
736
+ else: content.append(action)
737
+ else: content.extend(c)
738
+ return Div(*content, cls=' '.join(classes), **kwargs)
739
+
740
+ def Snackbar(*c, **kwargs):
741
+ """Alias for Toast component."""
742
+ return Toast(*c, **kwargs)
743
+
744
+ # %% ../nbs/02_components.ipynb 94
745
+ #| code-fold: true
746
+ class ContainerT(VEnum):
747
+ """Container size options (BeerCSS). Most alias to 'responsive'; use 'expand' for full-width."""
748
+ xs = 'responsive'
749
+ sm = 'responsive'
750
+ medium = 'responsive'
751
+ lg = 'responsive'
752
+ xl = 'responsive'
753
+ expand = 'responsive max'
754
+
755
+ # %% ../nbs/02_components.ipynb 96
756
+ #| code-fold: true
757
+ def _get_form_config(col: dict) -> dict:
758
+ """Extract form config from column, with sensible defaults."""
759
+ form = col.get("form", {})
760
+ if form is None:
761
+ form = {}
762
+
763
+ return {
764
+ "type": form.get("type", "text"),
765
+ "required": form.get("required", False),
766
+ "placeholder": form.get("placeholder", ""),
767
+ "options": form.get("options", []),
768
+ "disabled": form.get("disabled", False),
769
+ "hidden": form.get("hidden", False),
770
+ }
771
+
772
+
773
+ def FormField(
774
+ col: dict,
775
+ value: Any = None,
776
+ mode: str = "edit"
777
+ ) -> Any:
778
+ """Render a single form field based on column config.
779
+
780
+ Note: Currently uses DataTable column config schema.
781
+ Could be expanded to accept simpler field specs in the future.
782
+ """
783
+ key = col["key"]
784
+ label = col.get("label", key.replace("_", " ").title())
785
+ form_cfg = _get_form_config(col)
786
+
787
+ # Hidden fields
788
+ if form_cfg["hidden"]:
789
+ return None
790
+
791
+ # In view mode, all fields are disabled
792
+ is_disabled = mode == "view" or form_cfg["disabled"]
793
+ field_type = form_cfg["type"]
794
+
795
+ # Handle value for create mode (empty)
796
+ if mode == "create":
797
+ value = ""
798
+
799
+ # Build field based on type
800
+ if field_type == "select":
801
+ # Use menu-based Select from fh_matui.components
802
+ options = form_cfg["options"]
803
+ opt_items = []
804
+ for opt in options:
805
+ if isinstance(opt, dict):
806
+ opt_items.append(opt.get("label", opt.get("value", "")))
807
+ else:
808
+ opt_items.append(str(opt))
809
+
810
+ return Select(
811
+ *opt_items,
812
+ value=str(value) if value else "",
813
+ name=key,
814
+ placeholder=label
815
+ )
816
+
817
+ else:
818
+ # Use LabelInput for text, email, number, date, etc.
819
+ attrs = {}
820
+ if is_disabled:
821
+ attrs["disabled"] = True
822
+
823
+ return LabelInput(
824
+ label,
825
+ id=key,
826
+ input_type=field_type,
827
+ value=str(value) if value is not None else "",
828
+ **attrs
829
+ )
830
+
831
+ # %% ../nbs/02_components.ipynb 98
832
+ #| code-fold: true
833
+ from typing import Callable, Any
834
+
835
+ def FormModal(
836
+ columns: list[dict],
837
+ mode: str = "view",
838
+ record: dict = None,
839
+ modal_id: str = "form-modal",
840
+ title: str = None,
841
+ save_url: str = None,
842
+ save_target: str = None,
843
+ cancel_url: str = None,
844
+ cancel_target: str = None,
845
+ form_layout: Callable = None,
846
+ row_id_field: str = "id",
847
+ indicator_id: str = None
848
+ ) -> Any:
849
+ """Generate a modal dialog with form fields driven by column config.
850
+
851
+ Uses FormGrid (single column by default) for field layout.
852
+ Integrates with HTMX for form submission.
853
+ """
854
+ record = record or {}
855
+
856
+ # Auto-generate title
857
+ if title is None:
858
+ title = {
859
+ "view": "View Record",
860
+ "edit": "Edit Record",
861
+ "create": "New Record"
862
+ }.get(mode, "Record")
863
+
864
+ # Generate form fields
865
+ fields = {}
866
+ field_elements = []
867
+
868
+ for col in columns:
869
+ field = FormField(col, value=record.get(col["key"]), mode=mode)
870
+ if field is not None:
871
+ fields[col["key"]] = field
872
+ field_elements.append(field)
873
+
874
+ # Use custom layout or default single-column FormGrid
875
+ if form_layout and callable(form_layout):
876
+ form_content = form_layout(fields, mode, record)
877
+ else:
878
+ form_content = FormGrid(*field_elements, cols=1)
879
+
880
+ # Build form with hidden ID field for edit mode
881
+ form_children = [form_content]
882
+
883
+ if mode == "edit" and row_id_field in record:
884
+ form_children.insert(0, Input(
885
+ type="hidden",
886
+ name=row_id_field,
887
+ value=str(record[row_id_field])
888
+ ))
889
+
890
+ # Build footer buttons
891
+ footer_buttons = []
892
+
893
+ # Cancel button (always present)
894
+ cancel_attrs = {"cls": "border"}
895
+ if cancel_url:
896
+ cancel_attrs["hx_get"] = cancel_url
897
+ if cancel_target:
898
+ cancel_attrs["hx_target"] = cancel_target
899
+ cancel_attrs["hx_swap"] = "outerHTML"
900
+ else:
901
+ # Default: close modal via data-ui
902
+ cancel_attrs["data_ui"] = f"#{modal_id}"
903
+
904
+ footer_buttons.append(Button("Cancel", **cancel_attrs))
905
+
906
+ # Save button (only for edit/create modes)
907
+ if mode in ("edit", "create"):
908
+ save_attrs = {"cls": ButtonT.primary}
909
+
910
+ if save_url:
911
+ save_attrs["hx_post"] = save_url
912
+ if save_target:
913
+ save_attrs["hx_target"] = save_target
914
+ save_attrs["hx_swap"] = "outerHTML"
915
+ if indicator_id:
916
+ save_attrs["hx_indicator"] = f"#{indicator_id}"
917
+
918
+ save_label = "Create" if mode == "create" else "Save"
919
+ footer_buttons.append(Button(
920
+ save_label,
921
+ type="submit",
922
+ **save_attrs
923
+ ))
924
+
925
+ # Center buttons horizontally using flex justify-center
926
+ footer = Div(*footer_buttons, cls="row center small-space")
927
+
928
+ # Build form element
929
+ form_attrs = {"cls": "padding"}
930
+ if save_url and mode in ("edit", "create"):
931
+ form_attrs["hx_post"] = save_url
932
+ if save_target:
933
+ form_attrs["hx_target"] = save_target
934
+ form_attrs["hx_swap"] = "outerHTML"
935
+ if indicator_id:
936
+ form_attrs["hx_indicator"] = f"#{indicator_id}"
937
+
938
+ form = Form(*form_children, **form_attrs)
939
+
940
+ # Build modal with title and form
941
+ return Modal(
942
+ ModalTitle(title),
943
+ form,
944
+ id=modal_id,
945
+ footer=footer,
946
+ active=True,
947
+ cls="large-width"
948
+ )
949
+
950
+ # %% ../nbs/02_components.ipynb 100
951
+ #| code-fold: true
952
+ def NavContainer(*li, title=None, brand=None, position='left', close_button=True, cls='active', id=None, **kwargs):
953
+ """Slide-out navigation drawer with header and close button."""
954
+ children = []
955
+ header_nav_content = []
956
+
957
+ if brand:
958
+ if isinstance(brand, str): header_nav_content.append(Img(src=brand, cls='circle large'))
959
+ else: header_nav_content.append(brand)
960
+ if title: header_nav_content.append(H6(title, cls='max'))
961
+ if close_button and id: header_nav_content.append(FhButton(I('close'), cls='transparent circle large', data_ui=f'#{id}'))
962
+ if header_nav_content: children.append(Header(Nav(*header_nav_content)))
963
+
964
+ children.append(Div(cls='space'))
965
+
966
+ processed_items = []
967
+ for item in li:
968
+ if hasattr(item, 'tag') and item.tag == 'li':
969
+ item_attrs = getattr(item, 'attrs', {})
970
+ item_cls = item_attrs.get('cls', '')
971
+ if not item_cls:
972
+ new_attrs = dict(item_attrs)
973
+ new_attrs['cls'] = 'wave round'
974
+ processed_items.append(Li(*item.children, **new_attrs))
975
+ else: processed_items.append(item)
976
+ else: processed_items.append(item)
977
+
978
+ if processed_items: children.append(Ul(*processed_items, cls='list'))
979
+ dialog_cls = [position]
980
+ if cls: dialog_cls.append(cls)
981
+ cls_str = ' '.join(dialog_cls)
982
+ if id: return Dialog(*children, id=id, cls=cls_str, **kwargs)
983
+ return Dialog(*children, cls=cls_str, **kwargs)
984
+
985
+ def NavHeaderLi(*c, cls='horizontal-padding', **kwargs):
986
+ """Navigation header element."""
987
+ return Header(*c, cls=cls, **kwargs)
988
+
989
+ def NavDividerLi(cls='', **kwargs):
990
+ """Navigation divider (horizontal rule)."""
991
+ return Hr(cls=cls, **kwargs)
992
+
993
+ def NavCloseLi(dialog_id: str, icon='close', cls='circle transparent', **kwargs):
994
+ """Navigation close button."""
995
+ return FhButton(I(icon), cls=cls, onclick=f"closeDialog('{dialog_id}')", **kwargs)
996
+
997
+ def NavSubtitle(*c, cls='small-text gray-text padding', **kwargs):
998
+ """Navigation section subtitle label."""
999
+ return Label(*c, cls=cls, **kwargs)
1000
+
1001
+ def BottomNav(*c, cls='bottom', size='s', **kwargs):
1002
+ """Mobile bottom navigation bar."""
1003
+ size_cls = {'s': 'small', 'm': 'medium', 'l': 'large'}.get(size, 'small')
1004
+ final_cls = f"{cls} {size_cls}".strip()
1005
+ return Nav(*c, cls=final_cls, **kwargs)
1006
+
1007
+ # %% ../nbs/02_components.ipynb 103
1008
+ #| code-fold: true
1009
+ def NavSideBarHeader(*c, cls='', **kwargs):
1010
+ """Sidebar header section for menu buttons and branding."""
1011
+ return Header(*c, cls=cls, **kwargs)
1012
+
1013
+ def NavSideBarLinks(*children, as_list=False, cls='', **kwargs):
1014
+ """Container for navigation links (optional list wrapper)."""
1015
+ if as_list:
1016
+ list_cls = f"list {cls}".strip()
1017
+ return Ul(*children, cls=list_cls, **kwargs)
1018
+ return Group(*children) if len(children) > 1 else (children[0] if children else Group())
1019
+
1020
+ def NavSideBarContainer(*children, position='left', size='m', cls='', active=False, **kwargs):
1021
+ """BeerCSS navigation sidebar/drawer component."""
1022
+ base_cls = f"{size} {position} surface-container"
1023
+ if active: base_cls += " active"
1024
+ nav_cls = f"{base_cls} {cls}".strip()
1025
+ return Nav(*children, cls=nav_cls, **kwargs)
1026
+
1027
+ # %% ../nbs/02_components.ipynb 105
1028
+ #| code-fold: true
1029
+ 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)
1038
+
1039
+ if content:
1040
+ container_cls = stringify((container_size, 'padding', main_bg))
1041
+ main_content.append(Main(*content, cls=container_cls))
1042
+
1043
+ 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
+
1047
+ sidebar_children = [NavSideBarHeader(NavToggleButton(f"#{sidebar_id}"))]
1048
+
1049
+ if sidebar_links: sidebar_children.extend(sidebar_links)
1050
+ elif sidebar:
1051
+ if is_listy(sidebar): sidebar_children.extend(sidebar)
1052
+ else: sidebar_children.append(sidebar)
1053
+
1054
+ nav_rail = NavSideBarContainer(*sidebar_children, position='left', size='l', id=sidebar_id)
1055
+
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
+ layout_children = []
1063
+ if navbar_elem: layout_children.append(navbar_elem)
1064
+ layout_children.append(nav_rail)
1065
+ if content_items:
1066
+ container_cls = stringify((container_size, 'round', 'elevate', 'margin'))
1067
+ layout_children.append(Main(*content_items, cls=container_cls))
1068
+
1069
+ final_cls = f"surface-container {cls}".strip() if cls else "surface-container"
1070
+ return Div(*layout_children, cls=final_cls, **kwargs)
1071
+
1072
+ # %% ../nbs/02_components.ipynb 109
1073
+ #| code-fold: true
1074
+ class TextT(VEnum):
1075
+ """Text styles using BeerCSS typography classes."""
1076
+ italic = 'italic'
1077
+ bold = 'bold'
1078
+ underline = 'underline'
1079
+ overline = 'overline'
1080
+ upper = 'upper'
1081
+ lower = 'lower'
1082
+ capitalize = 'capitalize'
1083
+ small_text = 'small-text'
1084
+ medium_text = 'medium-text'
1085
+ large_text = 'large-text'
1086
+ left_align = 'left-align'
1087
+ right_align = 'right-align'
1088
+ center_align = 'center-align'
1089
+ primary_text = 'primary-text'
1090
+ secondary_text = 'secondary-text'
1091
+ tertiary_text = 'tertiary-text'
1092
+
1093
+ class TextPresets(VEnum):
1094
+ """Common typography presets combining multiple TextT values."""
1095
+ muted_sm = 'small-text secondary-text'
1096
+ muted_lg = 'large-text secondary-text'
1097
+ bold_sm = 'bold small-text'
1098
+ bold_lg = 'bold large-text'
1099
+ medium_sm = 'medium small-text'
1100
+ medium_muted = 'medium secondary-text'
1101
+ primary_link = 'link primary-text'
1102
+ muted_link = 'link secondary-text'
1103
+
1104
+ # %% ../nbs/02_components.ipynb 110
1105
+ #| code-fold: true
1106
+ def CodeSpan(*c, cls=(), **kwargs):
1107
+ """Inline code snippet."""
1108
+ cls_str = stringify(cls) if cls else None
1109
+ return Code(*c, cls=cls_str, **kwargs) if cls_str else Code(*c, **kwargs)
1110
+
1111
+ def CodeBlock(*c, cls=(), code_cls=(), **kwargs):
1112
+ """Block code with pre wrapper."""
1113
+ code_cls_str = stringify(code_cls) if code_cls else None
1114
+ pre_cls_str = stringify(cls) if cls else None
1115
+ code_elem = Code(*c, cls=code_cls_str, **kwargs) if code_cls_str else Code(*c, **kwargs)
1116
+ return Pre(code_elem, cls=pre_cls_str) if pre_cls_str else Pre(code_elem)
1117
+
1118
+ def Blockquote(*c, cls=(), **kwargs):
1119
+ """Blockquote element for quotes."""
1120
+ cls_str = stringify(cls) if cls else None
1121
+ return fc.Blockquote(*c, cls=cls_str, **kwargs) if cls_str else fc.Blockquote(*c, **kwargs)
1122
+
1123
+ def Q(*c, cls='italic large-text', **kwargs):
1124
+ """Styled inline quotation."""
1125
+ return fc.Q(*c, cls=cls, **kwargs)
1126
+
1127
+ def Em(*c, cls=(), **kwargs):
1128
+ """Emphasized text."""
1129
+ cls_str = stringify(cls) if cls else None
1130
+ return fc.Em(*c, cls=cls_str, **kwargs) if cls_str else fc.Em(*c, **kwargs)
1131
+
1132
+ def Strong(*c, cls='bold', **kwargs):
1133
+ """Strong (bold) text."""
1134
+ return fc.Strong(*c, cls=cls, **kwargs)
1135
+
1136
+ def Small(*c, cls='small-text', **kwargs):
1137
+ """Small text element."""
1138
+ return fc.Small(*c, cls=cls, **kwargs)
1139
+
1140
+ def Mark(*c, cls=(), **kwargs):
1141
+ """Highlighted/marked text."""
1142
+ cls_str = stringify(cls) if cls else None
1143
+ return fc.Mark(*c, cls=cls_str, **kwargs) if cls_str else fc.Mark(*c, **kwargs)
1144
+
1145
+ def Abbr(*c, title='', cls=(), **kwargs):
1146
+ """Abbreviation with title tooltip."""
1147
+ cls_str = stringify(cls) if cls else None
1148
+ return fc.Abbr(*c, title=title, cls=cls_str, **kwargs) if cls_str else fc.Abbr(*c, title=title, **kwargs)
1149
+
1150
+ def Sub(*c, cls=(), **kwargs):
1151
+ """Subscript text."""
1152
+ cls_str = stringify(cls) if cls else None
1153
+ return fc.Sub(*c, cls=cls_str, **kwargs) if cls_str else fc.Sub(*c, **kwargs)
1154
+
1155
+ def Sup(*c, cls=(), **kwargs):
1156
+ """Superscript text."""
1157
+ cls_str = stringify(cls) if cls else None
1158
+ return fc.Sup(*c, cls=cls_str, **kwargs) if cls_str else fc.Sup(*c, **kwargs)
1159
+
1160
+ # %% ../nbs/02_components.ipynb 112
1161
+ #| code-fold: true
1162
+ def FAQItem(question: str, answer: str, question_cls: str = '', answer_cls: str = ''):
1163
+ """Collapsible FAQ item using details/summary.
1164
+
1165
+ Atomic component for a single collapsible Q&A item.
1166
+ Use FAQSection from web_pages for a full FAQ section with title.
1167
+
1168
+ Args:
1169
+ question: The question text
1170
+ answer: The answer text
1171
+ question_cls: Additional classes for question styling
1172
+ answer_cls: Additional classes for answer styling
1173
+ """
1174
+ return Details(
1175
+ Summary(Article(Nav(Div(question, cls=f"max bold {question_cls}".strip()), I("expand_more")), cls="round surface-variant border no-elevate")),
1176
+ Article(P(answer, cls=f"secondary-text {answer_cls}".strip()), cls="round border padding"))
1177
+
1178
+ # %% ../nbs/02_components.ipynb 116
1179
+ #| code-fold: true
1180
+ def CookiesBanner(message='We use cookies to enhance your experience. By continuing to visit this site you agree to our use of cookies.',
1181
+ accept_text='Accept', decline_text='Decline', settings_text=None, policy_link='/cookies', policy_text='Learn more',
1182
+ position='bottom', on_accept='console.log("Accepted")', on_decline='console.log("Declined")',
1183
+ on_settings='console.log("Settings")', cls='', **kwargs):
1184
+ """GDPR-compliant cookie consent banner with accept/decline actions."""
1185
+ message_content = Div(Span(message, cls='small-text'), ' ', A(policy_text, href=policy_link, cls=AT.primary), cls='max')
1186
+
1187
+ buttons = []
1188
+ if decline_text: buttons.append(Button(decline_text, type='button', cls=ButtonT.secondary,
1189
+ onclick=f"{on_decline}; this.closest('.cookie-banner').remove();"))
1190
+ if settings_text: buttons.append(Button(settings_text, type='button', cls=ButtonT.secondary, onclick=on_settings))
1191
+ buttons.append(Button(accept_text, type='button', cls=ButtonT.primary,
1192
+ onclick=f"{on_accept}; this.closest('.cookie-banner').remove();"))
1193
+
1194
+ banner_content = Div(Icon('cookie', cls='medium'), message_content, Div(*buttons, cls='row'), cls='row middle-align')
1195
+
1196
+ position_style = {'top': 'position: fixed; top: 0; left: 0; right: 0; z-index: 9999;',
1197
+ 'bottom': 'position: fixed; bottom: 0; left: 0; right: 0; z-index: 9999;'}
1198
+ style = position_style.get(position, position_style['bottom'])
1199
+ banner_cls = f'cookie-banner surface-container padding shadow {cls}'.strip()
1200
+ return Div(banner_content, cls=banner_cls, style=style, **kwargs)