diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fb5593..8ee7a7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,90 @@ ## [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 +`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. + ### Added — real Tailwind build in CI (#17) - **A CI job that builds the vendored plugin through the actual Tailwind CLI.** diff --git a/README.md b/README.md index 090a775..9bc81ee 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`; see [docs/bootstrap.md](docs/bootstrap.md) | | Foundation | 📋 Planned | | Fomantic UI | 📋 Planned | @@ -298,6 +298,16 @@ 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. [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 in them is tree-shaken away, leaving correct markup with no styling and no 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/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..51dfad7 --- /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..3e6bd66 --- /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..e17b3b3 --- /dev/null +++ b/tests/unit/cotton/test_bootstrap.py @@ -0,0 +1,300 @@ +"""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 + # 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 new file mode 100644 index 0000000..34fef77 --- /dev/null +++ b/tests/unit/jinja/test_bootstrap.py @@ -0,0 +1,409 @@ +"""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 + # 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_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_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'` 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" 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):