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/forms.py ADDED
@@ -0,0 +1,489 @@
1
+ """表单组件:Form / FormGroup / Label / Input / Textarea / Select / Checkbox / RadioGroup /
2
+ InputGroup / Fieldset / Hidden。
3
+
4
+ 用法上分成两层:控件只渲染自身(``<input>``、``<select>`` …),
5
+ 由 :class:`FormGroup` 负责「标签 + 控件 + 帮助文本」的组合,
6
+ 与 Bootstrap 文档的结构一一对应。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from typing import Any, ClassVar, Literal
13
+
14
+ from pydantic import Field
15
+
16
+ from .constants import Color, Size, value as enum_value
17
+ from .content import Button
18
+ from .types import Node, NodeLike, escape, render_attrs, render_children
19
+
20
+ __all__ = [
21
+ "Form",
22
+ "FormGroup",
23
+ "Label",
24
+ "Input",
25
+ "Textarea",
26
+ "Select",
27
+ "Checkbox",
28
+ "RadioGroup",
29
+ "InputGroup",
30
+ "Fieldset",
31
+ "Hidden",
32
+ "field",
33
+ "field_id",
34
+ ]
35
+
36
+
37
+ def field_id(name: str) -> str:
38
+ """把字段名转成合法的 HTML id,例如 ``user[name]`` → ``field-user-name``。"""
39
+ return "field-" + re.sub(r"[^\w-]+", "-", name).strip("-")
40
+
41
+
42
+ def _normalize_options(options: list[Any] | None):
43
+ """把各种选项写法统一成 ``(label, value, disabled)``,支持 optgroup。
44
+
45
+ - ``"男"`` → label=value="男"
46
+ - ``("男", 1)`` / ``["男", 1]`` → label="男", value=1
47
+ - ``{"label": "男", "value": 1, "disabled": True}``
48
+ - ``{"label": "分组", "options": [...]}`` → optgroup
49
+ """
50
+ for option in options or []:
51
+ if isinstance(option, dict):
52
+ if "options" in option:
53
+ yield ("group", option.get("label", ""), list(option.get("options") or []))
54
+ continue
55
+ label = option.get("label", option.get("value"))
56
+ value = option.get("value", label)
57
+ yield (label, value, bool(option.get("disabled", False)))
58
+ elif isinstance(option, (list, tuple)):
59
+ values = list(option)
60
+ label = values[0] if values else ""
61
+ value = values[1] if len(values) > 1 else label
62
+ yield (label, value, bool(values[2]) if len(values) > 2 else False)
63
+ else:
64
+ yield (option, option, False)
65
+
66
+
67
+ class Form(Node):
68
+ """表单。"""
69
+
70
+ TAG: ClassVar[str | None] = "form"
71
+
72
+ action: str = ""
73
+ method: Literal["get", "post"] = "post"
74
+ enctype: str | None = None
75
+ body: list[NodeLike] = Field(default_factory=list)
76
+ inline: bool = False
77
+ novalidate: bool = False
78
+ submit_label: str | None = "提交"
79
+ submit_color: Color = Color.primary
80
+ reset_label: str | None = None
81
+
82
+ def html_attrs(self) -> dict[str, Any]:
83
+ attrs = super().html_attrs()
84
+ attrs["action"] = self.action
85
+ attrs["method"] = self.method
86
+ if self.enctype:
87
+ attrs["enctype"] = self.enctype
88
+ if self.novalidate:
89
+ attrs["novalidate"] = True
90
+ return attrs
91
+
92
+ def css_classes(self) -> list[str]:
93
+ return ["row", "row-cols-lg-auto", "g-3", "align-items-center"] if self.inline else []
94
+
95
+ def render_content(self) -> str:
96
+ parts = [render_children(self.body)]
97
+ if self.submit_label or self.reset_label:
98
+ buttons: list[str] = []
99
+ if self.submit_label:
100
+ buttons.append(
101
+ Button(text=self.submit_label, type="submit", color=self.submit_color).render()
102
+ )
103
+ if self.reset_label:
104
+ buttons.append(
105
+ Button(text=self.reset_label, type="reset", color=Color.secondary, outline=True).render()
106
+ )
107
+ parts.append(f'<div class="d-flex gap-2 mt-4">{"".join(buttons)}</div>')
108
+ return "".join(parts)
109
+
110
+
111
+ class Label(Node):
112
+ """表单标签。"""
113
+
114
+ TAG: ClassVar[str | None] = "label"
115
+
116
+ text: str = ""
117
+ for_: str | None = Field(default=None, alias="for")
118
+ required: bool = False
119
+
120
+ def html_attrs(self) -> dict[str, Any]:
121
+ attrs = super().html_attrs()
122
+ if self.for_:
123
+ attrs["for"] = self.for_
124
+ return attrs
125
+
126
+ def css_classes(self) -> list[str]:
127
+ return ["form-label"]
128
+
129
+ def render_content(self) -> str:
130
+ star = '<span class="text-danger ms-1">*</span>' if self.required else ""
131
+ return escape(self.text) + star
132
+
133
+
134
+ class _Control(Node):
135
+ """表单控件的公共行为:id 推导、校验反馈。"""
136
+
137
+ TAG: ClassVar[str | None] = None
138
+
139
+ name: str = ""
140
+ invalid: bool = False
141
+ valid: bool = False
142
+ invalid_feedback: str | None = None
143
+ valid_feedback: str | None = None
144
+
145
+ def control_id(self) -> str:
146
+ return self.id or field_id(self.name)
147
+
148
+ def validation_classes(self) -> list[str]:
149
+ classes: list[str] = []
150
+ if self.invalid:
151
+ classes.append("is-invalid")
152
+ elif self.valid:
153
+ classes.append("is-valid")
154
+ return classes
155
+
156
+ def feedback(self) -> str:
157
+ parts: list[str] = []
158
+ if self.invalid_feedback:
159
+ parts.append(f'<div class="invalid-feedback">{escape(self.invalid_feedback)}</div>')
160
+ if self.valid_feedback:
161
+ parts.append(f'<div class="valid-feedback">{escape(self.valid_feedback)}</div>')
162
+ return "".join(parts)
163
+
164
+
165
+ class Input(_Control):
166
+ """单行输入框。"""
167
+
168
+ TAG: ClassVar[str | None] = "input"
169
+
170
+ type: str = "text"
171
+ value: Any = None
172
+ placeholder: str | None = None
173
+ size: Size | None = None
174
+ required: bool = False
175
+ disabled: bool = False
176
+ readonly: bool = False
177
+ autocomplete: str | None = None
178
+
179
+ def html_attrs(self) -> dict[str, Any]:
180
+ attrs = super().html_attrs()
181
+ attrs["type"] = self.type
182
+ attrs["name"] = self.name
183
+ attrs.setdefault("id", self.control_id())
184
+ if self.value is not None:
185
+ attrs["value"] = self.value
186
+ if self.placeholder:
187
+ attrs["placeholder"] = self.placeholder
188
+ if self.required:
189
+ attrs["required"] = True
190
+ if self.disabled:
191
+ attrs["disabled"] = True
192
+ if self.readonly:
193
+ attrs["readonly"] = True
194
+ if self.autocomplete:
195
+ attrs["autocomplete"] = self.autocomplete
196
+ return attrs
197
+
198
+ def css_classes(self) -> list[str]:
199
+ classes = ["form-control"]
200
+ if self.size:
201
+ classes.append(f"form-control-{enum_value(self.size)}")
202
+ classes.extend(self.validation_classes())
203
+ return classes
204
+
205
+ def render(self) -> str:
206
+ return super().render() + self.feedback()
207
+
208
+
209
+ class Textarea(_Control):
210
+ """多行文本输入框。"""
211
+
212
+ TAG: ClassVar[str | None] = "textarea"
213
+
214
+ value: str | None = None
215
+ rows: int = 3
216
+ placeholder: str | None = None
217
+ required: bool = False
218
+ disabled: bool = False
219
+ readonly: bool = False
220
+
221
+ def html_attrs(self) -> dict[str, Any]:
222
+ attrs = super().html_attrs()
223
+ attrs["name"] = self.name
224
+ attrs.setdefault("id", self.control_id())
225
+ attrs["rows"] = self.rows
226
+ if self.placeholder:
227
+ attrs["placeholder"] = self.placeholder
228
+ if self.required:
229
+ attrs["required"] = True
230
+ if self.disabled:
231
+ attrs["disabled"] = True
232
+ if self.readonly:
233
+ attrs["readonly"] = True
234
+ return attrs
235
+
236
+ def css_classes(self) -> list[str]:
237
+ return ["form-control", *self.validation_classes()]
238
+
239
+ def render_content(self) -> str:
240
+ return escape(self.value or "")
241
+
242
+ def render(self) -> str:
243
+ return super().render() + self.feedback()
244
+
245
+
246
+ class Select(_Control):
247
+ """下拉选择框。"""
248
+
249
+ TAG: ClassVar[str | None] = "select"
250
+
251
+ options: list[Any] = Field(default_factory=list)
252
+ value: Any = None
253
+ multiple: bool = False
254
+ size: Size | None = None
255
+ placeholder: str | None = None
256
+ required: bool = False
257
+ disabled: bool = False
258
+
259
+ def html_attrs(self) -> dict[str, Any]:
260
+ attrs = super().html_attrs()
261
+ attrs["name"] = self.name
262
+ attrs.setdefault("id", self.control_id())
263
+ if self.multiple:
264
+ attrs["multiple"] = True
265
+ if self.required:
266
+ attrs["required"] = True
267
+ if self.disabled:
268
+ attrs["disabled"] = True
269
+ return attrs
270
+
271
+ def css_classes(self) -> list[str]:
272
+ classes = ["form-select"]
273
+ if self.size:
274
+ classes.append(f"form-select-{enum_value(self.size)}")
275
+ classes.extend(self.validation_classes())
276
+ return classes
277
+
278
+ def _options_html(self) -> str:
279
+ selected = self.value if isinstance(self.value, (list, tuple, set)) else [self.value]
280
+ selected_values = {str(item) for item in selected if item is not None}
281
+ parts: list[str] = []
282
+
283
+ if self.placeholder and not self.multiple and not selected_values:
284
+ parts.append(f'<option value="" disabled selected>{escape(self.placeholder)}</option>')
285
+
286
+ for label, value, disabled in _normalize_options(self.options):
287
+ if label == "group":
288
+ group_options = "".join(
289
+ self._option_html(*item, selected_values) for item in _normalize_options(value)
290
+ )
291
+ parts.append(f'<optgroup label="{escape(value)}">{group_options}</optgroup>')
292
+ else:
293
+ parts.append(self._option_html(label, value, disabled, selected_values))
294
+ return "".join(parts)
295
+
296
+ @staticmethod
297
+ def _option_html(label: Any, value: Any, disabled: bool, selected_values: set[str]) -> str:
298
+ attrs: list[str] = [f'value="{escape(value)}"']
299
+ if disabled:
300
+ attrs.append("disabled")
301
+ if str(value) in selected_values:
302
+ attrs.append("selected")
303
+ return f'<option {" ".join(attrs)}>{escape(label)}</option>'
304
+
305
+ def render_content(self) -> str:
306
+ return self._options_html()
307
+
308
+ def render(self) -> str:
309
+ return super().render() + self.feedback()
310
+
311
+
312
+ class Checkbox(Node):
313
+ """复选框 / 开关。"""
314
+
315
+ TAG: ClassVar[str | None] = None
316
+
317
+ name: str = ""
318
+ label: str | None = None
319
+ value: str = "on"
320
+ checked: bool = False
321
+ switch: bool = False
322
+ inline: bool = False
323
+ disabled: bool = False
324
+ required: bool = False
325
+
326
+ def render(self) -> str:
327
+ checkbox_id = self.id or field_id(self.name)
328
+ input_classes = ["form-check-input"]
329
+ wrapper_classes = ["form-check"]
330
+ if self.switch:
331
+ wrapper_classes.append("form-switch")
332
+ if self.inline:
333
+ wrapper_classes.append("form-check-inline")
334
+
335
+ attrs: dict[str, Any] = {
336
+ "class": " ".join(input_classes),
337
+ "type": "checkbox",
338
+ "id": checkbox_id,
339
+ "name": self.name,
340
+ "value": self.value,
341
+ }
342
+ if self.checked:
343
+ attrs["checked"] = True
344
+ if self.disabled:
345
+ attrs["disabled"] = True
346
+ if self.required:
347
+ attrs["required"] = True
348
+
349
+ label = (
350
+ f'<label class="form-check-label" for="{checkbox_id}">{escape(self.label)}</label>'
351
+ if self.label
352
+ else ""
353
+ )
354
+ return f'<div class="{" ".join(wrapper_classes)}"><input{render_attrs(attrs)}>{label}</div>'
355
+
356
+
357
+ class RadioGroup(Node):
358
+ """单选组。"""
359
+
360
+ TAG: ClassVar[str | None] = None
361
+
362
+ name: str = ""
363
+ options: list[Any] = Field(default_factory=list)
364
+ value: Any = None
365
+ inline: bool = True
366
+ disabled: bool = False
367
+ required: bool = False
368
+
369
+ def render(self) -> str:
370
+ parts: list[str] = []
371
+ for index, (label, value, disabled) in enumerate(_normalize_options(self.options)):
372
+ radio_id = f"{field_id(self.name)}-{index}"
373
+ attrs: dict[str, Any] = {
374
+ "class": "form-check-input",
375
+ "type": "radio",
376
+ "id": radio_id,
377
+ "name": self.name,
378
+ "value": value,
379
+ }
380
+ if self.value is not None and str(value) == str(self.value):
381
+ attrs["checked"] = True
382
+ if disabled or self.disabled:
383
+ attrs["disabled"] = True
384
+ if self.required:
385
+ attrs["required"] = True
386
+ wrapper = "form-check form-check-inline" if self.inline else "form-check"
387
+ parts.append(
388
+ f'<div class="{wrapper}"><input{render_attrs(attrs)}>'
389
+ f'<label class="form-check-label" for="{radio_id}">{escape(label)}</label></div>'
390
+ )
391
+ return "".join(parts)
392
+
393
+
394
+ class InputGroup(Node):
395
+ """输入框组合(前置 / 后置附加内容)。"""
396
+
397
+ control: NodeLike | None = None
398
+ prepend: NodeLike | None = None
399
+ append: NodeLike | None = None
400
+ size: Size | None = None
401
+
402
+ def css_classes(self) -> list[str]:
403
+ classes = ["input-group"]
404
+ if self.size:
405
+ classes.append(f"input-group-{enum_value(self.size)}")
406
+ return classes
407
+
408
+ def render_content(self) -> str:
409
+ parts: list[str] = []
410
+ if self.prepend is not None:
411
+ parts.append(f'<span class="input-group-text">{render_children(self.prepend)}</span>')
412
+ parts.append(render_children(self.control))
413
+ if self.append is not None:
414
+ parts.append(f'<span class="input-group-text">{render_children(self.append)}</span>')
415
+ return "".join(parts)
416
+
417
+
418
+ class Fieldset(Node):
419
+ """字段分组。"""
420
+
421
+ TAG: ClassVar[str | None] = "fieldset"
422
+
423
+ legend: str | None = None
424
+ body: list[NodeLike] = Field(default_factory=list)
425
+ disabled: bool = False
426
+
427
+ def html_attrs(self) -> dict[str, Any]:
428
+ attrs = super().html_attrs()
429
+ if self.disabled:
430
+ attrs["disabled"] = True
431
+ return attrs
432
+
433
+ def css_classes(self) -> list[str]:
434
+ return ["mb-3"]
435
+
436
+ def render_content(self) -> str:
437
+ legend = f"<legend>{escape(self.legend)}</legend>" if self.legend else ""
438
+ return legend + render_children(self.body)
439
+
440
+
441
+ class Hidden(Node):
442
+ """隐藏字段。"""
443
+
444
+ TAG: ClassVar[str | None] = "input"
445
+
446
+ name: str = ""
447
+ value: Any = None
448
+
449
+ def html_attrs(self) -> dict[str, Any]:
450
+ attrs = super().html_attrs()
451
+ attrs["type"] = "hidden"
452
+ attrs["name"] = self.name
453
+ if self.value is not None:
454
+ attrs["value"] = self.value
455
+ return attrs
456
+
457
+
458
+ class FormGroup(Node):
459
+ """「标签 + 控件 + 帮助文本」的组合。"""
460
+
461
+ label: str | None = None
462
+ control: NodeLike | None = None
463
+ help: str | None = None
464
+ required: bool = False
465
+
466
+ def css_classes(self) -> list[str]:
467
+ return ["mb-3"]
468
+
469
+ def _label_for(self) -> str | None:
470
+ control = self.control
471
+ if isinstance(control, _Control):
472
+ return control.control_id()
473
+ if isinstance(control, Checkbox):
474
+ return control.id or field_id(control.name)
475
+ return None
476
+
477
+ def render_content(self) -> str:
478
+ parts: list[str] = []
479
+ if self.label:
480
+ parts.append(Label(text=self.label, for_=self._label_for(), required=self.required).render())
481
+ parts.append(render_children(self.control))
482
+ if self.help:
483
+ parts.append(f'<div class="form-text">{escape(self.help)}</div>')
484
+ return "".join(parts)
485
+
486
+
487
+ def field(label: str, control: Node | str, help: str | None = None, required: bool = False) -> FormGroup:
488
+ """``FormGroup(label=..., control=...)`` 的简写。"""
489
+ return FormGroup(label=label, control=control, help=help, required=required)