From 56407112c7725000f35daa9c52200618fda3e94d Mon Sep 17 00:00:00 2001 From: Francis Secada Date: Wed, 29 Jul 2026 14:01:58 -0400 Subject: [PATCH 1/3] feat(themes): implement the Bootstrap 5 theme (#22) All 14 components in both template sets, replacing the PLANNED.md stubs. `bootstrap` is now accepted by CF_UI_THEME and install_cf_ui(theme=...); _CDN_CSS, _DEFAULTS and assets.jinja already carried it at 5.3.3. The theme loads no Bootstrap JavaScript, and that is the whole design. Bootstrap's data-bs-* API is a second state owner for the modal, the tabs and the accordion, and cf_ui_alpine.js is already the first. Loading both would make Alpine.store('cf').modal.open(id) behave differently under this theme than under every other one -- the cross-theme guarantee the theme work exists to protect. So the templates take Bootstrap's classes and markup structure and wire state through the existing Alpine components. Two places where "use Bootstrap's classes" is not enough on its own, both because the missing piece was in the bundle we are not loading: * `.modal` is display:none and `.show` only sets opacity -- Bootstrap's JS is what writes style.display = "block". The reveal rides on `d-block`, a real utility Alpine can toggle, and the E2E tier asserts visibility rather than the class list so a reveal that changes classes and nothing else cannot pass. The backdrop is the same story: Bootstrap appends one to at z-index 1050, under the modal's 1055, and we have no JS to do that. It lives inside the modal at a negative z-index instead, which keeps it under the dialog rather than eating every click; both halves are covered by a test. * The panel body deliberately carries no `.collapse`. That class is display:none without `.show`, which would hide a server-open panel permanently once Alpine is off -- exactly the bug #21 fixed. x-show owns display, seeded from data-cf-open. The navbar does use `.collapse`, where the pure-CSS `.collapse:not(.show)` / `.navbar-expand-lg .navbar-collapse` pair is correct with or without JS. Accessibility parity is enforced rather than asserted in prose: tests/unit/test_accessibility.py now parametrizes over three themes, so dialog semantics, the label fallback, the tabs' server-rendered active state with roving tabindex, and the panel's aria-expanded/aria-controls toggle all have to hold here too. "bootstrap" is added to THEMES last, so a half-finished theme was never selectable at any commit in this branch. Closes #22 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 45 ++ README.md | 12 +- .../cotton/_themes/bootstrap/breadcrumb.html | 13 + .../cotton/_themes/bootstrap/card.html | 11 + .../_themes/bootstrap/checkbox-group.html | 20 + .../cotton/_themes/bootstrap/form-field.html | 10 + .../cotton/_themes/bootstrap/modal.html | 30 ++ .../cotton/_themes/bootstrap/navbar.html | 21 + .../_themes/bootstrap/notification.html | 12 + .../cotton/_themes/bootstrap/pagination.html | 38 ++ .../cotton/_themes/bootstrap/panel.html | 25 ++ .../cotton/_themes/bootstrap/progress.html | 15 + .../cotton/_themes/bootstrap/select.html | 13 + .../cotton/_themes/bootstrap/table.html | 21 + .../cotton/_themes/bootstrap/tabs.html | 24 ++ .../cotton/_themes/bootstrap/textarea.html | 8 + .../templates/cotton/bootstrap/PLANNED.md | 3 - .../jinja/bootstrap/Breadcrumb.jinja | 16 + .../templates/jinja/bootstrap/Card.jinja | 16 + .../jinja/bootstrap/CheckboxGroup.jinja | 26 ++ .../templates/jinja/bootstrap/FormField.jinja | 16 + .../templates/jinja/bootstrap/Modal.jinja | 37 ++ .../templates/jinja/bootstrap/Navbar.jinja | 26 ++ .../jinja/bootstrap/Notification.jinja | 16 + .../templates/jinja/bootstrap/PLANNED.md | 3 - .../jinja/bootstrap/Pagination.jinja | 45 ++ .../templates/jinja/bootstrap/Panel.jinja | 30 ++ .../templates/jinja/bootstrap/Progress.jinja | 22 + .../templates/jinja/bootstrap/Select.jinja | 19 + .../templates/jinja/bootstrap/Table.jinja | 24 ++ .../templates/jinja/bootstrap/Tabs.jinja | 30 ++ .../templates/jinja/bootstrap/Textarea.jinja | 14 + src/cf_ui/themes.py | 2 +- tests/e2e/conftest.py | 37 ++ tests/e2e/test_bootstrap.py | 379 ++++++++++++++++ tests/integration/jinja_app/main.py | 4 + tests/unit/cotton/test_bootstrap.py | 298 +++++++++++++ tests/unit/jinja/test_bootstrap.py | 407 ++++++++++++++++++ tests/unit/test_accessibility.py | 14 +- tests/unit/test_theme_dispatch.py | 45 +- 40 files changed, 1816 insertions(+), 31 deletions(-) create mode 100644 src/cf_ui/templates/cotton/_themes/bootstrap/breadcrumb.html create mode 100644 src/cf_ui/templates/cotton/_themes/bootstrap/card.html create mode 100644 src/cf_ui/templates/cotton/_themes/bootstrap/checkbox-group.html create mode 100644 src/cf_ui/templates/cotton/_themes/bootstrap/form-field.html create mode 100644 src/cf_ui/templates/cotton/_themes/bootstrap/modal.html create mode 100644 src/cf_ui/templates/cotton/_themes/bootstrap/navbar.html create mode 100644 src/cf_ui/templates/cotton/_themes/bootstrap/notification.html create mode 100644 src/cf_ui/templates/cotton/_themes/bootstrap/pagination.html create mode 100644 src/cf_ui/templates/cotton/_themes/bootstrap/panel.html create mode 100644 src/cf_ui/templates/cotton/_themes/bootstrap/progress.html create mode 100644 src/cf_ui/templates/cotton/_themes/bootstrap/select.html create mode 100644 src/cf_ui/templates/cotton/_themes/bootstrap/table.html create mode 100644 src/cf_ui/templates/cotton/_themes/bootstrap/tabs.html create mode 100644 src/cf_ui/templates/cotton/_themes/bootstrap/textarea.html delete mode 100644 src/cf_ui/templates/cotton/bootstrap/PLANNED.md create mode 100644 src/cf_ui/templates/jinja/bootstrap/Breadcrumb.jinja create mode 100644 src/cf_ui/templates/jinja/bootstrap/Card.jinja create mode 100644 src/cf_ui/templates/jinja/bootstrap/CheckboxGroup.jinja create mode 100644 src/cf_ui/templates/jinja/bootstrap/FormField.jinja create mode 100644 src/cf_ui/templates/jinja/bootstrap/Modal.jinja create mode 100644 src/cf_ui/templates/jinja/bootstrap/Navbar.jinja create mode 100644 src/cf_ui/templates/jinja/bootstrap/Notification.jinja delete mode 100644 src/cf_ui/templates/jinja/bootstrap/PLANNED.md create mode 100644 src/cf_ui/templates/jinja/bootstrap/Pagination.jinja create mode 100644 src/cf_ui/templates/jinja/bootstrap/Panel.jinja create mode 100644 src/cf_ui/templates/jinja/bootstrap/Progress.jinja create mode 100644 src/cf_ui/templates/jinja/bootstrap/Select.jinja create mode 100644 src/cf_ui/templates/jinja/bootstrap/Table.jinja create mode 100644 src/cf_ui/templates/jinja/bootstrap/Tabs.jinja create mode 100644 src/cf_ui/templates/jinja/bootstrap/Textarea.jinja create mode 100644 tests/e2e/test_bootstrap.py create mode 100644 tests/unit/cotton/test_bootstrap.py create mode 100644 tests/unit/jinja/test_bootstrap.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 10a806d..97e70f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,51 @@ ## [Unreleased] +### Added — Bootstrap 5 theme (#22) + +All 14 components in both template sets, replacing the `PLANNED.md` stubs at +`templates/jinja/bootstrap/` and `templates/cotton/bootstrap/`. `bootstrap` is +now accepted by `CF_UI_THEME` and by `install_cf_ui(theme=…)`; `_CDN_CSS`, +`_DEFAULTS` and `assets.jinja` already carried it at 5.3.3. + +- **CSS only — do not load `bootstrap.bundle.js`.** Bootstrap's `data-bs-*` + API is a second state owner for the modal, the tabs and the accordion, and + `cf_ui_alpine.js` is already the first. Loading both would make + `Alpine.store('cf').modal.open(id)` behave differently under this theme than + under every other one, which is precisely the cross-theme guarantee the theme + work exists to protect. The templates use Bootstrap's classes and markup + structure and wire every piece of state through the existing Alpine + components; the absence of `data-bs-` is asserted per component rather than + left to prose. + +- **The modal reveal rides on `d-block`, not on `.show` alone.** `.modal` is + `display: none` and Bootstrap's `.show` only sets opacity — its own JS is + what writes `style.display = "block"`. A theme that toggled just `.show` + would change the class list and never become visible, so the E2E tier asserts + visibility rather than classes. The backdrop is likewise a special case: + Bootstrap appends one to `` at z-index 1050, below the modal's 1055, + and cf-ui has no JS to do that. It lives inside the modal with a negative + z-index instead, which keeps it under the dialog rather than swallowing every + click. Both halves — the dialog still takes clicks, the backdrop still closes + — are covered by an E2E test. + +- **The panel body carries no `.collapse`.** That class is `display: none` + without `.show`, which would hide a server-open panel permanently once Alpine + is off — exactly the bug #21 fixed. `x-show` owns display, seeded from + `data-cf-open`. The navbar *does* use `.collapse`, where the pure-CSS + `.collapse:not(.show)` / `.navbar-expand-lg .navbar-collapse` pair is the + correct behavior with or without JS. + +- Accessibility parity with Bulma and DaisyUI is enforced by the existing + `tests/unit/test_accessibility.py`, which now parametrizes over three themes + rather than two: dialog semantics and the `label` fallback, the tabs' + server-rendered active state with roving `tabindex`, and the panel's + `aria-expanded` / `aria-controls` toggle. + +- Prop vocabulary is unchanged and still theme-agnostic. `type="danger"` maps to + `alert-danger` / `bg-danger`, and `type="error"` maps there too, so a value + written for DaisyUI keeps working. + ### Fixed — accessibility (#21) Three gaps that predate 0.1.0 and were flagged during the #6 review, fixed diff --git a/README.md b/README.md index 090a775..b6a5d7d 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ pip install "cf-ui[bulma]" # Tailwind + DaisyUI pip install "cf-ui[daisy]" -# All themes (Bootstrap, Foundation and Fomantic are still stubs) +# All themes (Foundation and Fomantic are still stubs) pip install "cf-ui[all]" ``` @@ -290,7 +290,7 @@ from cf_ui import JINJA_TEMPLATES_DIR, COTTON_TEMPLATES_DIR |---|---| | Bulma | ✅ v0.1.0 | | Tailwind + DaisyUI | ✅ — see [docs/daisyui.md](docs/daisyui.md) | -| Bootstrap | 📋 Planned | +| Bootstrap 5 | ✅ — CSS only, no `bootstrap.bundle.js` | | Foundation | 📋 Planned | | Fomantic UI | 📋 Planned | @@ -298,6 +298,14 @@ Switching is one line — `CF_UI_THEME = "daisy"` on Django, `theme="daisy"` on FastAPI/Litestar — and needs no template edits in the consuming app. An unimplemented theme name is rejected at startup rather than at first render. +**Bootstrap ships CSS only, on purpose.** Do not load `bootstrap.bundle.js`. +Bootstrap's `data-bs-*` API is a second state owner for the modal, the tabs and +the accordion, and `cf_ui_alpine.js` is already the first — loading both makes +`Alpine.store('cf').modal.open(id)` mean something different under this theme +than under every other one. The templates use Bootstrap's classes and markup +structure and wire state through Alpine, so the CDN stylesheet is all a +consuming app needs. + **DaisyUI takes one extra step.** It compiles through Tailwind, so Tailwind's content scanner has to reach cf-ui's templates in site-packages or every class in them is tree-shaken away, leaving correct markup with no styling and no diff --git a/src/cf_ui/templates/cotton/_themes/bootstrap/breadcrumb.html b/src/cf_ui/templates/cotton/_themes/bootstrap/breadcrumb.html new file mode 100644 index 0000000..5ce7ff4 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bootstrap/breadcrumb.html @@ -0,0 +1,13 @@ + diff --git a/src/cf_ui/templates/cotton/_themes/bootstrap/card.html b/src/cf_ui/templates/cotton/_themes/bootstrap/card.html new file mode 100644 index 0000000..5698098 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bootstrap/card.html @@ -0,0 +1,11 @@ +
+ {% if header %} +
+
{{ header }}
+
+ {% endif %} +
{{ slot }}
+ {% if footer %} + + {% endif %} +
diff --git a/src/cf_ui/templates/cotton/_themes/bootstrap/checkbox-group.html b/src/cf_ui/templates/cotton/_themes/bootstrap/checkbox-group.html new file mode 100644 index 0000000..fcd7a06 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bootstrap/checkbox-group.html @@ -0,0 +1,20 @@ +
+ + + {% for choice in choices %} +
+ + +
+ {% endfor %} +
+ {# `.invalid-feedback` is display:none until an `.is-invalid` sibling precedes + it, and a checkbox group has none — `d-block` keeps the message from + rendering invisibly. #} + {% if error %}
{{ error }}
{% endif %} + diff --git a/src/cf_ui/templates/cotton/_themes/bootstrap/form-field.html b/src/cf_ui/templates/cotton/_themes/bootstrap/form-field.html new file mode 100644 index 0000000..e1defc6 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bootstrap/form-field.html @@ -0,0 +1,10 @@ +
+ + + {% if error %}
{{ error }}
{% endif %} +
diff --git a/src/cf_ui/templates/cotton/_themes/bootstrap/modal.html b/src/cf_ui/templates/cotton/_themes/bootstrap/modal.html new file mode 100644 index 0000000..144f999 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bootstrap/modal.html @@ -0,0 +1,30 @@ +{# `.modal` is display:none and `.show` only sets opacity — Bootstrap's own JS + is what writes `style.display = "block"`. cf-ui loads none, so the reveal + rides on `d-block`, a real Bootstrap utility Alpine can toggle. #} + diff --git a/src/cf_ui/templates/cotton/_themes/bootstrap/navbar.html b/src/cf_ui/templates/cotton/_themes/bootstrap/navbar.html new file mode 100644 index 0000000..1a11fe7 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bootstrap/navbar.html @@ -0,0 +1,21 @@ +{# `.collapse:not(.show) { display: none }` and + `.navbar-expand-lg .navbar-collapse { display: flex !important }` are both + plain CSS, so the menu collapses on mobile and stays open on desktop with + Alpine off. Only the toggle needs JS. #} + diff --git a/src/cf_ui/templates/cotton/_themes/bootstrap/notification.html b/src/cf_ui/templates/cotton/_themes/bootstrap/notification.html new file mode 100644 index 0000000..d3bc1c3 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bootstrap/notification.html @@ -0,0 +1,12 @@ + diff --git a/src/cf_ui/templates/cotton/_themes/bootstrap/pagination.html b/src/cf_ui/templates/cotton/_themes/bootstrap/pagination.html new file mode 100644 index 0000000..c4f390b --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bootstrap/pagination.html @@ -0,0 +1,38 @@ +{% load cf_ui %} + diff --git a/src/cf_ui/templates/cotton/_themes/bootstrap/panel.html b/src/cf_ui/templates/cotton/_themes/bootstrap/panel.html new file mode 100644 index 0000000..15d679f --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bootstrap/panel.html @@ -0,0 +1,25 @@ +{# The body deliberately carries no `.collapse`: that class is display:none + without `.show`, which would hide a server-open panel permanently once + Alpine is off. `x-show` owns display here, seeded from `data-cf-open`. #} +
+
+

+ +

+
+
{{ slot }}
+
+
+
diff --git a/src/cf_ui/templates/cotton/_themes/bootstrap/progress.html b/src/cf_ui/templates/cotton/_themes/bootstrap/progress.html new file mode 100644 index 0000000..8a580fe --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bootstrap/progress.html @@ -0,0 +1,15 @@ +{# Bootstrap's bar is a div sized in percent, not a native , so the + width has to be computed from value/max rather than emitted verbatim. + `widthratio` returns "0" on a zero max rather than raising. #} +
+ {% if label %}

{{ label }}

{% endif %} +
+
+
+
diff --git a/src/cf_ui/templates/cotton/_themes/bootstrap/select.html b/src/cf_ui/templates/cotton/_themes/bootstrap/select.html new file mode 100644 index 0000000..881d8e8 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bootstrap/select.html @@ -0,0 +1,13 @@ +
+ + + {% if error %}
{{ error }}
{% endif %} +
diff --git a/src/cf_ui/templates/cotton/_themes/bootstrap/table.html b/src/cf_ui/templates/cotton/_themes/bootstrap/table.html new file mode 100644 index 0000000..26c15a1 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bootstrap/table.html @@ -0,0 +1,21 @@ +{% load cf_ui %} +
+ + + + {% for col in columns %} + + {% endfor %} + + + + {% for row in rows %} + + {% for col in columns %} + + {% endfor %} + + {% endfor %} + +
{{ col.label }}
{{ row|get_item:col.key }}
+
diff --git a/src/cf_ui/templates/cotton/_themes/bootstrap/tabs.html b/src/cf_ui/templates/cotton/_themes/bootstrap/tabs.html new file mode 100644 index 0000000..9bbfbb3 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bootstrap/tabs.html @@ -0,0 +1,24 @@ +
+ +
{{ slot }}
+
diff --git a/src/cf_ui/templates/cotton/_themes/bootstrap/textarea.html b/src/cf_ui/templates/cotton/_themes/bootstrap/textarea.html new file mode 100644 index 0000000..29cedbb --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bootstrap/textarea.html @@ -0,0 +1,8 @@ +
+ + + {% if error %}
{{ error }}
{% endif %} +
diff --git a/src/cf_ui/templates/cotton/bootstrap/PLANNED.md b/src/cf_ui/templates/cotton/bootstrap/PLANNED.md deleted file mode 100644 index 78a18a0..0000000 --- a/src/cf_ui/templates/cotton/bootstrap/PLANNED.md +++ /dev/null @@ -1,3 +0,0 @@ -# Theme: Bootstrap — Planned - -Components for this theme are tracked in GitHub Issues. diff --git a/src/cf_ui/templates/jinja/bootstrap/Breadcrumb.jinja b/src/cf_ui/templates/jinja/bootstrap/Breadcrumb.jinja new file mode 100644 index 0000000..97cfb5b --- /dev/null +++ b/src/cf_ui/templates/jinja/bootstrap/Breadcrumb.jinja @@ -0,0 +1,16 @@ +{#def items=[], extra_class="" #} +{% set items = items if items is defined else [] %} +{% set extra_class = extra_class if extra_class is defined else "" %} + diff --git a/src/cf_ui/templates/jinja/bootstrap/Card.jinja b/src/cf_ui/templates/jinja/bootstrap/Card.jinja new file mode 100644 index 0000000..bac080e --- /dev/null +++ b/src/cf_ui/templates/jinja/bootstrap/Card.jinja @@ -0,0 +1,16 @@ +{#def content="", header="", footer="", extra_class="" #} +{% set header = header if header is defined else "" %} +{% set content = content if content is defined else "" %} +{% set footer = footer if footer is defined else "" %} +{% set extra_class = extra_class if extra_class is defined else "" %} +
+ {% if header %} +
+
{{ header }}
+
+ {% endif %} +
{{ content }}
+ {% if footer %} + + {% endif %} +
diff --git a/src/cf_ui/templates/jinja/bootstrap/CheckboxGroup.jinja b/src/cf_ui/templates/jinja/bootstrap/CheckboxGroup.jinja new file mode 100644 index 0000000..2240de3 --- /dev/null +++ b/src/cf_ui/templates/jinja/bootstrap/CheckboxGroup.jinja @@ -0,0 +1,26 @@ +{#def name, label, choices=[], selected=[], error="", extra_class="", control_class="" #} +{% set choices = choices if choices is defined else [] %} +{% set selected = selected if selected is defined else [] %} +{% set error = error if error is defined else "" %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{% set control_class = control_class if control_class is defined else "" %} +
+ + + {% for choice in choices %} +
+ + +
+ {% endfor %} +
+ {# `.invalid-feedback` is display:none until an `.is-invalid` sibling precedes + it, and a checkbox group has none — `d-block` keeps the message from + rendering invisibly. #} + {% if error %}
{{ error }}
{% endif %} + diff --git a/src/cf_ui/templates/jinja/bootstrap/FormField.jinja b/src/cf_ui/templates/jinja/bootstrap/FormField.jinja new file mode 100644 index 0000000..6887d59 --- /dev/null +++ b/src/cf_ui/templates/jinja/bootstrap/FormField.jinja @@ -0,0 +1,16 @@ +{#def name, label, value="", error="", type="text", required=false, extra_class="", input_class="" #} +{% set value = value if value is defined else "" %} +{% set error = error if error is defined else "" %} +{% set type = type if type is defined else "text" %} +{% set required = required if required is defined else false %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{% set input_class = input_class if input_class is defined else "" %} +
+ + + {% if error %}
{{ error }}
{% endif %} +
diff --git a/src/cf_ui/templates/jinja/bootstrap/Modal.jinja b/src/cf_ui/templates/jinja/bootstrap/Modal.jinja new file mode 100644 index 0000000..73b52ba --- /dev/null +++ b/src/cf_ui/templates/jinja/bootstrap/Modal.jinja @@ -0,0 +1,37 @@ +{#def id="modal", header="", content="", footer="", label="Dialog", extra_class="" #} +{% set id = id if id is defined else "modal" %} +{% set header = header if header is defined else "" %} +{% set content = content if content is defined else "" %} +{% set footer = footer if footer is defined else "" %} +{% set label = label if label is defined and label else "Dialog" %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{# `.modal` is display:none and `.show` only sets opacity — Bootstrap's own JS + is what writes `style.display = "block"`. cf-ui loads none, so the reveal + rides on `d-block`, a real Bootstrap utility Alpine can toggle. #} + diff --git a/src/cf_ui/templates/jinja/bootstrap/Navbar.jinja b/src/cf_ui/templates/jinja/bootstrap/Navbar.jinja new file mode 100644 index 0000000..80725f9 --- /dev/null +++ b/src/cf_ui/templates/jinja/bootstrap/Navbar.jinja @@ -0,0 +1,26 @@ +{#def brand="", start="", end="", extra_class="" #} +{% set brand = brand if brand is defined else "" %} +{% set start = start if start is defined else "" %} +{% set end = end if end is defined else "" %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{# `.collapse:not(.show) { display: none }` and + `.navbar-expand-lg .navbar-collapse { display: flex !important }` are both + plain CSS, so the menu collapses on mobile and stays open on desktop with + Alpine off. Only the toggle needs JS. #} + diff --git a/src/cf_ui/templates/jinja/bootstrap/Notification.jinja b/src/cf_ui/templates/jinja/bootstrap/Notification.jinja new file mode 100644 index 0000000..40f84ca --- /dev/null +++ b/src/cf_ui/templates/jinja/bootstrap/Notification.jinja @@ -0,0 +1,16 @@ +{#def message, type="info", dismissible=true, extra_class="" #} +{% set type = type if type is defined else "info" %} +{% set dismissible = dismissible if dismissible is defined else true %} +{% set extra_class = extra_class if extra_class is defined else "" %} + diff --git a/src/cf_ui/templates/jinja/bootstrap/PLANNED.md b/src/cf_ui/templates/jinja/bootstrap/PLANNED.md deleted file mode 100644 index 78a18a0..0000000 --- a/src/cf_ui/templates/jinja/bootstrap/PLANNED.md +++ /dev/null @@ -1,3 +0,0 @@ -# Theme: Bootstrap — Planned - -Components for this theme are tracked in GitHub Issues. diff --git a/src/cf_ui/templates/jinja/bootstrap/Pagination.jinja b/src/cf_ui/templates/jinja/bootstrap/Pagination.jinja new file mode 100644 index 0000000..11a2a17 --- /dev/null +++ b/src/cf_ui/templates/jinja/bootstrap/Pagination.jinja @@ -0,0 +1,45 @@ +{#def page=1, total_pages=1, hx_target="", hx_url="", extra_class="" #} +{% set page = page if page is defined else 1 %} +{% set total_pages = total_pages if total_pages is defined else 1 %} +{% set hx_target = hx_target if hx_target is defined else "" %} +{% set hx_url = hx_url if hx_url is defined else "" %} +{% set extra_class = extra_class if extra_class is defined else "" %} + diff --git a/src/cf_ui/templates/jinja/bootstrap/Panel.jinja b/src/cf_ui/templates/jinja/bootstrap/Panel.jinja new file mode 100644 index 0000000..9ea0ad2 --- /dev/null +++ b/src/cf_ui/templates/jinja/bootstrap/Panel.jinja @@ -0,0 +1,30 @@ +{#def title, id="panel", content="", open=false, extra_class="" #} +{% set content = content if content is defined else "" %} +{% set id = id if id is defined else "panel" %} +{% set open = open if open is defined else false %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{# The body deliberately carries no `.collapse`: that class is display:none + without `.show`, which would hide a server-open panel permanently once + Alpine is off. `x-show` owns display here, seeded from `data-cf-open`. #} +
+
+

+ +

+
+
{{ content }}
+
+
+
diff --git a/src/cf_ui/templates/jinja/bootstrap/Progress.jinja b/src/cf_ui/templates/jinja/bootstrap/Progress.jinja new file mode 100644 index 0000000..e3b2b00 --- /dev/null +++ b/src/cf_ui/templates/jinja/bootstrap/Progress.jinja @@ -0,0 +1,22 @@ +{#def value=0, max=100, type="primary", label="", extra_class="" #} +{% set value = value if value is defined else 0 %} +{% set max = max if max is defined else 100 %} +{% set type = type if type is defined else "primary" %} +{% set label = label if label is defined else "" %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{# Bootstrap's bar is a div sized in percent, not a native , so the + width has to be computed from value/max rather than emitted verbatim. #} +{% set cf_max = max | float %} +{% set cf_pct = (((value | float) / cf_max * 100) | round | int) if cf_max else 0 %} +
+ {% if label %}

{{ label }}

{% endif %} +
+
+
+
diff --git a/src/cf_ui/templates/jinja/bootstrap/Select.jinja b/src/cf_ui/templates/jinja/bootstrap/Select.jinja new file mode 100644 index 0000000..dae885e --- /dev/null +++ b/src/cf_ui/templates/jinja/bootstrap/Select.jinja @@ -0,0 +1,19 @@ +{#def name, label, value="", error="", options=[], extra_class="", input_class="" #} +{% set value = value if value is defined else "" %} +{% set error = error if error is defined else "" %} +{% set options = options if options is defined else [] %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{% set input_class = input_class if input_class is defined else "" %} +
+ + + {% if error %}
{{ error }}
{% endif %} +
diff --git a/src/cf_ui/templates/jinja/bootstrap/Table.jinja b/src/cf_ui/templates/jinja/bootstrap/Table.jinja new file mode 100644 index 0000000..c2d0049 --- /dev/null +++ b/src/cf_ui/templates/jinja/bootstrap/Table.jinja @@ -0,0 +1,24 @@ +{#def columns=[], rows=[], hx_target="", hx_url="", extra_class="" #} +{% set columns = columns if columns is defined else [] %} +{% set rows = rows if rows is defined else [] %} +{% set extra_class = extra_class if extra_class is defined else "" %} +
+ + + + {% for col in columns %} + + {% endfor %} + + + + {% for row in rows %} + + {% for col in columns %} + + {% endfor %} + + {% endfor %} + +
{{ col.label }}
{{ row[col.key] }}
+
diff --git a/src/cf_ui/templates/jinja/bootstrap/Tabs.jinja b/src/cf_ui/templates/jinja/bootstrap/Tabs.jinja new file mode 100644 index 0000000..40b274d --- /dev/null +++ b/src/cf_ui/templates/jinja/bootstrap/Tabs.jinja @@ -0,0 +1,30 @@ +{#def tabs=[], hx_target="tab-content", active="", content="", extra_class="" #} +{% set tabs = tabs if tabs is defined else [] %} +{% set content = content if content is defined else "" %} +{% set active = active if active is defined else "" %} +{% set hx_target = hx_target if hx_target is defined else "tab-content" %} +{% set extra_class = extra_class if extra_class is defined else "" %} +
+ +
{{ content }}
+
diff --git a/src/cf_ui/templates/jinja/bootstrap/Textarea.jinja b/src/cf_ui/templates/jinja/bootstrap/Textarea.jinja new file mode 100644 index 0000000..944164a --- /dev/null +++ b/src/cf_ui/templates/jinja/bootstrap/Textarea.jinja @@ -0,0 +1,14 @@ +{#def name, label, value="", error="", rows=4, extra_class="", input_class="" #} +{% set value = value if value is defined else "" %} +{% set error = error if error is defined else "" %} +{% set rows = rows if rows is defined else 4 %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{% set input_class = input_class if input_class is defined else "" %} +
+ + + {% if error %}
{{ error }}
{% endif %} +
diff --git a/src/cf_ui/themes.py b/src/cf_ui/themes.py index 3fbd49e..42cdde9 100644 --- a/src/cf_ui/themes.py +++ b/src/cf_ui/themes.py @@ -26,7 +26,7 @@ #: Themes with a real component set. The other directories under #: ``templates/`` hold a ``PLANNED.md`` stub and are not selectable. -THEMES = ("bulma", "daisy") +THEMES = ("bulma", "daisy", "bootstrap") DEFAULT_THEME = "bulma" diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 4e41381..449a84b 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -76,6 +76,25 @@ def daisy_cotton_server_url() -> Generator[str, None, None]: proc.wait(timeout=5) +@pytest.fixture(scope="session") +def bootstrap_jinja_server_url() -> Generator[str, None, None]: + thread = threading.Thread(target=_run_fastapi_server, args=("bootstrap", "127.0.0.1", 8775)) + thread.daemon = True + thread.start() + time.sleep(2.0) + yield "http://127.0.0.1:8775" + + +@pytest.fixture(scope="session") +def bootstrap_cotton_server_url() -> Generator[str, None, None]: + """The same consumer templates again, CF_UI_THEME="bootstrap".""" + proc = _start_cotton_server(8776, "bootstrap") + time.sleep(2.0) + yield "http://127.0.0.1:8776" + proc.terminate() + proc.wait(timeout=5) + + @pytest.fixture(params=["js_on", "js_off"]) def jinja_page(request, browser, jinja_server_url): ctx = browser.new_context(java_script_enabled=(request.param == "js_on")) @@ -110,3 +129,21 @@ def daisy_cotton_page(request, browser, daisy_cotton_server_url): page.set_default_timeout(5000) yield page, request.param ctx.close() + + +@pytest.fixture(params=["js_on", "js_off"]) +def bootstrap_jinja_page(request, browser, bootstrap_jinja_server_url): + ctx = browser.new_context(java_script_enabled=(request.param == "js_on")) + page = ctx.new_page() + page.set_default_timeout(5000) + yield page, request.param + ctx.close() + + +@pytest.fixture(params=["js_on", "js_off"]) +def bootstrap_cotton_page(request, browser, bootstrap_cotton_server_url): + ctx = browser.new_context(java_script_enabled=(request.param == "js_on")) + page = ctx.new_page() + page.set_default_timeout(5000) + yield page, request.param + ctx.close() diff --git a/tests/e2e/test_bootstrap.py b/tests/e2e/test_bootstrap.py new file mode 100644 index 0000000..869d579 --- /dev/null +++ b/tests/e2e/test_bootstrap.py @@ -0,0 +1,379 @@ +"""Bootstrap 5 E2E coverage (issue #22), parameterized over js_on / js_off. + +Two claims this tier exists to execute rather than assert: + +* The cotton pages render the *same* consumer templates as the Bulma and + DaisyUI E2E servers — ``tests/integration/cotton_app/templates/`` is neither + duplicated nor edited — through the real django-cotton compiler, with nothing + changed but ``CF_UI_THEME``. +* The behaviour is Alpine's, not Bootstrap's. No ``bootstrap.bundle.js`` is + loaded anywhere, so every open/close/switch below is proof that + ``cf_ui_alpine.js`` drives Bootstrap markup unaided. +""" + +import re + +import pytest +from playwright.sync_api import expect + + +def _wait_for_alpine(page) -> None: + page.wait_for_function( + "() => window.Alpine !== undefined && document.querySelectorAll('[x-cloak]').length === 0", + timeout=8000, + ) + + +def _wait_for_modal_focus(page) -> None: + """Wait for the dialog to actually hold focus — see the Bulma suite. + + `_focusFirst` retries across animation frames, so a one-shot read of + `document.activeElement` right after the reveal is a race that only a slow + machine loses. + """ + page.wait_for_function( + "() => document.getElementById('e2e-modal').contains(document.activeElement)", + timeout=5000, + ) + + +# --- No Bootstrap JavaScript anywhere on the page -------------------------- + + +def test_jinja_page_loads_no_bootstrap_javascript(bootstrap_jinja_page, bootstrap_jinja_server_url): + """Two state owners for `open` is the failure this theme is shaped around.""" + page, _ = bootstrap_jinja_page + page.goto(f"{bootstrap_jinja_server_url}/gallery") + srcs = page.eval_on_selector_all("script[src]", "els => els.map(e => e.src)") + assert not [s for s in srcs if "bootstrap" in s] + expect(page.locator("[data-bs-toggle]")).to_have_count(0) + + +def test_cotton_page_loads_no_bootstrap_javascript( + bootstrap_cotton_page, bootstrap_cotton_server_url +): + page, _ = bootstrap_cotton_page + page.goto(f"{bootstrap_cotton_server_url}/modal/") + expect(page.locator("[data-bs-toggle]")).to_have_count(0) + expect(page.locator("[data-bs-dismiss]")).to_have_count(0) + + +# --- django-cotton: one setting, no consumer template edits ---------------- + + +def test_cotton_form_field_renders_bootstrap_markup( + bootstrap_cotton_page, bootstrap_cotton_server_url +): + page, _ = bootstrap_cotton_page + page.goto(f"{bootstrap_cotton_server_url}/form-field/") + expect(page.locator("input[name='email']")).to_be_attached() + expect(page.locator("input.form-control")).to_be_attached() + expect(page.locator("label.form-label")).to_have_text("Email") + + +def test_cotton_form_field_has_no_bulma_markup(bootstrap_cotton_page, bootstrap_cotton_server_url): + """The Bulma partial must not leak through the dispatch.""" + page, _ = bootstrap_cotton_page + page.goto(f"{bootstrap_cotton_server_url}/form-field/") + expect(page.locator(".field")).to_have_count(0) + + +def test_cotton_card_renders_bootstrap_markup(bootstrap_cotton_page, bootstrap_cotton_server_url): + page, _ = bootstrap_cotton_page + page.goto(f"{bootstrap_cotton_server_url}/card/") + expect(page.locator(".card-header")).to_contain_text("Card Title") + expect(page.locator(".card-footer")).to_contain_text("Card Footer") + + +def test_cotton_card_slot_survives_the_dispatch(bootstrap_cotton_page, bootstrap_cotton_server_url): + """`{% include %}` must carry {{ slot }} into the theme partial.""" + page, _ = bootstrap_cotton_page + page.goto(f"{bootstrap_cotton_server_url}/card/") + expect(page.locator(".card-body")).to_contain_text("Card body content") + + +def test_cotton_modal_named_slot_survives_the_dispatch( + bootstrap_cotton_page, bootstrap_cotton_server_url +): + page, _ = bootstrap_cotton_page + page.goto(f"{bootstrap_cotton_server_url}/modal/") + expect(page.locator("#test-modal .modal-title")).to_contain_text("Test Modal") + expect(page.locator("#test-modal .modal-body")).to_contain_text("Modal body content") + + +def test_cotton_modal_closed_by_default(bootstrap_cotton_page, bootstrap_cotton_server_url): + """Class-level only: the cotton gallery pages are bare fragments that load + neither a stylesheet nor Alpine, so `display` proves nothing here. The + visibility claim is made against the JinjaX gallery, which loads both.""" + page, js_mode = bootstrap_cotton_page + page.goto(f"{bootstrap_cotton_server_url}/modal/") + modal = page.locator("#test-modal") + expect(modal).to_be_attached() + if js_mode == "js_on": + expect(modal).not_to_have_class(re.compile(r"\bshow\b")) + expect(modal).not_to_have_class(re.compile(r"\bd-block\b")) + + +def test_cotton_modal_declares_dialog_semantics(bootstrap_cotton_page, bootstrap_cotton_server_url): + page, _ = bootstrap_cotton_page + page.goto(f"{bootstrap_cotton_server_url}/modal/") + modal = page.locator("#test-modal") + expect(modal).to_have_attribute("role", "dialog") + expect(modal).to_have_attribute("aria-modal", "true") + expect(modal).to_have_attribute("aria-labelledby", "test-modal-title") + + +def test_cotton_tabs_render_the_active_tab(bootstrap_cotton_page, bootstrap_cotton_server_url): + """`active` has to survive — unit tests bypass that compiler.""" + page, _ = bootstrap_cotton_page + page.goto(f"{bootstrap_cotton_server_url}/tabs/") + expect(page.locator("[role='tab'].active")).to_have_count(1) + expect(page.locator("[role='tab'].active")).to_have_text("tab1") + expect(page.locator("[role='tab'][aria-selected='true']")).to_have_count(1) + expect(page.locator("[role='tab'][tabindex='0']")).to_have_count(1) + + +def test_cotton_panel_honors_its_open_prop(bootstrap_cotton_page, bootstrap_cotton_server_url): + page, _ = bootstrap_cotton_page + page.goto(f"{bootstrap_cotton_server_url}/panel/") + expect(page.locator("#open-panel-body")).not_to_have_attribute("x-cloak", "") + expect(page.locator('button[aria-controls="open-panel-body"]')).to_have_attribute( + "aria-expanded", "true" + ) + expect(page.locator('button[aria-controls="closed-panel-body"]')).to_have_attribute( + "aria-expanded", "false" + ) + + +def test_cotton_open_panel_is_readable_without_js( + bootstrap_cotton_page, bootstrap_cotton_server_url +): + """The page ships the `[x-cloak] { display: none }` rule but no Alpine, so + this is exactly the JS-less case: an open panel must not emit x-cloak.""" + page, _ = bootstrap_cotton_page + page.goto(f"{bootstrap_cotton_server_url}/panel/") + expect(page.locator("#open-panel-body")).to_be_visible() + expect(page.locator("#closed-panel-body")).to_be_hidden() + + +# --- JinjaX: same Alpine contract, Bootstrap classes ----------------------- + + +def test_jinja_modal_opens_and_closes(bootstrap_jinja_page, bootstrap_jinja_server_url): + """cfModal is theme-independent; only the toggled classes differ. + + `.modal` is `display:none` and Bootstrap's `.show` only sets opacity — its + own JS is what writes `display:block`. Asserting visibility rather than the + class list is what catches a reveal that changes classes and nothing else. + """ + page, js_mode = bootstrap_jinja_page + page.goto(f"{bootstrap_jinja_server_url}/gallery") + + modal = page.locator("#e2e-modal") + if js_mode == "js_on": + _wait_for_alpine(page) + expect(modal).to_be_hidden() + page.evaluate("Alpine.store('cf').modal.open('e2e-modal')") + expect(modal).to_be_visible() + expect(modal).to_have_class(re.compile(r"\bshow\b")) + modal.locator("button[aria-label='close']").click() + expect(modal).to_be_hidden() + else: + expect(modal).to_be_attached() + + +def test_jinja_modal_backdrop_closes_and_does_not_swallow_the_dialog( + bootstrap_jinja_page, bootstrap_jinja_server_url +): + """The backdrop lives *inside* the modal here, which is not where Bootstrap + puts it — its JS appends one to at z-index 1050, below the modal's + 1055. Inside, that same 1050 would paint over the dialog and eat every + click. The negative z-index is what prevents it, so both halves are + asserted: the dialog still takes clicks, and the backdrop still closes.""" + page, js_mode = bootstrap_jinja_page + if js_mode != "js_on": + pytest.skip("backdrop dismissal requires JS") + page.goto(f"{bootstrap_jinja_server_url}/gallery") + _wait_for_alpine(page) + modal = page.locator("#e2e-modal") + + page.evaluate("Alpine.store('cf').modal.open('e2e-modal')") + expect(modal).to_be_visible() + # A control inside the dialog is still reachable, not covered. + expect(modal.locator("#modal-ok")).to_be_visible() + modal.locator("#modal-ok").click(timeout=2000) + expect(modal).to_be_visible() + + modal.locator(".modal-backdrop").click(position={"x": 10, "y": 10}) + expect(modal).to_be_hidden() + + +def test_jinja_notification_dismisses(bootstrap_jinja_page, bootstrap_jinja_server_url): + page, js_mode = bootstrap_jinja_page + page.goto(f"{bootstrap_jinja_server_url}/gallery") + notification = page.locator(".alert") + + if js_mode == "js_on": + _wait_for_alpine(page) + expect(notification).to_be_visible() + notification.locator("button[aria-label='dismiss']").click() + expect(notification).to_be_hidden() + else: + expect(notification).to_be_attached() + + +def test_jinja_panel_expands(bootstrap_jinja_page, bootstrap_jinja_server_url): + page, js_mode = bootstrap_jinja_page + page.goto(f"{bootstrap_jinja_server_url}/gallery") + panel_body = page.locator("#e2e-panel-body") + + if js_mode == "js_on": + _wait_for_alpine(page) + expect(panel_body).to_be_hidden() + page.locator('button[aria-controls="e2e-panel-body"]').click() + expect(panel_body).to_be_visible() + else: + expect(panel_body).to_be_attached() + + +def test_jinja_navbar_toggler_toggles_menu(bootstrap_jinja_page, bootstrap_jinja_server_url): + page, js_mode = bootstrap_jinja_page + page.goto(f"{bootstrap_jinja_server_url}/gallery") + + if js_mode == "js_on": + _wait_for_alpine(page) + page.set_viewport_size({"width": 600, "height": 800}) + menu = page.locator(".navbar-collapse") + # Assert the computed display, not the class list: `.collapse:not(.show)` + # is what hides it, and only the computed value proves the toggle + # actually reached that rule. + display = "el => getComputedStyle(el).display" + assert menu.evaluate(display) == "none" + page.locator("button[aria-label='menu']").click() + # Not to_be_visible(): the gallery passes empty start/end slots, so the + # box has zero height even once it is displayed. + assert menu.evaluate(display) != "none" + else: + expect(page.locator(".navbar")).to_be_attached() + + +def test_jinja_tabs_activate(bootstrap_jinja_page, bootstrap_jinja_server_url): + """Clicking a tab moves the marker; the server-rendered one is the start.""" + page, js_mode = bootstrap_jinja_page + if js_mode != "js_on": + pytest.skip("covered without JS by test_jinja_exactly_one_tab_is_active") + page.goto(f"{bootstrap_jinja_server_url}/gallery") + _wait_for_alpine(page) + + tabs = page.locator("[role='tab']") + expect(tabs.nth(0)).to_have_class(re.compile(r"\bactive\b")) + tabs.nth(1).click() + expect(tabs.nth(1)).to_have_class(re.compile(r"\bactive\b")) + expect(tabs.nth(0)).not_to_have_class(re.compile(r"\bactive\b")) + + +def test_jinja_exactly_one_tab_is_active(bootstrap_jinja_page, bootstrap_jinja_server_url): + """#21: `to_be_attached()` passed against markup that was unusable.""" + page, js_mode = bootstrap_jinja_page + page.goto(f"{bootstrap_jinja_server_url}/gallery") + if js_mode == "js_on": + _wait_for_alpine(page) + expect(page.locator("[role='tab'].active")).to_have_count(1) + expect(page.locator("[role='tab'].active")).to_have_text("tab1") + expect(page.locator("[role='tab'][aria-selected='true']")).to_have_count(1) + expect(page.locator("[role='tab'][tabindex='0']")).to_have_count(1) + + +def test_jinja_arrow_keys_rove_focus_across_the_tablist( + bootstrap_jinja_page, bootstrap_jinja_server_url +): + page, js_mode = bootstrap_jinja_page + if js_mode != "js_on": + pytest.skip("roving tabindex requires JS") + page.goto(f"{bootstrap_jinja_server_url}/gallery") + _wait_for_alpine(page) + + page.locator("[role='tab']").first.focus() + page.keyboard.press("ArrowRight") + assert page.evaluate("() => document.activeElement.dataset.cfTab") == "tab2" + page.keyboard.press("Home") + assert page.evaluate("() => document.activeElement.dataset.cfTab") == "tab1" + + +# --- Dialog semantics and focus management (#21) --------------------------- + + +def test_jinja_modal_declares_dialog_semantics(bootstrap_jinja_page, bootstrap_jinja_server_url): + page, _ = bootstrap_jinja_page + page.goto(f"{bootstrap_jinja_server_url}/gallery") + modal = page.locator("#e2e-modal") + expect(modal).to_have_attribute("role", "dialog") + expect(modal).to_have_attribute("aria-modal", "true") + expect(modal).to_have_attribute("aria-labelledby", "e2e-modal-title") + + +def test_jinja_modal_manages_focus(bootstrap_jinja_page, bootstrap_jinja_server_url): + """Same Alpine contract as Bulma — the theme changes classes, not behavior.""" + page, js_mode = bootstrap_jinja_page + if js_mode != "js_on": + pytest.skip("focus management requires JS") + page.goto(f"{bootstrap_jinja_server_url}/gallery") + _wait_for_alpine(page) + + page.locator("#open-modal").click() + _wait_for_modal_focus(page) + page.keyboard.press("Escape") + expect(page.locator("#e2e-modal")).to_be_hidden() + assert page.evaluate("() => document.activeElement.id") == "open-modal" + + +def test_jinja_tab_does_not_escape_the_open_modal(bootstrap_jinja_page, bootstrap_jinja_server_url): + page, js_mode = bootstrap_jinja_page + if js_mode != "js_on": + pytest.skip("focus management requires JS") + page.goto(f"{bootstrap_jinja_server_url}/gallery") + _wait_for_alpine(page) + + page.locator("#open-modal").click() + _wait_for_modal_focus(page) + inside = "() => document.getElementById('e2e-modal').contains(document.activeElement)" + for _ in range(6): + page.keyboard.press("Tab") + assert page.evaluate(inside), "focus escaped the dialog on Tab" + + +def test_jinja_open_panel_is_readable_without_js(bootstrap_jinja_page, bootstrap_jinja_server_url): + page, js_mode = bootstrap_jinja_page + page.goto(f"{bootstrap_jinja_server_url}/gallery") + if js_mode == "js_on": + _wait_for_alpine(page) + expect(page.locator("#e2e-panel-open-body")).to_be_visible() + + +def test_jinja_panel_toggle_reports_its_state(bootstrap_jinja_page, bootstrap_jinja_server_url): + page, js_mode = bootstrap_jinja_page + page.goto(f"{bootstrap_jinja_server_url}/gallery") + if js_mode == "js_on": + _wait_for_alpine(page) + expect(page.locator('button[aria-controls="e2e-panel-body"]')).to_have_attribute( + "aria-expanded", "false" + ) + expect(page.locator('button[aria-controls="e2e-panel-open-body"]')).to_have_attribute( + "aria-expanded", "true" + ) + + +def test_jinja_form_field_renders(bootstrap_jinja_page, bootstrap_jinja_server_url): + page, _ = bootstrap_jinja_page + page.goto(f"{bootstrap_jinja_server_url}/form-field") + expect(page.locator("input[name='email']")).to_be_visible() + expect(page.locator(".form-label")).to_have_text("Email") + + +def test_jinja_page_usable_without_js(bootstrap_jinja_page, bootstrap_jinja_server_url): + page, js_mode = bootstrap_jinja_page + if js_mode != "js_off": + pytest.skip("only runs in js_off mode") + page.goto(f"{bootstrap_jinja_server_url}/gallery") + expect(page.locator("section.section")).to_be_visible() + expect(page.locator("body")).not_to_be_empty() diff --git a/tests/integration/jinja_app/main.py b/tests/integration/jinja_app/main.py index a9c54f4..b2ffddf 100644 --- a/tests/integration/jinja_app/main.py +++ b/tests/integration/jinja_app/main.py @@ -11,6 +11,7 @@ _THEME_CSS = { "bulma": "https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css", "daisy": "https://cdn.jsdelivr.net/npm/daisyui@4.7.2/dist/full.min.css", + "bootstrap": "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css", } # DaisyUI ships component classes but no Tailwind utilities. The components use @@ -21,6 +22,9 @@ _THEME_EXTRA_HEAD = { "bulma": "", "daisy": '', + # Bootstrap ships prebuilt CSS and cf-ui deliberately loads none of its + # JavaScript — Alpine owns modal, tab and panel state in every theme. + "bootstrap": "", } diff --git a/tests/unit/cotton/test_bootstrap.py b/tests/unit/cotton/test_bootstrap.py new file mode 100644 index 0000000..523eb7e --- /dev/null +++ b/tests/unit/cotton/test_bootstrap.py @@ -0,0 +1,298 @@ +"""Bootstrap 5 component set, django-cotton side (issue #22). + +These go through the public ``cotton/cf/.html`` entry points with +``CF_UI_THEME = "bootstrap"``, so they exercise the dispatch wrapper as well as +the partial. ``render_to_string`` bypasses the django-cotton compiler (props +arrive as plain context), which is why the E2E tier still matters — but the +``{% include %}`` dispatch itself is real Django template machinery and is +fully exercised here. +""" + +from pathlib import Path + +import pytest + +PARTIALS_DIR = ( + Path(__file__).parent.parent.parent.parent + / "src" + / "cf_ui" + / "templates" + / "cotton" + / "_themes" + / "bootstrap" +) + +STEMS = [ + "breadcrumb", + "card", + "checkbox-group", + "form-field", + "modal", + "navbar", + "notification", + "pagination", + "panel", + "progress", + "select", + "table", + "tabs", + "textarea", +] + + +@pytest.fixture +def bootstrap_render(settings, cotton_render): + settings.CF_UI_THEME = "bootstrap" + return cotton_render + + +# --- The set is complete, and carries no Bootstrap JS ---------------------- + + +def test_the_legacy_planned_stub_directory_is_gone(): + """`cotton/bootstrap/` predates the `_themes/` dispatch introduced in #6.""" + legacy = PARTIALS_DIR.parent.parent / "bootstrap" + assert not legacy.exists() + + +@pytest.mark.parametrize("stem", STEMS) +def test_partial_uses_no_bootstrap_javascript_api(stem): + """Alpine owns modal/tab/panel state in every theme — see issue #22.""" + source = (PARTIALS_DIR / f"{stem}.html").read_text(encoding="utf-8") + assert "data-bs-" not in source + assert "bootstrap.bundle" not in source + + +# --- Forms ----------------------------------------------------------------- + + +def test_form_field_uses_bootstrap_input_classes(bootstrap_render): + html = bootstrap_render("cf/form-field.html", name="email", label="Email Address") + assert "form-label" in html + assert "form-control" in html + assert "Email Address" in html + + +def test_form_field_error_uses_bootstrap_validation_classes(bootstrap_render): + html = bootstrap_render("cf/form-field.html", name="email", label="Email", error="Required") + assert "is-invalid" in html + assert "invalid-feedback d-block" in html + assert "Required" in html + + +def test_form_field_required_flag(bootstrap_render): + """`required>` rather than `required` — the uncompiled line the + wrapper emits under ``render_to_string`` contains ``required="false"``, + so a bare substring check passes either way.""" + html = bootstrap_render("cf/form-field.html", name="email", label="Email", required="true") + assert "required>" in html + + +def test_form_field_not_required_by_default(bootstrap_render): + """ arrives as the *string* "false".""" + html = bootstrap_render("cf/form-field.html", name="email", label="Email", required="false") + assert "required>" not in html + + +def test_form_field_input_class_still_applied(bootstrap_render): + html = bootstrap_render( + "cf/form-field.html", name="e", label="E", input_class="form-control-lg" + ) + assert "form-control-lg" in html + + +def test_select_uses_the_bootstrap_select_class(bootstrap_render): + html = bootstrap_render( + "cf/select.html", + name="choice", + label="Choose", + options=[{"value": "a", "label": "Option A"}], + ) + assert "form-select" in html + assert "Option A" in html + + +def test_select_marks_the_current_value(bootstrap_render): + html = bootstrap_render( + "cf/select.html", + name="choice", + label="Choose", + value="a", + options=[{"value": "a", "label": "A"}, {"value": "b", "label": "B"}], + ) + assert "selected" in html + + +def test_textarea_uses_the_form_control_class(bootstrap_render): + html = bootstrap_render("cf/textarea.html", name="bio", label="Bio", value="Hello", rows="4") + assert "form-control" in html + assert "Hello" in html + + +def test_checkbox_group_uses_the_form_check_classes(bootstrap_render): + html = bootstrap_render( + "cf/checkbox-group.html", + name="fruits", + label="Fruits", + choices=[{"value": "a", "label": "Apple"}], + selected=["a"], + ) + assert "form-check-input" in html + assert "form-check-label" in html + assert "checked" in html + assert "Apple" in html + + +def test_checkbox_group_labels_point_at_their_input(bootstrap_render): + html = bootstrap_render( + "cf/checkbox-group.html", + name="fruit", + label="Fruit", + choices=[{"value": "a", "label": "A"}, {"value": "b", "label": "B"}], + selected=[], + ) + assert 'id="fruit-a"' in html + assert 'for="fruit-b"' in html + + +def test_checkbox_group_control_class_still_applied(bootstrap_render): + html = bootstrap_render( + "cf/checkbox-group.html", + name="f", + label="F", + choices=[{"value": "a", "label": "A"}], + selected=[], + control_class="d-flex", + ) + assert "d-flex" in html + + +# --- Feedback -------------------------------------------------------------- + + +def test_modal_keeps_the_alpine_contract(bootstrap_render): + html = bootstrap_render("cf/modal.html", id="my-modal") + assert 'id="my-modal"' in html + assert 'x-data="cfModal"' in html + assert "initModal" in html + assert "close()" in html + + +def test_modal_uses_bootstrap_modal_classes(bootstrap_render): + html = bootstrap_render("cf/modal.html", id="m") + assert "modal-dialog" in html + assert "modal-content" in html + assert "modal-body" in html + assert "btn-close" in html + + +def test_modal_reveal_toggles_display_not_just_show(bootstrap_render): + html = bootstrap_render("cf/modal.html", id="m") + assert "d-block" in html + assert "'show'" in html + + +def test_notification_maps_danger_to_a_bootstrap_alert(bootstrap_render): + html = bootstrap_render("cf/notification.html", message="Boom", type="danger") + assert "alert-danger" in html + assert "alert-error" not in html + + +def test_notification_maps_error_onto_danger(bootstrap_render): + html = bootstrap_render("cf/notification.html", message="Boom", type="error") + assert "alert-danger" in html + + +def test_notification_dismissible(bootstrap_render): + html = bootstrap_render( + "cf/notification.html", message="Saved!", type="success", dismissible="true" + ) + assert "alert-success" in html + assert "alert-dismissible" in html + assert "visible = false" in html + + +def test_notification_non_dismissible_omits_the_button(bootstrap_render): + html = bootstrap_render("cf/notification.html", message="Hi", type="info", dismissible="false") + assert "visible = false" not in html + + +def test_progress_uses_the_bootstrap_progress_bar_markup(bootstrap_render): + html = bootstrap_render("cf/progress.html", value="40", max="100", type="primary") + assert "progress-bar" in html + assert "bg-primary" in html + assert 'role="progressbar"' in html + assert 'aria-valuenow="40"' in html + assert "width: 40%" in html + + +def test_progress_scales_the_bar_to_a_non_percentage_max(bootstrap_render): + html = bootstrap_render("cf/progress.html", value="1", max="4", type="info") + assert "width: 25%" in html + + +# --- Content + navigation -------------------------------------------------- + + +def test_card_renders_header_body_footer(bootstrap_render): + html = bootstrap_render("cf/card.html", header="Title", slot="Body", footer="Foot") + assert "card-header" in html + assert "card-body" in html + assert "card-footer" in html + assert "Body" in html + assert "Foot" in html + + +def test_table_uses_bootstrap_table_classes(bootstrap_render): + html = bootstrap_render( + "cf/table.html", columns=[{"key": "n", "label": "Name"}], rows=[{"n": "Ada"}] + ) + assert "table-striped" in html + assert "is-striped" not in html + assert "Name" in html + assert "Ada" in html + + +def test_pagination_uses_the_page_item_list(bootstrap_render): + html = bootstrap_render( + "cf/pagination.html", page="2", total_pages="3", hx_url="/x", hx_target="#t" + ) + assert "page-item" in html + assert "page-link" in html + assert 'aria-current="page"' in html + + +def test_panel_uses_the_accordion_markup(bootstrap_render): + html = bootstrap_render("cf/panel.html", title="Details", slot="Inner") + assert "accordion-button" in html + assert "accordion-body" in html + assert 'x-data="cfPanel"' in html + assert "x-cloak" in html + assert "Inner" in html + + +def test_panel_body_is_not_a_bootstrap_collapse(bootstrap_render): + html = bootstrap_render("cf/panel.html", id="p", title="T", slot="Inner", open="true") + assert 'class="collapse' not in html + assert " collapse " not in html + + +def test_navbar_keeps_the_alpine_contract(bootstrap_render): + html = bootstrap_render("cf/navbar.html", brand="Brand", start="S", end="E") + assert 'x-data="cfNavbar"' in html + assert "navbar-toggler" in html + assert "navbar-collapse" in html + + +def test_breadcrumb_uses_the_bootstrap_breadcrumb_classes(bootstrap_render): + html = bootstrap_render("cf/breadcrumb.html", items=[{"url": "/a", "label": "A"}]) + assert 'class="breadcrumb"' in html + assert "breadcrumb-item" in html + assert 'aria-current="page"' in html + + +def test_tabs_keep_the_alpine_contract(bootstrap_render): + html = bootstrap_render("cf/tabs.html", tabs=[{"id": "one", "url": "/one"}], slot="C") + assert 'x-data="cfTabs"' in html + assert "setActive('one')" in html + assert "nav-tabs" in html diff --git a/tests/unit/jinja/test_bootstrap.py b/tests/unit/jinja/test_bootstrap.py new file mode 100644 index 0000000..1a82501 --- /dev/null +++ b/tests/unit/jinja/test_bootstrap.py @@ -0,0 +1,407 @@ +"""Bootstrap 5 component set, Jinja2/JinjaX side (issue #22). + +The Jinja theme switch is already directory-based — ``install_cf_ui`` registers +``templates/jinja//`` — so this tier only has to prove the bootstrap +directory ships all 14 components, that they render under ``StrictUndefined``, +and that they speak Bootstrap 5's class vocabulary rather than Bulma's or +DaisyUI's. + +One claim gets more attention than the rest: **no Bootstrap JavaScript.** +``data-bs-*`` is Bootstrap's own state API, and cf-ui already has one — +``cf_ui_alpine.js``. Two owners for the same ``open`` flag is how +``Alpine.store('cf').modal.open(id)`` stops meaning the same thing in every +theme, so the absence of ``data-bs-`` is asserted per component, not in prose. +""" + +from collections.abc import Callable +from pathlib import Path + +import pytest +from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape + +BOOTSTRAP_DIR = ( + Path(__file__).parent.parent.parent.parent + / "src" + / "cf_ui" + / "templates" + / "jinja" + / "bootstrap" +) + +COMPONENTS = [ + "Breadcrumb", + "Card", + "CheckboxGroup", + "FormField", + "Modal", + "Navbar", + "Notification", + "Pagination", + "Panel", + "Progress", + "Select", + "Table", + "Tabs", + "Textarea", +] + +# Minimum props each component needs to render at all. +REQUIRED_PROPS = { + "Breadcrumb": {"items": [{"url": "/a", "label": "A"}]}, + "Card": {}, + "CheckboxGroup": {"name": "f", "label": "F", "choices": [{"value": "a", "label": "A"}]}, + "FormField": {"name": "f", "label": "F"}, + "Modal": {}, + "Navbar": {}, + "Notification": {"message": "Hi"}, + "Pagination": {"page": 1, "total_pages": 3}, + "Panel": {"title": "T"}, + "Progress": {}, + "Select": {"name": "f", "label": "F", "options": [{"value": "a", "label": "A"}]}, + "Table": {"columns": [{"key": "k", "label": "K"}], "rows": [{"k": "v"}]}, + "Tabs": {"tabs": [{"id": "one", "url": "/one"}]}, + "Textarea": {"name": "f", "label": "F"}, +} + + +@pytest.fixture +def render() -> Callable[..., str]: + env = Environment( + loader=FileSystemLoader(BOOTSTRAP_DIR), + autoescape=select_autoescape(["html"]), + undefined=StrictUndefined, + ) + + def _render(template_name: str, **ctx: object) -> str: + return env.get_template(template_name).render(**ctx) + + return _render + + +# --- Parity with the Bulma set --------------------------------------------- + + +def test_bootstrap_ships_the_same_components_as_bulma(): + bulma_dir = BOOTSTRAP_DIR.parent / "bulma" + assert sorted(p.name for p in BOOTSTRAP_DIR.glob("*.jinja")) == sorted( + p.name for p in bulma_dir.glob("*.jinja") + ) + + +def test_the_planned_stub_is_gone(): + """A theme cannot be both shipped and planned.""" + assert not (BOOTSTRAP_DIR / "PLANNED.md").exists() + + +@pytest.mark.parametrize("name", COMPONENTS) +def test_component_renders_with_only_its_required_props(render, name): + """StrictUndefined: every optional prop needs an is-defined guard.""" + html = render(f"{name}.jinja", **REQUIRED_PROPS[name]) + assert html.strip() + + +@pytest.mark.parametrize("name", COMPONENTS) +def test_component_carries_no_other_themes_class_names(render, name): + html = render(f"{name}.jinja", **REQUIRED_PROPS[name]) + for marker in ( + "is-danger", + "is-active", + "card-header-title", + "navbar-burger", + "input-bordered", + "tab-active", + "join-item", + ): + assert marker not in html, f"{name} still speaks another theme" + + +# --- The constraint that makes this theme cf-ui's rather than Bootstrap's --- + + +@pytest.mark.parametrize("name", COMPONENTS) +def test_component_uses_no_bootstrap_javascript_api(render, name): + """Alpine owns modal/tab/panel state in every theme — see issue #22. + + ``data-bs-toggle`` would hand the same state to Bootstrap's bundle, and + the two would fight the moment a page loaded both. + """ + html = render(f"{name}.jinja", **REQUIRED_PROPS[name]) + assert "data-bs-" not in html + + +@pytest.mark.parametrize("name", COMPONENTS) +def test_component_source_names_no_bootstrap_bundle(name): + source = (BOOTSTRAP_DIR / f"{name}.jinja").read_text(encoding="utf-8") + assert "bootstrap.bundle" not in source + assert "data-bs-" not in source + + +# --- Forms ----------------------------------------------------------------- + + +def test_form_field_uses_bootstrap_input_classes(render): + html = render("FormField.jinja", name="email", label="Email") + assert "form-label" in html + assert "form-control" in html + assert 'name="email"' in html + + +def test_form_field_error_uses_bootstrap_validation_classes(render): + html = render("FormField.jinja", name="email", label="Email", error="Required") + assert "is-invalid" in html + assert "invalid-feedback" in html + assert "Required" in html + + +def test_form_field_error_text_is_forced_visible(render): + """`.invalid-feedback` is display:none until a sibling is `.is-invalid`. + + The rule is `~`, so it only fires for the input case. Every error slot in + this theme carries `d-block` so the message never renders invisibly. + """ + html = render("FormField.jinja", name="email", label="Email", error="Required") + assert "invalid-feedback d-block" in html + + +def test_form_field_input_class_still_applied(render): + html = render("FormField.jinja", name="email", label="Email", input_class="form-control-lg") + assert "form-control-lg" in html + + +def test_form_field_required_flag(render): + html = render("FormField.jinja", name="email", label="Email", required=True) + assert "required" in html + + +def test_select_uses_the_bootstrap_select_class(render): + html = render( + "Select.jinja", name="c", label="C", options=[{"value": "a", "label": "Option A"}] + ) + assert "form-select" in html + assert "Option A" in html + + +def test_select_marks_the_current_value(render): + html = render( + "Select.jinja", + name="c", + label="C", + value="a", + options=[{"value": "a", "label": "A"}, {"value": "b", "label": "B"}], + ) + assert "selected" in html + + +def test_textarea_uses_the_form_control_class(render): + html = render("Textarea.jinja", name="bio", label="Bio", value="Hello") + assert "form-control" in html + assert "Hello" in html + assert 'rows="4"' in html + + +def test_checkbox_group_uses_the_form_check_classes(render): + html = render( + "CheckboxGroup.jinja", + name="f", + label="F", + choices=[{"value": "a", "label": "Apple"}], + selected=["a"], + ) + assert "form-check" in html + assert "form-check-input" in html + assert "form-check-label" in html + assert "checked" in html + assert "Apple" in html + + +def test_checkbox_group_labels_point_at_their_input(render): + """`for=` must be unique per choice or every label toggles the first box.""" + html = render( + "CheckboxGroup.jinja", + name="fruit", + label="Fruit", + choices=[{"value": "a", "label": "A"}, {"value": "b", "label": "B"}], + ) + assert 'id="fruit-a"' in html + assert 'for="fruit-a"' in html + assert 'id="fruit-b"' in html + assert 'for="fruit-b"' in html + + +def test_checkbox_group_control_class_still_applied(render): + html = render( + "CheckboxGroup.jinja", + name="f", + label="F", + choices=[{"value": "a", "label": "A"}], + control_class="d-flex", + ) + assert "d-flex" in html + + +# --- Feedback -------------------------------------------------------------- + + +def test_modal_keeps_the_alpine_contract(render): + html = render("Modal.jinja", id="my-modal") + assert 'id="my-modal"' in html + assert 'x-data="cfModal"' in html + assert "initModal" in html + assert "close()" in html + + +def test_modal_uses_bootstrap_modal_classes(render): + html = render("Modal.jinja", id="m") + assert "modal-dialog" in html + assert "modal-content" in html + assert "modal-body" in html + assert "btn-close" in html + + +def test_modal_reveal_toggles_display_not_just_show(render): + """`.modal` is `display:none`; `.show` alone never reveals it. + + Bootstrap's own JS sets `style.display = 'block'` — cf-ui has no Bootstrap + JS, so the reveal has to come from a class Alpine can toggle. Asserting + only `show` here would pass against a modal that never becomes visible. + """ + html = render("Modal.jinja", id="m") + assert "d-block" in html + assert "'show'" in html + + +def test_notification_maps_type_onto_a_bootstrap_alert(render): + html = render("Notification.jinja", message="Boom", type="danger") + assert "alert-danger" in html + assert "alert-error" not in html + + +def test_notification_maps_error_onto_danger(render): + """Bootstrap has no `alert-error`; the prop vocabulary stays stable.""" + html = render("Notification.jinja", message="Boom", type="error") + assert "alert-danger" in html + + +def test_notification_success_and_dismiss(render): + html = render("Notification.jinja", message="Saved!", type="success", dismissible=True) + assert "alert-success" in html + assert "alert-dismissible" in html + assert "Saved!" in html + assert "visible = false" in html + + +def test_notification_non_dismissible_omits_the_button(render): + html = render("Notification.jinja", message="Hi", type="info", dismissible=False) + assert "visible = false" not in html + assert "alert-dismissible" not in html + + +def test_progress_uses_the_bootstrap_progress_bar_markup(render): + html = render("Progress.jinja", value=40, max=100, type="primary") + assert "progress-bar" in html + assert "bg-primary" in html + assert 'role="progressbar"' in html + assert 'aria-valuenow="40"' in html + assert 'aria-valuemax="100"' in html + assert "width: 40%" in html + + +def test_progress_scales_the_bar_to_a_non_percentage_max(render): + """`max` is a prop; a bar hard-coded to `value%` is wrong for max != 100.""" + html = render("Progress.jinja", value=1, max=4) + assert "width: 25%" in html + + +def test_progress_survives_a_zero_max(render): + html = render("Progress.jinja", value=0, max=0) + assert "width: 0%" in html + + +def test_progress_maps_danger_to_bg_danger(render): + html = render("Progress.jinja", value=75, max=100, type="danger") + assert "bg-danger" in html + + +# --- Content + navigation -------------------------------------------------- + + +def test_card_renders_header_body_footer(render): + html = render("Card.jinja", header="Title", content="Body", footer="Foot") + assert "card-header" in html + assert "card-body" in html + assert "card-footer" in html + assert "Title" in html + assert "Body" in html + assert "Foot" in html + + +def test_table_uses_bootstrap_table_classes(render): + html = render("Table.jinja", columns=[{"key": "n", "label": "Name"}], rows=[{"n": "Ada"}]) + assert "table-striped" in html + assert "table-responsive" in html + assert "is-striped" not in html + assert "Name" in html + assert "Ada" in html + + +def test_pagination_uses_the_page_item_list(render): + html = render("Pagination.jinja", page=2, total_pages=3, hx_url="/x", hx_target="#t") + assert "page-item" in html + assert "page-link" in html + assert 'aria-current="page"' in html + + +def test_pagination_disables_the_edges(render): + html = render("Pagination.jinja", page=1, total_pages=1, hx_url="/x", hx_target="#t") + assert html.count("disabled") >= 2 + assert 'aria-disabled="true"' in html + + +def test_panel_uses_the_accordion_markup(render): + html = render("Panel.jinja", title="Details", content="Inner") + assert "accordion" in html + assert "accordion-button" in html + assert "accordion-body" in html + assert "Inner" in html + + +def test_panel_body_is_not_a_bootstrap_collapse(render): + """`.collapse` is `display:none` without `.show`, and x-show owns display. + + Carrying both would hide a server-open panel permanently with JS off — + exactly the bug the accessibility phase fixed. + """ + html = render("Panel.jinja", id="p", title="T", content="Inner", open=True) + assert 'class="collapse' not in html + assert " collapse " not in html + + +def test_panel_keeps_the_alpine_contract(render): + html = render("Panel.jinja", title="Details", content="Inner") + assert 'x-data="cfPanel"' in html + assert "x-show" in html + assert "x-cloak" in html + + +def test_navbar_keeps_the_alpine_contract(render): + html = render("Navbar.jinja", brand="Brand", start="S", end="E") + assert 'x-data="cfNavbar"' in html + assert "toggle()" in html + assert "navbar-toggler" in html + assert "navbar-collapse" in html + assert "Brand" in html + + +def test_breadcrumb_uses_the_bootstrap_breadcrumb_classes(render): + html = render("Breadcrumb.jinja", items=[{"url": "/a", "label": "A"}]) + assert 'class="breadcrumb"' in html + assert "breadcrumb-item" in html + assert 'aria-current="page"' in html + + +def test_tabs_keep_the_alpine_contract(render): + html = render("Tabs.jinja", tabs=[{"id": "one", "url": "/one"}], content="C") + assert 'x-data="cfTabs"' in html + assert "setActive('one')" in html + assert "nav-tabs" in html + assert "nav-link" in html diff --git a/tests/unit/test_accessibility.py b/tests/unit/test_accessibility.py index 7e03557..70f3269 100644 --- a/tests/unit/test_accessibility.py +++ b/tests/unit/test_accessibility.py @@ -10,9 +10,9 @@ test asserting ``role="dialog"`` is present proves nothing about focus, so no test in this module pretends otherwise. -Every case runs against all four template sets (2 engines x 2 themes). The -point of the phase is a pattern the three themes in the expansion epic copy; -a fix that landed in one set and not the others would be worse than none. +Every case runs against every template set (2 engines x every implemented +theme). The point of the phase is a pattern the themes in the expansion epic +copy; a fix that landed in one set and not the others would be worse than none. """ import re @@ -25,11 +25,13 @@ TEMPLATES_DIR = Path(__file__).parent.parent.parent / "src" / "cf_ui" / "templates" JINJA_DIR = TEMPLATES_DIR / "jinja" -THEMES = ["bulma", "daisy"] +THEMES = ["bulma", "daisy", "bootstrap"] #: The class each theme puts on the *selected* tab, and the element it sits on. -#: Bulma marks the ``
  • ``; DaisyUI marks the ```` itself. -ACTIVE_TAB_CLASS = {"bulma": "is-active", "daisy": "tab-active"} +#: Bulma marks the ``
  • ``; DaisyUI and Bootstrap mark the ```` +#: itself. Matched as a whole class token, so Bootstrap's bare ``active`` does +#: not collide with Bulma's ``is-active``. +ACTIVE_TAB_CLASS = {"bulma": "is-active", "daisy": "tab-active", "bootstrap": "active"} @pytest.fixture(params=THEMES) diff --git a/tests/unit/test_theme_dispatch.py b/tests/unit/test_theme_dispatch.py index a60af1f..e4094c8 100644 --- a/tests/unit/test_theme_dispatch.py +++ b/tests/unit/test_theme_dispatch.py @@ -37,7 +37,7 @@ "textarea", ] -IMPLEMENTED_THEMES = ["bulma", "daisy"] +IMPLEMENTED_THEMES = ["bulma", "daisy", "bootstrap"] # --- The theme registry ---------------------------------------------------- @@ -68,12 +68,18 @@ def test_resolve_theme_accepts_an_implemented_theme(): assert resolve_theme("daisy") == "daisy" +def test_resolve_theme_accepts_bootstrap(): + from cf_ui.themes import resolve_theme + + assert resolve_theme("bootstrap") == "bootstrap" + + def test_resolve_theme_rejects_a_stub_theme_by_name(): - """bootstrap/foundation/fomantic are PLANNED.md stubs, not usable themes.""" + """foundation/fomantic are PLANNED.md stubs, not usable themes.""" from cf_ui.themes import ThemeError, resolve_theme - with pytest.raises(ThemeError, match="bootstrap"): - resolve_theme("bootstrap") + with pytest.raises(ThemeError, match="foundation"): + resolve_theme("foundation") def test_resolve_theme_error_names_the_available_themes(): @@ -166,15 +172,16 @@ def test_appconfig_rejects_an_unimplemented_theme(settings): from django.apps import apps from django.core.exceptions import ImproperlyConfigured - settings.CF_UI_THEME = "bootstrap" - with pytest.raises(ImproperlyConfigured, match="bootstrap"): + settings.CF_UI_THEME = "fomantic" + with pytest.raises(ImproperlyConfigured, match="fomantic"): apps.get_app_config("cf_ui").ready() -def test_appconfig_accepts_daisy(settings): +@pytest.mark.parametrize("theme", IMPLEMENTED_THEMES) +def test_appconfig_accepts_every_implemented_theme(settings, theme): from django.apps import apps - settings.CF_UI_THEME = "daisy" + settings.CF_UI_THEME = theme apps.get_app_config("cf_ui").ready() @@ -197,17 +204,23 @@ def test_switching_the_setting_switches_the_rendered_markup(settings, stem): "choices": [{"value": "c", "label": "C"}], "options": [{"value": "o", "label": "O"}], "tabs": [{"id": "one", "url": "/one"}], + # `render_to_string` bypasses the cotton compiler, so + # defaults never apply. Restating this one matters: Bootstrap and + # DaisyUI both spell a plain info alert `alert alert-info`, and the + # themes only diverge once the dismiss control is rendered. + "dismissible": "true", } - settings.CF_UI_THEME = "bulma" - bulma = render_to_string(f"cotton/cf/{stem}.html", props) - - settings.CF_UI_THEME = "daisy" - daisy = render_to_string(f"cotton/cf/{stem}.html", props) + rendered = {} + for theme in IMPLEMENTED_THEMES: + settings.CF_UI_THEME = theme + html = render_to_string(f"cotton/cf/{stem}.html", props) + assert html.strip(), f"{theme} render produced nothing" + rendered[theme] = html - assert bulma.strip(), "bulma render produced nothing" - assert daisy.strip(), "daisy render produced nothing" - assert bulma != daisy, f"{stem} rendered identically under both themes" + assert len(set(rendered.values())) == len(IMPLEMENTED_THEMES), ( + f"{stem} rendered identically under two themes: {sorted(rendered)}" + ) def test_dispatch_passes_the_slot_through_to_the_partial(settings): From c972a2b1d90a9feb2fb2f0fca0f6b1579a4ed4e0 Mon Sep 17 00:00:00 2001 From: Francis Secada Date: Wed, 29 Jul 2026 14:10:01 -0400 Subject: [PATCH 2/3] test(themes): execute the CDN parity claim instead of eyeballing it (#22) Issue #22 asks to "confirm assets.jinja has the Bootstrap CDN entry to match _CDN_CSS". Confirming it by reading both files is exactly the kind of check that rots: `_CDN_CSS` serves Django and the assets.jinja macro serves Jinja2 apps, holding the same URLs in two places, and a theme wired into one and not the other renders unstyled on half the supported frameworks with no error at all. So the agreement is now asserted, parametrized over all five theme names including the two that are still stubs, plus a guard that every entry in THEMES has both a CDN URL and a pinned version -- a theme selectable at startup but unstyled at render is worse than one rejected at startup. Verified non-vacuous: bumping the bootstrap version in assets.jinja alone turns the new parity test red. Co-Authored-By: Claude Opus 5 --- tests/unit/test_asset_tags.py | 67 +++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/unit/test_asset_tags.py b/tests/unit/test_asset_tags.py index b3a276b..67f8d88 100644 --- a/tests/unit/test_asset_tags.py +++ b/tests/unit/test_asset_tags.py @@ -1,3 +1,12 @@ +import re +from pathlib import Path + +import pytest +from jinja2 import Environment, FileSystemLoader, select_autoescape + +TEMPLATES_DIR = Path(__file__).parent.parent.parent / "src" / "cf_ui" / "templates" + + def test_cf_ui_head_returns_bulma_cdn_link(settings): settings.CF_UI_THEME = "bulma" settings.CF_UI_CDN_VERSIONS = {} @@ -56,3 +65,61 @@ def test_cf_ui_body_cf_alpine_loads_before_alpine(settings): cf_pos = result.find("cf_ui_alpine.js") alpine_pos = result.find("alpinejs") assert cf_pos < alpine_pos, "cf_ui_alpine.js must appear before Alpine CDN" + + +# --- The two asset surfaces must not drift (#22) --------------------------- +# +# `_CDN_CSS` serves Django and the `assets.jinja` macro serves Jinja2 apps, and +# they hold the same URLs in two places. A theme wired into one and not the +# other renders unstyled on half the supported frameworks, with no error — so +# the agreement is executed here rather than eyeballed when a theme lands. + + +def _assets_head(theme: str) -> str: + env = Environment( + loader=FileSystemLoader(TEMPLATES_DIR), + autoescape=select_autoescape(["html", "jinja"]), + ) + module = env.get_template("cf_ui/assets.jinja").make_module() + return str(module.cf_ui_head(theme=theme)) + + +@pytest.mark.parametrize("theme", ["bulma", "daisy", "bootstrap", "foundation", "fomantic"]) +def test_the_jinja_macro_and_the_django_tag_serve_the_same_css_url(settings, theme): + from cf_ui.templatetags.cf_ui import _DEFAULTS, cf_ui_head + + settings.CF_UI_THEME = theme + settings.CF_UI_CDN_VERSIONS = {} + + def _href(html: str) -> str: + found = re.findall(r' Date: Wed, 29 Jul 2026 22:43:07 -0400 Subject: [PATCH 3/3] docs(bootstrap): record the JS decision, guard it with the pin (#33, #32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things #29 needed before merge. #33 — the CSS-only, Alpine-driven stance was decided during #22 and recorded nowhere a consumer or maintainer would look. `docs/bootstrap.md` states it, gives the three reasons in the order they mattered, and answers the question that was actually unanswered: what to do about a Bootstrap component cf-ui does not ship. Bootstrap 5.3.3 ships 12 JS-driven components (`base-component.js` is the shared base, not a component — the earlier "13" in #33's body counted it). cf-ui replaces four of those plugins across five of its own components, so eight are uncovered. The page names them, gives three ways forward, and states the one hard rule: never put a `data-bs-*` attribute on a cf-ui component, because Bootstrap's JS and `cf_ui_alpine.js` would then own the same state and the failure is load-order dependent and intermittent. The behaviour-driver abstraction is recorded as considered-and-deferred rather than left unmentioned, so it is not relitigated from scratch. `tests/unit/test_bootstrap_version_pin.py` turns "monitor for Bootstrap 6" into something mechanical. `_DEFAULTS["bootstrap"]` is the single place the major version is stated; the test fails the moment it leaves the `5.x` line and its failure message is the checklist of what to re-evaluate. Same pattern #17 established for Tailwind. Verified non-vacuous: bumping the pin to 6.0.0 fails with that checklist, and the pin was restored. What it points at, read off `v6-dev` directly rather than from release notes — several secondary sources claim v6 already adopted native `` citing twbs#41751, which is closed and was never merged: `_modal.scss` is replaced by `_dialog.scss` with no `.modal*` selector left, `modal.js` by `dialog.ts` on `HTMLDialogElement.showModal()`, and the JS surface is growing. cf-ui's bootstrap modal templates carry eight `modal-*` references each, so the markup breaks at v6 on the CSS alone — which is why deferring the JS question costs nothing. #32 — the four Alpine bindings on each tab now read `$el.dataset.cfTab` instead of an interpolated `'{{ tab.id }}'`. Applied here rather than waiting, so this theme does not land with a known injection and get patched twice; the tree-wide guard arrives with #32 itself and will hold whichever of the two merges second. 743 unit + integration pass, 129 E2E pass, 65 node pass, ruff and prek clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf --- CHANGELOG.md | 39 +++++ README.md | 6 +- docs/bootstrap.md | 158 ++++++++++++++++++ .../cotton/_themes/bootstrap/tabs.html | 8 +- .../templates/jinja/bootstrap/Tabs.jinja | 8 +- tests/unit/cotton/test_bootstrap.py | 4 +- tests/unit/jinja/test_bootstrap.py | 4 +- tests/unit/test_bootstrap_version_pin.py | 72 ++++++++ 8 files changed, 287 insertions(+), 12 deletions(-) create mode 100644 docs/bootstrap.md create mode 100644 tests/unit/test_bootstrap_version_pin.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fbd71ab..8ee7a7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,45 @@ ## [Unreleased] +### Added — Bootstrap JS decision record and a version tripwire (#33) + +- **`docs/bootstrap.md`.** The CSS-only, Alpine-driven stance was a decision + taken during #22 and recorded nowhere a consumer or maintainer would look. + This states it, gives the three reasons in the order they mattered, and — the + part that was actually missing — answers "may I use a Bootstrap component + cf-ui does not ship?". Bootstrap 5.3.3 ships 12 JS-driven components; cf-ui + replaces four of those plugins across five of its own components, so eight are + uncovered. The page names them and gives three ways forward, with the one hard + rule: never put a `data-bs-*` attribute on a cf-ui component, because + Bootstrap's JS and `cf_ui_alpine.js` would then own the same state and the + failure is load-order dependent and intermittent. + + It also records the per-theme behaviour driver as considered-and-deferred, so + the option is not relitigated from scratch, and states why: an abstraction for + a problem no consumer has reported, whose strongest motivation has an API that + does not exist yet. + +- **`tests/unit/test_bootstrap_version_pin.py`.** "Monitor for Bootstrap 6" is + not a commitment that survives; a red test is. `_DEFAULTS["bootstrap"]` is the + single place the major version is stated, and this fails the moment it leaves + the `5.x` line, with a failure message that *is* the checklist of what to + re-evaluate. Same pattern #17 established for Tailwind. + + What it points at, verified against `v6-dev` rather than release notes: + `_modal.scss` is replaced by `_dialog.scss` with no `.modal*` selector left, + `modal.js` by `dialog.ts` on `HTMLDialogElement.showModal()`, and the JS + surface is growing rather than shrinking. cf-ui's bootstrap modal templates + carry eight `modal-*` references each, so the markup breaks at v6 on the CSS + alone — which is the useful part, because it means the JS question gets + re-asked for free at the moment it is cheapest to answer. + +### Fixed — tab ids no longer reach Alpine as expression source (#32) + +- The four Alpine bindings on each tab now read `$el.dataset.cfTab` instead of + an interpolated `'{{ tab.id }}'`. Same fix as the two shipped themes get in + #32; applied here so this theme does not land with the bug and need patching + twice. See that ticket for why HTML escaping cannot address it. + ### Added — Bootstrap 5 theme (#22) All 14 components in both template sets, replacing the `PLANNED.md` stubs at diff --git a/README.md b/README.md index b6a5d7d..9bc81ee 100644 --- a/README.md +++ b/README.md @@ -290,7 +290,7 @@ from cf_ui import JINJA_TEMPLATES_DIR, COTTON_TEMPLATES_DIR |---|---| | Bulma | ✅ v0.1.0 | | Tailwind + DaisyUI | ✅ — see [docs/daisyui.md](docs/daisyui.md) | -| Bootstrap 5 | ✅ — CSS only, no `bootstrap.bundle.js` | +| Bootstrap 5 | ✅ — CSS only, no `bootstrap.bundle.js`; see [docs/bootstrap.md](docs/bootstrap.md) | | Foundation | 📋 Planned | | Fomantic UI | 📋 Planned | @@ -304,7 +304,9 @@ the accordion, and `cf_ui_alpine.js` is already the first — loading both makes `Alpine.store('cf').modal.open(id)` mean something different under this theme than under every other one. The templates use Bootstrap's classes and markup structure and wire state through Alpine, so the CDN stylesheet is all a -consuming app needs. +consuming app needs. [docs/bootstrap.md](docs/bootstrap.md) is the decision +record: which of Bootstrap's 12 JS components cf-ui replaces, what to do about +the eight it does not, and what changes at Bootstrap 6. **DaisyUI takes one extra step.** It compiles through Tailwind, so Tailwind's content scanner has to reach cf-ui's templates in site-packages or every class diff --git a/docs/bootstrap.md b/docs/bootstrap.md new file mode 100644 index 0000000..3e8069c --- /dev/null +++ b/docs/bootstrap.md @@ -0,0 +1,158 @@ +# Bootstrap theme + +Bootstrap is the one theme where cf-ui deliberately does **not** follow the +framework's own installation instructions. Bootstrap tells you to include +`bootstrap.bundle.min.js`. cf-ui ships CSS only and drives every interactive +component from Alpine. + +This file is the decision record for that, because it is a question a consumer +will reasonably ask and because it is not obvious from the templates. + +## Switching to it + +```python +# settings.py (Django) +CF_UI_THEME = "bootstrap" +``` + +```python +# FastAPI +from cf_ui.fastapi import install_cf_ui +install_cf_ui(catalog, theme="bootstrap") + +# Litestar +from cf_ui.litestar import install_cf_ui +install_cf_ui(template_config, theme="bootstrap") +``` + +`cf_ui_head()` emits the pinned `bootstrap.min.css`. `cf_ui_body()` emits +`cf_ui_alpine.js` and Alpine. Neither emits `bootstrap.bundle.min.js`, and there +is no setting that makes them. + +## The decision + +**CSS only. Alpine owns behaviour. `bootstrap.bundle.min.js` is not shipped.** + +Three reasons, in the order they mattered: + +**One behavioural contract across every theme.** `Alpine.store('cf').modal.open(id)` +opens a modal identically under Bulma, DaisyUI, Bootstrap, Foundation and +Fomantic. `cf_ui_alpine.js` owns modal focus management, tab switching, panel +state, and the navbar toggle for all of them. Loading Bootstrap's JS for one +theme would make Bootstrap a second state owner for components cf-ui already +drives, and would make "switch `CF_UI_THEME`" stop being a config change. + +**Bootstrap itself sanctions this.** Its docs are explicit that its JS is +incompatible with frameworks that own the DOM, and direct you to a +framework-native implementation instead. Alpine is a weaker case than React or +Vue — it has no virtual DOM to reconcile against — but the blessing is the same +one, and cf-ui is exercising it rather than working around it. + +**The no-JS tier is a stated guarantee here, and is not one in Bootstrap.** +Bootstrap ships no fallback for a JS-disabled page. cf-ui's E2E suite is +parameterized over `js_on` / `js_off` and asserts components stay usable in +both. That is a property worth keeping, and it constrains the markup — the +navbar collapse, for instance, is plain CSS (`.collapse:not(.show)` plus +`.navbar-expand-lg .navbar-collapse`), so only the toggle needs Alpine at all. + +### What this costs + +Bootstrap 5.3.3 ships **12** JS-driven components: `alert`, `button`, +`carousel`, `collapse`, `dropdown`, `modal`, `offcanvas`, `popover`, +`scrollspy`, `tab`, `toast`, `tooltip`. (`base-component.js` is the shared base +class, not a component.) + +cf-ui's 14 components overlap four of those plugins: + +| cf-ui component | Bootstrap plugin it replaces | Driven by | +|---|---|---| +| `CfModal` / `` | Modal | `cfModal` — focus in/out, `Escape`, tab trap | +| `CfTabs` / `` | Tab | `cfTabs` — roving tabindex, manual activation | +| `CfPanel` / `` | Collapse | `cfPanel` | +| `CfNavbar` / `` | Collapse | `cfNavbar` | +| `CfNotification` / `` | Alert (dismiss) | inline Alpine | + +The other eight — `button` (toggle), `carousel`, `dropdown`, `offcanvas`, +`popover`, `scrollspy`, `toast`, `tooltip` — cf-ui does not wrap at all. See +below. + +## Using a Bootstrap component cf-ui does not ship + +You have three options, in descending order of how much they will hurt. + +**Write it with Alpine.** For `dropdown`, `toast` and `offcanvas` this is a +handful of lines and stays consistent with everything else on the page. It is +also what cf-ui itself did. + +**Load Bootstrap's JS anyway, and keep it away from cf-ui components.** This +works, and nothing in cf-ui prevents it. The rule is absolute: + +> **Never put a `data-bs-*` attribute on a cf-ui component.** + +Bootstrap's JS binds by `data-bs-toggle` / `data-bs-target` and by class. Put +`data-bs-toggle="modal"` on a `` trigger and both Bootstrap and +Alpine will manage the same dialog — two owners of `.show`, two focus policies, +and a component whose behaviour depends on script load order. The failure is +intermittent, which is the worst kind. + +If you do load it, prefer the individual plugin over the bundle so it is +obvious what is active: `bootstrap.esm.js` exports each plugin separately, and +`popover`/`tooltip` are the only two that need Popper. + +**Use a different component library for that one widget.** Perfectly +reasonable. cf-ui's components carry a `cf.` / `Cf` namespace precisely so they +can coexist with anything else in the same template. + +### Considered and deferred: a behaviour driver + +The obvious abstraction is a per-theme behaviour driver — a facade over +"open a modal", "switch a tab", so a theme could swap Alpine for the framework's +native JS without changing a template. It would make this whole page a +configuration choice instead of a decision. + +It is deferred, not rejected. It is an abstraction for a problem no consumer has +reported, and the strongest reason to build it — Bootstrap 6 — has an API that +does not exist yet, so it would have to be designed against a guess. Revisit it +at the v6 rework (see below), when there is something concrete to design +against. + +## Bootstrap 6 + +The pin is `5.3.3`, and `tests/unit/test_bootstrap_version_pin.py` fails the +moment it leaves the `5.x` line. That is deliberate: a promise to watch a +dependency does not survive, and a red test does. The failure message is the +checklist. + +The short version, verified against `v6-dev` directly rather than from release +notes — several secondary sources claim v6 already adopted native `` +citing twbs#41751, which is **closed and was never merged**: + +- **`_modal.scss` is gone.** v6 has `_dialog.scss`, defining `.dialog`, + `.dialog-header`, `.dialog-body`, `.dialog-footer`, `.dialog-title`, + `.dialog-scrollable`, `.dialog-fullscreen`, `.dialog-nonmodal` and siblings. + No `.modal*` selector survives. +- **`modal.js` is gone**, replaced by `dialog.ts` / `dialog-base.ts` built on + `HTMLDialogElement.showModal()`. +- **The JS surface is growing**, not shrinking: `combobox`, `datepicker`, + `otp-input`, `chips`, `strength`, `drawer`, `menu`, `range` are all new. + +The consequence worth internalising: cf-ui's bootstrap modal templates carry +eight `modal-*` class references each, so **they break at v6 on the CSS alone, +whatever is decided about JS.** The markup needs rework regardless, which is +exactly when re-pricing the JS decision is cheapest. That is why this page +defers the question instead of pre-solving it. + +As of 2026-07-29 v6 is at `6.0.0-alpha1` and maintainers describe it as not +arriving soon. + +## What stays the same + +Everything a consumer touches. Component names are theme-agnostic (`CfCard`, +``), prop vocabulary is theme-agnostic (`type="danger"` maps to +`alert-danger` inside the partial), and the Alpine store API is identical. The +Tailwind content glob discussion in [docs/daisyui.md](daisyui.md) does **not** +apply — Bootstrap ships prebuilt CSS, so nothing gets tree-shaken. + +Accessibility guarantees and where they live are in +[docs/accessibility.md](accessibility.md). They are the same for every theme by +construction; that is the point of keeping behaviour in one file. diff --git a/src/cf_ui/templates/cotton/_themes/bootstrap/tabs.html b/src/cf_ui/templates/cotton/_themes/bootstrap/tabs.html index 9bbfbb3..51dfad7 100644 --- a/src/cf_ui/templates/cotton/_themes/bootstrap/tabs.html +++ b/src/cf_ui/templates/cotton/_themes/bootstrap/tabs.html @@ -11,10 +11,10 @@ aria-controls="{{ hx_target }}" aria-selected="{% if tab.id == active %}true{% else %}false{% endif %}" tabindex="{% if tab.id == active or not active and forloop.first %}0{% else %}-1{% endif %}" - :class="{ 'active': active === '{{ tab.id }}' }" - :aria-selected="active === '{{ tab.id }}'" - :tabindex="tabIndexFor('{{ tab.id }}')" - @click.prevent="setActive('{{ tab.id }}')" + :class="{ 'active': active === $el.dataset.cfTab }" + :aria-selected="active === $el.dataset.cfTab" + :tabindex="tabIndexFor($el.dataset.cfTab)" + @click.prevent="setActive($el.dataset.cfTab)" hx-get="{{ tab.url }}" hx-target="#{{ hx_target }}">{{ tab.id }}
  • diff --git a/src/cf_ui/templates/jinja/bootstrap/Tabs.jinja b/src/cf_ui/templates/jinja/bootstrap/Tabs.jinja index 40b274d..3e6bd66 100644 --- a/src/cf_ui/templates/jinja/bootstrap/Tabs.jinja +++ b/src/cf_ui/templates/jinja/bootstrap/Tabs.jinja @@ -17,10 +17,10 @@ aria-controls="{{ hx_target }}" aria-selected="{% if tab.id == active %}true{% else %}false{% endif %}" tabindex="{% if tab.id == active or (not active and loop.first) %}0{% else %}-1{% endif %}" - :class="{ 'active': active === '{{ tab.id }}' }" - :aria-selected="active === '{{ tab.id }}'" - :tabindex="tabIndexFor('{{ tab.id }}')" - @click.prevent="setActive('{{ tab.id }}')" + :class="{ 'active': active === $el.dataset.cfTab }" + :aria-selected="active === $el.dataset.cfTab" + :tabindex="tabIndexFor($el.dataset.cfTab)" + @click.prevent="setActive($el.dataset.cfTab)" hx-get="{{ tab.url }}" hx-target="#{{ hx_target }}">{{ tab.id }} diff --git a/tests/unit/cotton/test_bootstrap.py b/tests/unit/cotton/test_bootstrap.py index 523eb7e..e17b3b3 100644 --- a/tests/unit/cotton/test_bootstrap.py +++ b/tests/unit/cotton/test_bootstrap.py @@ -294,5 +294,7 @@ def test_breadcrumb_uses_the_bootstrap_breadcrumb_classes(bootstrap_render): def test_tabs_keep_the_alpine_contract(bootstrap_render): html = bootstrap_render("cf/tabs.html", tabs=[{"id": "one", "url": "/one"}], slot="C") assert 'x-data="cfTabs"' in html - assert "setActive('one')" in html + # The id reaches Alpine as data, never as expression text (#32). + assert 'data-cf-tab="one"' in html + assert "setActive($el.dataset.cfTab)" in html assert "nav-tabs" in html diff --git a/tests/unit/jinja/test_bootstrap.py b/tests/unit/jinja/test_bootstrap.py index 1a82501..34fef77 100644 --- a/tests/unit/jinja/test_bootstrap.py +++ b/tests/unit/jinja/test_bootstrap.py @@ -402,6 +402,8 @@ def test_breadcrumb_uses_the_bootstrap_breadcrumb_classes(render): def test_tabs_keep_the_alpine_contract(render): html = render("Tabs.jinja", tabs=[{"id": "one", "url": "/one"}], content="C") assert 'x-data="cfTabs"' in html - assert "setActive('one')" in html + # The id reaches Alpine as data, never as expression text (#32). + assert 'data-cf-tab="one"' in html + assert "setActive($el.dataset.cfTab)" in html assert "nav-tabs" in html assert "nav-link" in html diff --git a/tests/unit/test_bootstrap_version_pin.py b/tests/unit/test_bootstrap_version_pin.py new file mode 100644 index 0000000..e7bcf33 --- /dev/null +++ b/tests/unit/test_bootstrap_version_pin.py @@ -0,0 +1,72 @@ +"""The Bootstrap pin is a tripwire, not just a default (#33). + +cf-ui ships the `bootstrap` theme as CSS only, with Alpine driving every +interactive component. That decision is correct for Bootstrap 5 and is recorded +in `docs/bootstrap.md`. Bootstrap 6 changes the premises it rests on, and +nothing in the repo would otherwise notice. + +So the pin itself is the hook. `_DEFAULTS["bootstrap"]` is the one place the +major version is stated, and the test below fails the moment it leaves the `5.x` +line — with a message that *is* the checklist of what to re-evaluate. This is +the pattern #17 established for Tailwind: pin the thing and let a break on bump +be the signal, rather than trusting anyone to remember to look. + +A promise to monitor a dependency does not survive; a red test does. +""" + +from pathlib import Path + +from cf_ui.templatetags.cf_ui import _CDN_CSS, _DEFAULTS + +DOCS = Path(__file__).parent.parent.parent / "docs" / "bootstrap.md" + +#: Everything the v6 rework has to answer. Verified against `v6-dev` when #33 +#: was written, not inferred from release notes — several secondary sources +#: claim v6 already moved to native `` citing twbs#41751, which is +#: closed and was never merged. +V6_CHECKLIST = """\ +Bootstrap has moved off the 5.x line. Re-read docs/bootstrap.md before changing +this pin, and re-evaluate all four of these: + + 1. Class renames. v6 replaces `_modal.scss` with `_dialog.scss` and defines + `.dialog`, `.dialog-header`, `.dialog-body`, `.dialog-footer`, + `.dialog-title` and siblings — no `.modal*` selector survives. cf-ui's + bootstrap modal templates carry 8 `modal-*` references each, so they break + on the CSS alone, whatever is decided about JS. + 2. `showModal()` vs `role="dialog"`. v6's `dialog.ts` is built on + `HTMLDialogElement.showModal()`. #19 decided cf-ui keeps a `
    ` so `cfModal` does not branch per theme; the v6 rework is + when that trade is worth re-pricing. + 3. The CSS-only stance. v6 is *expanding* its JS surface — combobox, + datepicker, otp-input, chips, strength, drawer, menu, range — so the gap + between what Bootstrap does in JS and what cf-ui wraps grows, not shrinks. + 4. Whether a per-theme behaviour driver is warranted by then. Recorded in + docs/bootstrap.md as considered-and-deferred, on the grounds that no + consumer has reported needing it. That may have changed. +""" + + +def test_the_bootstrap_pin_is_still_on_the_5_line(): + major = _DEFAULTS["bootstrap"].split(".")[0] + assert major == "5", V6_CHECKLIST + + +def test_the_decision_record_exists(): + """The tripwire's message points at this file; it has to be there.""" + assert DOCS.is_file(), "docs/bootstrap.md is the decision this pin guards" + + +def test_the_decision_record_states_the_js_stance(): + """A reader has to be able to answer "may I add Bootstrap's JS?" from it.""" + text = DOCS.read_text(encoding="utf-8") + assert "bootstrap.bundle" in text, "does not name the bundle it declines to ship" + assert "data-bs-" in text, "does not warn about the attribute API" + assert "Bootstrap 6" in text, "does not tell the reader what changes at v6" + + +def test_the_theme_still_resolves_to_a_pinned_css_url(): + """A pin nothing reads is not a pin.""" + url = _CDN_CSS["bootstrap"].format(v=_DEFAULTS["bootstrap"]) + assert _DEFAULTS["bootstrap"] in url + assert url.endswith("bootstrap.min.css") + assert ".bundle." not in url, "cf-ui does not ship Bootstrap's JS — see docs/bootstrap.md"