Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<body>` 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.**
Expand Down
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]"
```

Expand Down Expand Up @@ -290,14 +290,24 @@ 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 |

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
Expand Down
158 changes: 158 additions & 0 deletions docs/bootstrap.md
Original file line number Diff line number Diff line change
@@ -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` / `<c-cf.modal>` | Modal | `cfModal` — focus in/out, `Escape`, tab trap |
| `CfTabs` / `<c-cf.tabs>` | Tab | `cfTabs` — roving tabindex, manual activation |
| `CfPanel` / `<c-cf.panel>` | Collapse | `cfPanel` |
| `CfNavbar` / `<c-cf.navbar>` | Collapse | `cfNavbar` |
| `CfNotification` / `<c-cf.notification>` | 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 `<c-cf.modal>` 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 `<dialog>`
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`,
`<c-cf.card>`), 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.
13 changes: 13 additions & 0 deletions src/cf_ui/templates/cotton/_themes/bootstrap/breadcrumb.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<nav class="{{ class }}" aria-label="breadcrumbs">
<ol class="breadcrumb">
{% for item in items %}
<li class="breadcrumb-item{% if forloop.last %} active{% endif %}">
{% if forloop.last %}
<a href="{{ item.url }}" aria-current="page">{{ item.label }}</a>
{% else %}
<a href="{{ item.url }}">{{ item.label }}</a>
{% endif %}
</li>
{% endfor %}
</ol>
</nav>
11 changes: 11 additions & 0 deletions src/cf_ui/templates/cotton/_themes/bootstrap/card.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<div class="card {{ class }}">
{% if header %}
<div class="card-header">
<h5 class="card-title mb-0">{{ header }}</h5>
</div>
{% endif %}
<div class="card-body">{{ slot }}</div>
{% if footer %}
<div class="card-footer">{{ footer }}</div>
{% endif %}
</div>
20 changes: 20 additions & 0 deletions src/cf_ui/templates/cotton/_themes/bootstrap/checkbox-group.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<div class="mb-3 {{ class }}">
<label class="form-label">{{ label }}</label>
<div{% if control_class %} class="{{ control_class }}"{% endif %}>
{% for choice in choices %}
<div class="form-check form-check-inline">
<input class="form-check-input"
type="checkbox"
id="{{ name }}-{{ choice.value }}"
name="{{ name }}"
value="{{ choice.value }}"
{% if choice.value in selected %}checked{% endif %}>
<label class="form-check-label" for="{{ name }}-{{ choice.value }}">{{ choice.label }}</label>
</div>
{% endfor %}
</div>
{# `.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 %}<div class="invalid-feedback d-block">{{ error }}</div>{% endif %}
</div>
10 changes: 10 additions & 0 deletions src/cf_ui/templates/cotton/_themes/bootstrap/form-field.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<div class="mb-3 {{ class }}">
<label class="form-label" for="{{ name }}">{{ label }}</label>
<input class="form-control{% if error %} is-invalid{% endif %}{% if input_class %} {{ input_class }}{% endif %}"
id="{{ name }}"
type="{{ type }}"
name="{{ name }}"
value="{{ value }}"
{% if required == "true" %}required{% endif %}>
{% if error %}<div class="invalid-feedback d-block">{{ error }}</div>{% endif %}
</div>
30 changes: 30 additions & 0 deletions src/cf_ui/templates/cotton/_themes/bootstrap/modal.html
Original file line number Diff line number Diff line change
@@ -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. #}
<div id="{{ id }}"
class="modal {{ class }}"
tabindex="-1"
role="dialog"
aria-modal="true"
{% if header %}aria-labelledby="{{ id }}-title"{% else %}aria-label="{{ label|default:'Dialog' }}"{% endif %}
x-data="cfModal"
x-init="initModal()"
:class="{ 'show': open, 'd-block': open }">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="{{ id }}-title">{{ header }}</h5>
<button type="button" class="btn-close" aria-label="close" @click="close()"></button>
</div>
<div class="modal-body">{{ slot }}</div>
{% if footer %}
<div class="modal-footer">{{ footer }}</div>
{% endif %}
</div>
</div>
{# Bootstrap puts the backdrop on <body> at z-index 1050, under the modal's
1055. Without its JS the backdrop has to live inside the modal, where 1050
would paint it *over* the dialog — `.modal` opens a stacking context. A
negative z-index puts it back underneath without inventing a class. #}
<div class="modal-backdrop show" style="z-index:-1" @click="close()"></div>
</div>
21 changes: 21 additions & 0 deletions src/cf_ui/templates/cotton/_themes/bootstrap/navbar.html
Original file line number Diff line number Diff line change
@@ -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. #}
<nav class="navbar navbar-expand-lg {{ class }}" role="navigation" aria-label="main navigation"
x-data="cfNavbar">
<div class="container-fluid">
<span class="navbar-brand">{{ brand }}</span>
<button type="button"
class="navbar-toggler"
aria-label="menu"
:aria-expanded="menuOpen"
@click="toggle()">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" :class="{ 'show': menuOpen }">
<div class="navbar-nav me-auto">{{ start }}</div>
<div class="navbar-nav">{{ end }}</div>
</div>
</div>
</nav>
Loading
Loading