bootstrap-python 0.1.0__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.
bootstrap_ui/data.py ADDED
@@ -0,0 +1,411 @@
1
+ """数据展示组件:Table / Progress / Spinner / Accordion / Tabs / Carousel。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, ClassVar, Literal
6
+ from uuid import uuid4
7
+
8
+ from pydantic import Field
9
+
10
+ from .constants import Color, value as enum_value
11
+ from .content import icon_name
12
+ from .types import Node, NodeLike, escape, render_attrs, render_children
13
+
14
+ __all__ = [
15
+ "TableColumn",
16
+ "Table",
17
+ "Progress",
18
+ "Spinner",
19
+ "AccordionItem",
20
+ "Accordion",
21
+ "TabItem",
22
+ "Tabs",
23
+ "CarouselItem",
24
+ "Carousel",
25
+ ]
26
+
27
+
28
+ class TableColumn(Node):
29
+ """表格列定义。"""
30
+
31
+ TAG: ClassVar[str | None] = None
32
+
33
+ label: str = ""
34
+ key: str | None = None
35
+ """从字典行里取值的键名,缺省用 ``label``"""
36
+
37
+ width: int | str | None = None
38
+ align: Literal["start", "center", "end"] | None = None
39
+ nowrap: bool = False
40
+
41
+
42
+ class Table(Node):
43
+ """数据表格。行可以是「列表的列表」,也可以是「字典的列表」。"""
44
+
45
+ columns: list[TableColumn | str] = Field(default_factory=list)
46
+ rows: list[Any] = Field(default_factory=list)
47
+ caption: str | None = None
48
+ footer: list[Any] | None = None
49
+ """汇总行单元格"""
50
+
51
+ striped: bool = False
52
+ hover: bool = True
53
+ bordered: bool = False
54
+ borderless: bool = False
55
+ small: bool = False
56
+ responsive: bool = True
57
+ vertical_align: Literal["top", "middle", "bottom"] | None = "middle"
58
+ empty_text: str = "暂无数据"
59
+
60
+ def _columns(self) -> list[TableColumn]:
61
+ if self.columns:
62
+ return [
63
+ column if isinstance(column, TableColumn) else TableColumn(label=str(column))
64
+ for column in self.columns
65
+ ]
66
+ # 没有显式列定义时,用第一行字典的键推导
67
+ first = self.rows[0] if self.rows else None
68
+ if isinstance(first, dict):
69
+ return [TableColumn(label=str(key), key=str(key)) for key in first]
70
+ return []
71
+
72
+ def css_classes(self) -> list[str]:
73
+ classes = ["table"]
74
+ if self.striped:
75
+ classes.append("table-striped")
76
+ if self.hover:
77
+ classes.append("table-hover")
78
+ if self.bordered:
79
+ classes.append("table-bordered")
80
+ if self.borderless:
81
+ classes.append("table-borderless")
82
+ if self.small:
83
+ classes.append("table-sm")
84
+ if self.vertical_align:
85
+ classes.append(f"align-{self.vertical_align}")
86
+ return classes
87
+
88
+ def _cell(self, value: Any, column: TableColumn, tag: str = "td") -> str:
89
+ if isinstance(value, Node):
90
+ inner = value.render()
91
+ elif value is None:
92
+ inner = ""
93
+ else:
94
+ inner = escape(value)
95
+ classes: list[str] = []
96
+ if column.align == "center":
97
+ classes.append("text-center")
98
+ elif column.align == "end":
99
+ classes.append("text-end")
100
+ if column.nowrap:
101
+ classes.append("text-nowrap")
102
+ attrs = f' class="{" ".join(classes)}"' if classes else ""
103
+ return f"<{tag}{attrs}>{inner}</{tag}>"
104
+
105
+ def render_content(self) -> str:
106
+ columns = self._columns()
107
+ head_cells = []
108
+ for column in columns:
109
+ attrs: dict[str, Any] = {"scope": "col"}
110
+ if column.width is not None:
111
+ attrs["style"] = f"width: {column.width}px" if isinstance(column.width, int) else f"width: {column.width}"
112
+ head_cells.append(f"<th{render_attrs(attrs)}>{escape(column.label)}</th>")
113
+ head = f'<thead><tr>{"".join(head_cells)}</tr></thead>'
114
+
115
+ body_rows: list[str] = []
116
+ for row in self.rows:
117
+ if isinstance(row, dict):
118
+ cells = [self._cell(row.get(column.key or column.label), column) for column in columns]
119
+ elif isinstance(row, (list, tuple)):
120
+ cells = [
121
+ self._cell(row[index] if index < len(row) else None, column)
122
+ for index, column in enumerate(columns)
123
+ ]
124
+ else: # 单值行
125
+ cells = [self._cell(row, columns[0])] if columns else []
126
+ body_rows.append(f"<tr>{''.join(cells)}</tr>")
127
+
128
+ if not body_rows:
129
+ colspan = max(len(columns), 1)
130
+ body_rows.append(
131
+ f'<tr><td colspan="{colspan}" class="text-center text-body-secondary py-4">'
132
+ f"{escape(self.empty_text)}</td></tr>"
133
+ )
134
+ body = f"<tbody>{''.join(body_rows)}</tbody>"
135
+
136
+ foot = ""
137
+ if self.footer:
138
+ cells = [
139
+ self._cell(value, columns[index] if index < len(columns) else TableColumn(), "th")
140
+ for index, value in enumerate(self.footer)
141
+ ]
142
+ foot = f"<tfoot><tr>{''.join(cells)}</tr></tfoot>"
143
+
144
+ caption = f"<caption>{escape(self.caption)}</caption>" if self.caption else ""
145
+ table = f'<table{render_attrs(self.html_attrs())}>{caption}{head}{body}{foot}</table>'
146
+ if self.responsive:
147
+ return f'<div class="table-responsive">{table}</div>'
148
+ return table
149
+
150
+ def render(self) -> str:
151
+ return self.render_content()
152
+
153
+
154
+ class Progress(Node):
155
+ """进度条。"""
156
+
157
+ value: float = 0
158
+ max: float = 100
159
+ color: Color | None = Color.primary
160
+ height: str | None = None
161
+ striped: bool = False
162
+ animated: bool = False
163
+ show_value: bool = True
164
+ label: str | None = None
165
+
166
+ def html_attrs(self) -> dict[str, Any]:
167
+ attrs = super().html_attrs()
168
+ attrs.setdefault("role", "progressbar")
169
+ attrs["aria-valuenow"] = self.value
170
+ attrs["aria-valuemin"] = 0
171
+ attrs["aria-valuemax"] = self.max
172
+ if self.height:
173
+ attrs["style"] = f"height: {self.height}" + (f"; {attrs['style']}" if "style" in attrs else "")
174
+ return attrs
175
+
176
+ def css_classes(self) -> list[str]:
177
+ return ["progress"]
178
+
179
+ def render_content(self) -> str:
180
+ total = self.max or 100
181
+ percent = 0 if total <= 0 else min(max(self.value / total * 100, 0), 100)
182
+ classes = ["progress-bar"]
183
+ if self.color:
184
+ classes.append(f"bg-{enum_value(self.color)}")
185
+ if self.striped:
186
+ classes.append("progress-bar-striped")
187
+ if self.animated:
188
+ classes.append("progress-bar-animated")
189
+ label = self.label if self.label is not None else (f"{percent:.0f}%" if self.show_value else "")
190
+ return f'<div class="{" ".join(classes)}" style="width: {percent:.1f}%">{escape(label)}</div>'
191
+
192
+
193
+ class Spinner(Node):
194
+ """加载指示器。"""
195
+
196
+ kind: Literal["border", "grow"] = "border"
197
+ color: Color | None = None
198
+ small: bool = False
199
+ label: str | None = None
200
+
201
+ def css_classes(self) -> list[str]:
202
+ classes = [f"spinner-{self.kind}"]
203
+ if self.small:
204
+ classes.append(f"spinner-{self.kind}-sm")
205
+ if self.color:
206
+ classes.append(f"text-{enum_value(self.color)}")
207
+ return classes
208
+
209
+ def html_attrs(self) -> dict[str, Any]:
210
+ attrs = super().html_attrs()
211
+ attrs.setdefault("role", "status")
212
+ return attrs
213
+
214
+ def render_content(self) -> str:
215
+ hidden = f'<span class="visually-hidden">{escape(self.label or "加载中…")}</span>'
216
+ if self.label:
217
+ return f'{hidden}<span class="ms-2">{escape(self.label)}</span>'
218
+ return hidden
219
+
220
+
221
+ class AccordionItem(Node):
222
+ """折叠面板中的一项。"""
223
+
224
+ TAG: ClassVar[str | None] = None
225
+
226
+ title: str = ""
227
+ body: list[NodeLike] = Field(default_factory=list)
228
+ expanded: bool = False
229
+
230
+
231
+ class Accordion(Node):
232
+ """折叠面板组。"""
233
+
234
+ id: str = Field(default_factory=lambda: f"accordion-{uuid4().hex[:6]}")
235
+ items: list[AccordionItem] = Field(default_factory=list)
236
+ flush: bool = False
237
+ always_open: bool = False
238
+ """``True`` 时允许多个面板同时展开"""
239
+
240
+ def css_classes(self) -> list[str]:
241
+ classes = ["accordion"]
242
+ if self.flush:
243
+ classes.append("accordion-flush")
244
+ return classes
245
+
246
+ def render_content(self) -> str:
247
+ parts: list[str] = []
248
+ for index, item in enumerate(self.items):
249
+ item_id = f"{self.id}-item-{index}"
250
+ expanded = item.expanded or (index == 0 and not any(i.expanded for i in self.items))
251
+ button_classes = "accordion-button" + ("" if expanded else " collapsed")
252
+ body_classes = "accordion-collapse collapse" + (" show" if expanded else "")
253
+ parent = "" if self.always_open else f' data-bs-parent="#{self.id}"'
254
+ parts.append(
255
+ '<div class="accordion-item">'
256
+ f'<h2 class="accordion-header" id="{item_id}-header">'
257
+ f'<button class="{button_classes}" type="button" data-bs-toggle="collapse" '
258
+ f'data-bs-target="#{item_id}-body" aria-expanded="{"true" if expanded else "false"}" '
259
+ f'aria-controls="{item_id}-body">{escape(item.title)}</button></h2>'
260
+ f'<div id="{item_id}-body" class="{body_classes}" aria-labelledby="{item_id}-header"{parent}>'
261
+ f'<div class="accordion-body">{render_children(item.body)}</div></div></div>'
262
+ )
263
+ return "".join(parts)
264
+
265
+
266
+ class TabItem(Node):
267
+ """标签页中的一页。"""
268
+
269
+ TAG: ClassVar[str | None] = None
270
+
271
+ label: str = ""
272
+ body: list[NodeLike] = Field(default_factory=list)
273
+ icon: str | None = None
274
+ active: bool = False
275
+ disabled: bool = False
276
+
277
+
278
+ class Tabs(Node):
279
+ """标签页(Bootstrap nav-tabs + tab-content)。"""
280
+
281
+ id: str = Field(default_factory=lambda: f"tabs-{uuid4().hex[:6]}")
282
+ items: list[TabItem] = Field(default_factory=list)
283
+ pill: bool = False
284
+ fade: bool = True
285
+ vertical: bool = False
286
+
287
+ def _is_active(self, index: int) -> bool:
288
+ if any(item.active for item in self.items):
289
+ return self.items[index].active
290
+ return index == 0
291
+
292
+ def render_content(self) -> str:
293
+ nav_classes = "nav nav-pills flex-column me-3" if self.vertical else ("nav nav-pills" if self.pill else "nav nav-tabs")
294
+ if not self.vertical:
295
+ nav_classes += " mb-3"
296
+ nav_links: list[str] = []
297
+ panes: list[str] = []
298
+
299
+ for index, item in enumerate(self.items):
300
+ tab_id = f"{self.id}-tab-{index}"
301
+ pane_id = f"{self.id}-pane-{index}"
302
+ active = self._is_active(index)
303
+ link_classes = ["nav-link"]
304
+ if active:
305
+ link_classes.append("active")
306
+ if item.disabled:
307
+ link_classes.append("disabled")
308
+ icon = f'<i class="{icon_name(item.icon)} me-1"></i>' if item.icon else ""
309
+ nav_links.append(
310
+ f'<li class="nav-item" role="presentation">'
311
+ f'<button class="{" ".join(link_classes)}" id="{tab_id}" type="button" '
312
+ f'data-bs-toggle="tab" data-bs-target="#{pane_id}" role="tab" '
313
+ f'aria-controls="{pane_id}" aria-selected="{"true" if active else "false"}"'
314
+ f'{" disabled" if item.disabled else ""}>{icon}{escape(item.label)}</button></li>'
315
+ )
316
+ pane_classes = ["tab-pane"]
317
+ if self.fade:
318
+ pane_classes.append("fade")
319
+ if active:
320
+ pane_classes.append("show active")
321
+ panes.append(
322
+ f'<div class="{" ".join(pane_classes)}" id="{pane_id}" role="tabpanel" '
323
+ f'aria-labelledby="{tab_id}" tabindex="0">{render_children(item.body)}</div>'
324
+ )
325
+
326
+ nav = f'<ul class="{nav_classes}" role="tablist">{ "".join(nav_links) }</ul>'
327
+ content = f'<div class="tab-content flex-grow-1">{"".join(panes)}</div>'
328
+ if self.vertical:
329
+ return f'<div class="d-flex align-items-start">{nav}{content}</div>'
330
+ return f"{nav}{content}"
331
+
332
+ def html_attrs(self) -> dict[str, Any]:
333
+ return {k: v for k, v in super().html_attrs().items() if k in {"id", "class", "style", "data-bs-theme"}}
334
+
335
+ def render(self) -> str:
336
+ attrs = render_attrs(self.html_attrs())
337
+ if attrs:
338
+ return f"<div{attrs}>{self.render_content()}</div>"
339
+ return self.render_content()
340
+
341
+
342
+ class CarouselItem(Node):
343
+ """轮播中的一张。"""
344
+
345
+ TAG: ClassVar[str | None] = None
346
+
347
+ image: str = ""
348
+ title: str | None = None
349
+ text: str | None = None
350
+ alt: str = ""
351
+ active: bool = False
352
+
353
+
354
+ class Carousel(Node):
355
+ """轮播图。"""
356
+
357
+ id: str = Field(default_factory=lambda: f"carousel-{uuid4().hex[:6]}")
358
+ items: list[CarouselItem] = Field(default_factory=list)
359
+ controls: bool = True
360
+ indicators: bool = True
361
+ interval: int = 5000
362
+ fade: bool = False
363
+ height: str | None = "20rem"
364
+
365
+ def render_content(self) -> str:
366
+ slides: list[str] = []
367
+ dots: list[str] = []
368
+ for index, item in enumerate(self.items):
369
+ active = item.active or (index == 0 and not any(i.active for i in self.items))
370
+ active_class = " active" if active else ""
371
+ caption = ""
372
+ if item.title or item.text:
373
+ title = f"<h5>{escape(item.title)}</h5>" if item.title else ""
374
+ text = f'<p class="mb-0">{escape(item.text)}</p>' if item.text else ""
375
+ caption = f'<div class="carousel-caption d-none d-md-block">{title}{text}</div>'
376
+ image_style = f' style="object-fit: cover; height: {self.height};"' if self.height else ""
377
+ slides.append(
378
+ f'<div class="carousel-item{active_class}">'
379
+ f'<img src="{escape(item.image)}" class="d-block w-100" alt="{escape(item.alt)}"{image_style}>'
380
+ f"{caption}</div>"
381
+ )
382
+ dots.append(
383
+ f'<li data-bs-target="#{self.id}" data-bs-slide-to="{index}" class="{active_class.strip()}"></li>'
384
+ )
385
+
386
+ indicators_html = (
387
+ f'<div class="carousel-indicators">{"".join(dots)}</div>' if self.indicators else ""
388
+ )
389
+ controls_html = (
390
+ f'<button class="carousel-control-prev" type="button" data-bs-target="#{self.id}" data-bs-slide="prev">'
391
+ f'<span class="carousel-control-prev-icon" aria-hidden="true"></span>'
392
+ f'<span class="visually-hidden">上一张</span></button>'
393
+ f'<button class="carousel-control-next" type="button" data-bs-target="#{self.id}" data-bs-slide="next">'
394
+ f'<span class="carousel-control-next-icon" aria-hidden="true"></span>'
395
+ f'<span class="visually-hidden">下一张</span></button>'
396
+ if self.controls
397
+ else ""
398
+ )
399
+ return indicators_html + f'<div class="carousel-inner">{"".join(slides)}</div>' + controls_html
400
+
401
+ def css_classes(self) -> list[str]:
402
+ classes = ["carousel", "slide"]
403
+ if self.fade:
404
+ classes.append("carousel-fade")
405
+ return classes
406
+
407
+ def html_attrs(self) -> dict[str, Any]:
408
+ attrs = super().html_attrs()
409
+ attrs["data-bs-ride"] = "carousel"
410
+ attrs["data-bs-interval"] = self.interval
411
+ return attrs