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/layout.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""页面与栅格布局组件:Page / Container / Row / Col / Stack / Hr。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, ClassVar, Literal
|
|
7
|
+
|
|
8
|
+
from jinja2 import Environment, FileSystemLoader
|
|
9
|
+
from pydantic import Field
|
|
10
|
+
|
|
11
|
+
from .types import Node, NodeLike, escape, render_children
|
|
12
|
+
|
|
13
|
+
__all__ = ["Page", "Container", "Row", "Col", "Stack", "Hr", "Box", "Fragment"]
|
|
14
|
+
|
|
15
|
+
_TEMPLATE_DIR = Path(__file__).parent / "templates"
|
|
16
|
+
|
|
17
|
+
_env = Environment(
|
|
18
|
+
loader=FileSystemLoader(_TEMPLATE_DIR),
|
|
19
|
+
autoescape=False, # 内容在 Python 侧已经渲染并转义过,这里不能二次转义
|
|
20
|
+
trim_blocks=True,
|
|
21
|
+
lstrip_blocks=True,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Page(Node):
|
|
26
|
+
"""完整的 HTML 页面(``<html>`` 文档),是渲染的入口。"""
|
|
27
|
+
|
|
28
|
+
TAG: ClassVar[str | None] = None
|
|
29
|
+
__default_template_path__: ClassVar[str] = "page.jinja2"
|
|
30
|
+
|
|
31
|
+
title: str = "Bootstrap"
|
|
32
|
+
"""浏览器标题"""
|
|
33
|
+
|
|
34
|
+
lang: str = "zh-CN"
|
|
35
|
+
"""``<html lang>``"""
|
|
36
|
+
|
|
37
|
+
body: list[NodeLike] = Field(default_factory=list)
|
|
38
|
+
"""页面主体内容"""
|
|
39
|
+
|
|
40
|
+
navbar: NodeLike | None = None
|
|
41
|
+
"""顶部导航栏(一般传 :class:`~bootstrap.nav.Navbar`)"""
|
|
42
|
+
|
|
43
|
+
footer: NodeLike | None = None
|
|
44
|
+
"""页脚内容"""
|
|
45
|
+
|
|
46
|
+
toolbar: NodeLike | None = None
|
|
47
|
+
"""主体内容上方的工具条(标题 + 按钮之类)"""
|
|
48
|
+
|
|
49
|
+
container: bool = True
|
|
50
|
+
"""是否用容器包裹主体内容"""
|
|
51
|
+
|
|
52
|
+
fluid: bool = False
|
|
53
|
+
"""容器是否全宽"""
|
|
54
|
+
|
|
55
|
+
body_class: str | None = None
|
|
56
|
+
"""``<body>`` 的 class"""
|
|
57
|
+
|
|
58
|
+
theme: Literal["light", "dark", "auto"] | None = None
|
|
59
|
+
"""Bootstrap 5.3 的 ``data-bs-theme``,用于内置暗色模式"""
|
|
60
|
+
|
|
61
|
+
version: str = "5.3.3"
|
|
62
|
+
"""Bootstrap 版本号"""
|
|
63
|
+
|
|
64
|
+
cdn: str = "https://cdn.jsdelivr.net/npm"
|
|
65
|
+
"""资源 CDN 前缀"""
|
|
66
|
+
|
|
67
|
+
icons: bool = True
|
|
68
|
+
"""是否引入 bootstrap-icons"""
|
|
69
|
+
|
|
70
|
+
icons_version: str = "1.11.3"
|
|
71
|
+
"""bootstrap-icons 版本号"""
|
|
72
|
+
|
|
73
|
+
css: list[str] = Field(default_factory=list)
|
|
74
|
+
"""额外样式表地址"""
|
|
75
|
+
|
|
76
|
+
js: list[str] = Field(default_factory=list)
|
|
77
|
+
"""额外脚本地址"""
|
|
78
|
+
|
|
79
|
+
custom_css: str | None = None
|
|
80
|
+
"""内联样式"""
|
|
81
|
+
|
|
82
|
+
custom_js: str | None = None
|
|
83
|
+
"""内联脚本(在 Bootstrap bundle 之后执行)"""
|
|
84
|
+
|
|
85
|
+
head: str | None = None
|
|
86
|
+
"""原样插入 ``<head>`` 末尾的内容"""
|
|
87
|
+
|
|
88
|
+
def render(self, template_name: str = "") -> str:
|
|
89
|
+
"""用 jinja2 模板渲染完整页面。"""
|
|
90
|
+
template = _env.get_template(template_name or self.__default_template_path__)
|
|
91
|
+
return template.render(
|
|
92
|
+
lang=escape(self.lang),
|
|
93
|
+
theme=self.theme,
|
|
94
|
+
title=escape(self.title),
|
|
95
|
+
icons=self.icons,
|
|
96
|
+
icons_version=self.icons_version,
|
|
97
|
+
cdn=self.cdn.rstrip("/"),
|
|
98
|
+
version=self.version,
|
|
99
|
+
css=self.css,
|
|
100
|
+
page_css=self.custom_css or "",
|
|
101
|
+
body_class=escape(self.body_class or ""),
|
|
102
|
+
navbar=self.navbar.render() if isinstance(self.navbar, Node) else "",
|
|
103
|
+
toolbar=render_children(self.toolbar, "\n") if self.toolbar is not None else "",
|
|
104
|
+
body=render_children(self.body, "\n"),
|
|
105
|
+
footer=self.footer.render() if isinstance(self.footer, Node) else "",
|
|
106
|
+
js=self.js,
|
|
107
|
+
page_js=self.custom_js or "",
|
|
108
|
+
container=self.container,
|
|
109
|
+
container_class="container-fluid" if self.fluid else "container",
|
|
110
|
+
head=self.head or "",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def save(self, path: str | Path, encoding: str = "utf-8") -> Path:
|
|
114
|
+
"""把渲染结果写入文件。"""
|
|
115
|
+
target = Path(path)
|
|
116
|
+
target.write_text(self.render(), encoding=encoding)
|
|
117
|
+
return target
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Container(Node):
|
|
121
|
+
"""固定宽度(或响应式)的内容容器。"""
|
|
122
|
+
|
|
123
|
+
fluid: bool | Literal["sm", "md", "lg", "xl", "xxl"] = False
|
|
124
|
+
"""``True`` 全宽;字符串表示 ``container-{断点}``"""
|
|
125
|
+
|
|
126
|
+
children: list[NodeLike] = Field(default_factory=list)
|
|
127
|
+
|
|
128
|
+
def css_classes(self) -> list[str]:
|
|
129
|
+
if self.fluid is True:
|
|
130
|
+
return ["container-fluid"]
|
|
131
|
+
if isinstance(self.fluid, str):
|
|
132
|
+
return [f"container-{self.fluid}"]
|
|
133
|
+
return ["container"]
|
|
134
|
+
|
|
135
|
+
def render_content(self) -> str:
|
|
136
|
+
return render_children(self.children, "\n")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class Row(Node):
|
|
140
|
+
"""栅格行(``.row``)。"""
|
|
141
|
+
|
|
142
|
+
children: list[NodeLike] = Field(default_factory=list)
|
|
143
|
+
|
|
144
|
+
gutter: int | str | None = None
|
|
145
|
+
"""列间距,渲染为 ``g-{gutter}``,如 3"""
|
|
146
|
+
|
|
147
|
+
align: Literal["start", "center", "end", "baseline", "stretch"] | None = None
|
|
148
|
+
"""垂直对齐(align-items-*)"""
|
|
149
|
+
|
|
150
|
+
justify: Literal["start", "center", "end", "between", "around", "evenly"] | None = None
|
|
151
|
+
"""水平分布(justify-content-*)"""
|
|
152
|
+
|
|
153
|
+
cols: int | str | None = None
|
|
154
|
+
"""每行固定列数,渲染为 ``row-cols-{cols}``"""
|
|
155
|
+
|
|
156
|
+
def css_classes(self) -> list[str]:
|
|
157
|
+
classes = ["row"]
|
|
158
|
+
if self.gutter is not None:
|
|
159
|
+
classes.append(f"g-{self.gutter}")
|
|
160
|
+
if self.align:
|
|
161
|
+
classes.append(f"align-items-{self.align}")
|
|
162
|
+
if self.justify:
|
|
163
|
+
classes.append(f"justify-content-{self.justify}")
|
|
164
|
+
if self.cols is not None:
|
|
165
|
+
classes.append(f"row-cols-{self.cols}")
|
|
166
|
+
return classes
|
|
167
|
+
|
|
168
|
+
def render_content(self) -> str:
|
|
169
|
+
return render_children(self.children, "\n")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class Col(Node):
|
|
173
|
+
"""栅格列(``.col``)。"""
|
|
174
|
+
|
|
175
|
+
body: list[NodeLike] = Field(default_factory=list)
|
|
176
|
+
|
|
177
|
+
width: int | str | None = None
|
|
178
|
+
"""默认宽度,1-12 或 ``auto``"""
|
|
179
|
+
|
|
180
|
+
xs: int | str | None = None
|
|
181
|
+
sm: int | str | None = None
|
|
182
|
+
md: int | str | None = None
|
|
183
|
+
lg: int | str | None = None
|
|
184
|
+
xl: int | str | None = None
|
|
185
|
+
xxl: int | str | None = None
|
|
186
|
+
"""各断点下的宽度"""
|
|
187
|
+
|
|
188
|
+
offset: int | None = None
|
|
189
|
+
"""左侧偏移列数"""
|
|
190
|
+
|
|
191
|
+
align_self: Literal["start", "center", "end", "baseline", "stretch"] | None = None
|
|
192
|
+
"""列自身在行内的垂直对齐"""
|
|
193
|
+
|
|
194
|
+
def css_classes(self) -> list[str]:
|
|
195
|
+
classes: list[str] = []
|
|
196
|
+
if self.width is not None:
|
|
197
|
+
classes.append(f"col-{self.width}")
|
|
198
|
+
for breakpoint in ("xs", "sm", "md", "lg", "xl", "xxl"):
|
|
199
|
+
item = getattr(self, breakpoint)
|
|
200
|
+
if item is not None:
|
|
201
|
+
classes.append(f"col-{breakpoint}-{item}")
|
|
202
|
+
if self.offset is not None:
|
|
203
|
+
classes.append(f"offset-{self.offset}")
|
|
204
|
+
if self.align_self:
|
|
205
|
+
classes.append(f"align-self-{self.align_self}")
|
|
206
|
+
if not classes:
|
|
207
|
+
classes.append("col")
|
|
208
|
+
return classes
|
|
209
|
+
|
|
210
|
+
def render_content(self) -> str:
|
|
211
|
+
return render_children(self.body, "\n")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class Stack(Node):
|
|
215
|
+
"""垂直 / 水平堆叠(``.vstack`` / ``.hstack``)。"""
|
|
216
|
+
|
|
217
|
+
direction: Literal["vertical", "horizontal"] = "vertical"
|
|
218
|
+
|
|
219
|
+
gap: int | str | None = 3
|
|
220
|
+
"""间距,渲染为 ``gap-{gap}``"""
|
|
221
|
+
|
|
222
|
+
children: list[NodeLike] = Field(default_factory=list)
|
|
223
|
+
|
|
224
|
+
def css_classes(self) -> list[str]:
|
|
225
|
+
classes = ["vstack" if self.direction == "vertical" else "hstack"]
|
|
226
|
+
if self.gap is not None:
|
|
227
|
+
classes.append(f"gap-{self.gap}")
|
|
228
|
+
return classes
|
|
229
|
+
|
|
230
|
+
def render_content(self) -> str:
|
|
231
|
+
return render_children(self.children, "\n")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class Hr(Node):
|
|
235
|
+
"""分割线。"""
|
|
236
|
+
|
|
237
|
+
TAG: ClassVar[str | None] = "hr"
|
|
238
|
+
|
|
239
|
+
def css_classes(self) -> list[str]:
|
|
240
|
+
return ["my-4"]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class Box(Node):
|
|
244
|
+
"""通用 div 容器,用来套用工具类或行内样式。"""
|
|
245
|
+
|
|
246
|
+
children: list[NodeLike] = Field(default_factory=list)
|
|
247
|
+
|
|
248
|
+
def render_content(self) -> str:
|
|
249
|
+
return render_children(self.children, "\n")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class Fragment(Node):
|
|
253
|
+
"""不产生包裹标签的片段,用于把多个节点当成一个整体传入。"""
|
|
254
|
+
|
|
255
|
+
TAG: ClassVar[str | None] = None
|
|
256
|
+
|
|
257
|
+
children: list[NodeLike] = Field(default_factory=list)
|
|
258
|
+
|
|
259
|
+
def render(self) -> str:
|
|
260
|
+
return render_children(self.children, "\n")
|
bootstrap_ui/nav.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""导航类组件:Navbar / Nav / NavItem / Dropdown / Pagination / Breadcrumb。"""
|
|
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, Size, 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
|
+
"Navbar",
|
|
16
|
+
"Nav",
|
|
17
|
+
"NavItem",
|
|
18
|
+
"Dropdown",
|
|
19
|
+
"DropdownItem",
|
|
20
|
+
"Pagination",
|
|
21
|
+
"Breadcrumb",
|
|
22
|
+
"BreadcrumbItem",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NavItem(Node):
|
|
27
|
+
"""导航项,渲染为 ``<li class="nav-item"><a class="nav-link">``。"""
|
|
28
|
+
|
|
29
|
+
TAG: ClassVar[str | None] = None
|
|
30
|
+
|
|
31
|
+
text: str = ""
|
|
32
|
+
href: str = "#"
|
|
33
|
+
icon: str | None = None
|
|
34
|
+
active: bool = False
|
|
35
|
+
disabled: bool = False
|
|
36
|
+
|
|
37
|
+
def render(self) -> str:
|
|
38
|
+
classes = ["nav-link"]
|
|
39
|
+
if self.active:
|
|
40
|
+
classes.append("active")
|
|
41
|
+
if self.disabled:
|
|
42
|
+
classes.append("disabled")
|
|
43
|
+
|
|
44
|
+
attrs: dict[str, Any] = {"class": " ".join(classes), "href": self.href}
|
|
45
|
+
if self.active:
|
|
46
|
+
attrs["aria-current"] = "page"
|
|
47
|
+
if self.disabled:
|
|
48
|
+
attrs["tabindex"] = "-1"
|
|
49
|
+
attrs["aria-disabled"] = "true"
|
|
50
|
+
if self.id:
|
|
51
|
+
attrs["id"] = self.id
|
|
52
|
+
|
|
53
|
+
icon = f'<i class="{icon_name(self.icon)} me-1"></i>' if self.icon else ""
|
|
54
|
+
return f'<li class="nav-item"><a{render_attrs(attrs)}>{icon}{escape(self.text)}</a></li>'
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DropdownItem(Node):
|
|
58
|
+
"""下拉菜单项,``divider=True`` 时渲染为分割线。"""
|
|
59
|
+
|
|
60
|
+
TAG: ClassVar[str | None] = None
|
|
61
|
+
|
|
62
|
+
text: str = ""
|
|
63
|
+
href: str = "#"
|
|
64
|
+
active: bool = False
|
|
65
|
+
disabled: bool = False
|
|
66
|
+
divider: bool = False
|
|
67
|
+
icon: str | None = None
|
|
68
|
+
|
|
69
|
+
def render(self) -> str:
|
|
70
|
+
if self.divider:
|
|
71
|
+
return '<li><hr class="dropdown-divider"></li>'
|
|
72
|
+
|
|
73
|
+
classes = ["dropdown-item"]
|
|
74
|
+
if self.active:
|
|
75
|
+
classes.append("active")
|
|
76
|
+
if self.disabled:
|
|
77
|
+
classes.append("disabled")
|
|
78
|
+
|
|
79
|
+
attrs: dict[str, Any] = {"class": " ".join(classes), "href": self.href}
|
|
80
|
+
if self.disabled:
|
|
81
|
+
attrs["aria-disabled"] = "true"
|
|
82
|
+
attrs["tabindex"] = "-1"
|
|
83
|
+
|
|
84
|
+
icon = f'<i class="{icon_name(self.icon)} me-2"></i>' if self.icon else ""
|
|
85
|
+
return f'<li><a{render_attrs(attrs)}>{icon}{escape(self.text)}</a></li>'
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Dropdown(Node):
|
|
89
|
+
"""下拉菜单。默认渲染为导航栏中的一项(``<li>`` 包裹)。"""
|
|
90
|
+
|
|
91
|
+
TAG: ClassVar[str | None] = None
|
|
92
|
+
|
|
93
|
+
text: str = ""
|
|
94
|
+
items: list[DropdownItem | str] = Field(default_factory=list)
|
|
95
|
+
color: Color = Color.primary
|
|
96
|
+
"""``as_button=True`` 时的按钮颜色"""
|
|
97
|
+
|
|
98
|
+
as_button: bool = False
|
|
99
|
+
"""``True`` 时渲染为按钮样式的下拉菜单"""
|
|
100
|
+
|
|
101
|
+
menu_end: bool = True
|
|
102
|
+
dark: bool = False
|
|
103
|
+
as_nav_item: bool = True
|
|
104
|
+
|
|
105
|
+
def render(self) -> str:
|
|
106
|
+
toggle_classes = ["dropdown-toggle"]
|
|
107
|
+
if self.as_button:
|
|
108
|
+
toggle_classes = ["btn", f"btn-{enum_value(self.color)}", "dropdown-toggle"]
|
|
109
|
+
else:
|
|
110
|
+
toggle_classes.insert(0, "nav-link")
|
|
111
|
+
|
|
112
|
+
menu_classes = ["dropdown-menu", "dropdown-menu-dark" if self.dark else "dropdown-menu-end" if self.menu_end else ""]
|
|
113
|
+
menu_class = " ".join(cls for cls in menu_classes if cls)
|
|
114
|
+
|
|
115
|
+
toggle = (
|
|
116
|
+
f'<a class="{" ".join(toggle_classes)}" href="#" role="button" '
|
|
117
|
+
f'data-bs-toggle="dropdown" aria-expanded="false">{escape(self.text)}</a>'
|
|
118
|
+
)
|
|
119
|
+
items = "".join(
|
|
120
|
+
item.render() if isinstance(item, DropdownItem) else DropdownItem(text=str(item)).render()
|
|
121
|
+
for item in self.items
|
|
122
|
+
)
|
|
123
|
+
menu = f'<ul class="{menu_class}">{items}</ul>'
|
|
124
|
+
|
|
125
|
+
if self.as_nav_item:
|
|
126
|
+
return f'<li class="nav-item dropdown">{toggle}{menu}</li>'
|
|
127
|
+
return f'<div class="dropdown">{toggle}{menu}</div>'
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Nav(Node):
|
|
131
|
+
"""独立导航(``.nav``),可用于侧边栏或标签式导航。"""
|
|
132
|
+
|
|
133
|
+
TAG: ClassVar[str | None] = "ul"
|
|
134
|
+
|
|
135
|
+
items: list[NavItem | Dropdown | str] = Field(default_factory=list)
|
|
136
|
+
vertical: bool = False
|
|
137
|
+
pills: bool = False
|
|
138
|
+
fill: bool = False
|
|
139
|
+
justified: bool = False
|
|
140
|
+
|
|
141
|
+
def css_classes(self) -> list[str]:
|
|
142
|
+
classes = ["nav"]
|
|
143
|
+
if self.vertical:
|
|
144
|
+
classes.append("flex-column")
|
|
145
|
+
classes.append("nav-pills" if self.pills else "nav-tabs")
|
|
146
|
+
if self.fill:
|
|
147
|
+
classes.append("nav-fill")
|
|
148
|
+
if self.justified:
|
|
149
|
+
classes.append("nav-justified")
|
|
150
|
+
return classes
|
|
151
|
+
|
|
152
|
+
def render_content(self) -> str:
|
|
153
|
+
return render_children(self.items)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Navbar(Node):
|
|
157
|
+
"""顶部导航栏。"""
|
|
158
|
+
|
|
159
|
+
TAG: ClassVar[str | None] = "nav"
|
|
160
|
+
|
|
161
|
+
brand: str | None = None
|
|
162
|
+
brand_href: str = "#"
|
|
163
|
+
brand_icon: str | None = None
|
|
164
|
+
|
|
165
|
+
items: list[NavItem | Dropdown | str] = Field(default_factory=list)
|
|
166
|
+
"""左侧导航项"""
|
|
167
|
+
|
|
168
|
+
actions: list[NodeLike] = Field(default_factory=list)
|
|
169
|
+
"""右侧操作区(按钮、下拉菜单等)"""
|
|
170
|
+
|
|
171
|
+
color: Color | None = None
|
|
172
|
+
"""``bg-{color}``,配合 ``dark=True`` 使用深色文字方案"""
|
|
173
|
+
|
|
174
|
+
dark: bool = False
|
|
175
|
+
expand: Literal["sm", "md", "lg", "xl", "xxl", False] = "lg"
|
|
176
|
+
sticky: bool = False
|
|
177
|
+
container: bool = True
|
|
178
|
+
fluid: bool = True
|
|
179
|
+
id: str = Field(default_factory=lambda: f"navbar-{uuid4().hex[:6]}")
|
|
180
|
+
|
|
181
|
+
def css_classes(self) -> list[str]:
|
|
182
|
+
classes = ["navbar"]
|
|
183
|
+
if self.color:
|
|
184
|
+
classes.append(f"bg-{enum_value(self.color)}")
|
|
185
|
+
else:
|
|
186
|
+
classes.append("bg-body-tertiary")
|
|
187
|
+
if self.dark or self.color in {Color.dark, Color.primary}:
|
|
188
|
+
classes.append("navbar-dark")
|
|
189
|
+
if self.sticky:
|
|
190
|
+
classes.append("sticky-top")
|
|
191
|
+
if self.expand:
|
|
192
|
+
classes.append(f"navbar-expand-{self.expand}")
|
|
193
|
+
else:
|
|
194
|
+
classes.append("navbar-expand")
|
|
195
|
+
return classes
|
|
196
|
+
|
|
197
|
+
def html_attrs(self) -> dict[str, Any]:
|
|
198
|
+
attrs = super().html_attrs()
|
|
199
|
+
attrs.setdefault("data-bs-theme", "dark" if (self.dark or self.color in {Color.dark, Color.primary}) else "light")
|
|
200
|
+
return attrs
|
|
201
|
+
|
|
202
|
+
def render_content(self) -> str:
|
|
203
|
+
collapse_id = f"{self.id}-nav"
|
|
204
|
+
brand_icon = f'<i class="{icon_name(self.brand_icon)} me-2"></i>' if self.brand_icon else ""
|
|
205
|
+
brand = (
|
|
206
|
+
f'<a class="navbar-brand" href="{escape(self.brand_href)}">{brand_icon}{escape(self.brand)}</a>'
|
|
207
|
+
if self.brand
|
|
208
|
+
else ""
|
|
209
|
+
)
|
|
210
|
+
toggler = (
|
|
211
|
+
'<button class="navbar-toggler" type="button" data-bs-toggle="collapse" '
|
|
212
|
+
f'data-bs-target="#{collapse_id}" aria-controls="{collapse_id}" '
|
|
213
|
+
'aria-expanded="false" aria-label="切换导航"><span class="navbar-toggler-icon"></span></button>'
|
|
214
|
+
)
|
|
215
|
+
items = render_children(self.items)
|
|
216
|
+
actions = render_children(self.actions)
|
|
217
|
+
actions_html = f'<div class="d-flex align-items-center gap-2">{actions}</div>' if actions else ""
|
|
218
|
+
|
|
219
|
+
inner = (
|
|
220
|
+
f"{brand}{toggler}"
|
|
221
|
+
f'<div class="collapse navbar-collapse" id="{collapse_id}">'
|
|
222
|
+
f'<ul class="navbar-nav me-auto mb-2 mb-lg-0">{items}</ul>'
|
|
223
|
+
f"{actions_html}</div>"
|
|
224
|
+
)
|
|
225
|
+
if self.container:
|
|
226
|
+
wrapper = "container-fluid" if self.fluid else "container"
|
|
227
|
+
inner = f'<div class="{wrapper}">{inner}</div>'
|
|
228
|
+
return inner
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class BreadcrumbItem(Node):
|
|
232
|
+
"""面包屑中的一项。"""
|
|
233
|
+
|
|
234
|
+
TAG: ClassVar[str | None] = None
|
|
235
|
+
|
|
236
|
+
text: str = ""
|
|
237
|
+
href: str | None = None
|
|
238
|
+
active: bool = False
|
|
239
|
+
|
|
240
|
+
def render(self) -> str:
|
|
241
|
+
classes = ["breadcrumb-item"]
|
|
242
|
+
if self.active:
|
|
243
|
+
classes.append("active")
|
|
244
|
+
if self.href and not self.active:
|
|
245
|
+
body = f'<a href="{escape(self.href)}">{escape(self.text)}</a>'
|
|
246
|
+
else:
|
|
247
|
+
body = escape(self.text)
|
|
248
|
+
return f'<li class="{" ".join(classes)}">{body}</li>'
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class Breadcrumb(Node):
|
|
252
|
+
"""面包屑导航。"""
|
|
253
|
+
|
|
254
|
+
TAG: ClassVar[str | None] = "nav"
|
|
255
|
+
|
|
256
|
+
items: list[BreadcrumbItem | str] = Field(default_factory=list)
|
|
257
|
+
|
|
258
|
+
def html_attrs(self) -> dict[str, Any]:
|
|
259
|
+
attrs = super().html_attrs()
|
|
260
|
+
attrs.setdefault("aria-label", "breadcrumb")
|
|
261
|
+
return attrs
|
|
262
|
+
|
|
263
|
+
def render_content(self) -> str:
|
|
264
|
+
nodes: list[str] = []
|
|
265
|
+
for index, item in enumerate(self.items):
|
|
266
|
+
if isinstance(item, BreadcrumbItem):
|
|
267
|
+
node = item
|
|
268
|
+
else:
|
|
269
|
+
node = BreadcrumbItem(text=str(item))
|
|
270
|
+
node.active = node.active or index == len(self.items) - 1
|
|
271
|
+
nodes.append(node.render())
|
|
272
|
+
return f'<ol class="breadcrumb mb-0">{"".join(nodes)}</ol>'
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class Pagination(Node):
|
|
276
|
+
"""分页导航。"""
|
|
277
|
+
|
|
278
|
+
TAG: ClassVar[str | None] = "nav"
|
|
279
|
+
|
|
280
|
+
current: int = 1
|
|
281
|
+
total: int = 1
|
|
282
|
+
href: str = "?page={page}"
|
|
283
|
+
"""页码链接模板,``{page}`` 会被替换"""
|
|
284
|
+
|
|
285
|
+
size: Size | None = None
|
|
286
|
+
max_buttons: int = 7
|
|
287
|
+
show_edges: bool = True
|
|
288
|
+
align: Literal["start", "center", "end"] = "center"
|
|
289
|
+
|
|
290
|
+
def html_attrs(self) -> dict[str, Any]:
|
|
291
|
+
attrs = super().html_attrs()
|
|
292
|
+
attrs.setdefault("aria-label", "分页")
|
|
293
|
+
return attrs
|
|
294
|
+
|
|
295
|
+
def _page_item(self, page: int, label: str, *, active: bool = False, disabled: bool = False) -> str:
|
|
296
|
+
classes = ["page-item"]
|
|
297
|
+
if active:
|
|
298
|
+
classes.append("active")
|
|
299
|
+
if disabled:
|
|
300
|
+
classes.append("disabled")
|
|
301
|
+
|
|
302
|
+
if active:
|
|
303
|
+
inner = f'<span class="page-link">{escape(label)}</span>'
|
|
304
|
+
elif disabled:
|
|
305
|
+
inner = f'<span class="page-link">{escape(label)}</span>'
|
|
306
|
+
else:
|
|
307
|
+
inner = f'<a class="page-link" href="{escape(self.href.format(page=page))}">{escape(label)}</a>'
|
|
308
|
+
return f'<li class="{" ".join(classes)}">{inner}</li>'
|
|
309
|
+
|
|
310
|
+
def _window(self) -> list[int | None]:
|
|
311
|
+
total = max(self.total, 1)
|
|
312
|
+
current = min(max(self.current, 1), total)
|
|
313
|
+
limit = max(self.max_buttons, 5)
|
|
314
|
+
if total <= limit:
|
|
315
|
+
return list(range(1, total + 1))
|
|
316
|
+
|
|
317
|
+
half = (limit - 3) // 2
|
|
318
|
+
start = max(2, current - half)
|
|
319
|
+
end = min(total - 1, start + (limit - 3) - 1)
|
|
320
|
+
start = max(2, end - (limit - 3) + 1)
|
|
321
|
+
|
|
322
|
+
pages: list[int | None] = [1]
|
|
323
|
+
if start > 2:
|
|
324
|
+
pages.append(None)
|
|
325
|
+
pages.extend(range(start, end + 1))
|
|
326
|
+
if end < total - 1:
|
|
327
|
+
pages.append(None)
|
|
328
|
+
pages.append(total)
|
|
329
|
+
return pages
|
|
330
|
+
|
|
331
|
+
def render_content(self) -> str:
|
|
332
|
+
total = max(self.total, 1)
|
|
333
|
+
current = min(max(self.current, 1), total)
|
|
334
|
+
parts: list[str] = []
|
|
335
|
+
|
|
336
|
+
if self.show_edges:
|
|
337
|
+
parts.append(self._page_item(1, "«", disabled=current == 1))
|
|
338
|
+
parts.append(self._page_item(current - 1, "‹", disabled=current == 1))
|
|
339
|
+
|
|
340
|
+
for page in self._window():
|
|
341
|
+
if page is None:
|
|
342
|
+
parts.append('<li class="page-item disabled"><span class="page-link">…</span></li>')
|
|
343
|
+
else:
|
|
344
|
+
parts.append(self._page_item(page, str(page), active=page == current))
|
|
345
|
+
|
|
346
|
+
parts.append(self._page_item(current + 1, "›", disabled=current == total))
|
|
347
|
+
if self.show_edges:
|
|
348
|
+
parts.append(self._page_item(total, "»", disabled=current == total))
|
|
349
|
+
|
|
350
|
+
size_class = f" pagination-{enum_value(self.size)}" if self.size else ""
|
|
351
|
+
justify = {
|
|
352
|
+
"start": "justify-content-start",
|
|
353
|
+
"center": "justify-content-center",
|
|
354
|
+
"end": "justify-content-end",
|
|
355
|
+
}[self.align]
|
|
356
|
+
return f'<ul class="pagination {justify}{size_class}">{"".join(parts)}</ul>'
|