diff --git a/CHANGELOG.md b/CHANGELOG.md index 27fb5c7..a7f643f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,48 @@ ## [Unreleased] ### Added +- **DaisyUI theme (#6)** — all 14 components in both template sets + (`jinja/daisy/*.jinja` and `cotton/_themes/daisy/*.html`), covered by the + full three-tier suite including Playwright E2E in `js_on` and `js_off` +- `cf_ui/themes.py` — theme registry (`THEMES`, `COMPONENTS`, `resolve_theme`, + `cotton_partial`, `tailwind_content_globs`); `CF_UI_THEME` is now validated + by `CfUiConfig.ready()` instead of failing later as `TemplateDoesNotExist` +- django-cotton theme dispatch: `cotton/cf/.html` is now a wrapper that + declares `` and includes `cotton/_themes//.html` via the + new `{% cf_ui_theme_path %}` tag. Consumer templates are unchanged — + `` still resolves at the same path, and `COTTON_DIR` is still + untouched (see #4) +- `docs/daisyui.md` — theme switch, the Tailwind content glob, and preflight + coexistence while migrating off another framework +- `python -m cf_ui.themes` prints the absolute Tailwind content globs for the + installed package, so consumers do not hand-write a site-packages path + +### Changed +- E2E harness is parameterized by theme: `make_app(theme)` for the FastAPI + gallery and `CF_UI_E2E_THEME` for the Django server. The DaisyUI E2E run + reuses the Bulma consumer templates verbatim, which is what makes + "switching themes needs no template edits" an executed claim rather than a + documented one + +### Technical Notes +- Every DaisyUI variant class is written out in full + (`{% if type == 'danger' %}alert-error{% endif %}`, never `alert-{{ type }}`) + because Tailwind's scanner reads source text and cannot see a class assembled + at render time. A test scans the DaisyUI templates for split class tokens +- The prop vocabulary is unchanged across themes: `type="danger"` still works, + and the templates map it onto DaisyUI's `alert-error` / `progress-error` +- Alpine components are untouched — `cfModal`, `cfNavbar`, `cfPanel`, `cfTabs` + and the `$cf` store are theme-independent; only the toggled class differs + (`is-active` → `modal-open` / `tab-active`) +- Django rejects template variables beginning with an underscore, so the + dispatch variable is `cf_ui_partial`, not `_cf_partial` +- Light and dark are declared independently per value, never derived by inversion; + light is the unqualified selector, dark is `[data-theme="dark"][data-*="..."]` +- Base declarations are sRGB hex (what the contrast gate is computed against); + wide-gamut chroma is layered behind `@media (color-gamut: p3)` — not + `@supports (color: oklch(...))`, which is a no-op in every current browser +- Density drives Tailwind v4's `--spacing`; accent aliases to `--color-primary*` + - Theme composition axes (#5): five orthogonal style axes — accent, surface, form, density, type — each keyed on a data attribute and carrying a closed set of named values. `data-theme` remains the light/dark switch and is not an axis. @@ -18,14 +60,6 @@ functions, registering the Jinja globals the macros delegate to - `docs/theming.md` — axis reference, custom value sets, and the contrast requirement -### Technical Notes -- Light and dark are declared independently per value, never derived by inversion; - light is the unqualified selector, dark is `[data-theme="dark"][data-*="..."]` -- Base declarations are sRGB hex (what the contrast gate is computed against); - wide-gamut chroma is layered behind `@media (color-gamut: p3)` — not - `@supports (color: oklch(...))`, which is a no-op in every current browser -- Density drives Tailwind v4's `--spacing`; accent aliases to `--color-primary*` - ## [0.1.1] — 2026-04-28 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index c7aea90..1a2457a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,14 +35,16 @@ pytest tests/e2e/ --browser chromium -v # E2E tests src/cf_ui/ ├── __init__.py # exports JINJA_TEMPLATES_DIR, COTTON_TEMPLATES_DIR, __version__ ├── _version.py -├── django.py # AppConfig — AppConfig auto-registers COTTON_DIRS at startup +├── django.py # AppConfig — validates CF_UI_THEME + CF_UI_COMPOSITION at startup +├── themes.py # theme registry, cotton partial paths, Tailwind content globs ├── fastapi.py # install_cf_ui(catalog, theme) — add_folder(prefix="Cf") ├── litestar.py # install_cf_ui(config, theme) — appends to TemplateConfig.directory ├── templatetags/cf_ui.py # Django simple_tags: cf_ui_head, cf_ui_body, get_item, make_list_1_to_n ├── templates/ │ ├── cf_ui/assets.jinja # Jinja2 macros: cf_ui_head(), cf_ui_body() -│ ├── jinja/bulma/ # 14 JinjaX component templates (*.jinja) -│ └── cotton/bulma/cf/ # 14 django-cotton component templates (*.html) +│ ├── jinja// # 14 JinjaX component templates (*.jinja) per theme +│ ├── cotton/cf/ # 14 public wrappers — , props + dispatch only +│ └── cotton/_themes// # 14 theme partials (*.html), included by the wrappers └── static/cf_ui/ └── cf_ui_alpine.js # Alpine named components + $cf global store ``` @@ -92,9 +94,22 @@ The `Cf` prefix / `cf.` namespace prevents collision with consumer app component ## Adding a New Theme -1. Create `src/cf_ui/templates/jinja/{theme}/` and `src/cf_ui/templates/cotton/{theme}/cf/` -2. Copy and adapt all 14 component templates, replacing Bulma-specific classes -3. Add the theme's CDN URL to `_CDN_CSS` in `templatetags/cf_ui.py` and to `assets.jinja` -4. Add default version to `_DEFAULTS` in `templatetags/cf_ui.py` -5. Add a stub `pyproject.toml` extras entry (already present as empty `[]`) -6. Add unit tests in `tests/unit/jinja/` and `tests/unit/cotton/` +1. Create `src/cf_ui/templates/jinja/{theme}/` and + `src/cf_ui/templates/cotton/_themes/{theme}/` +2. Copy and adapt all 14 component templates, replacing Bulma-specific classes. + The cotton partials carry **no** `` — that lives on the wrapper in + `cotton/cf/`, which needs no edit for a new theme +3. Add `{theme}` to `THEMES` in `themes.py` — until then `resolve_theme` rejects + it at startup +4. Add the theme's CDN URL to `_CDN_CSS` in `templatetags/cf_ui.py` and to + `assets.jinja` +5. Add default version to `_DEFAULTS` in `templatetags/cf_ui.py` +6. Add a stub `pyproject.toml` extras entry (already present as empty `[]`) +7. Add unit tests in `tests/unit/jinja/` and `tests/unit/cotton/`; the + parametrized tests in `tests/unit/test_theme_dispatch.py` pick the theme up + automatically once step 3 lands + +**Tailwind-based themes only:** write every variant class out in full +(`{% if type == 'danger' %}alert-error{% endif %}`) — Tailwind's scanner reads +source text, so `alert-{{ type }}` gets tree-shaken away silently. See +`tests/unit/test_tailwind_content.py` and `docs/daisyui.md`. diff --git a/README.md b/README.md index 5f05792..3ee754a 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,13 @@ Component names are **theme-agnostic** — `` and `` render t ## Installation ```bash -# Bulma (v0.1 — only supported theme) +# Bulma pip install "cf-ui[bulma]" -# All themes (stubs for future Bootstrap, Foundation, Fomantic, DaisyUI) +# Tailwind + DaisyUI +pip install "cf-ui[daisy]" + +# All themes (Bootstrap, Foundation and Fomantic are still stubs) pip install "cf-ui[all]" ``` @@ -241,25 +244,44 @@ from cf_ui import JINJA_TEMPLATES_DIR, COTTON_TEMPLATES_DIR # COTTON_TEMPLATES_DIR / "cf" → Path to Cotton component templates ``` -> **Cotton templates are theme-agnostic on disk.** They live at +> **Cotton component paths are theme-agnostic.** The public components live at > `cotton/cf/*.html` (not `cotton//cf/*.html`) so cf-ui can sit > alongside any consumer project's own `templates/cotton//...` tree -> without colliding on `COTTON_DIR`. The active CSS framework is selected -> via `CF_UI_THEME` (used by the asset tags) — switching themes in a -> future release will happen inside the templates, not via the directory -> layout. +> without colliding on `COTTON_DIR`. Each one is a thin wrapper that declares +> its props and includes a theme partial from `cotton/_themes//`, +> selected by `CF_UI_THEME`. Render ``; never reach into +> `_themes/` directly. +> +> The Jinja side needs no such indirection — `install_cf_ui(catalog, theme=…)` +> registers `templates/jinja//` and JinjaX resolves `` from there. --- -## Planned Themes +## Themes | Theme | Status | |---|---| | Bulma | ✅ v0.1.0 | +| Tailwind + DaisyUI | ✅ — see [docs/daisyui.md](docs/daisyui.md) | | Bootstrap | 📋 Planned | | Foundation | 📋 Planned | | Fomantic UI | 📋 Planned | -| Tailwind + DaisyUI | 📋 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. + +**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 +error. Get the glob from the package rather than hand-writing it: + +```bash +python -m cf_ui.themes +``` + +[docs/daisyui.md](docs/daisyui.md) covers that, plus disabling Tailwind's +preflight while an older framework is still styling the app. --- diff --git a/docs/daisyui.md b/docs/daisyui.md new file mode 100644 index 0000000..c9eb5d7 --- /dev/null +++ b/docs/daisyui.md @@ -0,0 +1,165 @@ +# DaisyUI theme + +DaisyUI is the one theme in cf-ui that is not just "a different class +vocabulary." It sits on Tailwind, so the consuming app compiles its own CSS +instead of linking a finished stylesheet. That changes two things — what +Tailwind has to scan, and what Tailwind's preflight does to whatever styling +you already had. + +## Switching to it + +```python +# settings.py (Django) +CF_UI_THEME = "daisy" +``` + +```python +# FastAPI +from cf_ui.fastapi import install_cf_ui +install_cf_ui(catalog, theme="daisy") + +# Litestar +from cf_ui.litestar import install_cf_ui +install_cf_ui(template_config, theme="daisy") +``` + +That is the whole change. Component names do not move: `` and +`` mean the same thing under every theme, and no template in the +consuming app needs an edit. An unimplemented theme name is rejected at +startup (`ImproperlyConfigured` on Django, `ThemeError` elsewhere) rather than +surfacing later as a confusing `TemplateDoesNotExist`. + +### How the switch works + +The two template sets dispatch differently, and the asymmetry is deliberate. + +**Jinja2/JinjaX** switches by directory — `install_cf_ui` registers +`templates/jinja//` with the catalog, so `` resolves to +whichever theme was installed. + +**django-cotton** cannot do that. `` resolves through +django-cotton's single global `COTTON_DIR` prefix, and cf-ui deliberately +does not touch `COTTON_DIR` — an earlier release did, and it broke every +consumer whose own components lived at `cotton//*.html`. So the +public component stays at the fixed path `cotton/cf/.html` and its +entire body is a dispatch: + +```django + +{% load cf_ui %}{% cf_ui_theme_path "card" as cf_ui_partial %}{% include cf_ui_partial %} +``` + +The theme markup lives in `cotton/_themes//.html`. `{% include %}` +inherits the caller's context, so props and `{{ slot }}` reach the partial +without being redeclared — `` stays in exactly one place per +component. The leading underscore marks the directory private: render +``, never ``. + +> **Requires** `"libraries": {"cf_ui": "cf_ui.templatetags.cf_ui"}` in +> `TEMPLATES[0]["OPTIONS"]`. This was already required for `{% cf_ui_head %}`; +> under the dispatch it is also required for components to render at all, +> because each wrapper does `{% load cf_ui %}`. The `cf_ui.django` app label +> prevents templatetag autodiscovery, so there is no way around declaring it. + +## Tailwind content glob — read this one + +Tailwind removes every class it does not find while scanning your source. Your +own templates are covered by your existing config; **cf-ui's templates live in +site-packages and are not**. Miss them and the page renders with correct markup +and no styling at all — no error, no warning. + +Add cf-ui's installed template directory to the content sources: + +```js +// tailwind.config.js (Tailwind v3) +module.exports = { + content: [ + "./templates/**/*.html", + "../.venv/lib/python3.12/site-packages/cf_ui/templates/**/*.{html,jinja}", + ], +}; +``` + +```css +/* app.css (Tailwind v4) */ +@import "tailwindcss"; +@plugin "daisyui"; +@source "../.venv/lib/python3.12/site-packages/cf_ui/templates/**/*.{html,jinja}"; +``` + +The path depends on your interpreter and platform, so do not hand-write it. +Ask the package: + +```bash +python -m cf_ui.themes +``` + +```python +from cf_ui.themes import tailwind_content_globs +tailwind_content_globs() +# ['.../site-packages/cf_ui/templates/**/*.html', +# '.../site-packages/cf_ui/templates/**/*.jinja'] +``` + +`tests/unit/test_tailwind_content.py` fails if those globs stop reaching every +shipped template, and this document is checked against the same constant, so +the two cannot drift apart. + +### The second way classes get shaken out + +Tailwind's scanner reads source text; it does not render templates. A class +assembled at render time is therefore invisible to it: + +```django +{# Never do this — Tailwind never sees "alert-error" #} +
+``` + +Every variant class in the DaisyUI templates is written out in full instead: + +```django +
+``` + +This is also why the prop vocabulary did not change between themes. `type="danger"` +still works; DaisyUI has no `alert-danger`, so the templates map it onto +`alert-error` internally. A test scans every DaisyUI template for class tokens +split across a template construct and fails on any it finds. + +## Coexistence with an existing framework + +Tailwind's preflight resets margins, font sizes, list styles, and form control +appearance globally. Dropping it into an app that Bulma (or Bootstrap) still +styles will visibly damage the pages you have not migrated yet. + +While both frameworks are live, disable preflight: + +```js +// tailwind.config.js (v3) +module.exports = { corePlugins: { preflight: false } }; +``` + +```css +/* app.css (v4) — import the layers you want, omit the reset */ +@layer theme, components, utilities; +@import "tailwindcss/theme.css" layer(theme); +@import "tailwindcss/utilities.css" layer(utilities); +@plugin "daisyui"; +``` + +DaisyUI's own component classes do not depend on preflight, so cf-ui's +components render correctly without it. Re-enable it once the previous +framework's stylesheet is gone. + +## What stays the same + +The Alpine behaviors are theme-independent and unchanged: `cfModal`, +`cfNavbar`, `cfPanel`, `cfTabs`, and the `$cf` store all work identically +under DaisyUI. Only the classes the bindings toggle differ — Bulma's +`is-active` becomes DaisyUI's `modal-open` on the modal, and `tab-active` on +the tabs. Page code that calls `Alpine.store('cf').modal.open('id')` needs no +change. + +The [theme composition axes](theming.md) are also unaffected. They are keyed +on `data-*` attributes and CSS custom properties, independent of which CSS +framework is loaded. diff --git a/pyproject.toml b/pyproject.toml index 0e5e41c..29e0fdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,14 @@ target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I", "N", "W", "UP"] +[tool.ruff.lint.isort] +# Stated explicitly because the pinned pre-commit hook (ruff v0.3.0) and the +# ruff CI resolves from `dev` (0.16.x) infer this differently for a src/ +# layout: the older version treats `cf_ui` as third-party, the newer as +# first-party, so each reverts the other's import fix and `prek` can pass +# while CI fails I001 on the same file. +known-first-party = ["cf_ui"] + [tool.ruff.format] quote-style = "double" indent-style = "space" diff --git a/src/cf_ui/django.py b/src/cf_ui/django.py index 058d402..28b2929 100644 --- a/src/cf_ui/django.py +++ b/src/cf_ui/django.py @@ -7,6 +7,7 @@ merge_value_sets, resolve_composition, ) +from cf_ui.themes import ThemeError, resolve_theme def axis_value_sets() -> dict: @@ -38,12 +39,21 @@ class CfUiConfig(AppConfig): # cotton templates live at cotton//*.html. def ready(self) -> None: - """Validate the axis composition at startup, not at first render. + """Validate the theme and axis composition at startup. + + Both would otherwise surface at first render — an unknown theme as a + ``TemplateDoesNotExist`` naming a partial the app never wrote, which + is a poor way to learn that ``CF_UI_THEME`` holds a stub theme's name. Deliberately touches no template settings — see the note above. """ from django.conf import settings + try: + resolve_theme(getattr(settings, "CF_UI_THEME", None)) + except ThemeError as exc: + raise ImproperlyConfigured(f"cf-ui: {exc}. Check CF_UI_THEME in settings.") from exc + try: resolve_composition( getattr(settings, "CF_UI_COMPOSITION", None), diff --git a/src/cf_ui/templates/cotton/_themes/bulma/breadcrumb.html b/src/cf_ui/templates/cotton/_themes/bulma/breadcrumb.html new file mode 100644 index 0000000..c78083d --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/breadcrumb.html @@ -0,0 +1,13 @@ + diff --git a/src/cf_ui/templates/cotton/_themes/bulma/card.html b/src/cf_ui/templates/cotton/_themes/bulma/card.html new file mode 100644 index 0000000..9f2e1b2 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/card.html @@ -0,0 +1,15 @@ +
+ {% if header %} +
+

