From d5fc937ed8eb77b76126a2c674c43f9b475d106b Mon Sep 17 00:00:00 2001 From: Francis Secada Date: Wed, 29 Jul 2026 14:05:19 -0400 Subject: [PATCH] feat(themes): implement the Foundation 6 theme, CSS only (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds all 14 components in both template sets and registers `foundation` in `THEMES` — last, so a half-finished theme was never selectable. The theme loads Foundation's stylesheet and nothing else. Reveal, Tabs, Accordion and Dropdown Menu are jQuery plugins, and pulling jQuery in for one theme would mean a `CF_UI_THEME` edit silently changed a consuming app's dependency graph. Alpine keeps owning modal, tab and panel state exactly as it does for bulma and daisy. Foundation's state mechanisms are not uniform, and the differences were verified against the compiled 6.7.5 stylesheet rather than the docs: * `.tabs-panel.is-active` is a real CSS rule, so tabs bind normally. The active tab's *styling* comes from `.tabs-title > a[aria-selected=true]`, which makes the aria attribute load-bearing for appearance, not only for the reader. * `.reveal` and `.reveal-overlay` are `display: none` with no counterpart rule at all — the plugin opens them by writing an inline `display`. `x-show` cannot substitute, because showing only *removes* the inline property and falls straight back to the rule that hid it. So the modal binds `:style`. * `.accordion-content` is `display: none` with nothing that ever un-hides it, so a server-open accordion panel would be invisible with Alpine off. The panel is therefore built from `card`/`card-section`, which the accessibility contract's "an open panel is readable without JS" requires. * Foundation 6.7.5 does not style a native ``; every colour rule is `.progress. .progress-meter`, which needs a child the native control cannot have. Progress renders the div-based meter with explicit ARIA. The navbar binds `hide-for-small-only` rather than `hide`: `.hide` is `display: none !important` at every width and would have collapsed the desktop menu too. Variant vocabulary is mapped inside the partials — Foundation uses bare `alert`/`success`/`warning` with no `is-` prefix, and has no `info` — so the public prop names are unchanged. Also fixes a Django templating trap found by these tests: `{# #}` is single-line only, so a multi-line comment renders verbatim into the page. All cotton partial comments are `{% comment %}` blocks, pinned by an E2E test. Co-Authored-By: Claude Opus 5 --- .../cotton/_themes/foundation/breadcrumb.html | 15 + .../cotton/_themes/foundation/card.html | 13 + .../_themes/foundation/checkbox-group.html | 17 + .../cotton/_themes/foundation/form-field.html | 17 + .../cotton/_themes/foundation/modal.html | 32 ++ .../cotton/_themes/foundation/navbar.html | 27 ++ .../_themes/foundation/notification.html | 17 + .../cotton/_themes/foundation/pagination.html | 39 ++ .../cotton/_themes/foundation/panel.html | 30 ++ .../cotton/_themes/foundation/progress.html | 21 + .../cotton/_themes/foundation/select.html | 16 + .../cotton/_themes/foundation/table.html | 23 + .../cotton/_themes/foundation/tabs.html | 31 ++ .../cotton/_themes/foundation/textarea.html | 11 + .../templates/cotton/foundation/PLANNED.md | 3 - .../jinja/foundation/Breadcrumb.jinja | 18 + .../templates/jinja/foundation/Card.jinja | 18 + .../jinja/foundation/CheckboxGroup.jinja | 23 + .../jinja/foundation/FormField.jinja | 24 + .../templates/jinja/foundation/Modal.jinja | 39 ++ .../templates/jinja/foundation/Navbar.jinja | 32 ++ .../jinja/foundation/Notification.jinja | 21 + .../templates/jinja/foundation/PLANNED.md | 3 - .../jinja/foundation/Pagination.jinja | 44 ++ .../templates/jinja/foundation/Panel.jinja | 35 ++ .../templates/jinja/foundation/Progress.jinja | 27 ++ .../templates/jinja/foundation/Select.jinja | 22 + .../templates/jinja/foundation/Table.jinja | 26 ++ .../templates/jinja/foundation/Tabs.jinja | 37 ++ .../templates/jinja/foundation/Textarea.jinja | 17 + src/cf_ui/themes.py | 2 +- tests/e2e/conftest.py | 37 ++ tests/e2e/test_foundation.py | 409 +++++++++++++++++ tests/integration/jinja_app/main.py | 6 + tests/unit/cotton/test_foundation.py | 265 +++++++++++ tests/unit/jinja/test_foundation.py | 431 ++++++++++++++++++ tests/unit/test_accessibility.py | 5 +- tests/unit/test_theme_dispatch.py | 2 +- 38 files changed, 1845 insertions(+), 10 deletions(-) create mode 100644 src/cf_ui/templates/cotton/_themes/foundation/breadcrumb.html create mode 100644 src/cf_ui/templates/cotton/_themes/foundation/card.html create mode 100644 src/cf_ui/templates/cotton/_themes/foundation/checkbox-group.html create mode 100644 src/cf_ui/templates/cotton/_themes/foundation/form-field.html create mode 100644 src/cf_ui/templates/cotton/_themes/foundation/modal.html create mode 100644 src/cf_ui/templates/cotton/_themes/foundation/navbar.html create mode 100644 src/cf_ui/templates/cotton/_themes/foundation/notification.html create mode 100644 src/cf_ui/templates/cotton/_themes/foundation/pagination.html create mode 100644 src/cf_ui/templates/cotton/_themes/foundation/panel.html create mode 100644 src/cf_ui/templates/cotton/_themes/foundation/progress.html create mode 100644 src/cf_ui/templates/cotton/_themes/foundation/select.html create mode 100644 src/cf_ui/templates/cotton/_themes/foundation/table.html create mode 100644 src/cf_ui/templates/cotton/_themes/foundation/tabs.html create mode 100644 src/cf_ui/templates/cotton/_themes/foundation/textarea.html delete mode 100644 src/cf_ui/templates/cotton/foundation/PLANNED.md create mode 100644 src/cf_ui/templates/jinja/foundation/Breadcrumb.jinja create mode 100644 src/cf_ui/templates/jinja/foundation/Card.jinja create mode 100644 src/cf_ui/templates/jinja/foundation/CheckboxGroup.jinja create mode 100644 src/cf_ui/templates/jinja/foundation/FormField.jinja create mode 100644 src/cf_ui/templates/jinja/foundation/Modal.jinja create mode 100644 src/cf_ui/templates/jinja/foundation/Navbar.jinja create mode 100644 src/cf_ui/templates/jinja/foundation/Notification.jinja delete mode 100644 src/cf_ui/templates/jinja/foundation/PLANNED.md create mode 100644 src/cf_ui/templates/jinja/foundation/Pagination.jinja create mode 100644 src/cf_ui/templates/jinja/foundation/Panel.jinja create mode 100644 src/cf_ui/templates/jinja/foundation/Progress.jinja create mode 100644 src/cf_ui/templates/jinja/foundation/Select.jinja create mode 100644 src/cf_ui/templates/jinja/foundation/Table.jinja create mode 100644 src/cf_ui/templates/jinja/foundation/Tabs.jinja create mode 100644 src/cf_ui/templates/jinja/foundation/Textarea.jinja create mode 100644 tests/e2e/test_foundation.py create mode 100644 tests/unit/cotton/test_foundation.py create mode 100644 tests/unit/jinja/test_foundation.py diff --git a/src/cf_ui/templates/cotton/_themes/foundation/breadcrumb.html b/src/cf_ui/templates/cotton/_themes/foundation/breadcrumb.html new file mode 100644 index 0000000..693ebf0 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/foundation/breadcrumb.html @@ -0,0 +1,15 @@ +{% comment %} Foundation draws the "/" separator with `.breadcrumbs li:not(:last-child)::after`, + so the markup must not carry one of its own. {% endcomment %} + diff --git a/src/cf_ui/templates/cotton/_themes/foundation/card.html b/src/cf_ui/templates/cotton/_themes/foundation/card.html new file mode 100644 index 0000000..9b08588 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/foundation/card.html @@ -0,0 +1,13 @@ +{% comment %} Foundation has no `.card-footer`; a trailing `.card-divider` is the + documented footer convention. {% endcomment %} +
+ {% if header %} +
+

{{ header }}

+
+ {% endif %} +
{{ slot }}
+ {% if footer %} +
{{ footer }}
+ {% endif %} +
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/checkbox-group.html b/src/cf_ui/templates/cotton/_themes/foundation/checkbox-group.html new file mode 100644 index 0000000..3cf4467 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/foundation/checkbox-group.html @@ -0,0 +1,17 @@ +{% comment %} Foundation groups checkboxes in a fieldset/legend and pairs a *sibling* + `label[for]` with each input rather than wrapping it, so every box needs an + id of its own — a shared one would point every label at the first input. {% endcomment %} +
+ {{ label }} +
+ {% for choice in choices %} + + + {% endfor %} +
+ {% if error %}{{ error }}{% endif %} +
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/form-field.html b/src/cf_ui/templates/cotton/_themes/foundation/form-field.html new file mode 100644 index 0000000..86b492d --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/foundation/form-field.html @@ -0,0 +1,17 @@ +{% comment %} `.form-error` is `display: none` until `.is-visible` joins it — emitting the + text without it renders an error nobody can see. These three classes are the + whole of what Foundation's Abide plugin does at runtime, so rendering them + server-side is a complete substitute for loading it. {% endcomment %} +
+ + + {% if error %} + {{ error }} + {% endif %} +
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/modal.html b/src/cf_ui/templates/cotton/_themes/foundation/modal.html new file mode 100644 index 0000000..5c53501 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/foundation/modal.html @@ -0,0 +1,32 @@ +{% comment %} Foundation ships no open-state class for Reveal. Both `.reveal` and + `.reveal-overlay` are `display: none` in the stylesheet, and the jQuery + plugin opens them by writing an inline `display` — there is no `.is-active` + to bind. `x-show` cannot stand in either: it *removes* the inline property + to show, which falls straight back to the rule that hid it. So the state + binding is `:style`, and the inner `.reveal` carries the static + `display: block` the plugin would otherwise have left on it. + + The overlay is the root because `.reveal` is `position: relative; top: 100px` + — it is only centred and scrollable because the fixed, full-screen overlay + is its container. The plugin injects that wrapper at runtime; here it is + just markup. {% endcomment %} + diff --git a/src/cf_ui/templates/cotton/_themes/foundation/navbar.html b/src/cf_ui/templates/cotton/_themes/foundation/navbar.html new file mode 100644 index 0000000..fafe372 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/foundation/navbar.html @@ -0,0 +1,27 @@ +{% comment %} `hide-for-small-only`, not `hide`. `.hide` is `display: none !important` at + every width, so binding it to `!menuOpen` would collapse the desktop menu + too; the breakpoint-scoped class reproduces what Foundation's own responsive + toggle does. With Alpine off no class is emitted at all, so the menu is + simply visible — which is the right no-JS answer. + + Foundation's `data-responsive-toggle` / `data-toggle` are inert without + `foundation.responsiveToggle.js`, so the burger is wired to cfNavbar. {% endcomment %} + diff --git a/src/cf_ui/templates/cotton/_themes/foundation/notification.html b/src/cf_ui/templates/cotton/_themes/foundation/notification.html new file mode 100644 index 0000000..86b0921 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/foundation/notification.html @@ -0,0 +1,17 @@ +{% comment %} Foundation's callout variants carry no `is-` prefix, and there is no `info` + — `primary` is the stand-in. The public prop vocabulary does not change. + + `.close-button` is CSS-only styling; `data-closable` needs the Toggler + plugin, so the dismiss is Alpine's. `.callout` is `position: relative` + precisely so the absolutely-positioned close button anchors to it. {% endcomment %} + diff --git a/src/cf_ui/templates/cotton/_themes/foundation/pagination.html b/src/cf_ui/templates/cotton/_themes/foundation/pagination.html new file mode 100644 index 0000000..66fb5d1 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/foundation/pagination.html @@ -0,0 +1,39 @@ +{% load cf_ui %} +{% comment %} Foundation generates the « / » arrows from `.pagination-previous a::before` + and `.pagination-next a::after`, and disabled edges are bare text rather + than links (`.pagination-previous.disabled::before` covers that case). {% endcomment %} + diff --git a/src/cf_ui/templates/cotton/_themes/foundation/panel.html b/src/cf_ui/templates/cotton/_themes/foundation/panel.html new file mode 100644 index 0000000..c1a90e9 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/foundation/panel.html @@ -0,0 +1,30 @@ +{% comment %} Deliberately not Foundation's accordion. `.accordion-content` is + `display: none` and — unlike `.tabs-panel` — the stylesheet carries *no* + rule that ever un-hides it; the plugin opens it with an inline + `slideDown()`. A server-open accordion panel would therefore be invisible + with Alpine off, which is exactly what the accessibility contract forbids. + `.card-section` has no such rule, so `x-show` + `x-cloak` behave here the + way they do in every other theme. {% endcomment %} +
+ +
+ {{ slot }} +
+
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/progress.html b/src/cf_ui/templates/cotton/_themes/foundation/progress.html new file mode 100644 index 0000000..618553c --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/foundation/progress.html @@ -0,0 +1,21 @@ +{% comment %} Not a native ``. Foundation 6.7.5's compiled CSS gives the bare + element only `vertical-align: baseline`; every colour rule is + `.progress. .progress-meter`, which needs a child the native + control cannot have. So the ARIA the native element would have supplied is + written out by hand, and the meter width is an inline style because + Foundation ships no width utility for it. `widthratio` returns "0" on a + zero max rather than raising. `tabindex` is deliberately omitted — a + progress bar is not an interactive stop. {% endcomment %} +
+ {% if label %}

{{ label }}

{% endif %} +
+ + {% widthratio value max 100 %}% + +
+
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/select.html b/src/cf_ui/templates/cotton/_themes/foundation/select.html new file mode 100644 index 0000000..e7a90ae --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/foundation/select.html @@ -0,0 +1,16 @@ +
+ + + {% if error %} + {{ error }} + {% endif %} +
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/table.html b/src/cf_ui/templates/cotton/_themes/foundation/table.html new file mode 100644 index 0000000..6c482c0 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/foundation/table.html @@ -0,0 +1,23 @@ +{% load cf_ui %} +{% comment %} Foundation styles the `` element itself — there is no `.table` class. + `.table-scroll` is the 6.2+ wrapper; `table.scroll` is the legacy form. {% endcomment %} +
+
+ + + {% 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/foundation/tabs.html b/src/cf_ui/templates/cotton/_themes/foundation/tabs.html new file mode 100644 index 0000000..af2a059 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/foundation/tabs.html @@ -0,0 +1,31 @@ +{% comment %} `.tabs-title > a[aria-selected=true]` is what actually restyles the selected + tab — `.is-active` on the `
  • ` alone changes nothing visually — so the + aria attribute is load-bearing for appearance here, not only for the reader. + Both are rendered server-side and both keep their Alpine binding. + + The panel is `.tabs-content` rather than `.tabs-panel`: this widget has one + panel that is always shown and HTMX-swapped, whereas `.tabs-panel` is + `display: none` until `.is-active` picks one of several siblings. {% endcomment %} +
    +
      + {% for tab in tabs %} +
    • + {{ tab.id }} +
    • + {% endfor %} +
    +
    {{ slot }}
    +
    diff --git a/src/cf_ui/templates/cotton/_themes/foundation/textarea.html b/src/cf_ui/templates/cotton/_themes/foundation/textarea.html new file mode 100644 index 0000000..1004efd --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/foundation/textarea.html @@ -0,0 +1,11 @@ +
    + + + {% if error %} + {{ error }} + {% endif %} +
    diff --git a/src/cf_ui/templates/cotton/foundation/PLANNED.md b/src/cf_ui/templates/cotton/foundation/PLANNED.md deleted file mode 100644 index 2c70743..0000000 --- a/src/cf_ui/templates/cotton/foundation/PLANNED.md +++ /dev/null @@ -1,3 +0,0 @@ -# Theme: Foundation — Planned - -Components for this theme are tracked in GitHub Issues. diff --git a/src/cf_ui/templates/jinja/foundation/Breadcrumb.jinja b/src/cf_ui/templates/jinja/foundation/Breadcrumb.jinja new file mode 100644 index 0000000..a0b2092 --- /dev/null +++ b/src/cf_ui/templates/jinja/foundation/Breadcrumb.jinja @@ -0,0 +1,18 @@ +{#def items=[], extra_class="" #} +{% set items = items if items is defined else [] %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{# Foundation draws the "/" separator with `.breadcrumbs li:not(:last-child)::after`, + so the markup must not carry one of its own. #} + diff --git a/src/cf_ui/templates/jinja/foundation/Card.jinja b/src/cf_ui/templates/jinja/foundation/Card.jinja new file mode 100644 index 0000000..2909b90 --- /dev/null +++ b/src/cf_ui/templates/jinja/foundation/Card.jinja @@ -0,0 +1,18 @@ +{#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 "" %} +{# Foundation has no `.card-footer`; a trailing `.card-divider` is the + documented footer convention. #} +
    + {% if header %} +
    +

    {{ header }}

    +
    + {% endif %} +
    {{ content }}
    + {% if footer %} +
    {{ footer }}
    + {% endif %} +
    diff --git a/src/cf_ui/templates/jinja/foundation/CheckboxGroup.jinja b/src/cf_ui/templates/jinja/foundation/CheckboxGroup.jinja new file mode 100644 index 0000000..e32817b --- /dev/null +++ b/src/cf_ui/templates/jinja/foundation/CheckboxGroup.jinja @@ -0,0 +1,23 @@ +{#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 "" %} +{# Foundation groups checkboxes in a fieldset/legend and pairs a *sibling* + `label[for]` with each input rather than wrapping it, so every box needs an + id of its own — a shared one would point every label at the first input. #} +
    + {{ label }} +
    + {% for choice in choices %} + + + {% endfor %} +
    + {% if error %}{{ error }}{% endif %} +
    diff --git a/src/cf_ui/templates/jinja/foundation/FormField.jinja b/src/cf_ui/templates/jinja/foundation/FormField.jinja new file mode 100644 index 0000000..df8bb35 --- /dev/null +++ b/src/cf_ui/templates/jinja/foundation/FormField.jinja @@ -0,0 +1,24 @@ +{#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 "" %} +{# `.form-error` is `display: none` until `.is-visible` joins it — emitting the + text without it renders an error nobody can see. These three classes are the + whole of what Foundation's Abide plugin does at runtime, so rendering them + server-side is a complete substitute for loading it. #} +
    + + + {% if error %} + {{ error }} + {% endif %} +
    diff --git a/src/cf_ui/templates/jinja/foundation/Modal.jinja b/src/cf_ui/templates/jinja/foundation/Modal.jinja new file mode 100644 index 0000000..00256d0 --- /dev/null +++ b/src/cf_ui/templates/jinja/foundation/Modal.jinja @@ -0,0 +1,39 @@ +{#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 "" %} +{# Foundation ships no open-state class for Reveal. Both `.reveal` and + `.reveal-overlay` are `display: none` in the stylesheet, and the jQuery + plugin opens them by writing an inline `display` — there is no `.is-active` + to bind. `x-show` cannot stand in either: it *removes* the inline property + to show, which falls straight back to the rule that hid it. So the state + binding is `:style`, and the inner `.reveal` carries the static + `display: block` the plugin would otherwise have left on it. + + The overlay is the root because `.reveal` is `position: relative; top: 100px` + — it is only centred and scrollable because the fixed, full-screen overlay + is its container. The plugin injects that wrapper at runtime; here it is + just markup. #} + diff --git a/src/cf_ui/templates/jinja/foundation/Navbar.jinja b/src/cf_ui/templates/jinja/foundation/Navbar.jinja new file mode 100644 index 0000000..49246b9 --- /dev/null +++ b/src/cf_ui/templates/jinja/foundation/Navbar.jinja @@ -0,0 +1,32 @@ +{#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 "" %} +{# `hide-for-small-only`, not `hide`. `.hide` is `display: none !important` at + every width, so binding it to `!menuOpen` would collapse the desktop menu + too; the breakpoint-scoped class reproduces what Foundation's own responsive + toggle does. With Alpine off no class is emitted at all, so the menu is + simply visible — which is the right no-JS answer. + + Foundation's `data-responsive-toggle` / `data-toggle` are inert without + `foundation.responsiveToggle.js`, so the burger is wired to cfNavbar. #} + diff --git a/src/cf_ui/templates/jinja/foundation/Notification.jinja b/src/cf_ui/templates/jinja/foundation/Notification.jinja new file mode 100644 index 0000000..7c66844 --- /dev/null +++ b/src/cf_ui/templates/jinja/foundation/Notification.jinja @@ -0,0 +1,21 @@ +{#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 "" %} +{# Foundation's callout variants carry no `is-` prefix, and there is no `info` + — `primary` is the stand-in. The public prop vocabulary does not change. + + `.close-button` is CSS-only styling; `data-closable` needs the Toggler + plugin, so the dismiss is Alpine's. `.callout` is `position: relative` + precisely so the absolutely-positioned close button anchors to it. #} + diff --git a/src/cf_ui/templates/jinja/foundation/PLANNED.md b/src/cf_ui/templates/jinja/foundation/PLANNED.md deleted file mode 100644 index 2c70743..0000000 --- a/src/cf_ui/templates/jinja/foundation/PLANNED.md +++ /dev/null @@ -1,3 +0,0 @@ -# Theme: Foundation — Planned - -Components for this theme are tracked in GitHub Issues. diff --git a/src/cf_ui/templates/jinja/foundation/Pagination.jinja b/src/cf_ui/templates/jinja/foundation/Pagination.jinja new file mode 100644 index 0000000..7400dfa --- /dev/null +++ b/src/cf_ui/templates/jinja/foundation/Pagination.jinja @@ -0,0 +1,44 @@ +{#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 "" %} +{# Foundation generates the « / » arrows from `.pagination-previous a::before` + and `.pagination-next a::after`, and disabled edges are bare text rather + than links (`.pagination-previous.disabled::before` covers that case). #} + diff --git a/src/cf_ui/templates/jinja/foundation/Panel.jinja b/src/cf_ui/templates/jinja/foundation/Panel.jinja new file mode 100644 index 0000000..411864c --- /dev/null +++ b/src/cf_ui/templates/jinja/foundation/Panel.jinja @@ -0,0 +1,35 @@ +{#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 "" %} +{# Deliberately not Foundation's accordion. `.accordion-content` is + `display: none` and — unlike `.tabs-panel` — the stylesheet carries *no* + rule that ever un-hides it; the plugin opens it with an inline + `slideDown()`. A server-open accordion panel would therefore be invisible + with Alpine off, which is exactly what the accessibility contract forbids. + `.card-section` has no such rule, so `x-show` + `x-cloak` behave here the + way they do in every other theme. #} +
    + +
    + {{ content }} +
    +
    diff --git a/src/cf_ui/templates/jinja/foundation/Progress.jinja b/src/cf_ui/templates/jinja/foundation/Progress.jinja new file mode 100644 index 0000000..e69b73a --- /dev/null +++ b/src/cf_ui/templates/jinja/foundation/Progress.jinja @@ -0,0 +1,27 @@ +{#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 pct = ((value | float) * 100 / (max | float)) | round | int if (max | float) else 0 %} +{# Not a native ``. Foundation 6.7.5's compiled CSS gives the bare + element only `vertical-align: baseline`; every colour rule is + `.progress. .progress-meter`, which needs a child the native + control cannot have. So the ARIA the native element would have supplied is + written out by hand, and the meter width is an inline style because + Foundation ships no width utility for it. `tabindex` is deliberately + omitted — a progress bar is not an interactive stop. #} +
    + {% if label %}

    {{ label }}

    {% endif %} +
    + + {{ pct }}% + +
    +
    diff --git a/src/cf_ui/templates/jinja/foundation/Select.jinja b/src/cf_ui/templates/jinja/foundation/Select.jinja new file mode 100644 index 0000000..a0e01b2 --- /dev/null +++ b/src/cf_ui/templates/jinja/foundation/Select.jinja @@ -0,0 +1,22 @@ +{#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/foundation/Table.jinja b/src/cf_ui/templates/jinja/foundation/Table.jinja new file mode 100644 index 0000000..f7c0ad6 --- /dev/null +++ b/src/cf_ui/templates/jinja/foundation/Table.jinja @@ -0,0 +1,26 @@ +{#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 "" %} +{# Foundation styles the `` element itself — there is no `.table` class. + `.table-scroll` is the 6.2+ wrapper; `table.scroll` is the legacy form. #} +
    +
    + + + {% 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/foundation/Tabs.jinja b/src/cf_ui/templates/jinja/foundation/Tabs.jinja new file mode 100644 index 0000000..2cd7f37 --- /dev/null +++ b/src/cf_ui/templates/jinja/foundation/Tabs.jinja @@ -0,0 +1,37 @@ +{#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 "" %} +{# `.tabs-title > a[aria-selected=true]` is what actually restyles the selected + tab — `.is-active` on the `
  • ` alone changes nothing visually — so the + aria attribute is load-bearing for appearance here, not only for the reader. + Both are rendered server-side and both keep their Alpine binding. + + The panel is `.tabs-content` rather than `.tabs-panel`: this widget has one + panel that is always shown and HTMX-swapped, whereas `.tabs-panel` is + `display: none` until `.is-active` picks one of several siblings. #} +
    +
      + {% for tab in tabs %} +
    • + {{ tab.id }} +
    • + {% endfor %} +
    +
    {{ content }}
    +
    diff --git a/src/cf_ui/templates/jinja/foundation/Textarea.jinja b/src/cf_ui/templates/jinja/foundation/Textarea.jinja new file mode 100644 index 0000000..456a558 --- /dev/null +++ b/src/cf_ui/templates/jinja/foundation/Textarea.jinja @@ -0,0 +1,17 @@ +{#def name, label, value="", error="", rows=4, extra_class="", input_class="" #} +{% set value = value if value is defined else "" %} +{% set error = error if error is defined else "" %} +{% set rows = rows if rows is defined else 4 %} +{% set extra_class = extra_class if extra_class is defined else "" %} +{% set input_class = input_class if input_class is defined else "" %} +
    + + + {% if error %} + {{ error }} + {% endif %} +
    diff --git a/src/cf_ui/themes.py b/src/cf_ui/themes.py index 3fbd49e..efcce67 100644 --- a/src/cf_ui/themes.py +++ b/src/cf_ui/themes.py @@ -26,7 +26,7 @@ #: Themes with a real component set. The other directories under #: ``templates/`` hold a ``PLANNED.md`` stub and are not selectable. -THEMES = ("bulma", "daisy") +THEMES = ("bulma", "daisy", "foundation") DEFAULT_THEME = "bulma" diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 4e41381..03dd36d 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -76,6 +76,25 @@ def daisy_cotton_server_url() -> Generator[str, None, None]: proc.wait(timeout=5) +@pytest.fixture(scope="session") +def foundation_jinja_server_url() -> Generator[str, None, None]: + thread = threading.Thread(target=_run_fastapi_server, args=("foundation", "127.0.0.1", 8777)) + thread.daemon = True + thread.start() + time.sleep(2.0) + yield "http://127.0.0.1:8777" + + +@pytest.fixture(scope="session") +def foundation_cotton_server_url() -> Generator[str, None, None]: + """The same consumer templates again, CF_UI_THEME="foundation".""" + proc = _start_cotton_server(8778, "foundation") + time.sleep(2.0) + yield "http://127.0.0.1:8778" + proc.terminate() + proc.wait(timeout=5) + + @pytest.fixture(params=["js_on", "js_off"]) def jinja_page(request, browser, jinja_server_url): ctx = browser.new_context(java_script_enabled=(request.param == "js_on")) @@ -110,3 +129,21 @@ def daisy_cotton_page(request, browser, daisy_cotton_server_url): page.set_default_timeout(5000) yield page, request.param ctx.close() + + +@pytest.fixture(params=["js_on", "js_off"]) +def foundation_jinja_page(request, browser, foundation_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 foundation_cotton_page(request, browser, foundation_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_foundation.py b/tests/e2e/test_foundation.py new file mode 100644 index 0000000..be4beba --- /dev/null +++ b/tests/e2e/test_foundation.py @@ -0,0 +1,409 @@ +"""Foundation E2E coverage (issue #23), parameterized over js_on / js_off. + +Two things make this suite different from the Bulma and DaisyUI ones, and both +come from the same constraint — the theme loads Foundation's **CSS only**: + +1. Reveal has no open-state class, so "is the modal open" cannot be asserted + with `to_have_class`. Every visibility claim here reads the **computed + display**, which is the property that actually matters and the only one that + would catch a binding that changes markup without changing the page. +2. `test_no_page_loads_jquery` is the executable form of the issue's "grep the + rendered output for jquery" step. Adding jQuery for one theme would make a + `CF_UI_THEME` edit change a consuming app's dependency graph. +""" + +import pytest +from playwright.sync_api import expect + +DISPLAY = "el => getComputedStyle(el).display" + + +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, + ) + + +def _wait_for_modal_shown(page, shown: bool) -> None: + op = "!==" if shown else "===" + page.wait_for_function( + f"() => getComputedStyle(document.getElementById('e2e-modal')).display {op} 'none'", + timeout=5000, + ) + + +# --- The CSS-only constraint, executed ------------------------------------- + + +def test_no_page_loads_jquery(foundation_jinja_page, foundation_jinja_server_url): + """Issue #23's verification step, as a test rather than a one-off grep.""" + page, _ = foundation_jinja_page + page.goto(f"{foundation_jinja_server_url}/gallery") + assert "jquery" not in page.content().lower() + + +def test_no_cotton_page_loads_jquery(foundation_cotton_page, foundation_cotton_server_url): + page, _ = foundation_cotton_page + page.goto(f"{foundation_cotton_server_url}/modal/") + assert "jquery" not in page.content().lower() + + +def test_foundation_js_is_never_initialised(foundation_jinja_page, foundation_jinja_server_url): + """`window.Foundation` existing would mean the plugins got loaded.""" + page, js_mode = foundation_jinja_page + if js_mode != "js_on": + pytest.skip("nothing to evaluate without JS") + page.goto(f"{foundation_jinja_server_url}/gallery") + _wait_for_alpine(page) + assert page.evaluate("() => typeof window.jQuery") == "undefined" + assert page.evaluate("() => typeof window.Foundation") == "undefined" + + +# --- JinjaX: same Alpine contract, Foundation classes ---------------------- + + +def test_jinja_modal_opens_and_closes(foundation_jinja_page, foundation_jinja_server_url): + """Reveal ships no state class, so this reads computed display. + + `.reveal-overlay` is `display: none` in Foundation's stylesheet with no + counterpart rule; the template drives an inline `display` from Alpine + exactly as the jQuery plugin would have. + """ + page, js_mode = foundation_jinja_page + page.goto(f"{foundation_jinja_server_url}/gallery") + + modal = page.locator("#e2e-modal") + if js_mode == "js_on": + _wait_for_alpine(page) + assert modal.evaluate(DISPLAY) == "none" + page.evaluate("Alpine.store('cf').modal.open('e2e-modal')") + _wait_for_modal_shown(page, shown=True) + modal.locator("button[aria-label='close']").click() + _wait_for_modal_shown(page, shown=False) + else: + # Without JS the stylesheet alone keeps it closed — which is the point + # of not needing a state class to hide it. + expect(modal).to_be_attached() + assert modal.evaluate(DISPLAY) == "none" + + +def test_jinja_notification_dismisses(foundation_jinja_page, foundation_jinja_server_url): + page, js_mode = foundation_jinja_page + page.goto(f"{foundation_jinja_server_url}/gallery") + notification = page.locator(".callout") + + 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(foundation_jinja_page, foundation_jinja_server_url): + page, js_mode = foundation_jinja_page + page.goto(f"{foundation_jinja_server_url}/gallery") + panel_body = page.locator("#e2e-panel-body") + + if js_mode == "js_on": + _wait_for_alpine(page) + expect(panel_body).to_be_hidden() + page.locator('button[aria-controls="e2e-panel-body"]').click() + expect(panel_body).to_be_visible() + else: + expect(panel_body).to_be_attached() + + +def test_jinja_navbar_burger_toggles_menu(foundation_jinja_page, foundation_jinja_server_url): + """`hide-for-small-only` is breakpoint-scoped on purpose. + + Binding plain `.hide` would have collapsed the desktop menu too. Asserting + the computed display at a small viewport is what distinguishes the two. + """ + page, js_mode = foundation_jinja_page + page.goto(f"{foundation_jinja_server_url}/gallery") + + if js_mode == "js_on": + _wait_for_alpine(page) + page.set_viewport_size({"width": 600, "height": 800}) + menu = page.locator(".top-bar-right") + assert menu.evaluate(DISPLAY) == "none" + page.locator("button[aria-label='menu']").click() + assert menu.evaluate(DISPLAY) != "none" + else: + expect(page.locator(".top-bar")).to_be_attached() + + +def test_jinja_navbar_menu_survives_at_desktop_width( + foundation_jinja_page, foundation_jinja_server_url +): + """The regression `hide-for-small-only` exists to prevent.""" + page, js_mode = foundation_jinja_page + if js_mode != "js_on": + pytest.skip("the collapse only happens under Alpine") + page.goto(f"{foundation_jinja_server_url}/gallery") + _wait_for_alpine(page) + page.set_viewport_size({"width": 1280, "height": 800}) + assert page.locator(".top-bar-right").evaluate(DISPLAY) != "none" + + +def test_jinja_tabs_activate(foundation_jinja_page, foundation_jinja_server_url): + page, js_mode = foundation_jinja_page + if js_mode != "js_on": + pytest.skip("covered without JS by test_jinja_exactly_one_tab_is_active") + page.goto(f"{foundation_jinja_server_url}/gallery") + _wait_for_alpine(page) + + tabs = page.locator("[role='tab']") + expect(page.locator("li.tabs-title.is-active")).to_have_text("tab1") + tabs.nth(1).click() + expect(page.locator("li.tabs-title.is-active")).to_have_text("tab2") + expect(page.locator("li.tabs-title.is-active")).to_have_count(1) + + +def test_jinja_exactly_one_tab_is_active(foundation_jinja_page, foundation_jinja_server_url): + page, js_mode = foundation_jinja_page + page.goto(f"{foundation_jinja_server_url}/gallery") + if js_mode == "js_on": + _wait_for_alpine(page) + expect(page.locator("li.tabs-title.is-active")).to_have_count(1) + expect(page.locator("li.tabs-title.is-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( + foundation_jinja_page, foundation_jinja_server_url +): + page, js_mode = foundation_jinja_page + if js_mode != "js_on": + pytest.skip("roving tabindex requires JS") + page.goto(f"{foundation_jinja_server_url}/gallery") + _wait_for_alpine(page) + + page.locator("[role='tab']").first.focus() + page.keyboard.press("ArrowRight") + assert page.evaluate("() => document.activeElement.dataset.cfTab") == "tab2" + page.keyboard.press("Home") + assert page.evaluate("() => document.activeElement.dataset.cfTab") == "tab1" + + +# --- Dialog semantics and focus management (#21) --------------------------- + + +def test_jinja_modal_declares_dialog_semantics(foundation_jinja_page, foundation_jinja_server_url): + page, _ = foundation_jinja_page + page.goto(f"{foundation_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(foundation_jinja_page, foundation_jinja_server_url): + """Same Alpine contract as Bulma — the theme changes classes, not behavior.""" + page, js_mode = foundation_jinja_page + if js_mode != "js_on": + pytest.skip("focus management requires JS") + page.goto(f"{foundation_jinja_server_url}/gallery") + _wait_for_alpine(page) + + page.locator("#open-modal").click() + _wait_for_modal_focus(page) + page.keyboard.press("Escape") + _wait_for_modal_shown(page, shown=False) + assert page.evaluate("() => document.activeElement.id") == "open-modal" + + +def test_jinja_tab_does_not_escape_the_open_modal( + foundation_jinja_page, foundation_jinja_server_url +): + page, js_mode = foundation_jinja_page + if js_mode != "js_on": + pytest.skip("focus management requires JS") + page.goto(f"{foundation_jinja_server_url}/gallery") + _wait_for_alpine(page) + + page.locator("#open-modal").click() + _wait_for_modal_focus(page) + inside = "() => document.getElementById('e2e-modal').contains(document.activeElement)" + for _ in range(6): + page.keyboard.press("Tab") + assert page.evaluate(inside), "focus escaped the dialog on Tab" + + +def test_jinja_open_panel_is_readable_without_js( + foundation_jinja_page, foundation_jinja_server_url +): + """The reason the panel is not built from Foundation's accordion. + + `.accordion-content` is `display: none` with no rule that ever un-hides it, + so this assertion is exactly what that markup would have failed. + """ + page, js_mode = foundation_jinja_page + page.goto(f"{foundation_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(foundation_jinja_page, foundation_jinja_server_url): + page, js_mode = foundation_jinja_page + page.goto(f"{foundation_jinja_server_url}/gallery") + if js_mode == "js_on": + _wait_for_alpine(page) + expect(page.locator('button[aria-controls="e2e-panel-body"]')).to_have_attribute( + "aria-expanded", "false" + ) + expect(page.locator('button[aria-controls="e2e-panel-open-body"]')).to_have_attribute( + "aria-expanded", "true" + ) + + +def test_jinja_form_field_renders(foundation_jinja_page, foundation_jinja_server_url): + page, _ = foundation_jinja_page + page.goto(f"{foundation_jinja_server_url}/form-field") + expect(page.locator("input[name='email']")).to_be_visible() + expect(page.locator("label[for='email']")).to_have_text("Email") + + +def test_jinja_page_usable_without_js(foundation_jinja_page, foundation_jinja_server_url): + page, js_mode = foundation_jinja_page + if js_mode != "js_off": + pytest.skip("only runs in js_off mode") + page.goto(f"{foundation_jinja_server_url}/gallery") + expect(page.locator("body")).not_to_be_empty() + expect(page.locator(".top-bar")).to_be_visible() + + +# --- django-cotton: one setting, no consumer template edits ---------------- + + +def test_cotton_form_field_renders_foundation_markup( + foundation_cotton_page, foundation_cotton_server_url +): + page, _ = foundation_cotton_page + page.goto(f"{foundation_cotton_server_url}/form-field/") + expect(page.locator("input[name='email']")).to_be_attached() + expect(page.locator("label[for='email']")).to_be_attached() + + +def test_cotton_form_field_has_no_bulma_markup( + foundation_cotton_page, foundation_cotton_server_url +): + """The Bulma partial must not leak through the dispatch.""" + page, _ = foundation_cotton_page + page.goto(f"{foundation_cotton_server_url}/form-field/") + expect(page.locator(".field")).to_have_count(0) + + +def test_cotton_card_renders_foundation_markup( + foundation_cotton_page, foundation_cotton_server_url +): + page, _ = foundation_cotton_page + page.goto(f"{foundation_cotton_server_url}/card/") + expect(page.locator(".card-section")).to_be_attached() + expect(page.locator(".card-divider").first).to_contain_text("Card Title") + + +def test_cotton_card_slot_survives_the_dispatch( + foundation_cotton_page, foundation_cotton_server_url +): + """`{% include %}` must carry {{ slot }} into the theme partial.""" + page, _ = foundation_cotton_page + page.goto(f"{foundation_cotton_server_url}/card/") + expect(page.locator(".card-section")).to_contain_text("Card body content") + + +def test_cotton_modal_named_slot_survives_the_dispatch( + foundation_cotton_page, foundation_cotton_server_url +): + page, _ = foundation_cotton_page + page.goto(f"{foundation_cotton_server_url}/modal/") + expect(page.locator("#test-modal .reveal")).to_contain_text("Test Modal") + expect(page.locator("#test-modal .reveal")).to_contain_text("Modal body content") + + +def test_cotton_modal_is_not_server_rendered_open( + foundation_cotton_page, foundation_cotton_server_url +): + """The cotton gallery pages are bare fragments — no stylesheet, no Alpine. + + So computed display proves nothing here (it would be measuring the + fixture), and Foundation has no `modal-open` class for the DaisyUI version + of this test to look for. What *is* assertable is the markup the dispatch + produced: the overlay carries the state binding and no server-rendered + inline `display`, so it is Alpine — not the server — that ever opens it. + Whether it is actually invisible when closed is asserted against the + JinjaX gallery, which does load Foundation's CSS. + """ + page, _ = foundation_cotton_page + page.goto(f"{foundation_cotton_server_url}/modal/") + modal = page.locator("#test-modal") + expect(modal).to_be_attached() + expect(modal).to_have_attribute(":style", "open ? 'display: block' : 'display: none'") + assert modal.get_attribute("style") is None + + +def test_cotton_modal_declares_dialog_semantics( + foundation_cotton_page, foundation_cotton_server_url +): + page, _ = foundation_cotton_page + page.goto(f"{foundation_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(foundation_cotton_page, foundation_cotton_server_url): + """`active` has to survive — unit tests bypass that compiler.""" + page, _ = foundation_cotton_page + page.goto(f"{foundation_cotton_server_url}/tabs/") + expect(page.locator("li.tabs-title.is-active")).to_have_count(1) + expect(page.locator("li.tabs-title.is-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(foundation_cotton_page, foundation_cotton_server_url): + page, _ = foundation_cotton_page + page.goto(f"{foundation_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_theme_comments_do_not_leak_into_the_page( + foundation_cotton_page, foundation_cotton_server_url +): + """Django's `{# #}` is single-line only — a multi-line one renders verbatim. + + Every explanatory comment in the cotton partials is therefore a + `{% comment %}` block. This caught a real leak during the phase, so it is + pinned rather than left to review. + """ + page, _ = foundation_cotton_page + for path in ("/form-field/", "/card/", "/modal/", "/panel/", "/tabs/"): + page.goto(f"{foundation_cotton_server_url}{path}") + body = page.locator("body").inner_text() + assert "{#" not in body + assert "{% comment" not in body diff --git a/tests/integration/jinja_app/main.py b/tests/integration/jinja_app/main.py index a9c54f4..2ddf797 100644 --- a/tests/integration/jinja_app/main.py +++ b/tests/integration/jinja_app/main.py @@ -11,6 +11,9 @@ _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", + "foundation": ( + "https://cdn.jsdelivr.net/npm/foundation-sites@6.7.5/dist/css/foundation.min.css" + ), } # DaisyUI ships component classes but no Tailwind utilities. The components use @@ -21,6 +24,9 @@ _THEME_EXTRA_HEAD = { "bulma": "", "daisy": '', + # Foundation ships prebuilt CSS — no build step, and deliberately no + # foundation.js: the theme is CSS-only and Alpine owns every behaviour. + "foundation": "", } diff --git a/tests/unit/cotton/test_foundation.py b/tests/unit/cotton/test_foundation.py new file mode 100644 index 0000000..1cd4bd8 --- /dev/null +++ b/tests/unit/cotton/test_foundation.py @@ -0,0 +1,265 @@ +"""Foundation 6 component set, django-cotton side (issue #23). + +These go through the public ``cotton/cf/.html`` entry points with +``CF_UI_THEME = "foundation"``, 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 foundation_render(settings, cotton_render): + settings.CF_UI_THEME = "foundation" + return cotton_render + + +# --- Forms ----------------------------------------------------------------- + + +def test_form_field_uses_foundation_error_classes(foundation_render): + html = foundation_render("cf/form-field.html", name="email", label="Email", error="Required") + assert "is-invalid-input" in html + assert "is-invalid-label" in html + assert "Required" in html + + +def test_form_field_error_text_is_visible(foundation_render): + """`.form-error` is `display: none` without `.is-visible`.""" + html = foundation_render("cf/form-field.html", name="email", label="Email", error="Required") + assert "form-error is-visible" in html + + +def test_form_field_without_an_error_stays_clean(foundation_render): + html = foundation_render("cf/form-field.html", name="email", label="Email Address") + assert "is-invalid-input" not in html + assert "form-error" not in html + assert "Email Address" in html + + +def test_form_field_required_flag(foundation_render): + html = foundation_render("cf/form-field.html", name="email", label="Email", required="true") + assert "required" in html + + +def test_form_field_input_class_still_applied(foundation_render): + html = foundation_render("cf/form-field.html", name="e", label="E", input_class="my-input") + assert "my-input" in html + + +def test_select_error_uses_the_invalid_input_class(foundation_render): + html = foundation_render( + "cf/select.html", + name="choice", + label="Choose", + error="Pick one", + options=[{"value": "a", "label": "Option A"}], + ) + assert "is-invalid-input" in html + assert "Option A" in html + + +def test_textarea_error_uses_the_invalid_input_class(foundation_render): + html = foundation_render( + "cf/textarea.html", name="bio", label="Bio", value="Hello", rows="4", error="Too short" + ) + assert "is-invalid-input" in html + assert "Hello" in html + + +def test_checkbox_group_uses_a_fieldset(foundation_render): + html = foundation_render( + "cf/checkbox-group.html", + name="fruits", + label="Fruits", + choices=[{"value": "a", "label": "Apple"}], + selected=["a"], + ) + assert 'class="fieldset' in html + assert "/`` — so this tier proves the foundation directory +ships all 14 components, that they render under ``StrictUndefined``, and that +they speak Foundation's class vocabulary rather than Bulma's. + +The theme is **CSS only**: Foundation's Reveal, Tabs, Accordion and Dropdown +Menu are jQuery plugins, and adding jQuery would make a theme choice change a +consuming app's dependency graph. Alpine keeps owning behaviour, so several +tests here pin *which* Foundation state mechanism each component uses, because +Foundation's are not uniform: + +* ``.tabs-panel``/``.is-active`` is a real CSS rule — bindable. +* ``.reveal`` and ``.reveal-overlay`` are ``display: none`` with **no** + counterpart rule; the plugin opens them by writing an inline ``display``. + So does this theme, via ``:style``. +* ``.accordion-content`` is ``display: none`` with no un-hiding rule at all, + which is why the panel is built from ``card``/``card-section`` instead. +""" + +from collections.abc import Callable +from pathlib import Path + +import pytest +from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape + +FOUNDATION_DIR = ( + Path(__file__).parent.parent.parent.parent + / "src" + / "cf_ui" + / "templates" + / "jinja" + / "foundation" +) + +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"}, +} + +# Bulma tokens that must not survive the port. `is-active` is deliberately +# absent: Foundation uses it too (`.tabs-title.is-active`), so asserting on it +# would forbid the correct markup. +BULMA_MARKERS = ( + "is-danger", + "card-header-title", + "navbar-burger", + "modal-card", + "pagination-link", +) + + +@pytest.fixture +def render() -> Callable[..., str]: + env = Environment( + loader=FileSystemLoader(FOUNDATION_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_foundation_ships_the_same_components_as_bulma(): + bulma_dir = FOUNDATION_DIR.parent / "bulma" + assert sorted(p.name for p in FOUNDATION_DIR.glob("*.jinja")) == sorted( + p.name for p in bulma_dir.glob("*.jinja") + ) + + +def test_the_planned_stub_is_gone(): + """The stub and a real component set must not coexist.""" + assert not (FOUNDATION_DIR / "PLANNED.md").exists() + + +@pytest.mark.parametrize("name", COMPONENTS) +def test_component_renders_with_only_its_required_props(render, name): + """StrictUndefined: every optional prop needs an is-defined guard.""" + html = render(f"{name}.jinja", **REQUIRED_PROPS[name]) + assert html.strip() + + +@pytest.mark.parametrize("name", COMPONENTS) +def test_component_carries_no_bulma_class_names(render, name): + html = render(f"{name}.jinja", **REQUIRED_PROPS[name]) + for marker in BULMA_MARKERS: + assert marker not in html, f"{name} still speaks Bulma" + + +# --- The CSS-only constraint (#23) ----------------------------------------- + + +@pytest.mark.parametrize("name", COMPONENTS) +def test_no_component_reaches_for_foundation_js(render, name): + """No jQuery, and no `data-` hooks that only Foundation's plugins read. + + A theme that quietly required jQuery would change a consuming app's + dependency graph as a side effect of a `CF_UI_THEME` edit. + """ + html = render(f"{name}.jinja", **REQUIRED_PROPS[name]).lower() + assert "jquery" not in html + for plugin_hook in ( + "data-reveal", + "data-toggler", + "data-closable", + "data-accordion", + "data-tabs", + "data-dropdown-menu", + "data-responsive-toggle", + "data-abide", + ): + assert plugin_hook not in html, f"{name} depends on a Foundation plugin" + + +# --- Forms ----------------------------------------------------------------- + + +def test_form_field_uses_foundation_error_classes(render): + """Foundation's Abide plugin only ever adds these three classes. + + Server-rendering them is a complete substitute for running it. + """ + html = render("FormField.jinja", name="email", label="Email", error="Required") + assert "is-invalid-input" in html + assert "is-invalid-label" in html + assert "Required" in html + + +def test_form_field_error_text_is_visible(render): + """`.form-error` is `display: none` until `.is-visible` joins it. + + Emitting the error text without it renders an invisible error message — + the failure mode is silent, which is why this is pinned. + """ + html = render("FormField.jinja", name="email", label="Email", error="Required") + assert "form-error is-visible" in html + + +def test_form_field_without_an_error_stays_clean(render): + html = render("FormField.jinja", name="email", label="Email") + assert "is-invalid-input" not in html + assert "form-error" not in html + assert 'name="email"' in html + + +def test_form_field_input_class_still_applied(render): + html = render("FormField.jinja", name="email", label="Email", input_class="my-input") + assert "my-input" 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_select_error_uses_the_invalid_input_class(render): + html = render( + "Select.jinja", + name="c", + label="C", + error="Pick one", + options=[{"value": "a", "label": "Option A"}], + ) + assert "is-invalid-input" in html + assert "Option A" in html + + +def test_textarea_error_uses_the_invalid_input_class(render): + html = render("Textarea.jinja", name="bio", label="Bio", value="Hello", error="Too short") + assert "is-invalid-input" in html + assert "Hello" in html + + +def test_checkbox_group_uses_a_fieldset(render): + """Foundation groups checkboxes in a `fieldset`/`legend`, not a bare label.""" + html = render( + "CheckboxGroup.jinja", + name="f", + label="Fruits", + choices=[{"value": "a", "label": "Apple"}], + selected=["a"], + ) + assert 'class="fieldset' in html + assert "` element. + + Its compiled CSS gives bare `progress` only `vertical-align: baseline`; + every colour rule is `.progress. .progress-meter`, which needs a + child element the native control cannot have. + """ + html = render("Progress.jinja", value=40, max=100, type="primary") + assert "progress-meter" in html + assert "width: 40%" in html + assert 'role="progressbar"' in html + assert 'aria-valuenow="40"' in html + assert 'aria-valuemax="100"' in html + + +def test_progress_maps_danger_to_alert(render): + html = render("Progress.jinja", value=75, max=100, type="danger") + assert "progress alert" in html + + +def test_progress_survives_a_zero_max(render): + """A division by zero here would be a 500 on an empty result set.""" + html = render("Progress.jinja", value=0, max=0) + assert "width: 0%" in html + + +def test_table_uses_the_scroll_wrapper(render): + html = render("Table.jinja", columns=[{"key": "n", "label": "Name"}], rows=[{"n": "Ada"}]) + assert "table-scroll" in html + assert "is-striped" not in html + assert "Name" in html + assert "Ada" in html + + +def test_pagination_uses_foundation_list_classes(render): + html = render("Pagination.jinja", page=2, total_pages=3, hx_url="/x", hx_target="#t") + assert "pagination-previous" in html + assert "pagination-next" in html + assert 'class="current"' in html + assert 'aria-current="page"' in html + + +def test_pagination_disables_the_edges(render): + html = render("Pagination.jinja", page=1, total_pages=1, hx_url="/x", hx_target="#t") + assert "pagination-previous disabled" in html + assert "pagination-next disabled" 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_panel_avoids_the_accordion(render): + """`.accordion-content` is `display: none` with no un-hiding rule. + + Foundation's plugin opens it with an inline `slideDown()`, so a + server-open accordion panel would be invisible with Alpine off — exactly + what the accessibility phase forbids. `card-section` has no such rule. + """ + html = render("Panel.jinja", title="T", content="Inner", open=True) + assert "accordion-content" not in html + assert "card-section" 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 "top-bar-left" in html + assert "top-bar-right" in html + + +def test_navbar_menu_stays_visible_on_desktop_when_collapsed(render): + """`hide-for-small-only`, not `hide`. + + `.hide` is `display: none !important` at every width, so binding it to + `!menuOpen` would collapse the desktop menu too. The breakpoint-scoped + class is the one that reproduces what Foundation's own responsive toggle + does — and with Alpine off no class is emitted at all, so the menu is + simply visible. + """ + html = render("Navbar.jinja", brand="Brand", start="S", end="E") + assert "hide-for-small-only" in html + assert "'hide':" not in html + + +def test_breadcrumb_uses_foundation_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 "tabs-title" in html + + +def test_tabs_bind_aria_selected_because_the_css_reads_it(render): + """`.tabs-title > a[aria-selected=true]` is what restyles the active tab. + + `.is-active` on the `
  • ` alone changes nothing visually, so the aria + attribute is load-bearing for appearance here, not only for the reader. + """ + html = render("Tabs.jinja", tabs=[{"id": "one", "url": "/one"}], active="one", content="C") + assert 'aria-selected="true"' in html + assert ":aria-selected=" in html diff --git a/tests/unit/test_accessibility.py b/tests/unit/test_accessibility.py index 7e03557..95fd7a0 100644 --- a/tests/unit/test_accessibility.py +++ b/tests/unit/test_accessibility.py @@ -25,11 +25,12 @@ TEMPLATES_DIR = Path(__file__).parent.parent.parent / "src" / "cf_ui" / "templates" JINJA_DIR = TEMPLATES_DIR / "jinja" -THEMES = ["bulma", "daisy"] +THEMES = ["bulma", "daisy", "foundation"] #: The class each theme puts on the *selected* tab, and the element it sits on. #: Bulma marks the ``
  • ``; DaisyUI marks the ```` itself. -ACTIVE_TAB_CLASS = {"bulma": "is-active", "daisy": "tab-active"} +#: Foundation marks the ``
  • ``, sharing Bulma's token. +ACTIVE_TAB_CLASS = {"bulma": "is-active", "daisy": "tab-active", "foundation": "is-active"} @pytest.fixture(params=THEMES) diff --git a/tests/unit/test_theme_dispatch.py b/tests/unit/test_theme_dispatch.py index a60af1f..4f272be 100644 --- a/tests/unit/test_theme_dispatch.py +++ b/tests/unit/test_theme_dispatch.py @@ -37,7 +37,7 @@ "textarea", ] -IMPLEMENTED_THEMES = ["bulma", "daisy"] +IMPLEMENTED_THEMES = ["bulma", "daisy", "foundation"] # --- The theme registry ----------------------------------------------------