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_python-0.1.0.dist-info/METADATA +226 -0
- bootstrap_python-0.1.0.dist-info/RECORD +15 -0
- bootstrap_python-0.1.0.dist-info/WHEEL +5 -0
- bootstrap_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- bootstrap_python-0.1.0.dist-info/top_level.txt +1 -0
- bootstrap_ui/__init__.py +162 -0
- bootstrap_ui/constants.py +85 -0
- bootstrap_ui/content.py +562 -0
- bootstrap_ui/data.py +411 -0
- bootstrap_ui/forms.py +489 -0
- bootstrap_ui/layout.py +260 -0
- bootstrap_ui/nav.py +356 -0
- bootstrap_ui/overlays.py +199 -0
- bootstrap_ui/templates/page.jinja2 +37 -0
- bootstrap_ui/types.py +236 -0
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
|