{{ header }}

+
+ {% endif %} +
+
{{ slot }}
+
+ {% if footer %} +
+ +
+ {% endif %} +
diff --git a/src/cf_ui/templates/cotton/_themes/bulma/checkbox-group.html b/src/cf_ui/templates/cotton/_themes/bulma/checkbox-group.html new file mode 100644 index 0000000..190977a --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/checkbox-group.html @@ -0,0 +1,15 @@ +
+ +
+ {% for choice in choices %} + + {% endfor %} +
+ {% if error %}

{{ error }}

{% endif %} +
diff --git a/src/cf_ui/templates/cotton/_themes/bulma/form-field.html b/src/cf_ui/templates/cotton/_themes/bulma/form-field.html new file mode 100644 index 0000000..d8d2c31 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/form-field.html @@ -0,0 +1,12 @@ +
+ +
+ +
+ {% if error %}

{{ error }}

{% endif %} +
diff --git a/src/cf_ui/templates/cotton/_themes/bulma/modal.html b/src/cf_ui/templates/cotton/_themes/bulma/modal.html new file mode 100644 index 0000000..18a3387 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/modal.html @@ -0,0 +1,19 @@ + diff --git a/src/cf_ui/templates/cotton/_themes/bulma/navbar.html b/src/cf_ui/templates/cotton/_themes/bulma/navbar.html new file mode 100644 index 0000000..5d80467 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/navbar.html @@ -0,0 +1,20 @@ + diff --git a/src/cf_ui/templates/cotton/_themes/bulma/notification.html b/src/cf_ui/templates/cotton/_themes/bulma/notification.html new file mode 100644 index 0000000..4666c34 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/notification.html @@ -0,0 +1,8 @@ +
+ {% if dismissible == "true" %} + + {% endif %} + {{ message }} +
diff --git a/src/cf_ui/templates/cotton/_themes/bulma/pagination.html b/src/cf_ui/templates/cotton/_themes/bulma/pagination.html new file mode 100644 index 0000000..f2f42b6 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/pagination.html @@ -0,0 +1,30 @@ +{% load cf_ui %} + diff --git a/src/cf_ui/templates/cotton/_themes/bulma/panel.html b/src/cf_ui/templates/cotton/_themes/bulma/panel.html new file mode 100644 index 0000000..57b7d5f --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/panel.html @@ -0,0 +1,14 @@ +
+
+

{{ title }}

