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.
@@ -0,0 +1,199 @@
1
+ """浮层组件:Modal / Offcanvas / Toast / ToastContainer / Tooltip。"""
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__ = ["Modal", "Offcanvas", "Toast", "ToastContainer", "Tooltip"]
15
+
16
+
17
+ class Modal(Node):
18
+ """模态对话框。用 ``Button(toggle_modal="你的id")`` 打开。"""
19
+
20
+ id: str = Field(default_factory=lambda: f"modal-{uuid4().hex[:6]}")
21
+ title: str | None = None
22
+ body: list[NodeLike] = Field(default_factory=list)
23
+ footer: list[NodeLike] | None = None
24
+ size: Literal["sm", "lg", "xl"] | None = None
25
+ fullscreen: bool = False
26
+ centered: bool = True
27
+ scrollable: bool = False
28
+ static_backdrop: bool = False
29
+ close_button: bool = True
30
+
31
+ def css_classes(self) -> list[str]:
32
+ return ["modal", "fade"]
33
+
34
+ def html_attrs(self) -> dict[str, Any]:
35
+ attrs = super().html_attrs()
36
+ attrs.setdefault("tabindex", "-1")
37
+ attrs.setdefault("aria-hidden", "true")
38
+ if self.title:
39
+ attrs.setdefault("aria-labelledby", f"{self.id}-label")
40
+ if self.static_backdrop:
41
+ attrs["data-bs-backdrop"] = "static"
42
+ attrs["data-bs-keyboard"] = "false"
43
+ return attrs
44
+
45
+ def render_content(self) -> str:
46
+ dialog_classes = ["modal-dialog"]
47
+ if self.size:
48
+ dialog_classes.append(f"modal-{self.size}")
49
+ if self.centered:
50
+ dialog_classes.append("modal-dialog-centered")
51
+ if self.scrollable:
52
+ dialog_classes.append("modal-dialog-scrollable")
53
+ if self.fullscreen:
54
+ dialog_classes.append("modal-fullscreen")
55
+
56
+ header = ""
57
+ if self.title or self.close_button:
58
+ title = (
59
+ f'<h5 class="modal-title" id="{self.id}-label">{escape(self.title)}</h5>'
60
+ if self.title
61
+ else ""
62
+ )
63
+ close = (
64
+ '<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="关闭"></button>'
65
+ if self.close_button
66
+ else ""
67
+ )
68
+ header = f'<div class="modal-header">{title}{close}</div>'
69
+
70
+ footer = ""
71
+ if self.footer:
72
+ footer = f'<div class="modal-footer">{render_children(self.footer)}</div>'
73
+
74
+ return (
75
+ f'<div class="{" ".join(dialog_classes)}">'
76
+ f'<div class="modal-content">{header}'
77
+ f'<div class="modal-body">{render_children(self.body)}</div>'
78
+ f"{footer}</div></div>"
79
+ )
80
+
81
+
82
+ class Offcanvas(Node):
83
+ """侧边抽屉。用 ``Button(toggle_offcanvas="你的id")`` 打开。"""
84
+
85
+ id: str = Field(default_factory=lambda: f"offcanvas-{uuid4().hex[:6]}")
86
+ title: str | None = None
87
+ body: list[NodeLike] = Field(default_factory=list)
88
+ footer: list[NodeLike] | None = None
89
+ placement: Literal["start", "end", "top", "bottom"] = "start"
90
+ close_button: bool = True
91
+ static_backdrop: bool = False
92
+
93
+ def css_classes(self) -> list[str]:
94
+ return ["offcanvas", f"offcanvas-{self.placement}"]
95
+
96
+ def html_attrs(self) -> dict[str, Any]:
97
+ attrs = super().html_attrs()
98
+ attrs.setdefault("tabindex", "-1")
99
+ attrs.setdefault("aria-labelledby", f"{self.id}-label")
100
+ if self.static_backdrop:
101
+ attrs["data-bs-backdrop"] = "static"
102
+ attrs["data-bs-keyboard"] = "false"
103
+ return attrs
104
+
105
+ def render_content(self) -> str:
106
+ header = ""
107
+ if self.title or self.close_button:
108
+ title = (
109
+ f'<h5 class="offcanvas-title" id="{self.id}-label">{escape(self.title)}</h5>'
110
+ if self.title
111
+ else ""
112
+ )
113
+ close = (
114
+ '<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="关闭"></button>'
115
+ if self.close_button
116
+ else ""
117
+ )
118
+ header = f'<div class="offcanvas-header">{title}{close}</div>'
119
+ footer = (
120
+ f'<div class="offcanvas-footer p-3 border-top">{render_children(self.footer)}</div>'
121
+ if self.footer
122
+ else ""
123
+ )
124
+ return f'{header}<div class="offcanvas-body">{render_children(self.body)}</div>{footer}'
125
+
126
+
127
+ class Toast(Node):
128
+ """轻提示。页面脚本加载后会自动调用 ``show()``。"""
129
+
130
+ id: str = Field(default_factory=lambda: f"toast-{uuid4().hex[:6]}")
131
+ title: str | None = None
132
+ body: list[NodeLike] = Field(default_factory=list)
133
+ color: Color | None = None
134
+ icon: str | None = None
135
+ delay: int = 4000
136
+ autohide: bool = True
137
+ close_button: bool = True
138
+
139
+ def css_classes(self) -> list[str]:
140
+ classes = ["toast"]
141
+ if self.color:
142
+ classes.append(f"text-bg-{enum_value(self.color)}")
143
+ return classes
144
+
145
+ def html_attrs(self) -> dict[str, Any]:
146
+ attrs = super().html_attrs()
147
+ attrs.setdefault("role", "alert")
148
+ attrs.setdefault("aria-live", "assertive")
149
+ attrs.setdefault("aria-atomic", "true")
150
+ attrs["data-bs-delay"] = self.delay
151
+ attrs["data-bs-autohide"] = "true" if self.autohide else "false"
152
+ return attrs
153
+
154
+ def render_content(self) -> str:
155
+ parts: list[str] = []
156
+ if self.icon:
157
+ parts.append(f'<i class="{icon_name(self.icon)} me-2"></i>')
158
+ if self.title:
159
+ parts.append(f'<strong class="d-block mb-1">{escape(self.title)}</strong>')
160
+ parts.append(render_children(self.body))
161
+ close = ""
162
+ if self.close_button:
163
+ close = (
164
+ '<button type="button" class="btn-close btn-close-white me-2 m-auto" '
165
+ 'data-bs-dismiss="toast" aria-label="关闭"></button>'
166
+ )
167
+ return f'<div class="d-flex"><div class="toast-body">{"".join(parts)}</div>{close}</div>'
168
+
169
+
170
+ class ToastContainer(Node):
171
+ """Toast 的固定定位容器。"""
172
+
173
+ children: list[NodeLike] = Field(default_factory=list)
174
+ placement: Literal["top-start", "top-end", "bottom-start", "bottom-end"] = "bottom-end"
175
+
176
+ def css_classes(self) -> list[str]:
177
+ return ["toast-container", "position-fixed", "p-3", self.placement]
178
+
179
+
180
+ class Tooltip(Node):
181
+ """带悬停提示的行内元素。"""
182
+
183
+ TAG: ClassVar[str | None] = "span"
184
+
185
+ text: str = ""
186
+ content: str = ""
187
+ placement: Literal["top", "bottom", "start", "end"] = "top"
188
+ icon: str | None = None
189
+
190
+ def html_attrs(self) -> dict[str, Any]:
191
+ attrs = super().html_attrs()
192
+ attrs["data-bs-toggle"] = "tooltip"
193
+ attrs["data-bs-placement"] = self.placement
194
+ attrs["title"] = self.content
195
+ return attrs
196
+
197
+ def render_content(self) -> str:
198
+ icon = f'<i class="{icon_name(self.icon)} me-1"></i>' if self.icon else ""
199
+ return icon + escape(self.text)
@@ -0,0 +1,37 @@
1
+ <!DOCTYPE html>
2
+ <html lang="{{ lang }}"{% if theme %} data-bs-theme="{{ theme }}"{% endif %}>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{{ title }}</title>
7
+ {% if icons %}<link rel="stylesheet" href="{{ cdn }}/bootstrap-icons@{{ icons_version }}/font/bootstrap-icons.min.css">
8
+ {% endif %}<link rel="stylesheet" href="{{ cdn }}/bootstrap@{{ version }}/dist/css/bootstrap.min.css">
9
+ {% for href in css %} <link rel="stylesheet" href="{{ href }}">
10
+ {% endfor %}{% if page_css %} <style>
11
+ {{ page_css }}
12
+ </style>
13
+ {% endif %}{% if head %}{{ head }}
14
+ {% endif %}</head>
15
+ <body{% if body_class %} class="{{ body_class }}"{% endif %}>
16
+ {{ navbar }}
17
+ {% if container %}<div class="{{ container_class }} py-4">
18
+ {% if toolbar %}<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-4">{{ toolbar }}</div>
19
+ {% endif %}{{ body }}
20
+ </div>
21
+ {% else %}{{ body }}
22
+ {% endif %}{{ footer }}
23
+ <script src="{{ cdn }}/bootstrap@{{ version }}/dist/js/bootstrap.bundle.min.js"></script>
24
+ {% for src in js %}<script src="{{ src }}"></script>
25
+ {% endfor %}<script>
26
+ // 初始化全部提示气泡
27
+ document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function (el) {
28
+ new bootstrap.Tooltip(el);
29
+ });
30
+ // 页面加载后自动弹出 Toast
31
+ document.querySelectorAll('.toast').forEach(function (el) {
32
+ new bootstrap.Toast(el).show();
33
+ });
34
+ {{ page_js }}
35
+ </script>
36
+ </body>
37
+ </html>
bootstrap_ui/types.py ADDED
@@ -0,0 +1,236 @@
1
+ """bootstrap-python 的渲染内核:Node 基类、属性序列化与 HTML 转义。
2
+
3
+ 统一的输出约定:
4
+
5
+ node.to_dict() # 结构化描述(可 JSON 序列化,便于调试或传给前端)
6
+ node.to_json() # JSON 字符串
7
+ node.render() # HTML 字符串
8
+
9
+ 设计要点
10
+ --------
11
+ 1. 每个组件都是 ``Node`` 的子类,只描述「我是哪个标签、有哪些 Bootstrap 类、装什么内容」;
12
+ 2. 文本内容默认转义,需要输出可信 HTML 时显式使用 :class:`RawHtml` / :func:`raw`;
13
+ 3. 组件字段用 pydantic 校验(``extra="forbid"``),写错字段名会立刻报错,而不是静默失效。
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import html as _html
19
+ import json
20
+ from typing import Any, ClassVar, Iterable, Sequence, Union
21
+
22
+ from pydantic import BaseModel, ConfigDict, Field
23
+
24
+ __all__ = [
25
+ "Node",
26
+ "RawHtml",
27
+ "raw",
28
+ "NodeLike",
29
+ "escape",
30
+ "render_attrs",
31
+ "render_children",
32
+ "VOID_TAGS",
33
+ ]
34
+
35
+ #: HTML 中不需要闭合标签的元素
36
+ VOID_TAGS: frozenset[str] = frozenset(
37
+ {
38
+ "area",
39
+ "base",
40
+ "br",
41
+ "col",
42
+ "embed",
43
+ "hr",
44
+ "img",
45
+ "input",
46
+ "link",
47
+ "meta",
48
+ "param",
49
+ "source",
50
+ "track",
51
+ "wbr",
52
+ }
53
+ )
54
+
55
+
56
+ def escape(value: Any) -> str:
57
+ """把任意值转成转义后的文本。"""
58
+ if value is None:
59
+ return ""
60
+ return _html.escape(str(value), quote=True)
61
+
62
+
63
+ def render_attrs(attrs: dict[str, Any]) -> str:
64
+ """把属性字典渲染成 `` key="value"`` 形式的片段。
65
+
66
+ - ``None`` / ``False`` 直接跳过;
67
+ - ``True`` 输出为布尔属性(如 ``required``);
68
+ - ``list`` / ``tuple`` 以空格连接(常用于 class);
69
+ - ``dict`` 序列化为 JSON(用于 data-* 配置)。
70
+ """
71
+ parts: list[str] = []
72
+ for key, value in attrs.items():
73
+ if value is None or value is False:
74
+ continue
75
+ name = str(key).rstrip("_")
76
+ if value is True:
77
+ parts.append(f" {name}")
78
+ continue
79
+ if isinstance(value, (list, tuple, set)):
80
+ value = " ".join(str(item) for item in value if item is not None)
81
+ elif isinstance(value, dict):
82
+ value = json.dumps(value, ensure_ascii=False)
83
+ parts.append(f' {name}="{escape(value)}"')
84
+ return "".join(parts)
85
+
86
+
87
+ def render_children(items: Any, separator: str = "") -> str:
88
+ """渲染子节点序列,自动处理嵌套列表与文本转义。"""
89
+ if items is None:
90
+ return ""
91
+ if isinstance(items, (str, bytes)) or isinstance(items, Node):
92
+ items = [items]
93
+ if not isinstance(items, Iterable):
94
+ return escape(items)
95
+
96
+ rendered: list[str] = []
97
+ for item in items:
98
+ if item is None:
99
+ continue
100
+ if isinstance(item, Node): # RawHtml 也是 Node,会走各自的 render
101
+ rendered.append(item.render())
102
+ elif isinstance(item, (list, tuple)):
103
+ rendered.append(render_children(item, separator))
104
+ else:
105
+ rendered.append(escape(item))
106
+ return separator.join(part for part in rendered if part != "")
107
+
108
+
109
+ class Node(BaseModel):
110
+ """所有组件的基类。"""
111
+
112
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
113
+
114
+ #: 组件对应的 HTML 标签,``None`` 表示不产生包裹标签
115
+ TAG: ClassVar[str | None] = "div"
116
+
117
+ id: str | None = None
118
+ """元素 id"""
119
+
120
+ class_name: str | None = Field(default=None, alias="class")
121
+ """追加到元素上的自定义 class(Bootstrap 原生类名)"""
122
+
123
+ style: str | dict[str, Any] | None = None
124
+ """行内样式,字符串或 ``{"min-width": "10rem"}`` 这样的字典"""
125
+
126
+ attrs: dict[str, Any] = Field(default_factory=dict)
127
+ """其它原生 HTML 属性,例如 ``{"data-bs-toggle": "tooltip", "aria-label": "提示"}``"""
128
+
129
+ # ------------------------------------------------------------------ 扩展点
130
+ def css_classes(self) -> list[str]:
131
+ """返回该组件的 Bootstrap 类名列表,子类按需覆写。"""
132
+ return []
133
+
134
+ def html_attrs(self) -> dict[str, Any]:
135
+ """返回最终写进标签的属性字典。"""
136
+ attrs: dict[str, Any] = {}
137
+
138
+ classes = [cls for cls in self.css_classes() if cls]
139
+ if self.class_name:
140
+ classes.append(self.class_name)
141
+ if classes:
142
+ attrs["class"] = " ".join(classes)
143
+
144
+ if self.id:
145
+ attrs["id"] = self.id
146
+
147
+ if self.style:
148
+ attrs["style"] = _style_to_string(self.style)
149
+
150
+ for key, value in self.attrs.items():
151
+ attrs[key] = value
152
+ return attrs
153
+
154
+ def tag_name(self) -> str | None:
155
+ """返回实际使用的标签名,需要动态标签的组件(如 Heading)可覆写。"""
156
+ return self.TAG
157
+
158
+ def render_content(self) -> str:
159
+ """返回标签内部的内容。"""
160
+ return ""
161
+
162
+ def render(self) -> str:
163
+ """渲染成 HTML 字符串。"""
164
+ content = self.render_content()
165
+ tag = self.tag_name()
166
+ if tag is None:
167
+ return content
168
+
169
+ attrs = render_attrs(self.html_attrs())
170
+ if tag in VOID_TAGS:
171
+ return f"<{tag}{attrs}>"
172
+ return f"<{tag}{attrs}>{content}</{tag}>"
173
+
174
+ # ------------------------------------------------------------------ 输出
175
+ def to_dict(self) -> dict[str, Any]:
176
+ """结构化描述,便于调试或把界面定义传给前端。"""
177
+ data = self.model_dump(
178
+ by_alias=True,
179
+ exclude_none=True,
180
+ mode="json",
181
+ serialize_as_any=True, # 保留子类组件的全部字段,而不是退化成 Node
182
+ )
183
+ return _clean(data)
184
+
185
+ def to_json(self) -> str:
186
+ return json.dumps(self.to_dict(), ensure_ascii=False, indent=2)
187
+
188
+ def __str__(self) -> str: # noqa: D105 - 方便 print(node)
189
+ return self.render()
190
+
191
+
192
+ def _clean(data: Any) -> Any:
193
+ """去掉序列化结果里的空字段(如 ``attrs: {}``),让 to_dict 更干净。"""
194
+ if isinstance(data, dict):
195
+ return {
196
+ key: _clean(item)
197
+ for key, item in data.items()
198
+ if not (key in {"attrs", "style"} and not item)
199
+ }
200
+ if isinstance(data, list):
201
+ return [_clean(item) for item in data]
202
+ return data
203
+
204
+
205
+ def _style_to_string(style: str | dict[str, Any]) -> str:
206
+ if isinstance(style, str):
207
+ return style
208
+ chunks: list[str] = []
209
+ for key, value in style.items():
210
+ if value is None:
211
+ continue
212
+ name = str(key).replace("_", "-")
213
+ chunks.append(f"{name}: {value}")
214
+ return "; ".join(chunks)
215
+
216
+
217
+ class RawHtml(Node):
218
+ """原样输出的 HTML 片段(不再转义)。"""
219
+
220
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
221
+
222
+ TAG: ClassVar[str | None] = None
223
+
224
+ html: str = ""
225
+
226
+ def render(self) -> str:
227
+ return self.html
228
+
229
+
230
+ def raw(source: str) -> RawHtml:
231
+ """快捷构造 :class:`RawHtml`。"""
232
+ return RawHtml(html=source)
233
+
234
+
235
+ #: 可以放进容器里的内容:组件、文本、数字
236
+ NodeLike = Union[Node, str, int, float]