violit 0.0.4.post1__py3-none-any.whl → 0.0.6__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.
@@ -1,529 +1,547 @@
1
- """Data Widgets Mixin for Violit"""
2
-
3
- from typing import Union, Callable, Optional, Any
4
- import json
5
- import pandas as pd
6
- from ..component import Component
7
- from ..context import rendering_ctx
8
- from ..state import State
9
-
10
-
11
- class DataWidgetsMixin:
12
- """Data display widgets (dataframe, table, data_editor, metric, json)"""
13
-
14
- def dataframe(self, df: Union[pd.DataFrame, Callable, State], height=400,
15
- column_defs=None, grid_options=None, on_cell_clicked=None, **props):
16
- """Display interactive dataframe with AG Grid"""
17
- cid = self._get_next_cid("df")
18
-
19
- def action(v):
20
- """Handle cell click events"""
21
- if on_cell_clicked and callable(on_cell_clicked):
22
- on_cell_clicked(v)
23
-
24
- def builder():
25
- # Handle Signal
26
- current_df = df
27
- if isinstance(df, State):
28
- token = rendering_ctx.set(cid)
29
- current_df = df.value
30
- rendering_ctx.reset(token)
31
- elif callable(df):
32
- token = rendering_ctx.set(cid)
33
- current_df = df()
34
- rendering_ctx.reset(token)
35
-
36
- if not isinstance(current_df, pd.DataFrame):
37
- # Fallback or try to convert
38
- try: current_df = pd.DataFrame(current_df)
39
- except: return Component("div", id=cid, content="Invalid data format")
40
-
41
- data = current_df.to_dict('records')
42
-
43
- # Use custom column_defs or generate defaults
44
- if column_defs:
45
- cols = column_defs
46
- else:
47
- cols = [{"field": c, "sortable": True, "filter": True} for c in current_df.columns]
48
-
49
- # Merge grid_options
50
- extra_options = grid_options or {}
51
-
52
- # Add cell click handler if provided
53
- cell_click_handler = ""
54
- if on_cell_clicked:
55
- cell_click_handler = f'''
56
- onCellClicked: (params) => {{
57
- const cellData = {{
58
- value: params.value,
59
- field: params.colDef.field,
60
- rowData: params.data,
61
- rowIndex: params.rowIndex
62
- }};
63
- {f"window.sendAction('{cid}', cellData);" if self.mode == 'ws' else f"htmx.ajax('POST', '/action/{cid}', {{values: {{value: JSON.stringify(cellData)}}, swap: 'none'}});"}
64
- }},
65
- '''
66
-
67
- html = f'''
68
- <div id="{cid}" style="height: {height}px; width: 100%;" class="ag-theme-alpine-dark"></div>
69
- <script>(function(){{
70
- const opt = {{
71
- columnDefs: {json.dumps(cols, default=str)},
72
- rowData: {json.dumps(data, default=str)},
73
- defaultColDef: {{flex: 1, minWidth: 100, resizable: true}},
74
- {cell_click_handler}
75
- ...{json.dumps(extra_options)}
76
- }};
77
- const el = document.querySelector('#{cid}');
78
- if (el && window.agGrid) {{
79
- const grid = new agGrid.Grid(el, opt);
80
- window['grid_{cid}'] = grid;
81
- }}
82
- else {{ console.error("agGrid not found"); }}
83
- }})();</script>
84
- '''
85
- return Component("div", id=f"{cid}_wrapper", content=html)
86
-
87
- self._register_component(cid, builder, action=action if on_cell_clicked else None)
88
-
89
- def table(self, df: Union[pd.DataFrame, Callable, State], **props):
90
- """Display static HTML table (Signal support)"""
91
- cid = self._get_next_cid("table")
92
- def builder():
93
- # Handle Signal
94
- current_df = df
95
- if isinstance(df, State):
96
- token = rendering_ctx.set(cid)
97
- current_df = df.value
98
- rendering_ctx.reset(token)
99
- elif callable(df):
100
- token = rendering_ctx.set(cid)
101
- current_df = df()
102
- rendering_ctx.reset(token)
103
-
104
- if not isinstance(current_df, pd.DataFrame):
105
- try: current_df = pd.DataFrame(current_df)
106
- except: return Component("div", id=cid, content="Invalid data format")
107
-
108
- # Convert dataframe to HTML table
109
- html_table = current_df.to_html(index=False, border=0, classes=['data-table'])
110
- styled_html = f'''
111
- <div style="overflow-x:auto;border:1px solid var(--sl-border);border-radius:0.5rem;">
112
- <style>
113
- .data-table {{
114
- width: 100%;
115
- border-collapse: collapse;
116
- background: var(--sl-bg-card);
117
- color: var(--sl-text);
118
- }}
119
- .data-table thead {{
120
- background: var(--sl-primary);
121
- color: white;
122
- }}
123
- .data-table th, .data-table td {{
124
- padding: 0.75rem;
125
- text-align: left;
126
- border-bottom: 1px solid var(--sl-border);
127
- }}
128
- .data-table tbody tr:hover {{
129
- background: color-mix(in srgb, var(--sl-bg-card), var(--sl-primary) 5%);
130
- }}
131
- </style>
132
- {html_table}
133
- </div>
134
- '''
135
- return Component("div", id=cid, content=styled_html)
136
- self._register_component(cid, builder)
137
-
138
- def data_editor(self, df: pd.DataFrame, num_rows="fixed", height=400, key=None, on_change=None, **props):
139
- """Interactive data editor (simplified version)"""
140
- cid = self._get_next_cid("data_editor")
141
-
142
- state_key = key or f"data_editor:{cid}"
143
- s = self.state(df.to_dict('records'), key=state_key)
144
-
145
- def action(v):
146
- try:
147
- new_data = json.loads(v) if isinstance(v, str) else v
148
- s.set(new_data)
149
- if on_change: on_change(pd.DataFrame(new_data))
150
- except: pass
151
-
152
- def builder():
153
- # Subscribe to own state - client-side will handle smart updates
154
- token = rendering_ctx.set(cid)
155
- data = s.value
156
- rendering_ctx.reset(token)
157
-
158
- cols = [{"field": c, "sortable": True, "filter": True, "editable": True} for c in df.columns]
159
- add_row_btn = '' if num_rows == "fixed" else f'''
160
- <sl-button size="small" style="margin-top:0.5rem;" onclick="addDataRow_{cid}()">
161
- <sl-icon slot="prefix" name="plus-circle"></sl-icon>
162
- Add Row
163
- </sl-button>
164
- '''
165
-
166
- html = f'''
167
- <div>
168
- <div id="{cid}" style="height: {height}px; width: 100%;" class="ag-theme-alpine-dark"></div>
169
- {add_row_btn}
170
- </div>
171
- <script>(function(){{
172
- const gridOptions = {{
173
- columnDefs: {json.dumps(cols, default=str)},
174
- rowData: {json.dumps(data, default=str)},
175
- defaultColDef: {{ flex: 1, minWidth: 100, resizable: true, editable: true }},
176
- onCellValueChanged: (params) => {{
177
- const allData = [];
178
- params.api.forEachNode(node => allData.push(node.data));
179
- {f"sendAction('{cid}', allData);" if self.mode == 'ws' else f"htmx.ajax('POST', '/action/{cid}', {{values: {{value: JSON.stringify(allData)}} , swap: 'none'}});"}
180
- }},
181
- onGridReady: (params) => {{
182
- // Store API when grid is ready
183
- window['gridApi_{cid}'] = params.api;
184
- }}
185
- }};
186
- const el = document.querySelector('#{cid}');
187
- if (el && window.agGrid) {{
188
- new agGrid.Grid(el, gridOptions);
189
- }}
190
-
191
- window.addDataRow_{cid} = function() {{
192
- // Access stored grid API
193
- const api = window['gridApi_{cid}'];
194
- if (api && api.applyTransaction) {{
195
- // Add empty row with all column fields
196
- const newRow = {{}};
197
- {json.dumps([c for c in df.columns])}.forEach(col => newRow[col] = '');
198
- api.applyTransaction({{add: [newRow]}});
199
- // Trigger data update to sync with backend
200
- const allData = [];
201
- api.forEachNode(node => allData.push(node.data));
202
- {f"sendAction('{cid}', allData);" if self.mode == 'ws' else f"htmx.ajax('POST', '/action/{cid}', {{values: {{value: JSON.stringify(allData)}} , swap: 'none'}});"}
203
- }}
204
- }};
205
- }})();</script>
206
- '''
207
- return Component("div", id=f"{cid}_wrapper", content=html)
208
-
209
- self._register_component(cid, builder, action=action)
210
- return s
211
-
212
- def metric(self, label: str, value: Union[str, int, float, State, Callable], delta: Optional[Union[str, State, Callable]] = None, delta_color: str = "normal"):
213
- """Display metric value with Signal support"""
214
- import html as html_lib
215
-
216
- cid = self._get_next_cid("metric")
217
-
218
- def builder():
219
- # Handle value signal
220
- curr_val = value
221
- if isinstance(value, State):
222
- token = rendering_ctx.set(cid)
223
- curr_val = value.value
224
- rendering_ctx.reset(token)
225
- elif callable(value):
226
- token = rendering_ctx.set(cid)
227
- curr_val = value()
228
- rendering_ctx.reset(token)
229
-
230
- # Handle delta signal
231
- curr_delta = delta
232
- if isinstance(delta, State):
233
- token = rendering_ctx.set(cid)
234
- curr_delta = delta.value
235
- rendering_ctx.reset(token)
236
- elif callable(delta):
237
- token = rendering_ctx.set(cid)
238
- curr_delta = delta()
239
- rendering_ctx.reset(token)
240
-
241
- # XSS protection: escape all values
242
- escaped_label = html_lib.escape(str(label))
243
- escaped_val = html_lib.escape(str(curr_val))
244
-
245
- delta_html = ""
246
- if curr_delta:
247
- escaped_delta = html_lib.escape(str(curr_delta))
248
- color_map = {"positive": "#10b981", "negative": "#ef4444", "normal": "var(--sl-text-muted)"}
249
- color = color_map.get(delta_color, "var(--sl-text-muted)")
250
- icon = "arrow-up" if delta_color == "positive" else "arrow-down" if delta_color == "negative" else ""
251
- icon_html = f'<sl-icon name="{icon}" style="font-size: 0.8em; margin-right: 2px;"></sl-icon>' if icon else ""
252
- delta_html = f'<div style="color: {color}; font-size: 0.9rem; margin-top: 0.25rem; font-weight: 500;">{icon_html}{escaped_delta}</div>'
253
-
254
- html_output = f'''
255
- <div class="card" style="padding: 1.25rem;">
256
- <div style="font-size: 0.875rem; color: var(--sl-text-muted); margin-bottom: 0.5rem; font-weight: 500;">{escaped_label}</div>
257
- <div style="font-size: 1.75rem; font-weight: 700; color: var(--sl-text);">{escaped_val}</div>
258
- {delta_html}
259
- </div>
260
- '''
261
- return Component("div", id=cid, content=html_output)
262
-
263
- self._register_component(cid, builder)
264
-
265
- def json(self, body: Any, expanded=True):
266
- """Display JSON data"""
267
- cid = self._get_next_cid("json")
268
- json_str = json.dumps(body, indent=2, default=str)
269
- html = f'''
270
- <details {"open" if expanded else ""} style="background:var(--sl-bg-card);border:1px solid var(--sl-border);border-radius:0.5rem;padding:0.5rem;">
271
- <summary style="cursor:pointer;font-size:0.875rem;color:var(--sl-text-muted);">JSON Data</summary>
272
- <pre style="margin:0.5rem 0 0 0;font-size:0.875rem;color:var(--sl-primary);">{json_str}</pre>
273
- </details>
274
- '''
275
- return Component("div", id=cid, content=html)
276
-
277
- def heatmap(self, data: Union[dict, State, Callable],
278
- start_date=None, end_date=None,
279
- color_map=None, show_legend=True,
280
- show_weekdays=True, show_months=True,
281
- cell_size=12, gap=3, on_cell_clicked=None, **props):
282
- """
283
- Display GitHub-style activity heatmap
284
-
285
- Args:
286
- data: Dict mapping date strings (YYYY-MM-DD) to values, or State/Callable
287
- start_date: Start date (string or date object)
288
- end_date: End date (string or date object)
289
- color_map: Dict mapping values to colors
290
- Example: {0: '#ebedf0', 1: '#10b981', 2: '#fbbf24'}
291
- show_legend: Show color legend
292
- show_weekdays: Show weekday labels
293
- show_months: Show month labels
294
- cell_size: Size of each cell in pixels
295
- gap: Gap between cells in pixels
296
- on_cell_clicked: Callback for cell clicks
297
-
298
- Example:
299
- app.heatmap(
300
- data={date: status for date, status in completions.items()},
301
- start_date='2026-01-01',
302
- end_date='2026-12-31',
303
- color_map={0: '#ebedf0', 1: '#10b981', 2: '#fbbf24'}
304
- )
305
- """
306
- from datetime import date as date_obj, timedelta
307
-
308
- cid = self._get_next_cid("heatmap")
309
-
310
- def action(v):
311
- """Handle cell click events"""
312
- if on_cell_clicked and callable(on_cell_clicked):
313
- on_cell_clicked(v)
314
-
315
- def builder():
316
- # Handle Signal/Callable
317
- current_data = data
318
- if isinstance(data, State):
319
- token = rendering_ctx.set(cid)
320
- current_data = data.value
321
- rendering_ctx.reset(token)
322
- elif callable(data):
323
- token = rendering_ctx.set(cid)
324
- current_data = data()
325
- rendering_ctx.reset(token)
326
-
327
- # Parse dates
328
- if start_date:
329
- if isinstance(start_date, str):
330
- start = date_obj.fromisoformat(start_date)
331
- else:
332
- start = start_date
333
- else:
334
- start = date_obj.today().replace(month=1, day=1)
335
-
336
- if end_date:
337
- if isinstance(end_date, str):
338
- end = date_obj.fromisoformat(end_date)
339
- else:
340
- end = end_date
341
- else:
342
- end = date_obj.today().replace(month=12, day=31)
343
-
344
- # Default color map (use current_color_map to avoid variable shadowing)
345
- current_color_map = color_map if color_map is not None else {
346
- 0: '#ebedf0',
347
- 1: '#10b981',
348
- 2: '#fbbf24'
349
- }
350
-
351
- # Adjust start to Sunday
352
- start_day = start - timedelta(days=start.weekday() + 1 if start.weekday() != 6 else 0)
353
-
354
- # Generate week data
355
- weeks = []
356
- current = start_day
357
-
358
- while current <= end:
359
- week = []
360
- for _ in range(7):
361
- if start <= current <= end:
362
- date_str = current.isoformat()
363
- value = current_data.get(date_str, 0)
364
- week.append({'date': current, 'value': value, 'valid': True})
365
- else:
366
- week.append({'date': current, 'value': 0, 'valid': False})
367
- current += timedelta(days=1)
368
- weeks.append(week)
369
-
370
- # CSS
371
- css = f'''
372
- <style>
373
- .heatmap-{cid} {{
374
- background: white;
375
- padding: 1.5rem;
376
- border-radius: 8px;
377
- overflow-x: auto;
378
- }}
379
- .heatmap-{cid} .grid {{
380
- display: flex;
381
- gap: {gap}px;
382
- }}
383
- .heatmap-{cid} .weekdays {{
384
- display: flex;
385
- flex-direction: column;
386
- gap: {gap}px;
387
- padding-top: 20px;
388
- margin-right: 4px;
389
- }}
390
- .heatmap-{cid} .day-label {{
391
- height: {cell_size}px;
392
- font-size: 9px;
393
- color: #666;
394
- display: flex;
395
- align-items: center;
396
- }}
397
- .heatmap-{cid} .week {{
398
- display: flex;
399
- flex-direction: column;
400
- gap: {gap}px;
401
- }}
402
- .heatmap-{cid} .month {{
403
- height: 14px;
404
- font-size: 9px;
405
- color: #666;
406
- text-align: center;
407
- margin-bottom: 2px;
408
- }}
409
- .heatmap-{cid} .cell {{
410
- width: {cell_size}px;
411
- height: {cell_size}px;
412
- border-radius: 2px;
413
- border: 1px solid #fff;
414
- cursor: pointer;
415
- }}
416
- .heatmap-{cid} .cell:hover {{
417
- opacity: 0.8;
418
- border: 1px solid #000;
419
- }}
420
- .heatmap-{cid} .cell.today {{
421
- border: 2px solid #000;
422
- }}
423
- .heatmap-{cid} .legend {{
424
- margin-top: 1rem;
425
- display: flex;
426
- gap: 1rem;
427
- font-size: 11px;
428
- color: #666;
429
- align-items: center;
430
- }}
431
- .heatmap-{cid} .legend-item {{
432
- display: flex;
433
- align-items: center;
434
- gap: 4px;
435
- }}
436
- </style>
437
- '''
438
-
439
- # Weekday labels
440
- weekday_html = ''
441
- if show_weekdays:
442
- weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
443
- weekday_html = f'''
444
- <div class="weekdays">
445
- {"".join(f'<div class="day-label">{d}</div>' for d in weekdays)}
446
- </div>
447
- '''
448
-
449
- # Generate weeks HTML
450
- today = date_obj.today()
451
- current_month = None
452
- weeks_html = []
453
-
454
- for week in weeks:
455
- # Month label
456
- first_valid = next((d for d in week if d['valid']), None)
457
- if first_valid and show_months:
458
- month = first_valid['date'].month
459
- month_label = f"{month}" if month != current_month else ""
460
- current_month = month
461
- else:
462
- month_label = ""
463
-
464
- # Cells
465
- cells_html = []
466
- for day in week:
467
- if not day['valid']:
468
- cells_html.append(f'<div style="width: {cell_size}px; height: {cell_size}px;"></div>')
469
- else:
470
- value = day['value']
471
- bg_color = current_color_map.get(value, '#ebedf0')
472
- is_today = day['date'] == today
473
- today_class = ' today' if is_today else ''
474
- date_str = day['date'].isoformat()
475
-
476
- # Click handler
477
- click_attr = ''
478
- if on_cell_clicked:
479
- click_js = f"window.sendAction('{cid}', {{date: '{date_str}', value: {value}}});" if self.mode == 'ws' else f"htmx.ajax('POST', '/action/{cid}', {{values: {{value: JSON.stringify({{date: '{date_str}', value: {value}}})}}), swap: 'none'}});"
480
- click_attr = f'onclick="{click_js}"'
481
-
482
- cells_html.append(
483
- f'<div class="cell{today_class}" style="background: {bg_color};" title="{date_str}" {click_attr}></div>'
484
- )
485
-
486
- weeks_html.append(f'''
487
- <div class="week">
488
- <div class="month">{month_label}</div>
489
- {"".join(cells_html)}
490
- </div>
491
- ''')
492
-
493
- # Legend
494
- legend_html = ''
495
- if show_legend:
496
- legend_items = [
497
- f'''<div class="legend-item">
498
- <div class="cell" style="background: {color}; border: 1px solid #ddd;"></div>
499
- <span>{label}</span>
500
- </div>'''
501
- for label, color in [('None', current_color_map.get(0, '#ebedf0')),
502
- ('Done', current_color_map.get(1, '#10b981')),
503
- ('Skip', current_color_map.get(2, '#fbbf24'))]
504
- if color in current_color_map.values()
505
- ]
506
- legend_html = f'''
507
- <div class="legend">
508
- <span>Legend:</span>
509
- {"".join(legend_items)}
510
- </div>
511
- '''
512
-
513
- # Final HTML
514
- html = f'''
515
- {css}
516
- <div class="heatmap-{cid}">
517
- <div class="grid">
518
- {weekday_html}
519
- <div style="display: flex; gap: {gap}px;">
520
- {"".join(weeks_html)}
521
- </div>
522
- </div>
523
- {legend_html}
524
- </div>
525
- '''
526
-
527
- return Component("div", id=cid, content=html)
528
-
529
- self._register_component(cid, builder, action=action if on_cell_clicked else None)
1
+ """Data Widgets Mixin for Violit"""
2
+
3
+ from typing import Union, Callable, Optional, Any
4
+ import json
5
+ import pandas as pd
6
+ from ..component import Component
7
+ from ..context import rendering_ctx
8
+ from ..state import State
9
+
10
+
11
+ class DataWidgetsMixin:
12
+ """Data display widgets (dataframe, table, data_editor, metric, json)"""
13
+
14
+ def dataframe(self, df: Union[pd.DataFrame, Callable, State], height=400,
15
+ column_defs=None, grid_options=None, on_cell_clicked=None, **props):
16
+ """Display interactive dataframe with AG Grid"""
17
+ cid = self._get_next_cid("df")
18
+
19
+ def action(v):
20
+ """Handle cell click events"""
21
+ if on_cell_clicked and callable(on_cell_clicked):
22
+ on_cell_clicked(v)
23
+
24
+ def builder():
25
+ # Handle Signal
26
+ current_df = df
27
+ if isinstance(df, State):
28
+ token = rendering_ctx.set(cid)
29
+ current_df = df.value
30
+ rendering_ctx.reset(token)
31
+ elif callable(df):
32
+ token = rendering_ctx.set(cid)
33
+ current_df = df()
34
+ rendering_ctx.reset(token)
35
+
36
+ if not isinstance(current_df, pd.DataFrame):
37
+ # Fallback or try to convert
38
+ try: current_df = pd.DataFrame(current_df)
39
+ except: return Component("div", id=cid, content="Invalid data format")
40
+
41
+ data = current_df.to_dict('records')
42
+
43
+ # Use custom column_defs or generate defaults
44
+ if column_defs:
45
+ cols = column_defs
46
+ else:
47
+ cols = [{"field": c, "sortable": True, "filter": True} for c in current_df.columns]
48
+
49
+ # Merge grid_options
50
+ extra_options = grid_options or {}
51
+
52
+ # Add cell click handler if provided
53
+ cell_click_handler = ""
54
+ if on_cell_clicked:
55
+ cell_click_handler = f'''
56
+ onCellClicked: (params) => {{
57
+ const cellData = {{
58
+ value: params.value,
59
+ field: params.colDef.field,
60
+ rowData: params.data,
61
+ rowIndex: params.rowIndex
62
+ }};
63
+ {f"window.sendAction('{cid}', cellData);" if self.mode == 'ws' else f"htmx.ajax('POST', '/action/{cid}', {{values: {{value: JSON.stringify(cellData)}}, swap: 'none'}});"}
64
+ }},
65
+ '''
66
+
67
+ html = f'''
68
+ <div id="{cid}" style="height: {height}px; width: 100%;" class="ag-theme-alpine-dark"></div>
69
+ <script>(function(){{
70
+ const opt = {{
71
+ columnDefs: {json.dumps(cols, default=str)},
72
+ rowData: {json.dumps(data, default=str)},
73
+ defaultColDef: {{flex: 1, minWidth: 100, resizable: true}},
74
+ {cell_click_handler}
75
+ ...{json.dumps(extra_options)}
76
+ }};
77
+ const el = document.querySelector('#{cid}');
78
+ if (el && window.agGrid) {{
79
+ const grid = new agGrid.Grid(el, opt);
80
+ window['grid_{cid}'] = grid;
81
+ }}
82
+ else {{ console.error("agGrid not found"); }}
83
+ }})();</script>
84
+ '''
85
+ return Component("div", id=f"{cid}_wrapper", content=html)
86
+
87
+ self._register_component(cid, builder, action=action if on_cell_clicked else None)
88
+
89
+ def table(self, df: Union[pd.DataFrame, Callable, State], **props):
90
+ """Display static HTML table (Signal support)"""
91
+ cid = self._get_next_cid("table")
92
+ def builder():
93
+ # Handle Signal
94
+ current_df = df
95
+ if isinstance(df, State):
96
+ token = rendering_ctx.set(cid)
97
+ current_df = df.value
98
+ rendering_ctx.reset(token)
99
+ elif callable(df):
100
+ token = rendering_ctx.set(cid)
101
+ current_df = df()
102
+ rendering_ctx.reset(token)
103
+
104
+ if not isinstance(current_df, pd.DataFrame):
105
+ try: current_df = pd.DataFrame(current_df)
106
+ except: return Component("div", id=cid, content="Invalid data format")
107
+
108
+ # Convert dataframe to HTML table
109
+ html_table = current_df.to_html(index=False, border=0, classes=['data-table'])
110
+ styled_html = f'''
111
+ <div style="overflow-x:auto;border:1px solid var(--sl-border);border-radius:0.5rem;">
112
+ <style>
113
+ .data-table {{
114
+ width: 100%;
115
+ border-collapse: collapse;
116
+ background: var(--sl-bg-card);
117
+ color: var(--sl-text);
118
+ }}
119
+ .data-table thead {{
120
+ background: var(--sl-primary);
121
+ color: white;
122
+ }}
123
+ .data-table th, .data-table td {{
124
+ padding: 0.75rem;
125
+ text-align: left;
126
+ border-bottom: 1px solid var(--sl-border);
127
+ }}
128
+ .data-table tbody tr:hover {{
129
+ background: color-mix(in srgb, var(--sl-bg-card), var(--sl-primary) 5%);
130
+ }}
131
+ </style>
132
+ {html_table}
133
+ </div>
134
+ '''
135
+ return Component("div", id=cid, content=styled_html)
136
+ self._register_component(cid, builder)
137
+
138
+ def data_editor(self, df: pd.DataFrame, num_rows="fixed", height=400, key=None, on_change=None, **props):
139
+ """Interactive data editor (simplified version)"""
140
+ cid = self._get_next_cid("data_editor")
141
+
142
+ state_key = key or f"data_editor:{cid}"
143
+ s = self.state(df.to_dict('records'), key=state_key)
144
+
145
+ def action(v):
146
+ try:
147
+ new_data = json.loads(v) if isinstance(v, str) else v
148
+ s.set(new_data)
149
+ if on_change: on_change(pd.DataFrame(new_data))
150
+ except: pass
151
+
152
+ def builder():
153
+ # Subscribe to own state - client-side will handle smart updates
154
+ token = rendering_ctx.set(cid)
155
+ data = s.value
156
+ rendering_ctx.reset(token)
157
+
158
+ cols = [{"field": c, "sortable": True, "filter": True, "editable": True} for c in df.columns]
159
+ add_row_btn = '' if num_rows == "fixed" else f'''
160
+ <sl-button size="small" style="margin-top:0.5rem;" onclick="addDataRow_{cid}()">
161
+ <sl-icon slot="prefix" name="plus-circle"></sl-icon>
162
+ Add Row
163
+ </sl-button>
164
+ '''
165
+
166
+ html = f'''
167
+ <div>
168
+ <div id="{cid}" style="height: {height}px; width: 100%;" class="ag-theme-alpine-dark"></div>
169
+ {add_row_btn}
170
+ </div>
171
+ <script>(function(){{
172
+ const gridOptions = {{
173
+ columnDefs: {json.dumps(cols, default=str)},
174
+ rowData: {json.dumps(data, default=str)},
175
+ defaultColDef: {{ flex: 1, minWidth: 100, resizable: true, editable: true }},
176
+ onCellValueChanged: (params) => {{
177
+ const allData = [];
178
+ params.api.forEachNode(node => allData.push(node.data));
179
+ {f"sendAction('{cid}', allData);" if self.mode == 'ws' else f"htmx.ajax('POST', '/action/{cid}', {{values: {{value: JSON.stringify(allData)}} , swap: 'none'}});"}
180
+ }},
181
+ onGridReady: (params) => {{
182
+ // Store API when grid is ready
183
+ window['gridApi_{cid}'] = params.api;
184
+ }}
185
+ }};
186
+ const el = document.querySelector('#{cid}');
187
+ if (el && window.agGrid) {{
188
+ new agGrid.Grid(el, gridOptions);
189
+ }}
190
+
191
+ window.addDataRow_{cid} = function() {{
192
+ // Access stored grid API
193
+ const api = window['gridApi_{cid}'];
194
+ if (api && api.applyTransaction) {{
195
+ // Add empty row with all column fields
196
+ const newRow = {{}};
197
+ {json.dumps([c for c in df.columns])}.forEach(col => newRow[col] = '');
198
+ api.applyTransaction({{add: [newRow]}});
199
+ // Trigger data update to sync with backend
200
+ const allData = [];
201
+ api.forEachNode(node => allData.push(node.data));
202
+ {f"sendAction('{cid}', allData);" if self.mode == 'ws' else f"htmx.ajax('POST', '/action/{cid}', {{values: {{value: JSON.stringify(allData)}} , swap: 'none'}});"}
203
+ }}
204
+ }};
205
+ }})();</script>
206
+ '''
207
+ return Component("div", id=f"{cid}_wrapper", content=html)
208
+
209
+ self._register_component(cid, builder, action=action)
210
+ return s
211
+
212
+ def metric(self, label: str, value: Union[str, int, float, State, Callable], delta: Optional[Union[str, State, Callable]] = None, delta_color: str = "normal"):
213
+ """Display metric value with Signal support"""
214
+ import html as html_lib
215
+
216
+ cid = self._get_next_cid("metric")
217
+
218
+ def builder():
219
+ # Handle value signal
220
+ curr_val = value
221
+ if isinstance(value, State):
222
+ token = rendering_ctx.set(cid)
223
+ curr_val = value.value
224
+ rendering_ctx.reset(token)
225
+ elif callable(value):
226
+ token = rendering_ctx.set(cid)
227
+ curr_val = value()
228
+ rendering_ctx.reset(token)
229
+
230
+ # Handle delta signal
231
+ curr_delta = delta
232
+ if isinstance(delta, State):
233
+ token = rendering_ctx.set(cid)
234
+ curr_delta = delta.value
235
+ rendering_ctx.reset(token)
236
+ elif callable(delta):
237
+ token = rendering_ctx.set(cid)
238
+ curr_delta = delta()
239
+ rendering_ctx.reset(token)
240
+
241
+ # XSS protection: escape all values
242
+ escaped_label = html_lib.escape(str(label))
243
+ escaped_val = html_lib.escape(str(curr_val))
244
+
245
+ delta_html = ""
246
+ if curr_delta:
247
+ escaped_delta = html_lib.escape(str(curr_delta))
248
+ color_map = {"positive": "#10b981", "negative": "#ef4444", "normal": "var(--sl-text-muted)"}
249
+ color = color_map.get(delta_color, "var(--sl-text-muted)")
250
+ icon = "arrow-up" if delta_color == "positive" else "arrow-down" if delta_color == "negative" else ""
251
+ icon_html = f'<sl-icon name="{icon}" style="font-size: 0.8em; margin-right: 2px;"></sl-icon>' if icon else ""
252
+ delta_html = f'<div style="color: {color}; font-size: 0.9rem; margin-top: 0.25rem; font-weight: 500;">{icon_html}{escaped_delta}</div>'
253
+
254
+ html_output = f'''
255
+ <div class="card" style="padding: 1.25rem;">
256
+ <div style="font-size: 0.875rem; color: var(--sl-text-muted); margin-bottom: 0.5rem; font-weight: 500;">{escaped_label}</div>
257
+ <div style="font-size: 1.75rem; font-weight: 700; color: var(--sl-text);">{escaped_val}</div>
258
+ {delta_html}
259
+ </div>
260
+ '''
261
+ return Component("div", id=cid, content=html_output)
262
+
263
+ self._register_component(cid, builder)
264
+
265
+ def json(self, body: Any, expanded=True):
266
+ """Display JSON data with Signal support"""
267
+ cid = self._get_next_cid("json")
268
+
269
+ def builder():
270
+ from ..state import State, ComputedState
271
+ import json as json_lib
272
+
273
+ # Handle Signal
274
+ current_body = body
275
+ if isinstance(body, (State, ComputedState)):
276
+ token = rendering_ctx.set(cid)
277
+ current_body = body.value
278
+ rendering_ctx.reset(token)
279
+ elif callable(body):
280
+ token = rendering_ctx.set(cid)
281
+ current_body = body()
282
+ rendering_ctx.reset(token)
283
+
284
+ json_str = json_lib.dumps(current_body, indent=2, default=str)
285
+ html = f'''
286
+ <details {"open" if expanded else ""} style="background:var(--sl-bg-card);border:1px solid var(--sl-border);border-radius:0.5rem;padding:0.5rem;">
287
+ <summary style="cursor:pointer;font-size:0.875rem;color:var(--sl-text-muted);">JSON Data</summary>
288
+ <pre style="margin:0.5rem 0 0 0;font-size:0.875rem;color:var(--sl-primary);overflow-x:auto;">{json_str}</pre>
289
+ </details>
290
+ '''
291
+ return Component("div", id=cid, content=html)
292
+
293
+ self._register_component(cid, builder)
294
+
295
+ def heatmap(self, data: Union[dict, State, Callable],
296
+ start_date=None, end_date=None,
297
+ color_map=None, show_legend=True,
298
+ show_weekdays=True, show_months=True,
299
+ cell_size=12, gap=3, on_cell_clicked=None, **props):
300
+ """
301
+ Display GitHub-style activity heatmap
302
+
303
+ Args:
304
+ data: Dict mapping date strings (YYYY-MM-DD) to values, or State/Callable
305
+ start_date: Start date (string or date object)
306
+ end_date: End date (string or date object)
307
+ color_map: Dict mapping values to colors
308
+ Example: {0: '#ebedf0', 1: '#10b981', 2: '#fbbf24'}
309
+ show_legend: Show color legend
310
+ show_weekdays: Show weekday labels
311
+ show_months: Show month labels
312
+ cell_size: Size of each cell in pixels
313
+ gap: Gap between cells in pixels
314
+ on_cell_clicked: Callback for cell clicks
315
+
316
+ Example:
317
+ app.heatmap(
318
+ data={date: status for date, status in completions.items()},
319
+ start_date='2026-01-01',
320
+ end_date='2026-12-31',
321
+ color_map={0: '#ebedf0', 1: '#10b981', 2: '#fbbf24'}
322
+ )
323
+ """
324
+ from datetime import date as date_obj, timedelta
325
+
326
+ cid = self._get_next_cid("heatmap")
327
+
328
+ def action(v):
329
+ """Handle cell click events"""
330
+ if on_cell_clicked and callable(on_cell_clicked):
331
+ on_cell_clicked(v)
332
+
333
+ def builder():
334
+ # Handle Signal/Callable
335
+ current_data = data
336
+ if isinstance(data, State):
337
+ token = rendering_ctx.set(cid)
338
+ current_data = data.value
339
+ rendering_ctx.reset(token)
340
+ elif callable(data):
341
+ token = rendering_ctx.set(cid)
342
+ current_data = data()
343
+ rendering_ctx.reset(token)
344
+
345
+ # Parse dates
346
+ if start_date:
347
+ if isinstance(start_date, str):
348
+ start = date_obj.fromisoformat(start_date)
349
+ else:
350
+ start = start_date
351
+ else:
352
+ start = date_obj.today().replace(month=1, day=1)
353
+
354
+ if end_date:
355
+ if isinstance(end_date, str):
356
+ end = date_obj.fromisoformat(end_date)
357
+ else:
358
+ end = end_date
359
+ else:
360
+ end = date_obj.today().replace(month=12, day=31)
361
+
362
+ # Default color map (use current_color_map to avoid variable shadowing)
363
+ current_color_map = color_map if color_map is not None else {
364
+ 0: '#ebedf0',
365
+ 1: '#10b981',
366
+ 2: '#fbbf24'
367
+ }
368
+
369
+ # Adjust start to Sunday
370
+ start_day = start - timedelta(days=start.weekday() + 1 if start.weekday() != 6 else 0)
371
+
372
+ # Generate week data
373
+ weeks = []
374
+ current = start_day
375
+
376
+ while current <= end:
377
+ week = []
378
+ for _ in range(7):
379
+ if start <= current <= end:
380
+ date_str = current.isoformat()
381
+ value = current_data.get(date_str, 0)
382
+ week.append({'date': current, 'value': value, 'valid': True})
383
+ else:
384
+ week.append({'date': current, 'value': 0, 'valid': False})
385
+ current += timedelta(days=1)
386
+ weeks.append(week)
387
+
388
+ # CSS
389
+ css = f'''
390
+ <style>
391
+ .heatmap-{cid} {{
392
+ background: white;
393
+ padding: 1.5rem;
394
+ border-radius: 8px;
395
+ overflow-x: auto;
396
+ }}
397
+ .heatmap-{cid} .grid {{
398
+ display: flex;
399
+ gap: {gap}px;
400
+ }}
401
+ .heatmap-{cid} .weekdays {{
402
+ display: flex;
403
+ flex-direction: column;
404
+ gap: {gap}px;
405
+ padding-top: 20px;
406
+ margin-right: 4px;
407
+ }}
408
+ .heatmap-{cid} .day-label {{
409
+ height: {cell_size}px;
410
+ font-size: 9px;
411
+ color: #666;
412
+ display: flex;
413
+ align-items: center;
414
+ }}
415
+ .heatmap-{cid} .week {{
416
+ display: flex;
417
+ flex-direction: column;
418
+ gap: {gap}px;
419
+ }}
420
+ .heatmap-{cid} .month {{
421
+ height: 14px;
422
+ font-size: 9px;
423
+ color: #666;
424
+ text-align: center;
425
+ margin-bottom: 2px;
426
+ }}
427
+ .heatmap-{cid} .cell {{
428
+ width: {cell_size}px;
429
+ height: {cell_size}px;
430
+ border-radius: 2px;
431
+ border: 1px solid #fff;
432
+ cursor: pointer;
433
+ }}
434
+ .heatmap-{cid} .cell:hover {{
435
+ opacity: 0.8;
436
+ border: 1px solid #000;
437
+ }}
438
+ .heatmap-{cid} .cell.today {{
439
+ border: 2px solid #000;
440
+ }}
441
+ .heatmap-{cid} .legend {{
442
+ margin-top: 1rem;
443
+ display: flex;
444
+ gap: 1rem;
445
+ font-size: 11px;
446
+ color: #666;
447
+ align-items: center;
448
+ }}
449
+ .heatmap-{cid} .legend-item {{
450
+ display: flex;
451
+ align-items: center;
452
+ gap: 4px;
453
+ }}
454
+ </style>
455
+ '''
456
+
457
+ # Weekday labels
458
+ weekday_html = ''
459
+ if show_weekdays:
460
+ weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
461
+ weekday_html = f'''
462
+ <div class="weekdays">
463
+ {"".join(f'<div class="day-label">{d}</div>' for d in weekdays)}
464
+ </div>
465
+ '''
466
+
467
+ # Generate weeks HTML
468
+ today = date_obj.today()
469
+ current_month = None
470
+ weeks_html = []
471
+
472
+ for week in weeks:
473
+ # Month label
474
+ first_valid = next((d for d in week if d['valid']), None)
475
+ if first_valid and show_months:
476
+ month = first_valid['date'].month
477
+ month_label = f"{month}" if month != current_month else ""
478
+ current_month = month
479
+ else:
480
+ month_label = ""
481
+
482
+ # Cells
483
+ cells_html = []
484
+ for day in week:
485
+ if not day['valid']:
486
+ cells_html.append(f'<div style="width: {cell_size}px; height: {cell_size}px;"></div>')
487
+ else:
488
+ value = day['value']
489
+ bg_color = current_color_map.get(value, '#ebedf0')
490
+ is_today = day['date'] == today
491
+ today_class = ' today' if is_today else ''
492
+ date_str = day['date'].isoformat()
493
+
494
+ # Click handler
495
+ click_attr = ''
496
+ if on_cell_clicked:
497
+ click_js = f"window.sendAction('{cid}', {{date: '{date_str}', value: {value}}});" if self.mode == 'ws' else f"htmx.ajax('POST', '/action/{cid}', {{values: {{value: JSON.stringify({{date: '{date_str}', value: {value}}})}}), swap: 'none'}});"
498
+ click_attr = f'onclick="{click_js}"'
499
+
500
+ cells_html.append(
501
+ f'<div class="cell{today_class}" style="background: {bg_color};" title="{date_str}" {click_attr}></div>'
502
+ )
503
+
504
+ weeks_html.append(f'''
505
+ <div class="week">
506
+ <div class="month">{month_label}</div>
507
+ {"".join(cells_html)}
508
+ </div>
509
+ ''')
510
+
511
+ # Legend
512
+ legend_html = ''
513
+ if show_legend:
514
+ legend_items = [
515
+ f'''<div class="legend-item">
516
+ <div class="cell" style="background: {color}; border: 1px solid #ddd;"></div>
517
+ <span>{label}</span>
518
+ </div>'''
519
+ for label, color in [('None', current_color_map.get(0, '#ebedf0')),
520
+ ('Done', current_color_map.get(1, '#10b981')),
521
+ ('Skip', current_color_map.get(2, '#fbbf24'))]
522
+ if color in current_color_map.values()
523
+ ]
524
+ legend_html = f'''
525
+ <div class="legend">
526
+ <span>Legend:</span>
527
+ {"".join(legend_items)}
528
+ </div>
529
+ '''
530
+
531
+ # Final HTML
532
+ html = f'''
533
+ {css}
534
+ <div class="heatmap-{cid}">
535
+ <div class="grid">
536
+ {weekday_html}
537
+ <div style="display: flex; gap: {gap}px;">
538
+ {"".join(weeks_html)}
539
+ </div>
540
+ </div>
541
+ {legend_html}
542
+ </div>
543
+ '''
544
+
545
+ return Component("div", id=cid, content=html)
546
+
547
+ self._register_component(cid, builder, action=action if on_cell_clicked else None)