+ +
+
+ {{ slot }} +
+
diff --git a/src/cf_ui/templates/cotton/_themes/bulma/progress.html b/src/cf_ui/templates/cotton/_themes/bulma/progress.html new file mode 100644 index 0000000..4e0945c --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/progress.html @@ -0,0 +1,6 @@ +
+ {% if label %}

{{ label }}

{% endif %} + {{ value }}% +
diff --git a/src/cf_ui/templates/cotton/_themes/bulma/select.html b/src/cf_ui/templates/cotton/_themes/bulma/select.html new file mode 100644 index 0000000..e64e598 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/select.html @@ -0,0 +1,15 @@ +
+ +
+
+ +
+
+ {% if error %}

{{ error }}

{% endif %} +
diff --git a/src/cf_ui/templates/cotton/_themes/bulma/table.html b/src/cf_ui/templates/cotton/_themes/bulma/table.html new file mode 100644 index 0000000..0bb6ed3 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/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/bulma/tabs.html b/src/cf_ui/templates/cotton/_themes/bulma/tabs.html new file mode 100644 index 0000000..7deb0b4 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/tabs.html @@ -0,0 +1,14 @@ +
+
+
    + {% for tab in tabs %} +
  • + {{ tab.id }} +
  • + {% endfor %} +
+
+
{{ slot }}
+
diff --git a/src/cf_ui/templates/cotton/_themes/bulma/textarea.html b/src/cf_ui/templates/cotton/_themes/bulma/textarea.html new file mode 100644 index 0000000..6402f56 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/bulma/textarea.html @@ -0,0 +1,10 @@ +
+ +
+ +
+ {% if error %}

{{ error }}

{% endif %} +
diff --git a/src/cf_ui/templates/cotton/_themes/daisy/breadcrumb.html b/src/cf_ui/templates/cotton/_themes/daisy/breadcrumb.html new file mode 100644 index 0000000..4616bb8 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/breadcrumb.html @@ -0,0 +1,13 @@ + diff --git a/src/cf_ui/templates/cotton/_themes/daisy/card.html b/src/cf_ui/templates/cotton/_themes/daisy/card.html new file mode 100644 index 0000000..67dd42d --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/card.html @@ -0,0 +1,9 @@ +
+
+ {% if header %}

{{ header }}

{% endif %} +
{{ slot }}
+ {% if footer %} +
{{ footer }}
+ {% endif %} +
+
diff --git a/src/cf_ui/templates/cotton/_themes/daisy/checkbox-group.html b/src/cf_ui/templates/cotton/_themes/daisy/checkbox-group.html new file mode 100644 index 0000000..78d5062 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/checkbox-group.html @@ -0,0 +1,16 @@ +
+ +
+ {% for choice in choices %} + + {% endfor %} +
+ {% if error %}{{ error }}{% endif %} +
diff --git a/src/cf_ui/templates/cotton/_themes/daisy/form-field.html b/src/cf_ui/templates/cotton/_themes/daisy/form-field.html new file mode 100644 index 0000000..cd0850e --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/form-field.html @@ -0,0 +1,10 @@ +
+ + + {% if error %}{{ error }}{% endif %} +
diff --git a/src/cf_ui/templates/cotton/_themes/daisy/modal.html b/src/cf_ui/templates/cotton/_themes/daisy/modal.html new file mode 100644 index 0000000..bf0ad41 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/modal.html @@ -0,0 +1,17 @@ + diff --git a/src/cf_ui/templates/cotton/_themes/daisy/navbar.html b/src/cf_ui/templates/cotton/_themes/daisy/navbar.html new file mode 100644 index 0000000..25f4455 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/navbar.html @@ -0,0 +1,18 @@ + diff --git a/src/cf_ui/templates/cotton/_themes/daisy/notification.html b/src/cf_ui/templates/cotton/_themes/daisy/notification.html new file mode 100644 index 0000000..8d6bb11 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/notification.html @@ -0,0 +1,12 @@ + diff --git a/src/cf_ui/templates/cotton/_themes/daisy/pagination.html b/src/cf_ui/templates/cotton/_themes/daisy/pagination.html new file mode 100644 index 0000000..6d23054 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/pagination.html @@ -0,0 +1,26 @@ +{% load cf_ui %} + diff --git a/src/cf_ui/templates/cotton/_themes/daisy/panel.html b/src/cf_ui/templates/cotton/_themes/daisy/panel.html new file mode 100644 index 0000000..2608638 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/panel.html @@ -0,0 +1,13 @@ +
+ +
+ {{ slot }} +
+
diff --git a/src/cf_ui/templates/cotton/_themes/daisy/progress.html b/src/cf_ui/templates/cotton/_themes/daisy/progress.html new file mode 100644 index 0000000..ab2e428 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/progress.html @@ -0,0 +1,6 @@ +
+ {% if label %}

{{ label }}

