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,562 @@
1
+ """基础内容组件:标题、文本、链接、图片、图标、徽标、提示框、卡片、列表组、按钮。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, ClassVar, Literal
6
+
7
+ from pydantic import Field
8
+
9
+ from .constants import Color, Size, value as enum_value
10
+ from .types import Node, NodeLike, escape, render_attrs, render_children
11
+
12
+ __all__ = [
13
+ "Heading",
14
+ "Paragraph",
15
+ "Span",
16
+ "Strong",
17
+ "Small",
18
+ "Link",
19
+ "Image",
20
+ "Icon",
21
+ "Badge",
22
+ "Alert",
23
+ "Card",
24
+ "ListGroup",
25
+ "ListItem",
26
+ "Button",
27
+ "ButtonGroup",
28
+ "CloseButton",
29
+ "icon_name",
30
+ ]
31
+
32
+
33
+ def icon_name(name: str) -> str:
34
+ """把 ``plus``、``bi-plus``、``bi bi-plus`` 统一成 ``bi bi-plus``。"""
35
+ cleaned = name.strip().removeprefix("bi ").removeprefix("bi-")
36
+ return f"bi bi-{cleaned}"
37
+
38
+
39
+ class Heading(Node):
40
+ """标题,``<h1>`` ~ ``<h6>``。"""
41
+
42
+ TAG: ClassVar[str | None] = None
43
+
44
+ text: str = ""
45
+ level: int = 2
46
+ """1-6,默认 2"""
47
+
48
+ size: Literal["1", "2", "3", "4", "5", "6"] | None = None
49
+ """视觉字号(class ``h1``~``h6``),与语义 level 解耦"""
50
+
51
+ body: list[NodeLike] = Field(default_factory=list)
52
+
53
+ def tag_name(self) -> str | None:
54
+ level = min(max(self.level, 1), 6)
55
+ return f"h{level}"
56
+
57
+ def css_classes(self) -> list[str]:
58
+ return [f"h{self.size}"] if self.size else []
59
+
60
+ def render_content(self) -> str:
61
+ return escape(self.text) + render_children(self.body)
62
+
63
+
64
+ class Paragraph(Node):
65
+ """段落。"""
66
+
67
+ TAG: ClassVar[str | None] = "p"
68
+
69
+ text: str = ""
70
+ body: list[NodeLike] = Field(default_factory=list)
71
+
72
+ lead: bool = False
73
+ """引导段落(``.lead``)"""
74
+
75
+ muted: bool = False
76
+ """次要文字色(``text-body-secondary``)"""
77
+
78
+ def css_classes(self) -> list[str]:
79
+ classes: list[str] = []
80
+ if self.lead:
81
+ classes.append("lead")
82
+ if self.muted:
83
+ classes.append("text-body-secondary")
84
+ return classes
85
+
86
+ def render_content(self) -> str:
87
+ return escape(self.text) + render_children(self.body)
88
+
89
+
90
+ class Span(Node):
91
+ """行内文本片段。"""
92
+
93
+ TAG: ClassVar[str | None] = "span"
94
+
95
+ text: str = ""
96
+ body: list[NodeLike] = Field(default_factory=list)
97
+
98
+ color: str | None = None
99
+ """``text-*`` 的颜色名,如 ``danger``、``body-secondary``"""
100
+
101
+ muted: bool = False
102
+ weight: Literal["bold", "semibold", "normal", "light"] | None = None
103
+ nowrap: bool = False
104
+
105
+ def css_classes(self) -> list[str]:
106
+ classes: list[str] = []
107
+ if self.color:
108
+ classes.append(f"text-{self.color}")
109
+ if self.muted:
110
+ classes.append("text-body-secondary")
111
+ if self.weight:
112
+ classes.append(f"fw-{self.weight}")
113
+ if self.nowrap:
114
+ classes.append("text-nowrap")
115
+ return classes
116
+
117
+ def render_content(self) -> str:
118
+ return escape(self.text) + render_children(self.body)
119
+
120
+
121
+ class Strong(Span):
122
+ """加粗文本。"""
123
+
124
+ TAG: ClassVar[str | None] = "strong"
125
+
126
+
127
+ class Small(Span):
128
+ """小号文本。"""
129
+
130
+ TAG: ClassVar[str | None] = "small"
131
+
132
+ def css_classes(self) -> list[str]:
133
+ classes = super().css_classes()
134
+ if not any(cls.startswith("text-") for cls in classes):
135
+ classes.append("text-body-secondary")
136
+ return classes
137
+
138
+
139
+ class Link(Node):
140
+ """超链接。"""
141
+
142
+ TAG: ClassVar[str | None] = "a"
143
+
144
+ text: str = ""
145
+ body: list[NodeLike] = Field(default_factory=list)
146
+
147
+ href: str = "#"
148
+ target: Literal["_blank", "_self", "_parent", "_top"] | None = None
149
+ color: str | None = None
150
+ no_underline: bool = False
151
+ icon: str | None = None
152
+
153
+ def html_attrs(self) -> dict[str, Any]:
154
+ attrs = super().html_attrs()
155
+ attrs["href"] = self.href
156
+ if self.target:
157
+ attrs["target"] = self.target
158
+ if self.target == "_blank":
159
+ attrs.setdefault("rel", "noopener noreferrer")
160
+ return attrs
161
+
162
+ def css_classes(self) -> list[str]:
163
+ classes: list[str] = []
164
+ if self.color:
165
+ classes.append(f"link-{self.color}")
166
+ if self.no_underline:
167
+ classes.append("text-decoration-none")
168
+ return classes
169
+
170
+ def render_content(self) -> str:
171
+ icon = f'<i class="{icon_name(self.icon)} me-1"></i>' if self.icon else ""
172
+ return icon + escape(self.text) + render_children(self.body)
173
+
174
+
175
+ class Image(Node):
176
+ """图片。"""
177
+
178
+ TAG: ClassVar[str | None] = "img"
179
+
180
+ src: str = ""
181
+ alt: str = ""
182
+
183
+ width: int | str | None = None
184
+ height: int | str | None = None
185
+
186
+ fluid: bool = False
187
+ """响应式图片(``img-fluid``)"""
188
+
189
+ rounded: bool | str = False
190
+ """``True`` → ``rounded``;字符串 → ``rounded-3`` 之类"""
191
+
192
+ circle: bool = False
193
+ """圆形(``rounded-circle``)"""
194
+
195
+ thumbnail: bool = False
196
+ """缩略图边框(``img-thumbnail``)"""
197
+
198
+ lazy: bool = True
199
+
200
+ def html_attrs(self) -> dict[str, Any]:
201
+ attrs = super().html_attrs()
202
+ attrs["src"] = self.src
203
+ attrs["alt"] = self.alt
204
+ if self.width is not None:
205
+ attrs["width"] = self.width
206
+ if self.height is not None:
207
+ attrs["height"] = self.height
208
+ if self.lazy:
209
+ attrs["loading"] = "lazy"
210
+ return attrs
211
+
212
+ def css_classes(self) -> list[str]:
213
+ classes: list[str] = []
214
+ if self.fluid:
215
+ classes.append("img-fluid")
216
+ if self.circle:
217
+ classes.append("rounded-circle")
218
+ elif self.rounded:
219
+ classes.append("rounded" if self.rounded is True else f"rounded-{self.rounded}")
220
+ if self.thumbnail:
221
+ classes.append("img-thumbnail")
222
+ return classes
223
+
224
+
225
+ class Icon(Node):
226
+ """Bootstrap Icons 图标。"""
227
+
228
+ TAG: ClassVar[str | None] = "i"
229
+
230
+ name: str = "circle"
231
+ size: str | int | None = None
232
+ color: str | None = None
233
+ spin: bool = False
234
+
235
+ def html_attrs(self) -> dict[str, Any]:
236
+ attrs = super().html_attrs()
237
+ attrs["aria-hidden"] = "true"
238
+ styles: list[str] = []
239
+ if self.size is not None:
240
+ size = f"{self.size}px" if isinstance(self.size, int) else str(self.size)
241
+ styles.append(f"font-size: {size}")
242
+ if self.color:
243
+ styles.append(f"color: var(--bs-{self.color})")
244
+ if styles:
245
+ attrs["style"] = "; ".join(styles) + (f"; {attrs['style']}" if "style" in attrs else "")
246
+ return attrs
247
+
248
+ def css_classes(self) -> list[str]:
249
+ classes = icon_name(self.name).split()
250
+ if self.spin:
251
+ classes.append("spinner-border spinner-border-sm")
252
+ return classes
253
+
254
+
255
+ class Badge(Node):
256
+ """徽标。"""
257
+
258
+ TAG: ClassVar[str | None] = "span"
259
+
260
+ text: str = ""
261
+ color: Color = Color.primary
262
+ pill: bool = False
263
+ icon: str | None = None
264
+
265
+ def css_classes(self) -> list[str]:
266
+ classes = [f"badge text-bg-{enum_value(self.color)}"]
267
+ if self.pill:
268
+ classes.append("rounded-pill")
269
+ return classes
270
+
271
+ def render_content(self) -> str:
272
+ icon = f'<i class="{icon_name(self.icon)} me-1"></i>' if self.icon else ""
273
+ return icon + escape(self.text)
274
+
275
+
276
+ class Alert(Node):
277
+ """提示框。"""
278
+
279
+ color: Color = Color.info
280
+ body: list[NodeLike] = Field(default_factory=list)
281
+ title: str | None = None
282
+ icon: str | None = None
283
+ dismissible: bool = True
284
+ show: bool = True
285
+
286
+ def css_classes(self) -> list[str]:
287
+ classes = [f"alert alert-{enum_value(self.color)}"]
288
+ if self.dismissible:
289
+ classes.append("alert-dismissible")
290
+ if self.show and self.dismissible:
291
+ classes.append("fade show")
292
+ return classes
293
+
294
+ def html_attrs(self) -> dict[str, Any]:
295
+ attrs = super().html_attrs()
296
+ attrs.setdefault("role", "alert")
297
+ return attrs
298
+
299
+ def render_content(self) -> str:
300
+ parts: list[str] = []
301
+ if self.icon:
302
+ parts.append(f'<i class="{icon_name(self.icon)} me-2"></i>')
303
+ if self.title:
304
+ parts.append(f"<strong>{escape(self.title)}</strong> ")
305
+ parts.append(render_children(self.body))
306
+ if self.dismissible:
307
+ parts.append(
308
+ '<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="关闭"></button>'
309
+ )
310
+ return "".join(parts)
311
+
312
+
313
+ class Card(Node):
314
+ """卡片。"""
315
+
316
+ body: list[NodeLike] = Field(default_factory=list)
317
+ title: str | None = None
318
+ subtitle: str | None = None
319
+ header: list[NodeLike] | None = None
320
+ """自定义页眉内容,设置后忽略 title/subtitle"""
321
+
322
+ footer: list[NodeLike] | None = None
323
+ actions: list[NodeLike] = Field(default_factory=list)
324
+ """放在页眉右侧的操作按钮"""
325
+
326
+ image: str | None = None
327
+ image_position: Literal["top", "bottom"] = "top"
328
+ color: Color | None = None
329
+ body_class: str | None = None
330
+
331
+ def css_classes(self) -> list[str]:
332
+ classes = ["card", "h-100"]
333
+ if self.color:
334
+ classes.append(f"text-bg-{enum_value(self.color)}")
335
+ return classes
336
+
337
+ def _render_header(self) -> str:
338
+ left: list[str] = []
339
+ if self.header:
340
+ left.append(render_children(self.header))
341
+ else:
342
+ if self.title:
343
+ left.append(f'<h5 class="card-title mb-0">{escape(self.title)}</h5>')
344
+ if self.subtitle:
345
+ left.append(f'<div class="small opacity-75 mt-1">{escape(self.subtitle)}</div>')
346
+ actions = render_children(self.actions)
347
+ if not left and not actions:
348
+ return ""
349
+
350
+ left_html = "".join(left)
351
+ if actions:
352
+ return (
353
+ '<div class="card-header d-flex justify-content-between align-items-center gap-2">'
354
+ f'<div>{left_html}</div><div class="d-flex gap-2">{actions}</div></div>'
355
+ )
356
+ return f'<div class="card-header">{left_html}</div>'
357
+
358
+ def render_content(self) -> str:
359
+ parts: list[str] = []
360
+ image = f'<img src="{escape(self.image)}" class="card-img-top" alt="">' if self.image else ""
361
+ if image and self.image_position == "top":
362
+ parts.append(image)
363
+ parts.append(self._render_header())
364
+ body_class = f"card-body {self.body_class}" if self.body_class else "card-body"
365
+ parts.append(f'<div class="{body_class}">{render_children(self.body)}</div>')
366
+ if self.footer:
367
+ parts.append(f'<div class="card-footer">{render_children(self.footer)}</div>')
368
+ if image and self.image_position == "bottom":
369
+ parts.append(image)
370
+ return "".join(parts)
371
+
372
+
373
+ class ListItem(Node):
374
+ """列表组中的一项。"""
375
+
376
+ TAG: ClassVar[str | None] = None
377
+
378
+ text: str = ""
379
+ body: list[NodeLike] = Field(default_factory=list)
380
+ href: str | None = None
381
+ active: bool = False
382
+ disabled: bool = False
383
+ color: str | None = None
384
+ badge: str | None = None
385
+ icon: str | None = None
386
+
387
+ def render(self) -> str:
388
+ classes = ["list-group-item"]
389
+ if self.href:
390
+ classes.append("list-group-item-action")
391
+ if self.active:
392
+ classes.append("active")
393
+ if self.disabled:
394
+ classes.append("disabled")
395
+ if self.color:
396
+ classes.append(f"list-group-item-{self.color}")
397
+ if self.class_name:
398
+ classes.append(self.class_name)
399
+
400
+ content: list[str] = []
401
+ if self.icon:
402
+ content.append(f'<i class="{icon_name(self.icon)} me-2"></i>')
403
+ content.append(escape(self.text))
404
+ content.append(render_children(self.body))
405
+ if self.badge:
406
+ content.append(f'<span class="badge text-bg-secondary float-end">{escape(self.badge)}</span>')
407
+
408
+ attrs = {"class": " ".join(classes)}
409
+ if self.id:
410
+ attrs["id"] = self.id
411
+ if self.disabled:
412
+ attrs["aria-disabled"] = "true"
413
+ inner = "".join(content)
414
+ if self.href and not self.disabled:
415
+ return f'<a href="{escape(self.href)}"{render_attrs(attrs)}>{inner}</a>'
416
+ return f'<div{render_attrs(attrs)}>{inner}</div>'
417
+
418
+
419
+ class ListGroup(Node):
420
+ """列表组。"""
421
+
422
+ items: list[ListItem | str] = Field(default_factory=list)
423
+ flush: bool = False
424
+ numbered: bool = False
425
+ horizontal: bool = False
426
+
427
+ def css_classes(self) -> list[str]:
428
+ classes = ["list-group"]
429
+ if self.flush:
430
+ classes.append("list-group-flush")
431
+ if self.numbered:
432
+ classes.append("list-group-numbered")
433
+ if self.horizontal:
434
+ classes.append("list-group-horizontal")
435
+ return classes
436
+
437
+ def render_content(self) -> str:
438
+ parts: list[str] = []
439
+ for item in self.items:
440
+ if isinstance(item, ListItem):
441
+ parts.append(item.render())
442
+ else:
443
+ parts.append(ListItem(text=str(item)).render())
444
+ return "".join(parts)
445
+
446
+
447
+ class Button(Node):
448
+ """按钮。``href`` 存在时渲染为 ``<a>``。"""
449
+
450
+ text: str = ""
451
+ color: Color = Color.primary
452
+ outline: bool = False
453
+ size: Size | None = None
454
+ type: Literal["button", "submit", "reset"] = "button"
455
+ href: str | None = None
456
+ icon: str | None = None
457
+ block: bool = False
458
+ disabled: bool = False
459
+ active: bool = False
460
+
461
+ toggle_modal: str | None = None
462
+ """点击打开指定 id 的模态框,自动补 ``data-bs-toggle/target``"""
463
+
464
+ toggle_offcanvas: str | None = None
465
+ """点击打开指定 id 的抽屉"""
466
+
467
+ dismiss: Literal["modal", "offcanvas", "alert", "toast"] | None = None
468
+ """点击关闭指定类型的浮层"""
469
+
470
+ tooltip: str | None = None
471
+ """鼠标悬停提示(由页面脚本初始化)"""
472
+
473
+ def tag_name(self) -> str | None:
474
+ return "a" if self.href else "button"
475
+
476
+ def html_attrs(self) -> dict[str, Any]:
477
+ attrs = super().html_attrs()
478
+ if self.href:
479
+ attrs["href"] = self.href
480
+ else:
481
+ attrs["type"] = self.type
482
+ if self.disabled:
483
+ if self.href:
484
+ attrs["aria-disabled"] = "true"
485
+ attrs["tabindex"] = "-1"
486
+ else:
487
+ attrs["disabled"] = True
488
+ if self.toggle_modal:
489
+ attrs["data-bs-toggle"] = "modal"
490
+ attrs["data-bs-target"] = f"#{self.toggle_modal.lstrip('#')}"
491
+ elif self.toggle_offcanvas:
492
+ attrs["data-bs-toggle"] = "offcanvas"
493
+ attrs["data-bs-target"] = f"#{self.toggle_offcanvas.lstrip('#')}"
494
+ if self.dismiss:
495
+ attrs["data-bs-dismiss"] = self.dismiss
496
+ if self.tooltip:
497
+ attrs["title"] = self.tooltip
498
+ attrs.setdefault("data-bs-toggle", "tooltip")
499
+ return attrs
500
+
501
+ def css_classes(self) -> list[str]:
502
+ variant = f"btn-outline-{enum_value(self.color)}" if self.outline else f"btn-{enum_value(self.color)}"
503
+ classes = ["btn", variant]
504
+ if self.size:
505
+ classes.append(f"btn-{enum_value(self.size)}")
506
+ if self.block:
507
+ classes.append("w-100")
508
+ if self.active:
509
+ classes.append("active")
510
+ if self.disabled:
511
+ classes.append("disabled")
512
+ return classes
513
+
514
+ def render_content(self) -> str:
515
+ icon = f'<i class="{icon_name(self.icon)} me-1"></i>' if self.icon else ""
516
+ return icon + escape(self.text)
517
+
518
+
519
+ class ButtonGroup(Node):
520
+ """按钮组。"""
521
+
522
+ buttons: list[NodeLike] = Field(default_factory=list)
523
+ vertical: bool = False
524
+ size: Size | None = None
525
+
526
+ def css_classes(self) -> list[str]:
527
+ classes = ["btn-group"]
528
+ if self.vertical:
529
+ classes.append("btn-group-vertical")
530
+ if self.size:
531
+ classes.append(f"btn-group-{enum_value(self.size)}")
532
+ return classes
533
+
534
+ def html_attrs(self) -> dict[str, Any]:
535
+ attrs = super().html_attrs()
536
+ attrs.setdefault("role", "group")
537
+ return attrs
538
+
539
+ def render_content(self) -> str:
540
+ return render_children(self.buttons)
541
+
542
+
543
+ class CloseButton(Node):
544
+ """关闭按钮(``.btn-close``)。"""
545
+
546
+ TAG: ClassVar[str | None] = "button"
547
+
548
+ dismiss: Literal["modal", "offcanvas", "alert", "toast"] = "modal"
549
+ white: bool = False
550
+
551
+ def html_attrs(self) -> dict[str, Any]:
552
+ attrs = super().html_attrs()
553
+ attrs["type"] = "button"
554
+ attrs["data-bs-dismiss"] = self.dismiss
555
+ attrs["aria-label"] = "关闭"
556
+ return attrs
557
+
558
+ def css_classes(self) -> list[str]:
559
+ classes = ["btn-close"]
560
+ if self.white:
561
+ classes.append("btn-close-white")
562
+ return classes