diff --git a/CHANGELOG.md b/CHANGELOG.md index 83afca1..49cb1be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,65 @@ ## [Unreleased] +### Added — Fomantic UI theme (#24) + +All 14 components in both template sets, replacing the `PLANNED.md` stub. +`CF_UI_THEME = "fomantic"` (Django) or `theme="fomantic"` (FastAPI/Litestar) +now resolves; it was rejected at startup before. The CDN entry and pinned +version (`fomantic-ui@2.9.3`, `dist/semantic.min.css`) already existed and were +verified to still resolve. + +- **CSS only — no jQuery, and no Fomantic JS.** Fomantic's Modal, Tab, + Accordion and Dropdown are jQuery plugins, which makes this the most + JS-dependent of the five themes. Loading them would mean a *styling* choice + silently changed a consuming app's dependency graph, so the theme uses + Fomantic's classes and markup structure only and Alpine drives every + behaviour, exactly as it does for Bulma and DaisyUI. A unit test scans all 28 + templates and an E2E test checks the delivered page and the live `window`. + +- Consequences of that, where they are not obvious from the markup: + - **Modal** renders its own `.ui.dimmer`. Fomantic normally injects it from + `$('.ui.modal').modal('show')`; here the dimmer and the modal each take + `active` from the same Alpine `open`, since both are `display:none` without + it. + - **Panel** binds `active` as a class rather than relying on `x-show` alone. + `.ui.accordion .title ~ .content:not(.active)` is `display:none`, so Alpine + merely dropping its inline style would leave the panel hidden. + - **Select** is a plain ` + + + + {% endfor %} + {% if error %}
{{ error }}
{% endif %} + + diff --git a/src/cf_ui/templates/cotton/_themes/fomantic/form-field.html b/src/cf_ui/templates/cotton/_themes/fomantic/form-field.html new file mode 100644 index 0000000..ebb05df --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/fomantic/form-field.html @@ -0,0 +1,21 @@ +{% comment %} Every Fomantic form rule is scoped `.ui.form .field`, so a standalone field + with no `.ui.form` ancestor is unstyled. The component carries its own, which + nests harmlessly inside a consumer's `
`. `input_class` + lands on the `.ui.input` wrapper because that is where Fomantic's size and + state modifiers live (`ui mini input`, `ui icon input`). {% endcomment %} +
+
+ +
+ +
+ {% comment %} Not `
`: `.ui.form .error.message` is + display:none until the *form* itself carries `.error`. The prompt label + is what Fomantic actually shows for a single field. {% endcomment %} + {% if error %}
{{ error }}
{% endif %} +
+
diff --git a/src/cf_ui/templates/cotton/_themes/fomantic/modal.html b/src/cf_ui/templates/cotton/_themes/fomantic/modal.html new file mode 100644 index 0000000..dc5cb8f --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/fomantic/modal.html @@ -0,0 +1,23 @@ +{% comment %} Fomantic's modal is normally positioned and revealed by + `$('.ui.modal').modal('show')`, which also injects the `.ui.dimmer` wrapper. + cf-ui loads no Fomantic JS, so the dimmer is part of the markup and both + `.ui.dimmer` and `.ui.modal` — each `display:none` until `.active` — get the + class from the same Alpine `open`. {% endcomment %} + diff --git a/src/cf_ui/templates/cotton/_themes/fomantic/navbar.html b/src/cf_ui/templates/cotton/_themes/fomantic/navbar.html new file mode 100644 index 0000000..ef0b056 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/fomantic/navbar.html @@ -0,0 +1,23 @@ +{% comment %} Fomantic's responsive nav collapse is its Sidebar/Dropdown *JS* module, which + cf-ui does not load. `ui stackable menu` is Fomantic's CSS-only equivalent: + below 768px the menu becomes a full-width column, so nothing is hidden that a + burger would have to reveal. The toggle is kept because `cfNavbar` is the + cross-theme contract — it reports its state through `aria-expanded` and + Fomantic's `active` item class rather than through a display utility that + this framework does not ship. {% endcomment %} + diff --git a/src/cf_ui/templates/cotton/_themes/fomantic/notification.html b/src/cf_ui/templates/cotton/_themes/fomantic/notification.html new file mode 100644 index 0000000..36a2736 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/fomantic/notification.html @@ -0,0 +1,12 @@ + diff --git a/src/cf_ui/templates/cotton/_themes/fomantic/pagination.html b/src/cf_ui/templates/cotton/_themes/fomantic/pagination.html new file mode 100644 index 0000000..e5c6e2b --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/fomantic/pagination.html @@ -0,0 +1,26 @@ +{% load cf_ui %} + diff --git a/src/cf_ui/templates/cotton/_themes/fomantic/panel.html b/src/cf_ui/templates/cotton/_themes/fomantic/panel.html new file mode 100644 index 0000000..8911aee --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/fomantic/panel.html @@ -0,0 +1,25 @@ +{% comment %} `.ui.accordion .title ~ .content:not(.active)` is `display:none`, so the open + state has to be a *class*, not only `x-show`: Alpine dropping its inline + `display:none` would leave the stylesheet's rule in force. Both are bound so + the panel is right with Alpine off (class, server-rendered) and with it on. {% endcomment %} +
+ +
+ {{ slot }} +
+
diff --git a/src/cf_ui/templates/cotton/_themes/fomantic/progress.html b/src/cf_ui/templates/cotton/_themes/fomantic/progress.html new file mode 100644 index 0000000..62cabb9 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/fomantic/progress.html @@ -0,0 +1,28 @@ +{% comment %} Fomantic's progress is a div whose `.bar` is sized by its Progress module. + With no JS the width is written server-side, and `data-percent` has to be + present — `.ui.progress:not([data-percent]) .bar` is given a transparent + background. A div is also not a ``, so the ARIA is explicit. + + The percentage is clamped to 0..100 to match the Jinja side. Bulma and Daisy + render a real ``, which the browser clamps for both painting and + the accessibility tree; a plain div would happily take `width: 150%` and + report `aria-valuenow="150"` against a max of 100. Django has no min/max + filter, so the clamp is three `widthratio` branches: `1 1 100` is 100 and + `0 1 100` is 0. The ARIA is stated in percent so it agrees with the visible + "40%" text. {% endcomment %} +{% widthratio value max 100 as cf_raw %} +{% if cf_raw|add:"0" > 100 %}{% widthratio 1 1 100 as cf_pct %}{% elif cf_raw|add:"0" < 0 %}{% widthratio 0 1 100 as cf_pct %}{% else %}{% widthratio value max 100 as cf_pct %}{% endif %} +
+
+
+
{{ cf_pct }}%
+
+ {% if label %}
{{ label }}
{% endif %} +
+
diff --git a/src/cf_ui/templates/cotton/_themes/fomantic/select.html b/src/cf_ui/templates/cotton/_themes/fomantic/select.html new file mode 100644 index 0000000..9c26b12 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/fomantic/select.html @@ -0,0 +1,20 @@ +{% comment %} A real ` + {% for opt in options %} + + {% endfor %} + + {% if error %}
{{ error }}
{% endif %} +
+ diff --git a/src/cf_ui/templates/cotton/_themes/fomantic/table.html b/src/cf_ui/templates/cotton/_themes/fomantic/table.html new file mode 100644 index 0000000..6ed59e8 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/fomantic/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/fomantic/tabs.html b/src/cf_ui/templates/cotton/_themes/fomantic/tabs.html new file mode 100644 index 0000000..b588eaa --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/fomantic/tabs.html @@ -0,0 +1,25 @@ +{% comment %} The panel is a plain attached segment, not `.ui.tab`: `.ui.tab` is + `display:none` until Fomantic's Tab module adds `.active`, and this one panel + is always the visible one — HTMX swaps its contents. {% endcomment %} +
+ +
{{ slot }}
+
diff --git a/src/cf_ui/templates/cotton/_themes/fomantic/textarea.html b/src/cf_ui/templates/cotton/_themes/fomantic/textarea.html new file mode 100644 index 0000000..b5d4bf1 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/fomantic/textarea.html @@ -0,0 +1,9 @@ +
+
+ + + {% if error %}
{{ error }}
{% endif %} +
+
diff --git a/src/cf_ui/templates/cotton/fomantic/PLANNED.md b/src/cf_ui/templates/cotton/fomantic/PLANNED.md deleted file mode 100644 index 805e408..0000000 --- a/src/cf_ui/templates/cotton/fomantic/PLANNED.md +++ /dev/null @@ -1,3 +0,0 @@ -# Theme: Fomantic — Planned - -Components for this theme are tracked in GitHub Issues. diff --git a/src/cf_ui/templates/jinja/fomantic/Breadcrumb.jinja b/src/cf_ui/templates/jinja/fomantic/Breadcrumb.jinja new file mode 100644 index 0000000..250f37c --- /dev/null +++ b/src/cf_ui/templates/jinja/fomantic/Breadcrumb.jinja @@ -0,0 +1,13 @@ +{#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/fomantic/Card.jinja b/src/cf_ui/templates/jinja/fomantic/Card.jinja new file mode 100644 index 0000000..80e4923 --- /dev/null +++ b/src/cf_ui/templates/jinja/fomantic/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/fomantic/CheckboxGroup.jinja b/src/cf_ui/templates/jinja/fomantic/CheckboxGroup.jinja new file mode 100644 index 0000000..a581e54 --- /dev/null +++ b/src/cf_ui/templates/jinja/fomantic/CheckboxGroup.jinja @@ -0,0 +1,28 @@ +{#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 %} +
+ {# Fomantic's CSS-only checkbox works because the real input is stretched + invisibly over the box, so a click anywhere hits it. That is a mouse + affordance, not an accessible name — the label is wired up with + for/id so a screen reader announces it too. #} +
+ + +
+
+ {% endfor %} + {% if error %}
{{ error }}
{% endif %} +
+
diff --git a/src/cf_ui/templates/jinja/fomantic/FormField.jinja b/src/cf_ui/templates/jinja/fomantic/FormField.jinja new file mode 100644 index 0000000..b32345b --- /dev/null +++ b/src/cf_ui/templates/jinja/fomantic/FormField.jinja @@ -0,0 +1,27 @@ +{#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 "" %} +{# Every Fomantic form rule is scoped `.ui.form .field`, so a standalone field + with no `.ui.form` ancestor is unstyled. The component carries its own, + which nests harmlessly inside a consumer's ``. + `input_class` lands on the `.ui.input` wrapper because that is where + Fomantic's size and state modifiers live (`ui mini input`, `ui icon input`). #} +
+
+ +
+ +
+ {# Not `
`: `.ui.form .error.message` is + display:none until the *form* itself carries `.error`. The prompt label + is what Fomantic actually shows for a single field. #} + {% if error %}
{{ error }}
{% endif %} +
+
diff --git a/src/cf_ui/templates/jinja/fomantic/Modal.jinja b/src/cf_ui/templates/jinja/fomantic/Modal.jinja new file mode 100644 index 0000000..995824c --- /dev/null +++ b/src/cf_ui/templates/jinja/fomantic/Modal.jinja @@ -0,0 +1,30 @@ +{#def id="modal", header="", content="", footer="", label="Dialog", extra_class="" #} +{% set id = id if id is defined else "modal" %} +{% set header = header if header is defined else "" %} +{% set content = content if content is defined else "" %} +{% set footer = footer if footer is defined else "" %} +{% set label = label if label is defined and label else "Dialog" %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{# Fomantic's modal is normally positioned and revealed by + `$('.ui.modal').modal('show')`, which also injects the `.ui.dimmer` wrapper. + cf-ui loads no Fomantic JS, so the dimmer is part of the markup and both + `.ui.dimmer` and `.ui.modal` — each `display:none` until `.active` — get the + class from the same Alpine `open`. #} + diff --git a/src/cf_ui/templates/jinja/fomantic/Navbar.jinja b/src/cf_ui/templates/jinja/fomantic/Navbar.jinja new file mode 100644 index 0000000..fc3b0a8 --- /dev/null +++ b/src/cf_ui/templates/jinja/fomantic/Navbar.jinja @@ -0,0 +1,28 @@ +{#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 "" %} +{# Fomantic's responsive nav collapse is its Sidebar/Dropdown *JS* module, which + cf-ui does not load. `ui stackable menu` is Fomantic's CSS-only equivalent: + below 768px the menu becomes a full-width column, so nothing is hidden that a + burger would have to reveal. The toggle is kept because `cfNavbar` is the + cross-theme contract — it reports its state through `aria-expanded` and + Fomantic's `active` item class rather than through a display utility that + this framework does not ship. #} + diff --git a/src/cf_ui/templates/jinja/fomantic/Notification.jinja b/src/cf_ui/templates/jinja/fomantic/Notification.jinja new file mode 100644 index 0000000..61af943 --- /dev/null +++ b/src/cf_ui/templates/jinja/fomantic/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/fomantic/PLANNED.md b/src/cf_ui/templates/jinja/fomantic/PLANNED.md deleted file mode 100644 index 805e408..0000000 --- a/src/cf_ui/templates/jinja/fomantic/PLANNED.md +++ /dev/null @@ -1,3 +0,0 @@ -# Theme: Fomantic — Planned - -Components for this theme are tracked in GitHub Issues. diff --git a/src/cf_ui/templates/jinja/fomantic/Pagination.jinja b/src/cf_ui/templates/jinja/fomantic/Pagination.jinja new file mode 100644 index 0000000..4b9fb15 --- /dev/null +++ b/src/cf_ui/templates/jinja/fomantic/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/fomantic/Panel.jinja b/src/cf_ui/templates/jinja/fomantic/Panel.jinja new file mode 100644 index 0000000..1c8658a --- /dev/null +++ b/src/cf_ui/templates/jinja/fomantic/Panel.jinja @@ -0,0 +1,30 @@ +{#def title, id="panel", content="", open=false, extra_class="" #} +{% set content = content if content is defined else "" %} +{% set id = id if id is defined else "panel" %} +{% set open = open if open is defined else false %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{# `.ui.accordion .title ~ .content:not(.active)` is `display:none`, so the open + state has to be a *class*, not only `x-show`: Alpine dropping its inline + `display:none` would leave the stylesheet's rule in force. Both are bound so + the panel is right with Alpine off (class, server-rendered) and with it on. #} +
+ +
+ {{ content }} +
+
diff --git a/src/cf_ui/templates/jinja/fomantic/Progress.jinja b/src/cf_ui/templates/jinja/fomantic/Progress.jinja new file mode 100644 index 0000000..0bc3f20 --- /dev/null +++ b/src/cf_ui/templates/jinja/fomantic/Progress.jinja @@ -0,0 +1,33 @@ +{#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 "" %} +{% set cf_max = max|float %} +{% set cf_raw = ((value|float / cf_max) * 100)|round|int if cf_max > 0 else 0 %} +{% set cf_pct = [[cf_raw, 0]|max, 100]|min %} +{# Fomantic's progress is a div whose `.bar` is sized by its Progress module. + With no JS the width is written server-side, and `data-percent` has to be + present — `.ui.progress:not([data-percent]) .bar` is given a transparent + background. A div is also not a ``, so the ARIA is explicit. + + The percentage is clamped to 0..100 because nothing else will do it here. + Bulma and Daisy render a real ``, which the browser clamps for + both painting and the accessibility tree; a plain div would happily take + `width: 150%` and report `aria-valuenow="150"` against a max of 100. The + ARIA is stated in percent so it agrees with the visible "40%" text. #} +
+
+
+
{{ cf_pct }}%
+
+ {% if label %}
{{ label }}
{% endif %} +
+
diff --git a/src/cf_ui/templates/jinja/fomantic/Select.jinja b/src/cf_ui/templates/jinja/fomantic/Select.jinja new file mode 100644 index 0000000..6e61f2c --- /dev/null +++ b/src/cf_ui/templates/jinja/fomantic/Select.jinja @@ -0,0 +1,26 @@ +{#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 "" %} +{# A real ` + {% for opt in options %} + + {% endfor %} + + {% if error %}
{{ error }}
{% endif %} +
+ diff --git a/src/cf_ui/templates/jinja/fomantic/Table.jinja b/src/cf_ui/templates/jinja/fomantic/Table.jinja new file mode 100644 index 0000000..c5241cc --- /dev/null +++ b/src/cf_ui/templates/jinja/fomantic/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/fomantic/Tabs.jinja b/src/cf_ui/templates/jinja/fomantic/Tabs.jinja new file mode 100644 index 0000000..bfc5d13 --- /dev/null +++ b/src/cf_ui/templates/jinja/fomantic/Tabs.jinja @@ -0,0 +1,31 @@ +{#def tabs=[], hx_target="tab-content", active="", content="", extra_class="" #} +{% set tabs = tabs if tabs is defined else [] %} +{% set content = content if content is defined else "" %} +{% set active = active if active is defined else "" %} +{% set hx_target = hx_target if hx_target is defined else "tab-content" %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{# The panel is a plain attached segment, not `.ui.tab`: `.ui.tab` is + `display:none` until Fomantic's Tab module adds `.active`, and this one + panel is always the visible one — HTMX swaps its contents. #} +
+ +
{{ content }}
+
diff --git a/src/cf_ui/templates/jinja/fomantic/Textarea.jinja b/src/cf_ui/templates/jinja/fomantic/Textarea.jinja new file mode 100644 index 0000000..ac3c7dc --- /dev/null +++ b/src/cf_ui/templates/jinja/fomantic/Textarea.jinja @@ -0,0 +1,15 @@ +{#def name, label, value="", error="", rows=4, extra_class="", input_class="" #} +{% set value = value if value is defined else "" %} +{% set error = error if error is defined else "" %} +{% set rows = rows if rows is defined else 4 %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{% set input_class = input_class if input_class is defined else "" %} +
+
+ + + {% if error %}
{{ error }}
{% endif %} +
+
diff --git a/src/cf_ui/themes.py b/src/cf_ui/themes.py index 209e6d3..d50de07 100644 --- a/src/cf_ui/themes.py +++ b/src/cf_ui/themes.py @@ -26,7 +26,7 @@ #: Themes with a real component set. The other directories under #: ``templates/`` hold a ``PLANNED.md`` stub and are not selectable. -THEMES = ("bulma", "daisy", "bootstrap", "foundation") +THEMES = ("bulma", "daisy", "bootstrap", "foundation", "fomantic") DEFAULT_THEME = "bulma" diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index add13e3..7465313 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -114,6 +114,25 @@ def foundation_cotton_server_url() -> Generator[str, None, None]: proc.wait(timeout=5) +@pytest.fixture(scope="session") +def fomantic_jinja_server_url() -> Generator[str, None, None]: + thread = threading.Thread(target=_run_fastapi_server, args=("fomantic", "127.0.0.1", 8779)) + thread.daemon = True + thread.start() + time.sleep(2.0) + yield "http://127.0.0.1:8779" + + +@pytest.fixture(scope="session") +def fomantic_cotton_server_url() -> Generator[str, None, None]: + """The same consumer templates again, ``CF_UI_THEME="fomantic"``.""" + proc = _start_cotton_server(8780, "fomantic") + time.sleep(2.0) + yield "http://127.0.0.1:8780" + 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")) @@ -184,3 +203,21 @@ def foundation_cotton_page(request, browser, foundation_cotton_server_url): page.set_default_timeout(5000) yield page, request.param ctx.close() + + +@pytest.fixture(params=["js_on", "js_off"]) +def fomantic_jinja_page(request, browser, fomantic_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 fomantic_cotton_page(request, browser, fomantic_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_fomantic.py b/tests/e2e/test_fomantic.py new file mode 100644 index 0000000..72ddafb --- /dev/null +++ b/tests/e2e/test_fomantic.py @@ -0,0 +1,376 @@ +"""Fomantic UI E2E coverage (issue #24), parameterized over js_on / js_off. + +Two claims this tier exists to execute rather than assert: + +* The **same consumer templates** as the Bulma and DaisyUI E2E servers — + ``tests/integration/cotton_app/templates/`` is not duplicated or edited — + render under ``CF_UI_THEME="fomantic"``. +* **No jQuery reaches the browser.** Fomantic's Modal, Tab, Accordion and + Dropdown are jQuery plugins, and this theme deliberately loads none of them: + Alpine drives every one of those behaviours, exactly as it does for the other + themes. ``test_no_jquery_reaches_the_browser`` checks the delivered page and + the live ``window`` object, not the templates — the templates are checked in + ``tests/unit/jinja/test_fomantic.py``. +""" + +import re + +import pytest +from playwright.sync_api import expect + + +def _wait_for_alpine(page) -> None: + page.wait_for_function( + "() => window.Alpine !== undefined && document.querySelectorAll('[x-cloak]').length === 0", + timeout=8000, + ) + + +def _wait_for_modal_focus(page) -> None: + """Wait for the dialog to actually hold focus — see the Bulma suite. + + `_focusFirst` retries across animation frames, so a one-shot read of + `document.activeElement` right after the reveal is a race that only a slow + machine loses. + """ + page.wait_for_function( + "() => document.getElementById('e2e-modal').contains(document.activeElement)", + timeout=5000, + ) + + +# --- The architectural constraint, executed -------------------------------- + + +def test_no_jquery_reaches_the_browser(fomantic_jinja_page, fomantic_jinja_server_url): + """A theme choice must not change a consuming app's dependency graph.""" + page, js_mode = fomantic_jinja_page + page.goto(f"{fomantic_jinja_server_url}/gallery") + + html = page.content().lower() + assert "jquery" not in html + assert "semantic.min.js" not in html + assert "semantic.js" not in html + + if js_mode == "js_on": + _wait_for_alpine(page) + assert page.evaluate("() => typeof window.jQuery") == "undefined" + assert page.evaluate("() => typeof window.$") == "undefined" + + +def test_cotton_pages_ship_no_jquery_either(fomantic_cotton_page, fomantic_cotton_server_url): + page, _ = fomantic_cotton_page + page.goto(f"{fomantic_cotton_server_url}/modal/") + assert "jquery" not in page.content().lower() + + +# --- django-cotton: one setting, no consumer template edits ---------------- + + +def test_cotton_form_field_renders_fomantic_markup( + fomantic_cotton_page, fomantic_cotton_server_url +): + page, _ = fomantic_cotton_page + page.goto(f"{fomantic_cotton_server_url}/form-field/") + expect(page.locator("input[name='email']")).to_be_attached() + expect(page.locator(".ui.form")).to_be_attached() + expect(page.locator(".ui.fluid.input")).to_be_attached() + + +def test_cotton_form_field_has_no_bulma_or_daisy_markup( + fomantic_cotton_page, fomantic_cotton_server_url +): + """Neither shipped partial may leak through the dispatch.""" + page, _ = fomantic_cotton_page + page.goto(f"{fomantic_cotton_server_url}/form-field/") + expect(page.locator(".control")).to_have_count(0) + expect(page.locator(".form-control")).to_have_count(0) + + +def test_cotton_card_renders_fomantic_markup(fomantic_cotton_page, fomantic_cotton_server_url): + page, _ = fomantic_cotton_page + page.goto(f"{fomantic_cotton_server_url}/card/") + expect(page.locator(".ui.card")).to_be_attached() + expect(page.locator(".ui.card .header")).to_have_text("Card Title") + expect(page.locator(".ui.card .extra.content")).to_contain_text("Card Footer") + + +def test_cotton_card_slot_survives_the_dispatch(fomantic_cotton_page, fomantic_cotton_server_url): + """`{% include %}` must carry {{ slot }} into the theme partial.""" + page, _ = fomantic_cotton_page + page.goto(f"{fomantic_cotton_server_url}/card/") + expect(page.locator(".ui.card .description")).to_contain_text("Card body content") + + +def test_cotton_modal_named_slot_survives_the_dispatch( + fomantic_cotton_page, fomantic_cotton_server_url +): + page, _ = fomantic_cotton_page + page.goto(f"{fomantic_cotton_server_url}/modal/") + expect(page.locator("#test-modal .ui.modal")).to_contain_text("Test Modal") + expect(page.locator("#test-modal .ui.modal")).to_contain_text("Modal body content") + + +def test_cotton_modal_closed_by_default(fomantic_cotton_page, fomantic_cotton_server_url): + page, js_mode = fomantic_cotton_page + page.goto(f"{fomantic_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"\bactive\b")) + + +def test_cotton_modal_declares_dialog_semantics(fomantic_cotton_page, fomantic_cotton_server_url): + page, _ = fomantic_cotton_page + page.goto(f"{fomantic_cotton_server_url}/modal/") + modal = page.locator("#test-modal") + expect(modal).to_have_attribute("role", "dialog") + expect(modal).to_have_attribute("aria-modal", "true") + expect(modal).to_have_attribute("aria-labelledby", "test-modal-title") + + +def test_cotton_tabs_render_the_active_tab(fomantic_cotton_page, fomantic_cotton_server_url): + """`active` has to survive — unit tests bypass that compiler.""" + page, _ = fomantic_cotton_page + page.goto(f"{fomantic_cotton_server_url}/tabs/") + expect(page.locator("[role='tab'].active")).to_have_count(1) + expect(page.locator("[role='tab'].active")).to_have_text("tab1") + expect(page.locator("[role='tab'][aria-selected='true']")).to_have_count(1) + + +def test_cotton_panel_honors_its_open_prop(fomantic_cotton_page, fomantic_cotton_server_url): + page, _ = fomantic_cotton_page + page.goto(f"{fomantic_cotton_server_url}/panel/") + expect(page.locator("#open-panel-body")).not_to_have_attribute("x-cloak", "") + expect(page.locator('button[aria-controls="open-panel-body"]')).to_have_attribute( + "aria-expanded", "true" + ) + expect(page.locator('button[aria-controls="closed-panel-body"]')).to_have_attribute( + "aria-expanded", "false" + ) + + +def test_cotton_open_panel_is_readable_without_js(fomantic_cotton_page, fomantic_cotton_server_url): + """`.ui.accordion .title~.content:not(.active)` would hide it permanently. + + The cotton gallery pages load no Alpine at all, so this is the pure-CSS + path in both js modes: `active` from the server reveals the open panel, + `x-cloak` plus the same `:not(.active)` rule keeps the closed one hidden. + """ + page, _ = fomantic_cotton_page + page.goto(f"{fomantic_cotton_server_url}/panel/") + expect(page.locator("#open-panel-body")).to_be_visible() + expect(page.locator("#closed-panel-body")).to_be_hidden() + + +# --- JinjaX: same Alpine contract, Fomantic classes ------------------------ + + +def test_jinja_modal_opens_and_closes(fomantic_jinja_page, fomantic_jinja_server_url): + """cfModal is theme-independent; only the toggled class differs. + + Fomantic normally gets `.active` from `$('.ui.modal').modal('show')`. Here + the dimmer and the modal both take it from the same Alpine `open`. + """ + page, js_mode = fomantic_jinja_page + page.goto(f"{fomantic_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"\bactive\b")) + page.evaluate("Alpine.store('cf').modal.open('e2e-modal')") + expect(modal).to_have_class(re.compile(r"\bactive\b")) + expect(page.locator("#e2e-modal .ui.modal")).to_have_class(re.compile(r"\bactive\b")) + modal.locator("button[aria-label='close']").click() + expect(modal).not_to_have_class(re.compile(r"\bactive\b")) + else: + expect(modal).to_be_attached() + + +def test_jinja_modal_is_actually_visible_when_open(fomantic_jinja_page, fomantic_jinja_server_url): + """Both `.ui.dimmer` and `.ui.modal` are display:none until `.active`. + + Asserting the class alone would pass against markup that never appears — + which is the failure the accessibility phase called out. + + This is the only test here that needs the stylesheet to have *arrived*: + every other assertion is on attributes, which do not wait on CSS. Fomantic + ships as a single 1.6 MB file from the CDN, so the default 5s navigation + budget is not reliably enough on a cold or contended connection. The + timeout is raised here rather than globally so the rest of the file keeps + failing fast. + """ + page, js_mode = fomantic_jinja_page + if js_mode != "js_on": + pytest.skip("reveal requires JS") + page.goto(f"{fomantic_jinja_server_url}/gallery", timeout=30_000) + _wait_for_alpine(page) + + box = page.locator("#e2e-modal .ui.modal") + expect(box).to_be_hidden() + page.evaluate("Alpine.store('cf').modal.open('e2e-modal')") + expect(box).to_be_visible() + + +def test_jinja_modal_declares_dialog_semantics(fomantic_jinja_page, fomantic_jinja_server_url): + page, _ = fomantic_jinja_page + page.goto(f"{fomantic_jinja_server_url}/gallery") + modal = page.locator("#e2e-modal") + expect(modal).to_have_attribute("role", "dialog") + expect(modal).to_have_attribute("aria-modal", "true") + expect(modal).to_have_attribute("aria-labelledby", "e2e-modal-title") + + +def test_jinja_modal_manages_focus(fomantic_jinja_page, fomantic_jinja_server_url): + """Same Alpine contract as Bulma — the theme changes classes, not behavior.""" + page, js_mode = fomantic_jinja_page + if js_mode != "js_on": + pytest.skip("focus management requires JS") + page.goto(f"{fomantic_jinja_server_url}/gallery") + _wait_for_alpine(page) + + page.locator("#open-modal").click() + _wait_for_modal_focus(page) + page.keyboard.press("Escape") + expect(page.locator("#e2e-modal")).not_to_have_class(re.compile(r"\bactive\b")) + assert page.evaluate("() => document.activeElement.id") == "open-modal" + + +def test_jinja_tab_does_not_escape_the_open_modal(fomantic_jinja_page, fomantic_jinja_server_url): + page, js_mode = fomantic_jinja_page + if js_mode != "js_on": + pytest.skip("focus management requires JS") + page.goto(f"{fomantic_jinja_server_url}/gallery") + _wait_for_alpine(page) + + page.locator("#open-modal").click() + _wait_for_modal_focus(page) + inside = "() => document.getElementById('e2e-modal').contains(document.activeElement)" + for _ in range(6): + page.keyboard.press("Tab") + assert page.evaluate(inside), "focus escaped the dialog on Tab" + + +def test_jinja_notification_dismisses(fomantic_jinja_page, fomantic_jinja_server_url): + page, js_mode = fomantic_jinja_page + page.goto(f"{fomantic_jinja_server_url}/gallery") + notification = page.locator(".ui.message") + + 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(fomantic_jinja_page, fomantic_jinja_server_url): + """Fomantic's accordion is a jQuery module; Alpine drives this one.""" + page, js_mode = fomantic_jinja_page + page.goto(f"{fomantic_jinja_server_url}/gallery") + panel_body = page.locator("#e2e-panel-body") + + if js_mode == "js_on": + _wait_for_alpine(page) + expect(panel_body).to_be_hidden() + page.locator('button[aria-controls="e2e-panel-body"]').click() + expect(panel_body).to_be_visible() + else: + expect(panel_body).to_be_attached() + + +def test_jinja_open_panel_is_readable_without_js(fomantic_jinja_page, fomantic_jinja_server_url): + page, js_mode = fomantic_jinja_page + page.goto(f"{fomantic_jinja_server_url}/gallery") + if js_mode == "js_on": + _wait_for_alpine(page) + expect(page.locator("#e2e-panel-open-body")).to_be_visible() + + +def test_jinja_panel_toggle_reports_its_state(fomantic_jinja_page, fomantic_jinja_server_url): + page, js_mode = fomantic_jinja_page + page.goto(f"{fomantic_jinja_server_url}/gallery") + if js_mode == "js_on": + _wait_for_alpine(page) + expect(page.locator('button[aria-controls="e2e-panel-body"]')).to_have_attribute( + "aria-expanded", "false" + ) + expect(page.locator('button[aria-controls="e2e-panel-open-body"]')).to_have_attribute( + "aria-expanded", "true" + ) + + +def test_jinja_navbar_toggle_reports_its_state(fomantic_jinja_page, fomantic_jinja_server_url): + """`ui stackable menu` does the responsive layout in CSS; the toggle owns + the state and reports it, because Fomantic's collapse is a JS module.""" + page, js_mode = fomantic_jinja_page + if js_mode != "js_on": + pytest.skip("state toggle requires JS") + page.goto(f"{fomantic_jinja_server_url}/gallery") + _wait_for_alpine(page) + + burger = page.locator("button[aria-label='menu']") + expect(burger).to_have_attribute("aria-expanded", "false") + burger.click() + expect(burger).to_have_attribute("aria-expanded", "true") + expect(burger).to_have_class(re.compile(r"\bactive\b")) + + +def test_jinja_tabs_activate(fomantic_jinja_page, fomantic_jinja_server_url): + page, js_mode = fomantic_jinja_page + if js_mode != "js_on": + pytest.skip("covered without JS by test_jinja_exactly_one_tab_is_active") + page.goto(f"{fomantic_jinja_server_url}/gallery") + _wait_for_alpine(page) + + tabs = page.locator("[role='tab']") + expect(tabs.nth(0)).to_have_class(re.compile(r"\bactive\b")) + tabs.nth(1).click() + expect(tabs.nth(1)).to_have_class(re.compile(r"\bactive\b")) + expect(tabs.nth(0)).not_to_have_class(re.compile(r"\bactive\b")) + + +def test_jinja_exactly_one_tab_is_active(fomantic_jinja_page, fomantic_jinja_server_url): + """#21: `to_be_attached()` passed against markup that was unusable.""" + page, js_mode = fomantic_jinja_page + page.goto(f"{fomantic_jinja_server_url}/gallery") + if js_mode == "js_on": + _wait_for_alpine(page) + expect(page.locator("[role='tab'].active")).to_have_count(1) + expect(page.locator("[role='tab'].active")).to_have_text("tab1") + expect(page.locator("[role='tab'][aria-selected='true']")).to_have_count(1) + expect(page.locator("[role='tab'][tabindex='0']")).to_have_count(1) + + +def test_jinja_arrow_keys_rove_focus_across_the_tablist( + fomantic_jinja_page, fomantic_jinja_server_url +): + page, js_mode = fomantic_jinja_page + if js_mode != "js_on": + pytest.skip("roving tabindex requires JS") + page.goto(f"{fomantic_jinja_server_url}/gallery") + _wait_for_alpine(page) + + page.locator("[role='tab']").first.focus() + page.keyboard.press("ArrowRight") + assert page.evaluate("() => document.activeElement.dataset.cfTab") == "tab2" + page.keyboard.press("Home") + assert page.evaluate("() => document.activeElement.dataset.cfTab") == "tab1" + + +def test_jinja_form_field_renders(fomantic_jinja_page, fomantic_jinja_server_url): + page, _ = fomantic_jinja_page + page.goto(f"{fomantic_jinja_server_url}/form-field") + expect(page.locator("input[name='email']")).to_be_visible() + expect(page.locator(".ui.form label")).to_have_text("Email") + + +def test_jinja_page_usable_without_js(fomantic_jinja_page, fomantic_jinja_server_url): + page, js_mode = fomantic_jinja_page + if js_mode != "js_off": + pytest.skip("only runs in js_off mode") + page.goto(f"{fomantic_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 6f42e2c..5aeb708 100644 --- a/tests/integration/jinja_app/main.py +++ b/tests/integration/jinja_app/main.py @@ -15,6 +15,7 @@ "foundation": ( "https://cdn.jsdelivr.net/npm/foundation-sites@6.7.5/dist/css/foundation.min.css" ), + "fomantic": "https://cdn.jsdelivr.net/npm/fomantic-ui@2.9.3/dist/semantic.min.css", } # DaisyUI ships component classes but no Tailwind utilities. The components use @@ -25,11 +26,14 @@ _THEME_EXTRA_HEAD = { "bulma": "", "daisy": '', - # Bootstrap and Foundation both ship prebuilt CSS, and cf-ui deliberately - # loads neither one's JavaScript — Alpine owns modal, tab and panel state in - # every theme, so no bootstrap.bundle.js and no foundation.js. + # Bootstrap, Foundation and Fomantic all ship prebuilt CSS, and cf-ui + # deliberately loads none of their JavaScript — Alpine owns modal, tab and + # panel state in every theme, so no bootstrap.bundle.js, no foundation.js, + # and none of Fomantic's jQuery Modal/Tab/Accordion/Dropdown modules. The + # point of each theme is that the pages work without them. "bootstrap": "", "foundation": "", + "fomantic": "", } diff --git a/tests/unit/cotton/test_fomantic.py b/tests/unit/cotton/test_fomantic.py new file mode 100644 index 0000000..8eb8c89 --- /dev/null +++ b/tests/unit/cotton/test_fomantic.py @@ -0,0 +1,258 @@ +"""Fomantic UI component set, django-cotton side (issue #24). + +These go through the public ``cotton/cf/.html`` entry points with +``CF_UI_THEME = "fomantic"``, 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 fomantic_render(settings, cotton_render): + settings.CF_UI_THEME = "fomantic" + return cotton_render + + +# --- Forms ----------------------------------------------------------------- + + +def test_form_field_uses_fomantic_form_classes(fomantic_render): + html = fomantic_render("cf/form-field.html", name="email", label="Email Address") + assert "ui form" in html + assert 'class="field' in html + assert "ui fluid input" in html + assert "Email Address" in html + + +def test_form_field_error_marks_the_field_and_prompts(fomantic_render): + html = fomantic_render("cf/form-field.html", name="email", label="Email", error="Required") + assert "field error" in html + assert "ui basic red pointing prompt label" in html + assert "Required" in html + + +def test_form_field_error_does_not_use_a_ui_error_message(fomantic_render): + html = fomantic_render("cf/form-field.html", name="email", label="Email", error="Required") + assert "ui error message" not in html + + +def test_form_field_required_flag(fomantic_render): + html = fomantic_render("cf/form-field.html", name="email", label="Email", required="true") + assert "required" in html + + +def test_form_field_input_class_still_applied(fomantic_render): + html = fomantic_render("cf/form-field.html", name="e", label="E", input_class="mini") + assert "mini" in html + + +def test_select_uses_a_plain_styled_select(fomantic_render): + html = fomantic_render( + "cf/select.html", + name="choice", + label="Choose", + options=[{"value": "a", "label": "Option A"}], + ) + assert " Callable[..., str]: + env = Environment( + loader=FileSystemLoader(FOMANTIC_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_fomantic_ships_the_same_components_as_bulma(): + bulma_dir = FOMANTIC_DIR.parent / "bulma" + assert sorted(p.name for p in FOMANTIC_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" + + +@pytest.mark.parametrize("name", COMPONENTS) +def test_component_carries_no_daisy_class_names(render, name): + html = render(f"{name}.jinja", **REQUIRED_PROPS[name]) + for daisy_marker in ("form-control", "input-bordered", "modal-box", "join-item", "alert-"): + assert daisy_marker not in html, f"{name} still speaks DaisyUI" + + +# --- The whole point of the theme: no jQuery, no Fomantic JS --------------- + + +@pytest.mark.parametrize( + "path", + sorted([*FOMANTIC_DIR.glob("*.jinja"), *COTTON_FOMANTIC_DIR.glob("*.html")]), + ids=lambda p: f"{p.parent.name}/{p.name}", +) +def test_no_fomantic_template_reaches_for_jquery_or_fomantic_js(path): + """Adding jQuery for one theme would change a consuming app's deps. + + Fomantic's Modal/Tab/Accordion/Dropdown are jQuery plugins; Alpine owns all + of that behaviour in cf-ui, for every theme. + + Template comments are stripped first — Jinja's ``{# #}`` and Django's + ``{% comment %}`` both. Several of these templates explain in prose *which* + jQuery module they are standing in for, and a guard that forbade naming the + thing would push that reasoning out of the code. + """ + source = path.read_text(encoding="utf-8") + source = re.sub(r"\{#.*?#\}", "", source, flags=re.DOTALL) + source = re.sub(r"\{%\s*comment\s*%\}.*?\{%\s*endcomment\s*%\}", "", source, flags=re.DOTALL) + source = source.lower() + for forbidden in ("jquery", "semantic.min.js", "semantic.js", "fomantic.min.js", "$("): + assert forbidden not in source, f"{path.name} pulls in {forbidden!r}" + + +def test_the_fomantic_template_set_is_complete(): + """Without this, the scan above could silently degrade to zero cases.""" + found = [*FOMANTIC_DIR.glob("*.jinja"), *COTTON_FOMANTIC_DIR.glob("*.html")] + assert len(found) == 28, f"expected 14 jinja + 14 cotton, got {sorted(p.name for p in found)}" + + +# --- Forms ----------------------------------------------------------------- + + +def test_form_field_uses_fomantic_form_classes(render): + html = render("FormField.jinja", name="email", label="Email") + assert "ui form" in html + assert 'class="field' in html + assert "ui fluid input" in html + assert 'name="email"' in html + + +def test_form_field_error_marks_the_field_and_prompts(render): + """Fomantic styles the input through `.ui.form .field.error`.""" + html = render("FormField.jinja", name="email", label="Email", error="Required") + assert "field error" in html + assert "ui basic red pointing prompt label" in html + assert "Required" in html + + +def test_form_field_error_does_not_use_a_ui_error_message(render): + """`.ui.form .error.message` is `display:none` until the *form* has .error. + + A prompt label is the element Fomantic actually shows for a field error. + """ + html = render("FormField.jinja", name="email", label="Email", error="Required") + assert "ui error message" not in html + + +def test_form_field_input_class_still_applied(render): + html = render("FormField.jinja", name="email", label="Email", input_class="mini") + assert "mini" in html + + +def test_form_field_required_flag(render): + html = render("FormField.jinja", name="email", label="Email", required=True) + assert "required" in html + + +def test_select_uses_a_plain_styled_select(render): + """Fomantic's `ui dropdown` widget is JS-built; the contract is a control. + + A real `