{% endif %} + {{ value }}% +
diff --git a/src/cf_ui/templates/cotton/_themes/daisy/select.html b/src/cf_ui/templates/cotton/_themes/daisy/select.html new file mode 100644 index 0000000..424afd3 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/select.html @@ -0,0 +1,13 @@ +
+ + + {% if error %}{{ error }}{% endif %} +
diff --git a/src/cf_ui/templates/cotton/_themes/daisy/table.html b/src/cf_ui/templates/cotton/_themes/daisy/table.html new file mode 100644 index 0000000..abe1c5e --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/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/daisy/tabs.html b/src/cf_ui/templates/cotton/_themes/daisy/tabs.html new file mode 100644 index 0000000..b7f2271 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/tabs.html @@ -0,0 +1,13 @@ +
+
+ {% for tab in tabs %} + {{ tab.id }} + {% endfor %} +
+
{{ slot }}
+
diff --git a/src/cf_ui/templates/cotton/_themes/daisy/textarea.html b/src/cf_ui/templates/cotton/_themes/daisy/textarea.html new file mode 100644 index 0000000..9244472 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/daisy/textarea.html @@ -0,0 +1,8 @@ +
+ + + {% if error %}{{ error }}{% endif %} +
diff --git a/src/cf_ui/templates/cotton/cf/breadcrumb.html b/src/cf_ui/templates/cotton/cf/breadcrumb.html index 72e1ff7..2fac730 100644 --- a/src/cf_ui/templates/cotton/cf/breadcrumb.html +++ b/src/cf_ui/templates/cotton/cf/breadcrumb.html @@ -1,14 +1,2 @@ - +{% load cf_ui %}{% cf_ui_theme_path "breadcrumb" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/card.html b/src/cf_ui/templates/cotton/cf/card.html index 53c2ce0..99db637 100644 --- a/src/cf_ui/templates/cotton/cf/card.html +++ b/src/cf_ui/templates/cotton/cf/card.html @@ -1,16 +1,2 @@ -
- {% if header %} -
-

{{ header }}

-
- {% endif %} -
-
{{ slot }}
-
- {% if footer %} -
- -
- {% endif %} -
+{% load cf_ui %}{% cf_ui_theme_path "card" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/checkbox-group.html b/src/cf_ui/templates/cotton/cf/checkbox-group.html index 4725c12..9798a1d 100644 --- a/src/cf_ui/templates/cotton/cf/checkbox-group.html +++ b/src/cf_ui/templates/cotton/cf/checkbox-group.html @@ -1,16 +1,2 @@ -
- -
- {% for choice in choices %} - - {% endfor %} -
- {% if error %}

{{ error }}

{% endif %} -
\ No newline at end of file +{% load cf_ui %}{% cf_ui_theme_path "checkbox-group" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/form-field.html b/src/cf_ui/templates/cotton/cf/form-field.html index e5622a5..16ccf1c 100644 --- a/src/cf_ui/templates/cotton/cf/form-field.html +++ b/src/cf_ui/templates/cotton/cf/form-field.html @@ -1,13 +1,2 @@ -
- -
- -
- {% if error %}

{{ error }}

{% endif %} -
+{% load cf_ui %}{% cf_ui_theme_path "form-field" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/modal.html b/src/cf_ui/templates/cotton/cf/modal.html index 4ab8e60..3326396 100644 --- a/src/cf_ui/templates/cotton/cf/modal.html +++ b/src/cf_ui/templates/cotton/cf/modal.html @@ -1,20 +1,2 @@ - +{% load cf_ui %}{% cf_ui_theme_path "modal" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/navbar.html b/src/cf_ui/templates/cotton/cf/navbar.html index 7e54967..9133b5c 100644 --- a/src/cf_ui/templates/cotton/cf/navbar.html +++ b/src/cf_ui/templates/cotton/cf/navbar.html @@ -1,21 +1,2 @@ - +{% load cf_ui %}{% cf_ui_theme_path "navbar" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/notification.html b/src/cf_ui/templates/cotton/cf/notification.html index 4944285..0d1a641 100644 --- a/src/cf_ui/templates/cotton/cf/notification.html +++ b/src/cf_ui/templates/cotton/cf/notification.html @@ -1,9 +1,2 @@ -
- {% if dismissible == "true" %} - - {% endif %} - {{ message }} -
+{% load cf_ui %}{% cf_ui_theme_path "notification" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/pagination.html b/src/cf_ui/templates/cotton/cf/pagination.html index 8df7307..8e3e9ac 100644 --- a/src/cf_ui/templates/cotton/cf/pagination.html +++ b/src/cf_ui/templates/cotton/cf/pagination.html @@ -1,31 +1,2 @@ -{% load cf_ui %} - +{% load cf_ui %}{% cf_ui_theme_path "pagination" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/panel.html b/src/cf_ui/templates/cotton/cf/panel.html index 76bd9f4..5c264c6 100644 --- a/src/cf_ui/templates/cotton/cf/panel.html +++ b/src/cf_ui/templates/cotton/cf/panel.html @@ -1,15 +1,2 @@ -
-
-

{{ title }}

- -
-
- {{ slot }} -
-
+{% load cf_ui %}{% cf_ui_theme_path "panel" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/progress.html b/src/cf_ui/templates/cotton/cf/progress.html index 3a66eaf..b8dce55 100644 --- a/src/cf_ui/templates/cotton/cf/progress.html +++ b/src/cf_ui/templates/cotton/cf/progress.html @@ -1,7 +1,2 @@ -
- {% if label %}

{{ label }}

{% endif %} - {{ value }}% -
+{% load cf_ui %}{% cf_ui_theme_path "progress" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/select.html b/src/cf_ui/templates/cotton/cf/select.html index 072dcf6..5025de5 100644 --- a/src/cf_ui/templates/cotton/cf/select.html +++ b/src/cf_ui/templates/cotton/cf/select.html @@ -1,16 +1,2 @@ -
- -
-
- -
-
- {% if error %}

{{ error }}

{% endif %} -
+{% load cf_ui %}{% cf_ui_theme_path "select" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/table.html b/src/cf_ui/templates/cotton/cf/table.html index 9c6b891..d27a427 100644 --- a/src/cf_ui/templates/cotton/cf/table.html +++ b/src/cf_ui/templates/cotton/cf/table.html @@ -1,22 +1,2 @@ -{% 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 }}
-
+{% load cf_ui %}{% cf_ui_theme_path "table" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/tabs.html b/src/cf_ui/templates/cotton/cf/tabs.html index c9cd9ee..fe69836 100644 --- a/src/cf_ui/templates/cotton/cf/tabs.html +++ b/src/cf_ui/templates/cotton/cf/tabs.html @@ -1,15 +1,2 @@ -
-
-
    - {% for tab in tabs %} -
  • - {{ tab.id }} -
  • - {% endfor %} -
-
-
{{ slot }}
-
+{% load cf_ui %}{% cf_ui_theme_path "tabs" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/cf/textarea.html b/src/cf_ui/templates/cotton/cf/textarea.html index 6ff1a11..13da7a8 100644 --- a/src/cf_ui/templates/cotton/cf/textarea.html +++ b/src/cf_ui/templates/cotton/cf/textarea.html @@ -1,11 +1,2 @@ -
- -
- -
- {% if error %}

{{ error }}

{% endif %} -
+{% load cf_ui %}{% cf_ui_theme_path "textarea" as cf_ui_partial %}{% include cf_ui_partial %} diff --git a/src/cf_ui/templates/cotton/daisy/PLANNED.md b/src/cf_ui/templates/cotton/daisy/PLANNED.md deleted file mode 100644 index efa17cd..0000000 --- a/src/cf_ui/templates/cotton/daisy/PLANNED.md +++ /dev/null @@ -1,3 +0,0 @@ -# Theme: DaisyUI — Planned - -Components for this theme are tracked in GitHub Issues. diff --git a/src/cf_ui/templates/jinja/daisy/Breadcrumb.jinja b/src/cf_ui/templates/jinja/daisy/Breadcrumb.jinja new file mode 100644 index 0000000..daa1687 --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/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/daisy/Card.jinja b/src/cf_ui/templates/jinja/daisy/Card.jinja new file mode 100644 index 0000000..b6d3423 --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Card.jinja @@ -0,0 +1,14 @@ +{#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 %} +
{{ footer }}
+ {% endif %} +
+
diff --git a/src/cf_ui/templates/jinja/daisy/CheckboxGroup.jinja b/src/cf_ui/templates/jinja/daisy/CheckboxGroup.jinja new file mode 100644 index 0000000..ac44489 --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/CheckboxGroup.jinja @@ -0,0 +1,22 @@ +{#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 %} +
+ {% if error %}{{ error }}{% endif %} +
diff --git a/src/cf_ui/templates/jinja/daisy/FormField.jinja b/src/cf_ui/templates/jinja/daisy/FormField.jinja new file mode 100644 index 0000000..2d4f35b --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/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/daisy/Modal.jinja b/src/cf_ui/templates/jinja/daisy/Modal.jinja new file mode 100644 index 0000000..2ecb46d --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Modal.jinja @@ -0,0 +1,23 @@ +{#def id="modal", header="", content="", footer="", 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 extra_class = extra_class if extra_class is defined else "" %} + diff --git a/src/cf_ui/templates/jinja/daisy/Navbar.jinja b/src/cf_ui/templates/jinja/daisy/Navbar.jinja new file mode 100644 index 0000000..f7b3f90 --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Navbar.jinja @@ -0,0 +1,23 @@ +{#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 "" %} + diff --git a/src/cf_ui/templates/jinja/daisy/Notification.jinja b/src/cf_ui/templates/jinja/daisy/Notification.jinja new file mode 100644 index 0000000..77e407f --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/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/daisy/PLANNED.md b/src/cf_ui/templates/jinja/daisy/PLANNED.md deleted file mode 100644 index efa17cd..0000000 --- a/src/cf_ui/templates/jinja/daisy/PLANNED.md +++ /dev/null @@ -1,3 +0,0 @@ -# Theme: DaisyUI — Planned - -Components for this theme are tracked in GitHub Issues. diff --git a/src/cf_ui/templates/jinja/daisy/Pagination.jinja b/src/cf_ui/templates/jinja/daisy/Pagination.jinja new file mode 100644 index 0000000..8c94a60 --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Pagination.jinja @@ -0,0 +1,33 @@ +{#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/daisy/Panel.jinja b/src/cf_ui/templates/jinja/daisy/Panel.jinja new file mode 100644 index 0000000..efcad7c --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Panel.jinja @@ -0,0 +1,16 @@ +{#def title, content="", open=false, extra_class="" #} +{% set content = content if content is defined else "" %} +{% set extra_class = extra_class if extra_class is defined else "" %} +
+ +
+ {{ content }} +
+
diff --git a/src/cf_ui/templates/jinja/daisy/Progress.jinja b/src/cf_ui/templates/jinja/daisy/Progress.jinja new file mode 100644 index 0000000..aad8e56 --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Progress.jinja @@ -0,0 +1,12 @@ +{#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 "" %} +
+ {% if label %}

{{ label }}

{% endif %} + {{ value }}% +
diff --git a/src/cf_ui/templates/jinja/daisy/Select.jinja b/src/cf_ui/templates/jinja/daisy/Select.jinja new file mode 100644 index 0000000..f7946e4 --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/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/daisy/Table.jinja b/src/cf_ui/templates/jinja/daisy/Table.jinja new file mode 100644 index 0000000..d0a4938 --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/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/daisy/Tabs.jinja b/src/cf_ui/templates/jinja/daisy/Tabs.jinja new file mode 100644 index 0000000..6d55128 --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/Tabs.jinja @@ -0,0 +1,18 @@ +{#def tabs=[], hx_target="tab-content", content="", extra_class="" #} +{% set tabs = tabs if tabs is defined else [] %} +{% set content = content if content 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 "" %} +
+
+ {% for tab in tabs %} + {{ tab.id }} + {% endfor %} +
+
{{ content }}
+
diff --git a/src/cf_ui/templates/jinja/daisy/Textarea.jinja b/src/cf_ui/templates/jinja/daisy/Textarea.jinja new file mode 100644 index 0000000..79e820e --- /dev/null +++ b/src/cf_ui/templates/jinja/daisy/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/templatetags/cf_ui.py b/src/cf_ui/templatetags/cf_ui.py index 5e7d3c9..2868dde 100644 --- a/src/cf_ui/templatetags/cf_ui.py +++ b/src/cf_ui/templatetags/cf_ui.py @@ -5,6 +5,7 @@ from cf_ui.axes import root_attrs, style_element from cf_ui.django import axis_value_sets +from cf_ui.themes import cotton_partial register = template.Library() @@ -77,6 +78,23 @@ def cf_ui_root_attrs() -> str: return mark_safe(root_attrs(composition, value_sets=axis_value_sets())) +@register.simple_tag +def cf_ui_theme_path(component: str) -> str: + """Resolve a component to its partial for the configured theme. + + Used by the public wrappers at ``cotton/cf/.html``:: + + {% cf_ui_theme_path "card" as cf_ui_partial %}{% include cf_ui_partial %} + + (Django rejects template variables that begin with an underscore, so the + name is prefixed rather than marked private.) + + ``{% include %}`` inherits the caller's context, so the component's own + props and ``{{ slot }}`` reach the partial without being re-declared. + """ + return cotton_partial(component, getattr(settings, "CF_UI_THEME", None)) + + @register.simple_tag def cf_ui_body(alpine: bool = True) -> str: if not alpine: diff --git a/src/cf_ui/themes.py b/src/cf_ui/themes.py new file mode 100644 index 0000000..3fbd49e --- /dev/null +++ b/src/cf_ui/themes.py @@ -0,0 +1,111 @@ +"""Theme registry and django-cotton theme dispatch. + +The two template sets switch themes differently, for reasons that are not +cosmetic: + +* **Jinja2/JinjaX** switches by *directory*. ``install_cf_ui`` registers + ``templates/jinja//`` with the catalog, so ```` resolves to + whichever theme was installed. Nothing in this module is needed for that. + +* **django-cotton** cannot. ```` resolves through django-cotton's + single global ``COTTON_DIR`` prefix, and cf-ui deliberately does not touch + ``COTTON_DIR`` — an earlier release did, and it broke every consumer whose + own components lived at ``cotton//*.html`` + (see tests/unit/test_consumer_compatibility.py). + + So the public component stays at the fixed, theme-free path + ``cotton/cf/.html`` and its body is one ``{% include %}`` of a + per-theme partial at ``cotton/_themes//.html``. The consuming + app keeps writing ````; ``CF_UI_THEME`` moves all 14 components + at once. +""" + +from pathlib import Path + +_HERE = Path(__file__).parent + +#: Themes with a real component set. The other directories under +#: ``templates/`` hold a ``PLANNED.md`` stub and are not selectable. +THEMES = ("bulma", "daisy") + +DEFAULT_THEME = "bulma" + +#: django-cotton file stems, as used by ````. +COMPONENTS = ( + "breadcrumb", + "card", + "checkbox-group", + "form-field", + "modal", + "navbar", + "notification", + "pagination", + "panel", + "progress", + "select", + "table", + "tabs", + "textarea", +) + +#: Where the per-theme cotton partials live, relative to a template root. +#: Leading underscore marks them private — consumers render ````, +#: never ````. +COTTON_THEME_ROOT = "cotton/_themes" + +#: The tail of the Tailwind content glob, in Tailwind's own brace syntax. +#: Kept as a constant so docs and code cannot drift apart. +TAILWIND_CONTENT_SUFFIX = "cf_ui/templates/**/*.{html,jinja}" + + +class ThemeError(ValueError): + """Raised for an unknown theme or component name.""" + + +def resolve_theme(theme: str | None = None) -> str: + """Validate a theme name, defaulting to Bulma. + + Fails loudly here rather than as a ``TemplateDoesNotExist`` at first + render — a stub theme name is a configuration mistake, not a missing file. + """ + if not theme: + return DEFAULT_THEME + if theme not in THEMES: + available = ", ".join(THEMES) + raise ThemeError(f"unknown theme {theme!r} — implemented themes are: {available}") + return theme + + +def cotton_partial(component: str, theme: str | None = None) -> str: + """Template path of a component's partial for ``theme``.""" + if component not in COMPONENTS: + known = ", ".join(COMPONENTS) + raise ThemeError(f"unknown component {component!r} — known components: {known}") + return f"{COTTON_THEME_ROOT}/{resolve_theme(theme)}/{component}.html" + + +def tailwind_content_globs() -> list[str]: + """Absolute globs a Tailwind build must scan to keep cf-ui's classes. + + DaisyUI compiles through Tailwind, so any class in these templates that + Tailwind's scanner never sees is removed from the output — silently, with + no error and an unstyled page as the only symptom. Paths are absolute and + derived from ``cf_ui.__file__`` so they work from a site-packages install, + an editable install, or a vendored copy. + + Separators are always forward slashes, including on Windows. These strings + are pasted into a ``tailwind.config.js`` or an ``@source`` directive, where + a backslash is a JavaScript escape character and fast-glob (Tailwind's + matcher) treats it as an escape rather than a separator — a native Windows + path would match nothing and shake every class out, silently. + """ + templates = _HERE / "templates" + return [ + (templates / "**" / "*.html").as_posix(), + (templates / "**" / "*.jinja").as_posix(), + ] + + +if __name__ == "__main__": # pragma: no cover - developer convenience + for pattern in tailwind_content_globs(): + print(pattern) diff --git a/tests/e2e/_e2e_django_settings.py b/tests/e2e/_e2e_django_settings.py index 8a692b0..71a634f 100644 --- a/tests/e2e/_e2e_django_settings.py +++ b/tests/e2e/_e2e_django_settings.py @@ -8,6 +8,7 @@ the shared Django settings used by unit and integration tests. """ +import os from pathlib import Path from cf_ui import JINJA_TEMPLATES_DIR @@ -41,7 +42,8 @@ }, } ] -CF_UI_THEME = "bulma" +# Set by the conftest fixture so one server script covers both themes. +CF_UI_THEME = os.environ.get("CF_UI_E2E_THEME", "bulma") # django-cotton default COTTON_DIR="cotton" resolves -> # cotton/cf/card.html, picked up via APP_DIRS from cf_ui's package templates. # Allow hyphenated filenames (form-field.html, checkbox-group.html) diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index ff26b28..4e41381 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -1,3 +1,4 @@ +import os import subprocess import sys import threading @@ -9,17 +10,36 @@ import uvicorn -def _run_fastapi_server(host: str = "127.0.0.1", port: int = 8771) -> None: - from tests.integration.jinja_app.main import app +def _run_fastapi_server(theme: str = "bulma", host: str = "127.0.0.1", port: int = 8771) -> None: + from tests.integration.jinja_app.main import make_app - config = uvicorn.Config(app, host=host, port=port, log_level="error") + config = uvicorn.Config(make_app(theme), host=host, port=port, log_level="error") server = uvicorn.Server(config) server.run() +def _start_cotton_server(port: int, theme: str) -> subprocess.Popen: + """Start the Django cotton E2E server as a subprocess. + + A separate process so it gets its own Django configuration including + django_cotton, without contaminating the pytest process's settings (which + unit and integration tests rely on). The theme arrives via the environment + because Django settings are read once at startup. + """ + server_script = Path(__file__).parent / "_django_e2e_server.py" + env = {**os.environ, "CF_UI_E2E_THEME": theme} + return subprocess.Popen( + [sys.executable, str(server_script), "127.0.0.1", str(port)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=env, + ) + + @pytest.fixture(scope="session") def jinja_server_url() -> Generator[str, None, None]: - thread = threading.Thread(target=_run_fastapi_server, daemon=True) + thread = threading.Thread(target=_run_fastapi_server, args=("bulma", "127.0.0.1", 8771)) + thread.daemon = True thread.start() time.sleep(2.0) yield "http://127.0.0.1:8771" @@ -27,24 +47,35 @@ def jinja_server_url() -> Generator[str, None, None]: @pytest.fixture(scope="session") def cotton_server_url() -> Generator[str, None, None]: - """ - Start the Django cotton E2E server as a subprocess so it has its own - Python process with isolated Django settings that include django_cotton. - This prevents contaminating the main pytest process's Django configuration - (which unit and integration tests rely on). - """ - server_script = Path(__file__).parent / "_django_e2e_server.py" - proc = subprocess.Popen( - [sys.executable, str(server_script), "127.0.0.1", "8772"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) + proc = _start_cotton_server(8772, "bulma") time.sleep(2.0) yield "http://127.0.0.1:8772" proc.terminate() proc.wait(timeout=5) +@pytest.fixture(scope="session") +def daisy_jinja_server_url() -> Generator[str, None, None]: + thread = threading.Thread(target=_run_fastapi_server, args=("daisy", "127.0.0.1", 8773)) + thread.daemon = True + thread.start() + time.sleep(2.0) + yield "http://127.0.0.1:8773" + + +@pytest.fixture(scope="session") +def daisy_cotton_server_url() -> Generator[str, None, None]: + """The same consumer templates as the Bulma server, CF_UI_THEME="daisy". + + Nothing else differs — that is the claim this tier exists to check. + """ + proc = _start_cotton_server(8774, "daisy") + time.sleep(2.0) + yield "http://127.0.0.1:8774" + 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")) @@ -61,3 +92,21 @@ def cotton_page(request, browser, cotton_server_url): page.set_default_timeout(5000) yield page, request.param ctx.close() + + +@pytest.fixture(params=["js_on", "js_off"]) +def daisy_jinja_page(request, browser, daisy_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 daisy_cotton_page(request, browser, daisy_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_daisy.py b/tests/e2e/test_daisy.py new file mode 100644 index 0000000..4d08883 --- /dev/null +++ b/tests/e2e/test_daisy.py @@ -0,0 +1,171 @@ +"""DaisyUI E2E coverage (issue #6), parameterized over js_on / js_off. + +The cotton half matters most: these pages render the *same* consumer templates +as the Bulma E2E server — ``tests/integration/cotton_app/templates/`` is not +duplicated or edited — through the real django-cotton compiler, with nothing +changed but ``CF_UI_THEME``. That is the acceptance criterion, executed rather +than asserted. +""" + +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, + ) + + +# --- django-cotton: one setting, no consumer template edits ---------------- + + +def test_cotton_form_field_renders_daisy_markup(daisy_cotton_page, daisy_cotton_server_url): + page, _ = daisy_cotton_page + page.goto(f"{daisy_cotton_server_url}/form-field/") + expect(page.locator("input[name='email']")).to_be_attached() + expect(page.locator(".form-control")).to_be_attached() + expect(page.locator("input.input-bordered")).to_be_attached() + + +def test_cotton_form_field_has_no_bulma_markup(daisy_cotton_page, daisy_cotton_server_url): + """The Bulma partial must not leak through the dispatch.""" + page, _ = daisy_cotton_page + page.goto(f"{daisy_cotton_server_url}/form-field/") + expect(page.locator(".field")).to_have_count(0) + + +def test_cotton_card_renders_daisy_markup(daisy_cotton_page, daisy_cotton_server_url): + page, _ = daisy_cotton_page + page.goto(f"{daisy_cotton_server_url}/card/") + expect(page.locator(".card-body")).to_be_attached() + expect(page.locator(".card-title")).to_have_text("Card Title") + expect(page.locator(".card-actions")).to_contain_text("Card Footer") + + +def test_cotton_card_slot_survives_the_dispatch(daisy_cotton_page, daisy_cotton_server_url): + """`{% include %}` must carry {{ slot }} into the theme partial.""" + page, _ = daisy_cotton_page + page.goto(f"{daisy_cotton_server_url}/card/") + expect(page.locator(".card-body")).to_contain_text("Card body content") + + +def test_cotton_modal_named_slot_survives_the_dispatch(daisy_cotton_page, daisy_cotton_server_url): + page, _ = daisy_cotton_page + page.goto(f"{daisy_cotton_server_url}/modal/") + expect(page.locator("#test-modal .modal-box")).to_contain_text("Test Modal") + expect(page.locator("#test-modal .modal-box")).to_contain_text("Modal body content") + + +def test_cotton_modal_closed_by_default(daisy_cotton_page, daisy_cotton_server_url): + page, js_mode = daisy_cotton_page + page.goto(f"{daisy_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"modal-open")) + + +# --- JinjaX: same Alpine contract, DaisyUI classes ------------------------- + + +def test_jinja_modal_opens_and_closes(daisy_jinja_page, daisy_jinja_server_url): + """cfModal is theme-independent; only the toggled class differs.""" + page, js_mode = daisy_jinja_page + page.goto(f"{daisy_jinja_server_url}/gallery") + + modal = page.locator("#e2e-modal") + if js_mode == "js_on": + _wait_for_alpine(page) + expect(modal).not_to_have_class(re.compile(r"modal-open")) + page.evaluate("Alpine.store('cf').modal.open('e2e-modal')") + expect(modal).to_have_class(re.compile(r"modal-open")) + modal.locator("button[aria-label='close']").click() + expect(modal).not_to_have_class(re.compile(r"modal-open")) + else: + expect(modal).to_be_attached() + + +def test_jinja_notification_dismisses(daisy_jinja_page, daisy_jinja_server_url): + page, js_mode = daisy_jinja_page + page.goto(f"{daisy_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(daisy_jinja_page, daisy_jinja_server_url): + page, js_mode = daisy_jinja_page + page.goto(f"{daisy_jinja_server_url}/gallery") + panel_body = page.locator(".card-body").first + + if js_mode == "js_on": + _wait_for_alpine(page) + expect(panel_body).to_be_hidden() + # Scoped to the panel's own header — the navbar burger also carries + # aria-expanded, and it comes first in the document. + page.locator("div.card > button").first.click() + expect(panel_body).to_be_visible() + else: + expect(panel_body).to_be_attached() + + +def test_jinja_navbar_burger_toggles_menu(daisy_jinja_page, daisy_jinja_server_url): + page, js_mode = daisy_jinja_page + page.goto(f"{daisy_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-end") + # Assert the computed display, not the class list. An earlier version + # toggled `flex` onto an element that kept `hidden`; the class list + # changed and the menu still never appeared, because Tailwind emits + # `hidden` after `flex`. Only computed style catches that. + display = "el => getComputedStyle(el).display" + assert menu.evaluate(display) == "none" + page.locator("button[aria-label='menu']").click() + # Not to_be_visible(): the gallery passes an empty `end` slot, 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(daisy_jinja_page, daisy_jinja_server_url): + page, js_mode = daisy_jinja_page + page.goto(f"{daisy_jinja_server_url}/gallery") + first_tab = page.locator("[role='tab']").first + + if js_mode == "js_on": + _wait_for_alpine(page) + expect(first_tab).not_to_have_class(re.compile(r"tab-active")) + first_tab.click() + expect(first_tab).to_have_class(re.compile(r"tab-active")) + else: + expect(first_tab).to_be_attached() + + +def test_jinja_form_field_renders(daisy_jinja_page, daisy_jinja_server_url): + page, _ = daisy_jinja_page + page.goto(f"{daisy_jinja_server_url}/form-field") + expect(page.locator("input[name='email']")).to_be_visible() + expect(page.locator(".label-text")).to_have_text("Email") + + +def test_jinja_page_usable_without_js(daisy_jinja_page, daisy_jinja_server_url): + page, js_mode = daisy_jinja_page + if js_mode != "js_off": + pytest.skip("only runs in js_off mode") + page.goto(f"{daisy_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 d408d3a..ad881ea 100644 --- a/tests/integration/jinja_app/main.py +++ b/tests/integration/jinja_app/main.py @@ -8,79 +8,97 @@ _CF_UI_STATIC_DIR = JINJA_TEMPLATES_DIR.parent.parent / "static" / "cf_ui" -catalog = Catalog() -install_cf_ui(catalog, theme="bulma") - -app = FastAPI() -app.mount("/static/cf_ui", StaticFiles(directory=str(_CF_UI_STATIC_DIR)), name="cf_ui_static") - - -@app.get("/form-field", response_class=HTMLResponse) -async def form_field(): - return catalog.render( - "Cf:FormField", - name="email", - label="Email", - value="", - error="", - type="email", - required=False, - extra_class="", - ) - - -@app.get("/modal", response_class=HTMLResponse) -async def modal(): - return catalog.render("Cf:Modal", id="test-modal", extra_class="") - - -@app.get("/card", response_class=HTMLResponse) -async def card(): - return catalog.render( - "Cf:Card", - _content="Card body", - header="Card Title", - footer="", - extra_class="", - ) - - -@app.get("/navbar", response_class=HTMLResponse) -async def navbar(): - return catalog.render("Cf:Navbar", brand="", start="", end="", extra_class="") - - -@app.get("/tabs", response_class=HTMLResponse) -async def tabs(): - return catalog.render( - "Cf:Tabs", - tabs=[{"id": "one", "url": "/tab/one/"}, {"id": "two", "url": "/tab/two/"}], - hx_target="tab-content", - extra_class="", - ) - - -@app.get("/gallery", response_class=HTMLResponse) -async def gallery(): - modal_html = catalog.render("Cf:Modal", id="e2e-modal", extra_class="") - notification_html = catalog.render( - "Cf:Notification", message="Hello!", type="info", dismissible=True, extra_class="" - ) - navbar_html = catalog.render("Cf:Navbar", brand="Brand", start="", end="", extra_class="") - panel_html = catalog.render( - "Cf:Panel", title="Accordion", _content="Hidden content", open=False, extra_class="" - ) - tabs_html = catalog.render( - "Cf:Tabs", - tabs=[{"id": "tab1", "url": "/tab/one/"}, {"id": "tab2", "url": "/tab/two/"}], - hx_target="tab-content", - _content="Initial content", - extra_class="", - ) - return f""" +_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", +} + +# DaisyUI ships component classes but no Tailwind utilities. The components use +# utilities for layout and responsive behavior (`hidden`, `lg:flex`), so without +# a Tailwind build those classes resolve to nothing and the E2E tier cannot see +# whether a toggle actually changes anything. The play CDN is a real in-browser +# Tailwind JIT, which makes the gallery representative of a consuming app. +_THEME_EXTRA_HEAD = { + "bulma": "", + "daisy": '', +} + + +def make_app(theme: str = "bulma") -> FastAPI: + """Build a JinjaX gallery app for one theme. + + Parameterized by theme so the E2E tier can run the same pages under both + Bulma and DaisyUI — the component names and props are identical, which is + the property being tested. + """ + catalog = Catalog() + install_cf_ui(catalog, theme=theme) + + app = FastAPI() + app.mount("/static/cf_ui", StaticFiles(directory=str(_CF_UI_STATIC_DIR)), name="cf_ui_static") + + @app.get("/form-field", response_class=HTMLResponse) + async def form_field(): + return catalog.render( + "Cf:FormField", + name="email", + label="Email", + value="", + error="", + type="email", + required=False, + extra_class="", + ) + + @app.get("/modal", response_class=HTMLResponse) + async def modal(): + return catalog.render("Cf:Modal", id="test-modal", extra_class="") + + @app.get("/card", response_class=HTMLResponse) + async def card(): + return catalog.render( + "Cf:Card", + _content="Card body", + header="Card Title", + footer="", + extra_class="", + ) + + @app.get("/navbar", response_class=HTMLResponse) + async def navbar(): + return catalog.render("Cf:Navbar", brand="", start="", end="", extra_class="") + + @app.get("/tabs", response_class=HTMLResponse) + async def tabs(): + return catalog.render( + "Cf:Tabs", + tabs=[{"id": "one", "url": "/tab/one/"}, {"id": "two", "url": "/tab/two/"}], + hx_target="tab-content", + extra_class="", + ) + + @app.get("/gallery", response_class=HTMLResponse) + async def gallery(): + modal_html = catalog.render("Cf:Modal", id="e2e-modal", extra_class="") + notification_html = catalog.render( + "Cf:Notification", message="Hello!", type="info", dismissible=True, extra_class="" + ) + navbar_html = catalog.render("Cf:Navbar", brand="Brand", start="", end="", extra_class="") + panel_html = catalog.render( + "Cf:Panel", title="Accordion", _content="Hidden content", open=False, extra_class="" + ) + tabs_html = catalog.render( + "Cf:Tabs", + tabs=[{"id": "tab1", "url": "/tab/one/"}, {"id": "tab2", "url": "/tab/two/"}], + hx_target="tab-content", + _content="Initial content", + extra_class="", + ) + return f""" - + {_THEME_EXTRA_HEAD[theme]} + @@ -96,3 +114,8 @@ async def gallery(): """ + + return app + + +app = make_app("bulma") diff --git a/tests/unit/cotton/test_daisy.py b/tests/unit/cotton/test_daisy.py new file mode 100644 index 0000000..5a48001 --- /dev/null +++ b/tests/unit/cotton/test_daisy.py @@ -0,0 +1,184 @@ +"""DaisyUI component set, django-cotton side (issue #6). + +These go through the public ``cotton/cf/.html`` entry points with +``CF_UI_THEME = "daisy"``, 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. +""" + +import pytest + + +@pytest.fixture +def daisy_render(settings, cotton_render): + settings.CF_UI_THEME = "daisy" + return cotton_render + + +# --- Forms ----------------------------------------------------------------- + + +def test_form_field_uses_daisy_input_classes(daisy_render): + html = daisy_render("cf/form-field.html", name="email", label="Email Address") + assert "form-control" in html + assert "input-bordered" in html + assert "Email Address" in html + + +def test_form_field_error_uses_daisy_error_classes(daisy_render): + html = daisy_render("cf/form-field.html", name="email", label="Email", error="Required") + assert "input-error" in html + assert "text-error" in html + assert "Required" in html + + +def test_form_field_required_flag(daisy_render): + html = daisy_render("cf/form-field.html", name="email", label="Email", required="true") + assert "required" in html + + +def test_form_field_input_class_still_applied(daisy_render): + html = daisy_render("cf/form-field.html", name="e", label="E", input_class="input-lg") + assert "input-lg" in html + + +def test_select_uses_daisy_select_classes(daisy_render): + html = daisy_render( + "cf/select.html", + name="choice", + label="Choose", + options=[{"value": "a", "label": "Option A"}], + ) + assert "select-bordered" in html + assert "Option A" in html + + +def test_textarea_uses_daisy_textarea_classes(daisy_render): + html = daisy_render("cf/textarea.html", name="bio", label="Bio", value="Hello", rows="4") + assert "textarea-bordered" in html + assert "Hello" in html + + +def test_checkbox_group_uses_daisy_checkbox_class(daisy_render): + html = daisy_render( + "cf/checkbox-group.html", + name="fruits", + label="Fruits", + choices=[{"value": "a", "label": "Apple"}], + selected=["a"], + ) + assert 'class="checkbox' in html + assert "checked" in html + assert "Apple" in html + + +def test_checkbox_group_control_class_still_applied(daisy_render): + html = daisy_render( + "cf/checkbox-group.html", + name="f", + label="F", + choices=[{"value": "a", "label": "A"}], + selected=[], + control_class="flex-row", + ) + assert "flex-row" in html + + +# --- Feedback -------------------------------------------------------------- + + +def test_modal_keeps_the_alpine_contract(daisy_render): + html = daisy_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_daisy_modal_classes(daisy_render): + html = daisy_render("cf/modal.html", id="m") + assert "modal-box" in html + assert "modal-open" in html + + +def test_notification_maps_danger_to_daisy_error(daisy_render): + html = daisy_render("cf/notification.html", message="Boom", type="danger") + assert "alert-error" in html + assert "alert-danger" not in html + + +def test_notification_dismissible(daisy_render): + html = daisy_render( + "cf/notification.html", message="Saved!", type="success", dismissible="true" + ) + assert "alert-success" in html + assert "visible = false" in html + + +def test_notification_non_dismissible_omits_the_button(daisy_render): + html = daisy_render("cf/notification.html", message="Hi", type="info", dismissible="false") + assert "visible = false" not in html + + +def test_progress_uses_daisy_progress_classes(daisy_render): + html = daisy_render("cf/progress.html", value="40", max="100", type="primary") + assert "progress-primary" in html + assert 'value="40"' in html + + +# --- Content + navigation -------------------------------------------------- + + +def test_card_renders_header_body_footer(daisy_render): + html = daisy_render("cf/card.html", header="Title", slot="Body", footer="Foot") + assert "card-body" in html + assert "card-title" in html + assert "Body" in html + assert "Foot" in html + + +def test_table_uses_daisy_table_classes(daisy_render): + html = daisy_render( + "cf/table.html", columns=[{"key": "n", "label": "Name"}], rows=[{"n": "Ada"}] + ) + assert "table-zebra" in html + assert "is-striped" not in html + assert "Name" in html + assert "Ada" in html + + +def test_pagination_uses_the_join_group(daisy_render): + html = daisy_render( + "cf/pagination.html", page="2", total_pages="3", hx_url="/x", hx_target="#t" + ) + assert "join-item" in html + assert "btn-active" in html + + +def test_panel_keeps_the_alpine_contract(daisy_render): + html = daisy_render("cf/panel.html", title="Details", slot="Inner") + assert 'x-data="cfPanel"' in html + assert "x-cloak" in html + assert "Inner" in html + + +def test_navbar_keeps_the_alpine_contract(daisy_render): + html = daisy_render("cf/navbar.html", brand="Brand", start="S", end="E") + assert 'x-data="cfNavbar"' in html + assert "navbar-start" in html + assert "navbar-end" in html + + +def test_breadcrumb_uses_daisy_breadcrumbs_class(daisy_render): + html = daisy_render("cf/breadcrumb.html", items=[{"url": "/a", "label": "A"}]) + assert 'class="breadcrumbs' in html + assert 'aria-current="page"' in html + + +def test_tabs_keeps_the_alpine_contract(daisy_render): + html = daisy_render("cf/tabs.html", tabs=[{"id": "one", "url": "/one"}], slot="C") + assert 'x-data="cfTabs"' in html + assert "setActive('one')" in html + assert "tab-active" in html diff --git a/tests/unit/jinja/test_daisy.py b/tests/unit/jinja/test_daisy.py new file mode 100644 index 0000000..970787a --- /dev/null +++ b/tests/unit/jinja/test_daisy.py @@ -0,0 +1,254 @@ +"""DaisyUI component set, Jinja2/JinjaX side (issue #6). + +The Jinja theme switch is already directory-based — ``install_cf_ui`` registers +``templates/jinja//`` — so this tier only has to prove the daisy +directory ships all 14 components, that they render under ``StrictUndefined``, +and that they speak DaisyUI's class vocabulary rather than Bulma's. +""" + +from collections.abc import Callable +from pathlib import Path + +import pytest +from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape + +DAISY_DIR = ( + Path(__file__).parent.parent.parent.parent / "src" / "cf_ui" / "templates" / "jinja" / "daisy" +) + +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(DAISY_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_daisy_ships_the_same_components_as_bulma(): + bulma_dir = DAISY_DIR.parent / "bulma" + assert sorted(p.name for p in DAISY_DIR.glob("*.jinja")) == sorted( + p.name for p in bulma_dir.glob("*.jinja") + ) + + +@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_bulma_class_names(render, name): + html = render(f"{name}.jinja", **REQUIRED_PROPS[name]) + for bulma_marker in ("is-danger", "is-active", "card-header-title", "navbar-burger", "help"): + assert bulma_marker not in html, f"{name} still speaks Bulma" + + +# --- Forms ----------------------------------------------------------------- + + +def test_form_field_uses_daisy_input_classes(render): + html = render("FormField.jinja", name="email", label="Email") + assert "form-control" in html + assert "input-bordered" in html + assert 'name="email"' in html + + +def test_form_field_error_uses_daisy_error_classes(render): + html = render("FormField.jinja", name="email", label="Email", error="Required") + assert "input-error" in html + assert "text-error" in html + assert "Required" in html + + +def test_form_field_input_class_still_applied(render): + html = render("FormField.jinja", name="email", label="Email", input_class="input-lg") + assert "input-lg" in html + + +def test_select_uses_daisy_select_classes(render): + html = render( + "Select.jinja", name="c", label="C", options=[{"value": "a", "label": "Option A"}] + ) + assert "select-bordered" 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_daisy_textarea_classes(render): + html = render("Textarea.jinja", name="bio", label="Bio", value="Hello") + assert "textarea-bordered" in html + assert "Hello" in html + + +def test_checkbox_group_uses_daisy_checkbox_class(render): + html = render( + "CheckboxGroup.jinja", + name="f", + label="F", + choices=[{"value": "a", "label": "Apple"}], + selected=["a"], + ) + assert 'class="checkbox' in html + assert "checked" in html + assert "Apple" 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_daisy_modal_classes(render): + html = render("Modal.jinja", id="m") + assert "modal-box" in html + assert "modal-open" in html + + +def test_notification_maps_danger_to_daisy_error(render): + """DaisyUI has no `alert-danger`; the prop vocabulary stays stable.""" + html = render("Notification.jinja", message="Boom", type="danger") + assert "alert-error" in html + assert "alert-danger" not 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 "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 + + +def test_progress_uses_daisy_progress_classes(render): + html = render("Progress.jinja", value=40, max=100, type="primary") + assert "progress-primary" in html + assert 'value="40"' in html + assert 'max="100"' in html + + +def test_progress_maps_danger_to_error(render): + html = render("Progress.jinja", value=75, max=100, type="danger") + assert "progress-error" in html + + +# --- Content + navigation -------------------------------------------------- + + +def test_card_renders_header_body_footer(render): + html = render("Card.jinja", header="Title", content="Body", footer="Foot") + assert "card-body" in html + assert "card-title" in html + assert "Title" in html + assert "Body" in html + assert "Foot" in html + + +def test_table_uses_daisy_table_classes(render): + html = render("Table.jinja", columns=[{"key": "n", "label": "Name"}], rows=[{"n": "Ada"}]) + assert "table-zebra" in html + assert "is-striped" not in html + assert "Name" in html + assert "Ada" in html + + +def test_pagination_uses_the_join_group(render): + html = render("Pagination.jinja", page=2, total_pages=3, hx_url="/x", hx_target="#t") + assert "join-item" in html + assert "btn-active" in html + assert 'aria-current="page"' 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 + assert "Inner" 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-start" in html + assert "navbar-end" in html + + +def test_breadcrumb_uses_daisy_breadcrumbs_class(render): + html = render("Breadcrumb.jinja", items=[{"url": "/a", "label": "A"}]) + assert 'class="breadcrumbs' in html + assert 'aria-current="page"' in html + + +def test_tabs_keeps_the_alpine_contract(render): + html = render("Tabs.jinja", tabs=[{"id": "one", "url": "/one"}], content="C") + assert 'x-data="cfTabs"' in html + assert "setActive('one')" in html + assert "tab-active" in html diff --git a/tests/unit/test_tailwind_content.py b/tests/unit/test_tailwind_content.py new file mode 100644 index 0000000..7d333b6 --- /dev/null +++ b/tests/unit/test_tailwind_content.py @@ -0,0 +1,160 @@ +"""Tailwind tree-shaking guards for the DaisyUI theme (issue #6). + +DaisyUI sits on Tailwind, so a consuming app builds its own CSS. Every class +cf-ui's templates use has to be visible to Tailwind's content scanner, or it +is shaken out and the page renders unstyled. That fails silently — nothing +errors, the markup is just naked. + +There are exactly two ways it happens, and both are tested here: + +1. The content glob does not reach the installed package's templates. +2. A class name is assembled at render time (``alert-{{ type }}``), so the + complete token never appears in the source the scanner reads. +""" + +import glob as globlib +import re +from pathlib import Path + +import pytest + +PKG_DIR = Path(__file__).parent.parent.parent / "src" / "cf_ui" +TEMPLATES_DIR = PKG_DIR / "templates" + +DAISY_TEMPLATES = sorted( + [*(TEMPLATES_DIR / "jinja" / "daisy").glob("*.jinja")] + + [*(TEMPLATES_DIR / "cotton" / "_themes" / "daisy").glob("*.html")] +) + + +# --- Failure mode 1: the glob does not reach the templates ----------------- + + +def test_tailwind_content_globs_are_published(): + from cf_ui.themes import tailwind_content_globs + + patterns = tailwind_content_globs() + assert patterns, "no content globs published" + assert all(isinstance(p, str) for p in patterns) + + +def test_content_globs_resolve_to_absolute_paths(): + """A consumer's tailwind.config.js lives elsewhere — relative would break.""" + from cf_ui.themes import tailwind_content_globs + + for pattern in tailwind_content_globs(): + assert Path(pattern).is_absolute(), pattern + + +def test_content_globs_use_forward_slashes_on_every_platform(): + """These strings get pasted into JS, where a backslash is an escape. + + fast-glob (Tailwind's matcher) reads ``\\`` as an escape rather than a + separator, so a native Windows path matches nothing — and matching nothing + is indistinguishable from the tree-shaking this whole module prevents. + """ + from cf_ui.themes import tailwind_content_globs + + for pattern in tailwind_content_globs(): + assert "\\" not in pattern, pattern + + +def test_content_globs_cover_every_daisy_template(): + """The test that fails when the templates would be tree-shaken away.""" + from cf_ui.themes import tailwind_content_globs + + covered = set() + for pattern in tailwind_content_globs(): + covered.update(Path(p).resolve() for p in globlib.glob(pattern, recursive=True)) + + assert DAISY_TEMPLATES, "no daisy templates found — the suite would pass vacuously" + missing = [t for t in DAISY_TEMPLATES if t.resolve() not in covered] + assert not missing, f"not reachable by the documented content glob: {missing}" + + +def test_content_globs_cover_the_public_cotton_wrappers(): + """The wrappers hold no classes today, but the glob must not exclude them.""" + from cf_ui.themes import tailwind_content_globs + + covered = set() + for pattern in tailwind_content_globs(): + covered.update(Path(p).resolve() for p in globlib.glob(pattern, recursive=True)) + + wrapper = (TEMPLATES_DIR / "cotton" / "cf" / "card.html").resolve() + assert wrapper in covered + + +def test_content_globs_are_rooted_in_the_installed_package(): + """Derived from ``cf_ui.__file__``, so a site-packages install works too.""" + from cf_ui.themes import tailwind_content_globs + + for pattern in tailwind_content_globs(): + assert str(PKG_DIR.resolve()) in str(Path(pattern).resolve()) + + +def test_documentation_publishes_the_same_glob(): + """Drift guard: docs that disagree with the code cause silent unstyled pages.""" + from cf_ui.themes import TAILWIND_CONTENT_SUFFIX + + doc = (Path(__file__).parent.parent.parent / "docs" / "daisyui.md").read_text(encoding="utf-8") + assert TAILWIND_CONTENT_SUFFIX in doc + + +# --- Failure mode 2: class names assembled at render time ------------------ + +# A class token split across a template construct is invisible to the scanner. +# alert-{{ type }} -> flagged (variable glued to a prefix) +# alert-{% if x %}error{% -> flagged (tag glued to a prefix) +# input-bordered{% if e %} -> fine (complete token, then a tag) +# {{ extra_class }} -> fine (a whole class handed in by the consumer) +_SPLIT_TOKEN = re.compile(r"[\w-]\{\{|\}\}[\w-]|-\{%|%\}-") +_CLASS_ATTR = re.compile(r"""\bclass="([^"]*)\"""") + + +def test_the_daisy_template_set_is_complete(): + """Without this, the parametrized scan below silently degrades to a skip.""" + assert len(DAISY_TEMPLATES) == 28, f"expected 14 jinja + 14 cotton, got {DAISY_TEMPLATES}" + + +@pytest.mark.parametrize("template", DAISY_TEMPLATES, ids=lambda p: p.name) +def test_no_daisy_class_name_is_assembled_at_render_time(template): + source = template.read_text(encoding="utf-8") + offenders = [value for value in _CLASS_ATTR.findall(source) if _SPLIT_TOKEN.search(value)] + assert not offenders, ( + f"{template.name}: class token split across a template construct — " + f"Tailwind will tree-shake it: {offenders}" + ) + + +def test_the_split_token_detector_actually_detects_a_split(): + """Meta-test: a guard that cannot fail is not a guard.""" + assert _SPLIT_TOKEN.search("alert alert-{{ type }}") + assert _SPLIT_TOKEN.search("alert alert-{% if e %}error{% endif %}") + assert not _SPLIT_TOKEN.search("input input-bordered {{ extra_class }}") + assert not _SPLIT_TOKEN.search("input input-bordered{% if error %} input-error{% endif %}") + + +# --- The type -> daisy class maps emit whole tokens ------------------------ + + +@pytest.mark.parametrize( + "prefix,expected", + [ + ("alert", {"alert-info", "alert-success", "alert-warning", "alert-error"}), + ( + "progress", + { + "progress-primary", + "progress-info", + "progress-success", + "progress-warning", + "progress-error", + }, + ), + ], +) +def test_variant_class_names_appear_literally_in_the_sources(prefix, expected): + """Whatever a component maps `type=` onto must be greppable as a whole word.""" + blob = "\n".join(t.read_text(encoding="utf-8") for t in DAISY_TEMPLATES) + missing = {name for name in expected if name not in blob} + assert not missing, f"{prefix} variants never appear literally: {missing}" diff --git a/tests/unit/test_theme_dispatch.py b/tests/unit/test_theme_dispatch.py new file mode 100644 index 0000000..a60af1f --- /dev/null +++ b/tests/unit/test_theme_dispatch.py @@ -0,0 +1,218 @@ +"""Theme dispatch for the django-cotton template set (issue #6). + +The Jinja side already switches themes by directory — ``install_cf_ui`` +registers ``templates/jinja//`` with JinjaX. Cotton has no equivalent: +```` resolves through django-cotton's single global ``COTTON_DIR`` +to exactly one file, and cf-ui deliberately does not touch ``COTTON_DIR`` +(see tests/unit/test_consumer_compatibility.py). + +So the public component at ``cotton/cf/.html`` stays a stable, theme-free +entry point and dispatches to a per-theme partial at +``cotton/_themes//.html``. One setting — ``CF_UI_THEME`` — moves +all 14 components at once, with no template edits in the consuming app. +""" + +from pathlib import Path + +import pytest + +TEMPLATES_DIR = Path(__file__).parent.parent.parent / "src" / "cf_ui" / "templates" +COTTON_DIR = TEMPLATES_DIR / "cotton" + +# Cotton file stems (hyphenated), as used by . +COMPONENT_STEMS = [ + "breadcrumb", + "card", + "checkbox-group", + "form-field", + "modal", + "navbar", + "notification", + "pagination", + "panel", + "progress", + "select", + "table", + "tabs", + "textarea", +] + +IMPLEMENTED_THEMES = ["bulma", "daisy"] + + +# --- The theme registry ---------------------------------------------------- + + +def test_themes_module_lists_the_implemented_themes(): + from cf_ui.themes import THEMES + + assert sorted(THEMES) == sorted(IMPLEMENTED_THEMES) + + +def test_components_tuple_matches_the_shipped_cotton_files(): + """Drift guard: the registry and the filesystem must not disagree.""" + from cf_ui.themes import COMPONENTS + + assert sorted(COMPONENTS) == sorted(COMPONENT_STEMS) + + +def test_resolve_theme_defaults_to_bulma(): + from cf_ui.themes import resolve_theme + + assert resolve_theme(None) == "bulma" + + +def test_resolve_theme_accepts_an_implemented_theme(): + from cf_ui.themes import resolve_theme + + assert resolve_theme("daisy") == "daisy" + + +def test_resolve_theme_rejects_a_stub_theme_by_name(): + """bootstrap/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") + + +def test_resolve_theme_error_names_the_available_themes(): + from cf_ui.themes import ThemeError, resolve_theme + + with pytest.raises(ThemeError) as exc: + resolve_theme("tailwind") + assert "daisy" in str(exc.value) + + +def test_cotton_partial_builds_the_theme_scoped_path(): + from cf_ui.themes import cotton_partial + + assert cotton_partial("modal", "daisy") == "cotton/_themes/daisy/modal.html" + + +def test_cotton_partial_rejects_an_unknown_component(): + """Guards against a typo in a wrapper silently resolving to nothing.""" + from cf_ui.themes import ThemeError, cotton_partial + + with pytest.raises(ThemeError, match="dropdown"): + cotton_partial("dropdown", "bulma") + + +# --- The partials exist on disk ------------------------------------------- + + +@pytest.mark.parametrize("theme", IMPLEMENTED_THEMES) +@pytest.mark.parametrize("stem", COMPONENT_STEMS) +def test_every_theme_ships_every_cotton_partial(theme, stem): + assert (COTTON_DIR / "_themes" / theme / f"{stem}.html").is_file() + + +@pytest.mark.parametrize("stem", COMPONENT_STEMS) +def test_public_cotton_component_holds_no_theme_markup(stem): + """The wrapper declares props and dispatches — nothing else. + + If Bulma class names leak back into cotton/cf/*.html, switching themes + stops being a config change, which is exactly what this phase promises. + """ + source = (COTTON_DIR / "cf" / f"{stem}.html").read_text(encoding="utf-8") + assert "{% include" in source + for bulma_marker in ('class="field', 'class="card', "is-danger", 'class="navbar'): + assert bulma_marker not in source + + +@pytest.mark.parametrize("stem", COMPONENT_STEMS) +def test_theme_partials_declare_no_cotton_vars(stem): + """ belongs on the public wrapper only — it is the prop contract. + + Duplicating it in each partial would mean 28 places to keep in sync. + """ + for theme in IMPLEMENTED_THEMES: + source = (COTTON_DIR / "_themes" / theme / f"{stem}.html").read_text(encoding="utf-8") + assert "