violit 0.0.4.post1__py3-none-any.whl → 0.0.5__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.
- violit/app.py +2229 -1988
- violit/context.py +1 -0
- violit/state.py +33 -0
- violit/widgets/__init__.py +30 -30
- violit/widgets/card_widgets.py +595 -595
- violit/widgets/data_widgets.py +529 -529
- violit/widgets/layout_widgets.py +419 -419
- violit/widgets/text_widgets.py +413 -413
- {violit-0.0.4.post1.dist-info → violit-0.0.5.dist-info}/METADATA +1 -1
- {violit-0.0.4.post1.dist-info → violit-0.0.5.dist-info}/RECORD +13 -13
- {violit-0.0.4.post1.dist-info → violit-0.0.5.dist-info}/WHEEL +0 -0
- {violit-0.0.4.post1.dist-info → violit-0.0.5.dist-info}/licenses/LICENSE +0 -0
- {violit-0.0.4.post1.dist-info → violit-0.0.5.dist-info}/top_level.txt +0 -0
violit/widgets/card_widgets.py
CHANGED
|
@@ -1,595 +1,595 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Card Widgets - Shoelace Card Components
|
|
3
|
-
Provides easy-to-use wrappers for Shoelace card components
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
from ..component import Component
|
|
7
|
-
from ..context import rendering_ctx
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class CardWidgetsMixin:
|
|
11
|
-
"""Mixin for Shoelace Card components"""
|
|
12
|
-
|
|
13
|
-
def card(self, content=None, header=None, footer=None, **kwargs):
|
|
14
|
-
"""
|
|
15
|
-
Create a Shoelace card component
|
|
16
|
-
|
|
17
|
-
Args:
|
|
18
|
-
content: Optional card content (str). If None, use as context manager
|
|
19
|
-
header: Optional header content (str)
|
|
20
|
-
footer: Optional footer content (str)
|
|
21
|
-
**kwargs: Additional attributes (e.g., data_post_id="123", class_="custom")
|
|
22
|
-
|
|
23
|
-
Returns:
|
|
24
|
-
CardContext if content is None, otherwise None
|
|
25
|
-
|
|
26
|
-
Examples:
|
|
27
|
-
# Simple card with content
|
|
28
|
-
app.card("Hello World!")
|
|
29
|
-
|
|
30
|
-
# Card with header and footer
|
|
31
|
-
app.card("Content", header="Title", footer="Footer text")
|
|
32
|
-
|
|
33
|
-
# Context manager for complex content
|
|
34
|
-
with app.card(header="My Card"):
|
|
35
|
-
app.text("Line 1")
|
|
36
|
-
app.text("Line 2")
|
|
37
|
-
|
|
38
|
-
# With custom attributes
|
|
39
|
-
with app.card(data_post_id="123", class_="custom-card"):
|
|
40
|
-
app.text("Content")
|
|
41
|
-
"""
|
|
42
|
-
cid = self._get_next_cid("card")
|
|
43
|
-
|
|
44
|
-
# Convert kwargs to HTML attributes
|
|
45
|
-
attrs = []
|
|
46
|
-
for key, value in kwargs.items():
|
|
47
|
-
# Convert Python naming to HTML attributes
|
|
48
|
-
attr_name = key.replace('_', '-')
|
|
49
|
-
attrs.append(f'{attr_name}="{value}"')
|
|
50
|
-
|
|
51
|
-
attrs_str = ' ' + ' '.join(attrs) if attrs else ''
|
|
52
|
-
|
|
53
|
-
if content is None:
|
|
54
|
-
# Context manager mode
|
|
55
|
-
return CardContext(self, cid, header, footer, attrs_str)
|
|
56
|
-
else:
|
|
57
|
-
# Direct content mode
|
|
58
|
-
def builder():
|
|
59
|
-
token = rendering_ctx.set(cid)
|
|
60
|
-
|
|
61
|
-
try:
|
|
62
|
-
# Handle callable content (Lambda support for content, header, and footer)
|
|
63
|
-
current_content = content
|
|
64
|
-
if callable(content):
|
|
65
|
-
current_content = content() # Execute lambda
|
|
66
|
-
|
|
67
|
-
current_header = header
|
|
68
|
-
if callable(header):
|
|
69
|
-
current_header = header()
|
|
70
|
-
|
|
71
|
-
current_footer = footer
|
|
72
|
-
if callable(footer):
|
|
73
|
-
current_footer = footer()
|
|
74
|
-
|
|
75
|
-
# Set full width for consistency
|
|
76
|
-
card_style = 'style="width: 100%;"'
|
|
77
|
-
html_parts = [f'<sl-card{attrs_str} {card_style}>']
|
|
78
|
-
|
|
79
|
-
if current_header:
|
|
80
|
-
html_parts.append(f'<div slot="header">{current_header}</div>')
|
|
81
|
-
|
|
82
|
-
# Don't wrap content in extra div - user provides styled HTML
|
|
83
|
-
html_parts.append(str(current_content))
|
|
84
|
-
|
|
85
|
-
if current_footer:
|
|
86
|
-
html_parts.append(f'<div slot="footer">{current_footer}</div>')
|
|
87
|
-
|
|
88
|
-
html_parts.append('</sl-card>')
|
|
89
|
-
|
|
90
|
-
# Apply full width to wrapper div for consistency
|
|
91
|
-
return Component("div", id=cid, content=''.join(html_parts), style="width: 100%;")
|
|
92
|
-
finally:
|
|
93
|
-
rendering_ctx.reset(token)
|
|
94
|
-
|
|
95
|
-
self._register_component(cid, builder)
|
|
96
|
-
|
|
97
|
-
def badge(self, text, variant="neutral", pill=False, pulse=False):
|
|
98
|
-
"""
|
|
99
|
-
Create a Shoelace badge component
|
|
100
|
-
|
|
101
|
-
Args:
|
|
102
|
-
text: Badge text
|
|
103
|
-
variant: Badge variant (primary, success, neutral, warning, danger)
|
|
104
|
-
pill: Whether to display as pill shape
|
|
105
|
-
pulse: Whether to show pulse animation
|
|
106
|
-
|
|
107
|
-
Example:
|
|
108
|
-
app.badge("LIVE", variant="danger", pulse=True)
|
|
109
|
-
app.badge("New", variant="primary", pill=True)
|
|
110
|
-
"""
|
|
111
|
-
cid = self._get_next_cid("badge")
|
|
112
|
-
|
|
113
|
-
def builder():
|
|
114
|
-
token = rendering_ctx.set(cid)
|
|
115
|
-
|
|
116
|
-
attrs = [f'variant="{variant}"']
|
|
117
|
-
if pill:
|
|
118
|
-
attrs.append('pill')
|
|
119
|
-
if pulse:
|
|
120
|
-
attrs.append('pulse')
|
|
121
|
-
|
|
122
|
-
attrs_str = ' '.join(attrs)
|
|
123
|
-
html = f'<sl-badge {attrs_str}>{text}</sl-badge>'
|
|
124
|
-
|
|
125
|
-
rendering_ctx.reset(token)
|
|
126
|
-
return Component("span", id=cid, content=html)
|
|
127
|
-
|
|
128
|
-
self._register_component(cid, builder)
|
|
129
|
-
|
|
130
|
-
def icon(self, name, size=None, label=None):
|
|
131
|
-
"""
|
|
132
|
-
Create a Shoelace icon component
|
|
133
|
-
|
|
134
|
-
Args:
|
|
135
|
-
name: Icon name (from Shoelace icon library)
|
|
136
|
-
size: Icon size (e.g., "small", "medium", "large" or CSS value)
|
|
137
|
-
label: Accessibility label
|
|
138
|
-
|
|
139
|
-
Example:
|
|
140
|
-
app.icon("clock")
|
|
141
|
-
app.icon("heart-fill", size="large", label="Favorite")
|
|
142
|
-
"""
|
|
143
|
-
cid = self._get_next_cid("icon")
|
|
144
|
-
|
|
145
|
-
def builder():
|
|
146
|
-
token = rendering_ctx.set(cid)
|
|
147
|
-
|
|
148
|
-
attrs = [f'name="{name}"']
|
|
149
|
-
if size:
|
|
150
|
-
attrs.append(f'style="font-size: {size};"' if not size in ['small', 'medium', 'large'] else f'style="font-size: var(--sl-font-size-{size});"')
|
|
151
|
-
if label:
|
|
152
|
-
attrs.append(f'label="{label}"')
|
|
153
|
-
|
|
154
|
-
attrs_str = ' '.join(attrs)
|
|
155
|
-
html = f'<sl-icon {attrs_str}></sl-icon>'
|
|
156
|
-
|
|
157
|
-
rendering_ctx.reset(token)
|
|
158
|
-
return Component("span", id=cid, content=html)
|
|
159
|
-
|
|
160
|
-
self._register_component(cid, builder)
|
|
161
|
-
|
|
162
|
-
# ============= Predefined Card Themes =============
|
|
163
|
-
|
|
164
|
-
def live_card(self, content, timestamp=None, post_id=None):
|
|
165
|
-
"""
|
|
166
|
-
Create a LIVE card with danger badge and pulse animation
|
|
167
|
-
|
|
168
|
-
Args:
|
|
169
|
-
content: Card content (will be auto-escaped)
|
|
170
|
-
timestamp: Optional timestamp to display in footer
|
|
171
|
-
post_id: Optional post ID for data attribute
|
|
172
|
-
|
|
173
|
-
Example:
|
|
174
|
-
app.live_card("Breaking news!", timestamp="2026-01-18 10:30")
|
|
175
|
-
app.live_card(post['content'], post['created_at'], post['id'])
|
|
176
|
-
"""
|
|
177
|
-
import html
|
|
178
|
-
escaped_content = html.escape(str(content))
|
|
179
|
-
|
|
180
|
-
header = '<div><sl-badge variant="danger" pulse><sl-icon name="circle-fill" style="font-size: 0.5rem;"></sl-icon> LIVE</sl-badge></div>'
|
|
181
|
-
footer = None
|
|
182
|
-
if timestamp:
|
|
183
|
-
footer = f'<div style="text-align: right; font-size: 0.85rem; color: var(--sl-color-neutral-600);"><sl-icon name="clock"></sl-icon> {timestamp}</div>'
|
|
184
|
-
|
|
185
|
-
kwargs = {"style": "margin-bottom: 1rem; width: 100%;"}
|
|
186
|
-
if post_id is not None:
|
|
187
|
-
kwargs["data_post_id"] = str(post_id)
|
|
188
|
-
|
|
189
|
-
self.card(
|
|
190
|
-
content=f'<div style="font-size: 1.1rem; line-height: 1.6; white-space: pre-wrap;">{escaped_content}</div>',
|
|
191
|
-
header=header,
|
|
192
|
-
footer=footer,
|
|
193
|
-
**kwargs
|
|
194
|
-
)
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
def styled_card(self,
|
|
198
|
-
content: str,
|
|
199
|
-
style: str = 'default',
|
|
200
|
-
header_badge: str = None,
|
|
201
|
-
header_badge_variant: str = 'neutral',
|
|
202
|
-
header_text: str = None,
|
|
203
|
-
footer_text: str = None,
|
|
204
|
-
data_id: str = None,
|
|
205
|
-
return_html: bool = False):
|
|
206
|
-
"""Styled card with various preset styles
|
|
207
|
-
|
|
208
|
-
Args:
|
|
209
|
-
content: Card content (auto-escaped)
|
|
210
|
-
style: Card style ('default', 'live', 'admin', 'info', 'warning')
|
|
211
|
-
header_badge: Header badge text
|
|
212
|
-
header_badge_variant: Badge color ('primary', 'success', 'neutral', 'warning', 'danger')
|
|
213
|
-
header_text: Additional header text (e.g., timestamp)
|
|
214
|
-
footer_text: Footer text
|
|
215
|
-
data_id: ID to add as data attribute (for broadcast)
|
|
216
|
-
return_html: If True, return HTML string; if False, register component (for broadcast)
|
|
217
|
-
|
|
218
|
-
Returns:
|
|
219
|
-
HTML string if return_html=True, otherwise None
|
|
220
|
-
|
|
221
|
-
Examples:
|
|
222
|
-
# Display on screen
|
|
223
|
-
app.styled_card(
|
|
224
|
-
"Hello world",
|
|
225
|
-
style='live',
|
|
226
|
-
header_badge='LIVE',
|
|
227
|
-
footer_text='2026-01-18'
|
|
228
|
-
)
|
|
229
|
-
|
|
230
|
-
# Generate HTML for broadcast
|
|
231
|
-
html = app.styled_card(
|
|
232
|
-
"Hello world",
|
|
233
|
-
style='live',
|
|
234
|
-
header_badge='LIVE',
|
|
235
|
-
footer_text='2026-01-18',
|
|
236
|
-
data_id='123',
|
|
237
|
-
return_html=True # Return HTML only
|
|
238
|
-
)
|
|
239
|
-
"""
|
|
240
|
-
import html as html_lib
|
|
241
|
-
escaped_content = html_lib.escape(str(content))
|
|
242
|
-
|
|
243
|
-
# Style-specific configuration
|
|
244
|
-
styles_config = {
|
|
245
|
-
'live': {
|
|
246
|
-
'content_style': 'font-size: 1.1rem; line-height: 1.6; white-space: pre-wrap;',
|
|
247
|
-
'badge_variant': 'danger',
|
|
248
|
-
'badge_pulse': True,
|
|
249
|
-
'badge_icon': 'circle-fill'
|
|
250
|
-
},
|
|
251
|
-
'admin': {
|
|
252
|
-
'content_style': 'white-space: pre-wrap; line-height: 1.5;',
|
|
253
|
-
'badge_variant': 'neutral',
|
|
254
|
-
'badge_pill': True,
|
|
255
|
-
'badge_icon': None
|
|
256
|
-
},
|
|
257
|
-
'info': {
|
|
258
|
-
'content_style': 'white-space: pre-wrap; line-height: 1.5;',
|
|
259
|
-
'badge_variant': 'primary',
|
|
260
|
-
'badge_icon': 'info-circle'
|
|
261
|
-
},
|
|
262
|
-
'warning': {
|
|
263
|
-
'content_style': 'white-space: pre-wrap; line-height: 1.5;',
|
|
264
|
-
'badge_variant': 'warning',
|
|
265
|
-
'badge_icon': 'exclamation-triangle'
|
|
266
|
-
},
|
|
267
|
-
'default': {
|
|
268
|
-
'content_style': 'white-space: pre-wrap; line-height: 1.5;',
|
|
269
|
-
'badge_variant': header_badge_variant,
|
|
270
|
-
'badge_icon': None
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
config = styles_config.get(style, styles_config['default'])
|
|
275
|
-
|
|
276
|
-
# Create header
|
|
277
|
-
header_parts = []
|
|
278
|
-
if header_badge:
|
|
279
|
-
badge_attrs = [f'variant="{config["badge_variant"]}"']
|
|
280
|
-
if config.get('badge_pulse'):
|
|
281
|
-
badge_attrs.append('pulse')
|
|
282
|
-
if config.get('badge_pill'):
|
|
283
|
-
badge_attrs.append('pill')
|
|
284
|
-
|
|
285
|
-
badge_content = header_badge
|
|
286
|
-
if config.get('badge_icon'):
|
|
287
|
-
badge_content = f'<sl-icon name="{config["badge_icon"]}" style="font-size: 0.5rem;"></sl-icon> {header_badge}'
|
|
288
|
-
|
|
289
|
-
header_parts.append(f'<sl-badge {" ".join(badge_attrs)}>{badge_content}</sl-badge>')
|
|
290
|
-
|
|
291
|
-
if header_text:
|
|
292
|
-
header_parts.append(f'<small style="color: var(--sl-color-neutral-500);"><sl-icon name="clock"></sl-icon> {header_text}</small>')
|
|
293
|
-
|
|
294
|
-
header_html = None
|
|
295
|
-
if header_parts:
|
|
296
|
-
header_html = f'<div style="display: flex; gap: 0.5rem; align-items: center;">{"".join(header_parts)}</div>'
|
|
297
|
-
|
|
298
|
-
# Create footer
|
|
299
|
-
footer_html = None
|
|
300
|
-
if footer_text:
|
|
301
|
-
footer_html = f'<div style="text-align: right; font-size: 0.85rem; color: var(--sl-color-neutral-600);"><sl-icon name="clock"></sl-icon> {footer_text}</div>'
|
|
302
|
-
|
|
303
|
-
# If return_html=True, return HTML only (for broadcast)
|
|
304
|
-
if return_html:
|
|
305
|
-
# Data attribute
|
|
306
|
-
data_attr = f' data-post-id="{data_id}"' if data_id else ''
|
|
307
|
-
|
|
308
|
-
# Wrap header in slot
|
|
309
|
-
header_slot = f'<div slot="header">{header_html}</div>' if header_html else ''
|
|
310
|
-
|
|
311
|
-
# Wrap footer in slot
|
|
312
|
-
footer_slot = f'<div slot="footer">{footer_html}</div>' if footer_html else ''
|
|
313
|
-
|
|
314
|
-
# Return full HTML (include wrapper div for layout consistency)
|
|
315
|
-
return f'<div style="width: 100%;"><sl-card{data_attr} style="width: 100%;">{header_slot}<div style="{config["content_style"]}">{escaped_content}</div>{footer_slot}</sl-card></div>'
|
|
316
|
-
|
|
317
|
-
# Normal mode: register component
|
|
318
|
-
kwargs = {}
|
|
319
|
-
if data_id:
|
|
320
|
-
kwargs['data_post_id'] = str(data_id)
|
|
321
|
-
|
|
322
|
-
self.card(
|
|
323
|
-
content=f'<div style="{config["content_style"]}">{escaped_content}</div>',
|
|
324
|
-
header=header_html,
|
|
325
|
-
footer=footer_html,
|
|
326
|
-
**kwargs
|
|
327
|
-
)
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
def card_with_actions(self,
|
|
331
|
-
content: str,
|
|
332
|
-
style: str = 'default',
|
|
333
|
-
header_badge: str = None,
|
|
334
|
-
header_badge_variant: str = 'neutral',
|
|
335
|
-
header_text: str = None,
|
|
336
|
-
footer_text: str = None,
|
|
337
|
-
data_id: str = None):
|
|
338
|
-
"""
|
|
339
|
-
Card widget with action buttons
|
|
340
|
-
|
|
341
|
-
Arranges card and buttons using flexbox, wraps entire thing with data-id.
|
|
342
|
-
|
|
343
|
-
Args:
|
|
344
|
-
content: Card content
|
|
345
|
-
style: Card style
|
|
346
|
-
header_badge: Header badge
|
|
347
|
-
header_badge_variant: Badge color
|
|
348
|
-
header_text: Additional header text
|
|
349
|
-
footer_text: Footer text
|
|
350
|
-
data_id: data-post-id attribute (for broadcast removal)
|
|
351
|
-
|
|
352
|
-
Example:
|
|
353
|
-
# Admin page
|
|
354
|
-
app.card_with_actions(
|
|
355
|
-
content=post['content'],
|
|
356
|
-
style='admin',
|
|
357
|
-
header_badge=f'#{post["id"]}',
|
|
358
|
-
header_text=post['created_at'],
|
|
359
|
-
data_id=post['id']
|
|
360
|
-
)
|
|
361
|
-
"""
|
|
362
|
-
import html as html_lib
|
|
363
|
-
escaped_content = html_lib.escape(str(content))
|
|
364
|
-
|
|
365
|
-
# Same style configuration as styled_card
|
|
366
|
-
styles_config = {
|
|
367
|
-
'live': {
|
|
368
|
-
'content_style': 'font-size: 1.1rem; line-height: 1.6; white-space: pre-wrap;',
|
|
369
|
-
'badge_variant': 'danger',
|
|
370
|
-
'badge_pulse': True,
|
|
371
|
-
'badge_icon': 'circle-fill'
|
|
372
|
-
},
|
|
373
|
-
'admin': {
|
|
374
|
-
'content_style': 'white-space: pre-wrap; line-height: 1.5;',
|
|
375
|
-
'badge_variant': 'neutral',
|
|
376
|
-
'badge_pill': True,
|
|
377
|
-
'badge_icon': None
|
|
378
|
-
},
|
|
379
|
-
'info': {
|
|
380
|
-
'content_style': 'white-space: pre-wrap; line-height: 1.5;',
|
|
381
|
-
'badge_variant': 'primary',
|
|
382
|
-
'badge_icon': 'info-circle'
|
|
383
|
-
},
|
|
384
|
-
'warning': {
|
|
385
|
-
'content_style': 'white-space: pre-wrap; line-height: 1.5;',
|
|
386
|
-
'badge_variant': 'warning',
|
|
387
|
-
'badge_icon': 'exclamation-triangle'
|
|
388
|
-
},
|
|
389
|
-
'default': {
|
|
390
|
-
'content_style': 'white-space: pre-wrap; line-height: 1.5;',
|
|
391
|
-
'badge_variant': header_badge_variant,
|
|
392
|
-
'badge_icon': None
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
config = styles_config.get(style, styles_config['default'])
|
|
397
|
-
|
|
398
|
-
# Create header
|
|
399
|
-
header_parts = []
|
|
400
|
-
if header_badge:
|
|
401
|
-
badge_attrs = [f'variant="{config["badge_variant"]}"']
|
|
402
|
-
if config.get('badge_pulse'):
|
|
403
|
-
badge_attrs.append('pulse')
|
|
404
|
-
if config.get('badge_pill'):
|
|
405
|
-
badge_attrs.append('pill')
|
|
406
|
-
|
|
407
|
-
badge_content = header_badge
|
|
408
|
-
if config.get('badge_icon'):
|
|
409
|
-
badge_content = f'<sl-icon name="{config["badge_icon"]}" style="font-size: 0.5rem;"></sl-icon> {header_badge}'
|
|
410
|
-
|
|
411
|
-
header_parts.append(f'<sl-badge {" ".join(badge_attrs)}>{badge_content}</sl-badge>')
|
|
412
|
-
|
|
413
|
-
if header_text:
|
|
414
|
-
header_parts.append(f'<small style="color: var(--sl-color-neutral-500);"><sl-icon name="clock"></sl-icon> {header_text}</small>')
|
|
415
|
-
|
|
416
|
-
header_html = ''
|
|
417
|
-
if header_parts:
|
|
418
|
-
header_html = f'<div slot="header" style="display: flex; gap: 0.5rem; align-items: center;">{"".join(header_parts)}</div>'
|
|
419
|
-
|
|
420
|
-
# Create footer
|
|
421
|
-
footer_html = ''
|
|
422
|
-
if footer_text:
|
|
423
|
-
footer_html = f'<div slot="footer"><div style="text-align: right; font-size: 0.85rem; color: var(--sl-color-neutral-600);"><sl-icon name="clock"></sl-icon> {footer_text}</div></div>'
|
|
424
|
-
|
|
425
|
-
# Data attribute
|
|
426
|
-
data_attr = f' data-post-id="{data_id}"' if data_id else ''
|
|
427
|
-
|
|
428
|
-
# Arrange card + action area using flexbox layout
|
|
429
|
-
html_content = f'''<div{data_attr} style="display: flex; gap: 1rem; align-items: flex-start; margin-bottom: 1rem;">
|
|
430
|
-
<div style="flex: 1;">
|
|
431
|
-
<sl-card style="width: 100%;">
|
|
432
|
-
{header_html}
|
|
433
|
-
<div style="{config["content_style"]}">{escaped_content}</div>
|
|
434
|
-
{footer_html}
|
|
435
|
-
</sl-card>
|
|
436
|
-
</div>
|
|
437
|
-
</div>'''
|
|
438
|
-
|
|
439
|
-
# Render with markdown
|
|
440
|
-
self.markdown(html_content)
|
|
441
|
-
|
|
442
|
-
def info_card(self, content, title=None):
|
|
443
|
-
"""
|
|
444
|
-
Create an info card with primary variant
|
|
445
|
-
|
|
446
|
-
Args:
|
|
447
|
-
content: Card content
|
|
448
|
-
title: Optional title in header
|
|
449
|
-
|
|
450
|
-
Example:
|
|
451
|
-
app.info_card("Important information", title="Notice")
|
|
452
|
-
"""
|
|
453
|
-
header = None
|
|
454
|
-
if title:
|
|
455
|
-
header = f'<div><sl-badge variant="primary"><sl-icon name="info-circle"></sl-icon> {title}</sl-badge></div>'
|
|
456
|
-
|
|
457
|
-
self.card(
|
|
458
|
-
content=f'<div style="line-height: 1.6;">{content}</div>',
|
|
459
|
-
header=header,
|
|
460
|
-
style="margin-bottom: 1rem;"
|
|
461
|
-
)
|
|
462
|
-
|
|
463
|
-
def success_card(self, content, title=None):
|
|
464
|
-
"""
|
|
465
|
-
Create a success card with success variant
|
|
466
|
-
|
|
467
|
-
Args:
|
|
468
|
-
content: Card content
|
|
469
|
-
title: Optional title in header
|
|
470
|
-
|
|
471
|
-
Example:
|
|
472
|
-
app.success_card("Operation completed!", title="Success")
|
|
473
|
-
"""
|
|
474
|
-
header = None
|
|
475
|
-
if title:
|
|
476
|
-
header = f'<div><sl-badge variant="success"><sl-icon name="check-circle"></sl-icon> {title}</sl-badge></div>'
|
|
477
|
-
|
|
478
|
-
self.card(
|
|
479
|
-
content=f'<div style="line-height: 1.6;">{content}</div>',
|
|
480
|
-
header=header,
|
|
481
|
-
style="margin-bottom: 1rem;"
|
|
482
|
-
)
|
|
483
|
-
|
|
484
|
-
def warning_card(self, content, title=None):
|
|
485
|
-
"""
|
|
486
|
-
Create a warning card with warning variant
|
|
487
|
-
|
|
488
|
-
Args:
|
|
489
|
-
content: Card content
|
|
490
|
-
title: Optional title in header
|
|
491
|
-
|
|
492
|
-
Example:
|
|
493
|
-
app.warning_card("Please check your settings", title="Warning")
|
|
494
|
-
"""
|
|
495
|
-
header = None
|
|
496
|
-
if title:
|
|
497
|
-
header = f'<div><sl-badge variant="warning"><sl-icon name="exclamation-triangle"></sl-icon> {title}</sl-badge></div>'
|
|
498
|
-
|
|
499
|
-
self.card(
|
|
500
|
-
content=f'<div style="line-height: 1.6;">{content}</div>',
|
|
501
|
-
header=header,
|
|
502
|
-
style="margin-bottom: 1rem;"
|
|
503
|
-
)
|
|
504
|
-
|
|
505
|
-
def danger_card(self, content, title=None):
|
|
506
|
-
"""
|
|
507
|
-
Create a danger card with danger variant
|
|
508
|
-
|
|
509
|
-
Args:
|
|
510
|
-
content: Card content
|
|
511
|
-
title: Optional title in header
|
|
512
|
-
|
|
513
|
-
Example:
|
|
514
|
-
app.danger_card("Critical error occurred", title="Error")
|
|
515
|
-
"""
|
|
516
|
-
header = None
|
|
517
|
-
if title:
|
|
518
|
-
header = f'<div><sl-badge variant="danger"><sl-icon name="x-circle"></sl-icon> {title}</sl-badge></div>'
|
|
519
|
-
|
|
520
|
-
self.card(
|
|
521
|
-
content=f'<div style="line-height: 1.6;">{content}</div>',
|
|
522
|
-
header=header,
|
|
523
|
-
style="margin-bottom: 1rem;"
|
|
524
|
-
)
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
class CardContext:
|
|
528
|
-
"""Context manager for card with complex content"""
|
|
529
|
-
|
|
530
|
-
def __init__(self, app, cid, header, footer, attrs_str):
|
|
531
|
-
self.app = app
|
|
532
|
-
self.cid = cid
|
|
533
|
-
self.header = header
|
|
534
|
-
self.footer = footer
|
|
535
|
-
self.attrs_str = attrs_str
|
|
536
|
-
self.components = []
|
|
537
|
-
|
|
538
|
-
def __enter__(self):
|
|
539
|
-
from ..context import layout_ctx
|
|
540
|
-
self.token = layout_ctx.set(f"card_{self.cid}")
|
|
541
|
-
return self
|
|
542
|
-
|
|
543
|
-
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
544
|
-
from ..context import layout_ctx
|
|
545
|
-
from ..state import get_session_store
|
|
546
|
-
|
|
547
|
-
# Collect all components added inside this context
|
|
548
|
-
store = get_session_store()
|
|
549
|
-
card_components = []
|
|
550
|
-
|
|
551
|
-
# Get components that were added in this context
|
|
552
|
-
for comp_cid in store['order']:
|
|
553
|
-
if comp_cid.startswith(f"card_{self.cid}") or len(card_components) > 0:
|
|
554
|
-
builder = store['builders'].get(comp_cid) or self.app.static_builders.get(comp_cid)
|
|
555
|
-
if builder:
|
|
556
|
-
card_components.append(builder().render())
|
|
557
|
-
|
|
558
|
-
# Build final card HTML
|
|
559
|
-
def builder():
|
|
560
|
-
token = rendering_ctx.set(self.cid)
|
|
561
|
-
|
|
562
|
-
try:
|
|
563
|
-
# Handle callable header and footer (Lambda support)
|
|
564
|
-
current_header = self.header
|
|
565
|
-
if callable(self.header):
|
|
566
|
-
current_header = self.header()
|
|
567
|
-
|
|
568
|
-
current_footer = self.footer
|
|
569
|
-
if callable(self.footer):
|
|
570
|
-
current_footer = self.footer()
|
|
571
|
-
|
|
572
|
-
# Add width: 100% to sl-card (consistency with broadcast)
|
|
573
|
-
card_style = 'style="width: 100%;"'
|
|
574
|
-
html_parts = [f'<sl-card{self.attrs_str} {card_style}>']
|
|
575
|
-
|
|
576
|
-
if current_header:
|
|
577
|
-
html_parts.append(f'<div slot="header">{current_header}</div>')
|
|
578
|
-
|
|
579
|
-
# Add collected components as content (no wrapper div)
|
|
580
|
-
if card_components:
|
|
581
|
-
html_parts.extend(card_components)
|
|
582
|
-
|
|
583
|
-
if current_footer:
|
|
584
|
-
html_parts.append(f'<div slot="footer">{current_footer}</div>')
|
|
585
|
-
|
|
586
|
-
html_parts.append('</sl-card>')
|
|
587
|
-
|
|
588
|
-
# Apply width: 100% to wrapper div (consistency with list_container)
|
|
589
|
-
return Component("div", id=self.cid, content=''.join(html_parts), style="width: 100%;")
|
|
590
|
-
finally:
|
|
591
|
-
rendering_ctx.reset(token)
|
|
592
|
-
|
|
593
|
-
self.app._register_component(self.cid, builder)
|
|
594
|
-
layout_ctx.reset(self.token)
|
|
595
|
-
|
|
1
|
+
"""
|
|
2
|
+
Card Widgets - Shoelace Card Components
|
|
3
|
+
Provides easy-to-use wrappers for Shoelace card components
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from ..component import Component
|
|
7
|
+
from ..context import rendering_ctx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CardWidgetsMixin:
|
|
11
|
+
"""Mixin for Shoelace Card components"""
|
|
12
|
+
|
|
13
|
+
def card(self, content=None, header=None, footer=None, **kwargs):
|
|
14
|
+
"""
|
|
15
|
+
Create a Shoelace card component
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
content: Optional card content (str). If None, use as context manager
|
|
19
|
+
header: Optional header content (str)
|
|
20
|
+
footer: Optional footer content (str)
|
|
21
|
+
**kwargs: Additional attributes (e.g., data_post_id="123", class_="custom")
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
CardContext if content is None, otherwise None
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
# Simple card with content
|
|
28
|
+
app.card("Hello World!")
|
|
29
|
+
|
|
30
|
+
# Card with header and footer
|
|
31
|
+
app.card("Content", header="Title", footer="Footer text")
|
|
32
|
+
|
|
33
|
+
# Context manager for complex content
|
|
34
|
+
with app.card(header="My Card"):
|
|
35
|
+
app.text("Line 1")
|
|
36
|
+
app.text("Line 2")
|
|
37
|
+
|
|
38
|
+
# With custom attributes
|
|
39
|
+
with app.card(data_post_id="123", class_="custom-card"):
|
|
40
|
+
app.text("Content")
|
|
41
|
+
"""
|
|
42
|
+
cid = self._get_next_cid("card")
|
|
43
|
+
|
|
44
|
+
# Convert kwargs to HTML attributes
|
|
45
|
+
attrs = []
|
|
46
|
+
for key, value in kwargs.items():
|
|
47
|
+
# Convert Python naming to HTML attributes
|
|
48
|
+
attr_name = key.replace('_', '-')
|
|
49
|
+
attrs.append(f'{attr_name}="{value}"')
|
|
50
|
+
|
|
51
|
+
attrs_str = ' ' + ' '.join(attrs) if attrs else ''
|
|
52
|
+
|
|
53
|
+
if content is None:
|
|
54
|
+
# Context manager mode
|
|
55
|
+
return CardContext(self, cid, header, footer, attrs_str)
|
|
56
|
+
else:
|
|
57
|
+
# Direct content mode
|
|
58
|
+
def builder():
|
|
59
|
+
token = rendering_ctx.set(cid)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
# Handle callable content (Lambda support for content, header, and footer)
|
|
63
|
+
current_content = content
|
|
64
|
+
if callable(content):
|
|
65
|
+
current_content = content() # Execute lambda
|
|
66
|
+
|
|
67
|
+
current_header = header
|
|
68
|
+
if callable(header):
|
|
69
|
+
current_header = header()
|
|
70
|
+
|
|
71
|
+
current_footer = footer
|
|
72
|
+
if callable(footer):
|
|
73
|
+
current_footer = footer()
|
|
74
|
+
|
|
75
|
+
# Set full width for consistency
|
|
76
|
+
card_style = 'style="width: 100%;"'
|
|
77
|
+
html_parts = [f'<sl-card{attrs_str} {card_style}>']
|
|
78
|
+
|
|
79
|
+
if current_header:
|
|
80
|
+
html_parts.append(f'<div slot="header">{current_header}</div>')
|
|
81
|
+
|
|
82
|
+
# Don't wrap content in extra div - user provides styled HTML
|
|
83
|
+
html_parts.append(str(current_content))
|
|
84
|
+
|
|
85
|
+
if current_footer:
|
|
86
|
+
html_parts.append(f'<div slot="footer">{current_footer}</div>')
|
|
87
|
+
|
|
88
|
+
html_parts.append('</sl-card>')
|
|
89
|
+
|
|
90
|
+
# Apply full width to wrapper div for consistency
|
|
91
|
+
return Component("div", id=cid, content=''.join(html_parts), style="width: 100%;")
|
|
92
|
+
finally:
|
|
93
|
+
rendering_ctx.reset(token)
|
|
94
|
+
|
|
95
|
+
self._register_component(cid, builder)
|
|
96
|
+
|
|
97
|
+
def badge(self, text, variant="neutral", pill=False, pulse=False):
|
|
98
|
+
"""
|
|
99
|
+
Create a Shoelace badge component
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
text: Badge text
|
|
103
|
+
variant: Badge variant (primary, success, neutral, warning, danger)
|
|
104
|
+
pill: Whether to display as pill shape
|
|
105
|
+
pulse: Whether to show pulse animation
|
|
106
|
+
|
|
107
|
+
Example:
|
|
108
|
+
app.badge("LIVE", variant="danger", pulse=True)
|
|
109
|
+
app.badge("New", variant="primary", pill=True)
|
|
110
|
+
"""
|
|
111
|
+
cid = self._get_next_cid("badge")
|
|
112
|
+
|
|
113
|
+
def builder():
|
|
114
|
+
token = rendering_ctx.set(cid)
|
|
115
|
+
|
|
116
|
+
attrs = [f'variant="{variant}"']
|
|
117
|
+
if pill:
|
|
118
|
+
attrs.append('pill')
|
|
119
|
+
if pulse:
|
|
120
|
+
attrs.append('pulse')
|
|
121
|
+
|
|
122
|
+
attrs_str = ' '.join(attrs)
|
|
123
|
+
html = f'<sl-badge {attrs_str}>{text}</sl-badge>'
|
|
124
|
+
|
|
125
|
+
rendering_ctx.reset(token)
|
|
126
|
+
return Component("span", id=cid, content=html)
|
|
127
|
+
|
|
128
|
+
self._register_component(cid, builder)
|
|
129
|
+
|
|
130
|
+
def icon(self, name, size=None, label=None):
|
|
131
|
+
"""
|
|
132
|
+
Create a Shoelace icon component
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
name: Icon name (from Shoelace icon library)
|
|
136
|
+
size: Icon size (e.g., "small", "medium", "large" or CSS value)
|
|
137
|
+
label: Accessibility label
|
|
138
|
+
|
|
139
|
+
Example:
|
|
140
|
+
app.icon("clock")
|
|
141
|
+
app.icon("heart-fill", size="large", label="Favorite")
|
|
142
|
+
"""
|
|
143
|
+
cid = self._get_next_cid("icon")
|
|
144
|
+
|
|
145
|
+
def builder():
|
|
146
|
+
token = rendering_ctx.set(cid)
|
|
147
|
+
|
|
148
|
+
attrs = [f'name="{name}"']
|
|
149
|
+
if size:
|
|
150
|
+
attrs.append(f'style="font-size: {size};"' if not size in ['small', 'medium', 'large'] else f'style="font-size: var(--sl-font-size-{size});"')
|
|
151
|
+
if label:
|
|
152
|
+
attrs.append(f'label="{label}"')
|
|
153
|
+
|
|
154
|
+
attrs_str = ' '.join(attrs)
|
|
155
|
+
html = f'<sl-icon {attrs_str}></sl-icon>'
|
|
156
|
+
|
|
157
|
+
rendering_ctx.reset(token)
|
|
158
|
+
return Component("span", id=cid, content=html)
|
|
159
|
+
|
|
160
|
+
self._register_component(cid, builder)
|
|
161
|
+
|
|
162
|
+
# ============= Predefined Card Themes =============
|
|
163
|
+
|
|
164
|
+
def live_card(self, content, timestamp=None, post_id=None):
|
|
165
|
+
"""
|
|
166
|
+
Create a LIVE card with danger badge and pulse animation
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
content: Card content (will be auto-escaped)
|
|
170
|
+
timestamp: Optional timestamp to display in footer
|
|
171
|
+
post_id: Optional post ID for data attribute
|
|
172
|
+
|
|
173
|
+
Example:
|
|
174
|
+
app.live_card("Breaking news!", timestamp="2026-01-18 10:30")
|
|
175
|
+
app.live_card(post['content'], post['created_at'], post['id'])
|
|
176
|
+
"""
|
|
177
|
+
import html
|
|
178
|
+
escaped_content = html.escape(str(content))
|
|
179
|
+
|
|
180
|
+
header = '<div><sl-badge variant="danger" pulse><sl-icon name="circle-fill" style="font-size: 0.5rem;"></sl-icon> LIVE</sl-badge></div>'
|
|
181
|
+
footer = None
|
|
182
|
+
if timestamp:
|
|
183
|
+
footer = f'<div style="text-align: right; font-size: 0.85rem; color: var(--sl-color-neutral-600);"><sl-icon name="clock"></sl-icon> {timestamp}</div>'
|
|
184
|
+
|
|
185
|
+
kwargs = {"style": "margin-bottom: 1rem; width: 100%;"}
|
|
186
|
+
if post_id is not None:
|
|
187
|
+
kwargs["data_post_id"] = str(post_id)
|
|
188
|
+
|
|
189
|
+
self.card(
|
|
190
|
+
content=f'<div style="font-size: 1.1rem; line-height: 1.6; white-space: pre-wrap;">{escaped_content}</div>',
|
|
191
|
+
header=header,
|
|
192
|
+
footer=footer,
|
|
193
|
+
**kwargs
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def styled_card(self,
|
|
198
|
+
content: str,
|
|
199
|
+
style: str = 'default',
|
|
200
|
+
header_badge: str = None,
|
|
201
|
+
header_badge_variant: str = 'neutral',
|
|
202
|
+
header_text: str = None,
|
|
203
|
+
footer_text: str = None,
|
|
204
|
+
data_id: str = None,
|
|
205
|
+
return_html: bool = False):
|
|
206
|
+
"""Styled card with various preset styles
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
content: Card content (auto-escaped)
|
|
210
|
+
style: Card style ('default', 'live', 'admin', 'info', 'warning')
|
|
211
|
+
header_badge: Header badge text
|
|
212
|
+
header_badge_variant: Badge color ('primary', 'success', 'neutral', 'warning', 'danger')
|
|
213
|
+
header_text: Additional header text (e.g., timestamp)
|
|
214
|
+
footer_text: Footer text
|
|
215
|
+
data_id: ID to add as data attribute (for broadcast)
|
|
216
|
+
return_html: If True, return HTML string; if False, register component (for broadcast)
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
HTML string if return_html=True, otherwise None
|
|
220
|
+
|
|
221
|
+
Examples:
|
|
222
|
+
# Display on screen
|
|
223
|
+
app.styled_card(
|
|
224
|
+
"Hello world",
|
|
225
|
+
style='live',
|
|
226
|
+
header_badge='LIVE',
|
|
227
|
+
footer_text='2026-01-18'
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Generate HTML for broadcast
|
|
231
|
+
html = app.styled_card(
|
|
232
|
+
"Hello world",
|
|
233
|
+
style='live',
|
|
234
|
+
header_badge='LIVE',
|
|
235
|
+
footer_text='2026-01-18',
|
|
236
|
+
data_id='123',
|
|
237
|
+
return_html=True # Return HTML only
|
|
238
|
+
)
|
|
239
|
+
"""
|
|
240
|
+
import html as html_lib
|
|
241
|
+
escaped_content = html_lib.escape(str(content))
|
|
242
|
+
|
|
243
|
+
# Style-specific configuration
|
|
244
|
+
styles_config = {
|
|
245
|
+
'live': {
|
|
246
|
+
'content_style': 'font-size: 1.1rem; line-height: 1.6; white-space: pre-wrap;',
|
|
247
|
+
'badge_variant': 'danger',
|
|
248
|
+
'badge_pulse': True,
|
|
249
|
+
'badge_icon': 'circle-fill'
|
|
250
|
+
},
|
|
251
|
+
'admin': {
|
|
252
|
+
'content_style': 'white-space: pre-wrap; line-height: 1.5;',
|
|
253
|
+
'badge_variant': 'neutral',
|
|
254
|
+
'badge_pill': True,
|
|
255
|
+
'badge_icon': None
|
|
256
|
+
},
|
|
257
|
+
'info': {
|
|
258
|
+
'content_style': 'white-space: pre-wrap; line-height: 1.5;',
|
|
259
|
+
'badge_variant': 'primary',
|
|
260
|
+
'badge_icon': 'info-circle'
|
|
261
|
+
},
|
|
262
|
+
'warning': {
|
|
263
|
+
'content_style': 'white-space: pre-wrap; line-height: 1.5;',
|
|
264
|
+
'badge_variant': 'warning',
|
|
265
|
+
'badge_icon': 'exclamation-triangle'
|
|
266
|
+
},
|
|
267
|
+
'default': {
|
|
268
|
+
'content_style': 'white-space: pre-wrap; line-height: 1.5;',
|
|
269
|
+
'badge_variant': header_badge_variant,
|
|
270
|
+
'badge_icon': None
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
config = styles_config.get(style, styles_config['default'])
|
|
275
|
+
|
|
276
|
+
# Create header
|
|
277
|
+
header_parts = []
|
|
278
|
+
if header_badge:
|
|
279
|
+
badge_attrs = [f'variant="{config["badge_variant"]}"']
|
|
280
|
+
if config.get('badge_pulse'):
|
|
281
|
+
badge_attrs.append('pulse')
|
|
282
|
+
if config.get('badge_pill'):
|
|
283
|
+
badge_attrs.append('pill')
|
|
284
|
+
|
|
285
|
+
badge_content = header_badge
|
|
286
|
+
if config.get('badge_icon'):
|
|
287
|
+
badge_content = f'<sl-icon name="{config["badge_icon"]}" style="font-size: 0.5rem;"></sl-icon> {header_badge}'
|
|
288
|
+
|
|
289
|
+
header_parts.append(f'<sl-badge {" ".join(badge_attrs)}>{badge_content}</sl-badge>')
|
|
290
|
+
|
|
291
|
+
if header_text:
|
|
292
|
+
header_parts.append(f'<small style="color: var(--sl-color-neutral-500);"><sl-icon name="clock"></sl-icon> {header_text}</small>')
|
|
293
|
+
|
|
294
|
+
header_html = None
|
|
295
|
+
if header_parts:
|
|
296
|
+
header_html = f'<div style="display: flex; gap: 0.5rem; align-items: center;">{"".join(header_parts)}</div>'
|
|
297
|
+
|
|
298
|
+
# Create footer
|
|
299
|
+
footer_html = None
|
|
300
|
+
if footer_text:
|
|
301
|
+
footer_html = f'<div style="text-align: right; font-size: 0.85rem; color: var(--sl-color-neutral-600);"><sl-icon name="clock"></sl-icon> {footer_text}</div>'
|
|
302
|
+
|
|
303
|
+
# If return_html=True, return HTML only (for broadcast)
|
|
304
|
+
if return_html:
|
|
305
|
+
# Data attribute
|
|
306
|
+
data_attr = f' data-post-id="{data_id}"' if data_id else ''
|
|
307
|
+
|
|
308
|
+
# Wrap header in slot
|
|
309
|
+
header_slot = f'<div slot="header">{header_html}</div>' if header_html else ''
|
|
310
|
+
|
|
311
|
+
# Wrap footer in slot
|
|
312
|
+
footer_slot = f'<div slot="footer">{footer_html}</div>' if footer_html else ''
|
|
313
|
+
|
|
314
|
+
# Return full HTML (include wrapper div for layout consistency)
|
|
315
|
+
return f'<div style="width: 100%;"><sl-card{data_attr} style="width: 100%;">{header_slot}<div style="{config["content_style"]}">{escaped_content}</div>{footer_slot}</sl-card></div>'
|
|
316
|
+
|
|
317
|
+
# Normal mode: register component
|
|
318
|
+
kwargs = {}
|
|
319
|
+
if data_id:
|
|
320
|
+
kwargs['data_post_id'] = str(data_id)
|
|
321
|
+
|
|
322
|
+
self.card(
|
|
323
|
+
content=f'<div style="{config["content_style"]}">{escaped_content}</div>',
|
|
324
|
+
header=header_html,
|
|
325
|
+
footer=footer_html,
|
|
326
|
+
**kwargs
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def card_with_actions(self,
|
|
331
|
+
content: str,
|
|
332
|
+
style: str = 'default',
|
|
333
|
+
header_badge: str = None,
|
|
334
|
+
header_badge_variant: str = 'neutral',
|
|
335
|
+
header_text: str = None,
|
|
336
|
+
footer_text: str = None,
|
|
337
|
+
data_id: str = None):
|
|
338
|
+
"""
|
|
339
|
+
Card widget with action buttons
|
|
340
|
+
|
|
341
|
+
Arranges card and buttons using flexbox, wraps entire thing with data-id.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
content: Card content
|
|
345
|
+
style: Card style
|
|
346
|
+
header_badge: Header badge
|
|
347
|
+
header_badge_variant: Badge color
|
|
348
|
+
header_text: Additional header text
|
|
349
|
+
footer_text: Footer text
|
|
350
|
+
data_id: data-post-id attribute (for broadcast removal)
|
|
351
|
+
|
|
352
|
+
Example:
|
|
353
|
+
# Admin page
|
|
354
|
+
app.card_with_actions(
|
|
355
|
+
content=post['content'],
|
|
356
|
+
style='admin',
|
|
357
|
+
header_badge=f'#{post["id"]}',
|
|
358
|
+
header_text=post['created_at'],
|
|
359
|
+
data_id=post['id']
|
|
360
|
+
)
|
|
361
|
+
"""
|
|
362
|
+
import html as html_lib
|
|
363
|
+
escaped_content = html_lib.escape(str(content))
|
|
364
|
+
|
|
365
|
+
# Same style configuration as styled_card
|
|
366
|
+
styles_config = {
|
|
367
|
+
'live': {
|
|
368
|
+
'content_style': 'font-size: 1.1rem; line-height: 1.6; white-space: pre-wrap;',
|
|
369
|
+
'badge_variant': 'danger',
|
|
370
|
+
'badge_pulse': True,
|
|
371
|
+
'badge_icon': 'circle-fill'
|
|
372
|
+
},
|
|
373
|
+
'admin': {
|
|
374
|
+
'content_style': 'white-space: pre-wrap; line-height: 1.5;',
|
|
375
|
+
'badge_variant': 'neutral',
|
|
376
|
+
'badge_pill': True,
|
|
377
|
+
'badge_icon': None
|
|
378
|
+
},
|
|
379
|
+
'info': {
|
|
380
|
+
'content_style': 'white-space: pre-wrap; line-height: 1.5;',
|
|
381
|
+
'badge_variant': 'primary',
|
|
382
|
+
'badge_icon': 'info-circle'
|
|
383
|
+
},
|
|
384
|
+
'warning': {
|
|
385
|
+
'content_style': 'white-space: pre-wrap; line-height: 1.5;',
|
|
386
|
+
'badge_variant': 'warning',
|
|
387
|
+
'badge_icon': 'exclamation-triangle'
|
|
388
|
+
},
|
|
389
|
+
'default': {
|
|
390
|
+
'content_style': 'white-space: pre-wrap; line-height: 1.5;',
|
|
391
|
+
'badge_variant': header_badge_variant,
|
|
392
|
+
'badge_icon': None
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
config = styles_config.get(style, styles_config['default'])
|
|
397
|
+
|
|
398
|
+
# Create header
|
|
399
|
+
header_parts = []
|
|
400
|
+
if header_badge:
|
|
401
|
+
badge_attrs = [f'variant="{config["badge_variant"]}"']
|
|
402
|
+
if config.get('badge_pulse'):
|
|
403
|
+
badge_attrs.append('pulse')
|
|
404
|
+
if config.get('badge_pill'):
|
|
405
|
+
badge_attrs.append('pill')
|
|
406
|
+
|
|
407
|
+
badge_content = header_badge
|
|
408
|
+
if config.get('badge_icon'):
|
|
409
|
+
badge_content = f'<sl-icon name="{config["badge_icon"]}" style="font-size: 0.5rem;"></sl-icon> {header_badge}'
|
|
410
|
+
|
|
411
|
+
header_parts.append(f'<sl-badge {" ".join(badge_attrs)}>{badge_content}</sl-badge>')
|
|
412
|
+
|
|
413
|
+
if header_text:
|
|
414
|
+
header_parts.append(f'<small style="color: var(--sl-color-neutral-500);"><sl-icon name="clock"></sl-icon> {header_text}</small>')
|
|
415
|
+
|
|
416
|
+
header_html = ''
|
|
417
|
+
if header_parts:
|
|
418
|
+
header_html = f'<div slot="header" style="display: flex; gap: 0.5rem; align-items: center;">{"".join(header_parts)}</div>'
|
|
419
|
+
|
|
420
|
+
# Create footer
|
|
421
|
+
footer_html = ''
|
|
422
|
+
if footer_text:
|
|
423
|
+
footer_html = f'<div slot="footer"><div style="text-align: right; font-size: 0.85rem; color: var(--sl-color-neutral-600);"><sl-icon name="clock"></sl-icon> {footer_text}</div></div>'
|
|
424
|
+
|
|
425
|
+
# Data attribute
|
|
426
|
+
data_attr = f' data-post-id="{data_id}"' if data_id else ''
|
|
427
|
+
|
|
428
|
+
# Arrange card + action area using flexbox layout
|
|
429
|
+
html_content = f'''<div{data_attr} style="display: flex; gap: 1rem; align-items: flex-start; margin-bottom: 1rem;">
|
|
430
|
+
<div style="flex: 1;">
|
|
431
|
+
<sl-card style="width: 100%;">
|
|
432
|
+
{header_html}
|
|
433
|
+
<div style="{config["content_style"]}">{escaped_content}</div>
|
|
434
|
+
{footer_html}
|
|
435
|
+
</sl-card>
|
|
436
|
+
</div>
|
|
437
|
+
</div>'''
|
|
438
|
+
|
|
439
|
+
# Render with markdown
|
|
440
|
+
self.markdown(html_content)
|
|
441
|
+
|
|
442
|
+
def info_card(self, content, title=None):
|
|
443
|
+
"""
|
|
444
|
+
Create an info card with primary variant
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
content: Card content
|
|
448
|
+
title: Optional title in header
|
|
449
|
+
|
|
450
|
+
Example:
|
|
451
|
+
app.info_card("Important information", title="Notice")
|
|
452
|
+
"""
|
|
453
|
+
header = None
|
|
454
|
+
if title:
|
|
455
|
+
header = f'<div><sl-badge variant="primary"><sl-icon name="info-circle"></sl-icon> {title}</sl-badge></div>'
|
|
456
|
+
|
|
457
|
+
self.card(
|
|
458
|
+
content=f'<div style="line-height: 1.6;">{content}</div>',
|
|
459
|
+
header=header,
|
|
460
|
+
style="margin-bottom: 1rem;"
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
def success_card(self, content, title=None):
|
|
464
|
+
"""
|
|
465
|
+
Create a success card with success variant
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
content: Card content
|
|
469
|
+
title: Optional title in header
|
|
470
|
+
|
|
471
|
+
Example:
|
|
472
|
+
app.success_card("Operation completed!", title="Success")
|
|
473
|
+
"""
|
|
474
|
+
header = None
|
|
475
|
+
if title:
|
|
476
|
+
header = f'<div><sl-badge variant="success"><sl-icon name="check-circle"></sl-icon> {title}</sl-badge></div>'
|
|
477
|
+
|
|
478
|
+
self.card(
|
|
479
|
+
content=f'<div style="line-height: 1.6;">{content}</div>',
|
|
480
|
+
header=header,
|
|
481
|
+
style="margin-bottom: 1rem;"
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
def warning_card(self, content, title=None):
|
|
485
|
+
"""
|
|
486
|
+
Create a warning card with warning variant
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
content: Card content
|
|
490
|
+
title: Optional title in header
|
|
491
|
+
|
|
492
|
+
Example:
|
|
493
|
+
app.warning_card("Please check your settings", title="Warning")
|
|
494
|
+
"""
|
|
495
|
+
header = None
|
|
496
|
+
if title:
|
|
497
|
+
header = f'<div><sl-badge variant="warning"><sl-icon name="exclamation-triangle"></sl-icon> {title}</sl-badge></div>'
|
|
498
|
+
|
|
499
|
+
self.card(
|
|
500
|
+
content=f'<div style="line-height: 1.6;">{content}</div>',
|
|
501
|
+
header=header,
|
|
502
|
+
style="margin-bottom: 1rem;"
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
def danger_card(self, content, title=None):
|
|
506
|
+
"""
|
|
507
|
+
Create a danger card with danger variant
|
|
508
|
+
|
|
509
|
+
Args:
|
|
510
|
+
content: Card content
|
|
511
|
+
title: Optional title in header
|
|
512
|
+
|
|
513
|
+
Example:
|
|
514
|
+
app.danger_card("Critical error occurred", title="Error")
|
|
515
|
+
"""
|
|
516
|
+
header = None
|
|
517
|
+
if title:
|
|
518
|
+
header = f'<div><sl-badge variant="danger"><sl-icon name="x-circle"></sl-icon> {title}</sl-badge></div>'
|
|
519
|
+
|
|
520
|
+
self.card(
|
|
521
|
+
content=f'<div style="line-height: 1.6;">{content}</div>',
|
|
522
|
+
header=header,
|
|
523
|
+
style="margin-bottom: 1rem;"
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
class CardContext:
|
|
528
|
+
"""Context manager for card with complex content"""
|
|
529
|
+
|
|
530
|
+
def __init__(self, app, cid, header, footer, attrs_str):
|
|
531
|
+
self.app = app
|
|
532
|
+
self.cid = cid
|
|
533
|
+
self.header = header
|
|
534
|
+
self.footer = footer
|
|
535
|
+
self.attrs_str = attrs_str
|
|
536
|
+
self.components = []
|
|
537
|
+
|
|
538
|
+
def __enter__(self):
|
|
539
|
+
from ..context import layout_ctx
|
|
540
|
+
self.token = layout_ctx.set(f"card_{self.cid}")
|
|
541
|
+
return self
|
|
542
|
+
|
|
543
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
544
|
+
from ..context import layout_ctx
|
|
545
|
+
from ..state import get_session_store
|
|
546
|
+
|
|
547
|
+
# Collect all components added inside this context
|
|
548
|
+
store = get_session_store()
|
|
549
|
+
card_components = []
|
|
550
|
+
|
|
551
|
+
# Get components that were added in this context
|
|
552
|
+
for comp_cid in store['order']:
|
|
553
|
+
if comp_cid.startswith(f"card_{self.cid}") or len(card_components) > 0:
|
|
554
|
+
builder = store['builders'].get(comp_cid) or self.app.static_builders.get(comp_cid)
|
|
555
|
+
if builder:
|
|
556
|
+
card_components.append(builder().render())
|
|
557
|
+
|
|
558
|
+
# Build final card HTML
|
|
559
|
+
def builder():
|
|
560
|
+
token = rendering_ctx.set(self.cid)
|
|
561
|
+
|
|
562
|
+
try:
|
|
563
|
+
# Handle callable header and footer (Lambda support)
|
|
564
|
+
current_header = self.header
|
|
565
|
+
if callable(self.header):
|
|
566
|
+
current_header = self.header()
|
|
567
|
+
|
|
568
|
+
current_footer = self.footer
|
|
569
|
+
if callable(self.footer):
|
|
570
|
+
current_footer = self.footer()
|
|
571
|
+
|
|
572
|
+
# Add width: 100% to sl-card (consistency with broadcast)
|
|
573
|
+
card_style = 'style="width: 100%;"'
|
|
574
|
+
html_parts = [f'<sl-card{self.attrs_str} {card_style}>']
|
|
575
|
+
|
|
576
|
+
if current_header:
|
|
577
|
+
html_parts.append(f'<div slot="header">{current_header}</div>')
|
|
578
|
+
|
|
579
|
+
# Add collected components as content (no wrapper div)
|
|
580
|
+
if card_components:
|
|
581
|
+
html_parts.extend(card_components)
|
|
582
|
+
|
|
583
|
+
if current_footer:
|
|
584
|
+
html_parts.append(f'<div slot="footer">{current_footer}</div>')
|
|
585
|
+
|
|
586
|
+
html_parts.append('</sl-card>')
|
|
587
|
+
|
|
588
|
+
# Apply width: 100% to wrapper div (consistency with list_container)
|
|
589
|
+
return Component("div", id=self.cid, content=''.join(html_parts), style="width: 100%;")
|
|
590
|
+
finally:
|
|
591
|
+
rendering_ctx.reset(token)
|
|
592
|
+
|
|
593
|
+
self.app._register_component(self.cid, builder)
|
|
594
|
+
layout_ctx.reset(self.token)
|
|
595
|
+
|