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/__init__.py +1 -0
- fh_matui/_modidx.py +192 -0
- fh_matui/app_pages.py +291 -0
- fh_matui/components.py +1200 -0
- fh_matui/core.py +230 -0
- fh_matui/datatable.py +870 -0
- fh_matui/foundations.py +59 -0
- fh_matui/web_pages.py +919 -0
- fh_matui-0.9.7.dist-info/METADATA +243 -0
- fh_matui-0.9.7.dist-info/RECORD +14 -0
- fh_matui-0.9.7.dist-info/WHEEL +5 -0
- fh_matui-0.9.7.dist-info/entry_points.txt +2 -0
- fh_matui-0.9.7.dist-info/licenses/LICENSE +201 -0
- fh_matui-0.9.7.dist-info/top_level.txt +1 -0
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)
|