diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ee7a7d..57bf969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,51 @@ ## [Unreleased] +### Added — Foundation 6 theme (#23) + +All 14 components in both template sets, replacing the `PLANNED.md` stub at +`templates/jinja/foundation/` and creating `templates/cotton/_themes/foundation/`. +`foundation` is now accepted by `CF_UI_THEME` and by `install_cf_ui(theme=…)`; +`_CDN_CSS` and `_DEFAULTS` already carried it at `6.7.5`. + +- **CSS only, and no jQuery.** Foundation's interactive components — Reveal, + Tabs, Accordion, Dropdown Menu — are jQuery plugins. Loading them would make a + theme choice change a consuming app's dependency graph, and would put a second + owner on state `cf_ui_alpine.js` already holds. The templates use Foundation's + classes and markup structure; every piece of state is wired through the + existing Alpine components. Asserted per component rather than left to prose. + +- **Three places where Foundation's CSS assumes its JS is present**, and what + each does instead: + - `.reveal` has no open-state class — Foundation's Reveal writes + `style.display` directly — so the modal toggles inline display rather than a + class, and the E2E tier asserts visibility instead of a class list. + - `.accordion-content` is `display: none` with no un-hiding rule in the + stylesheet, so the panel stays off the accordion entirely and `x-show` owns + display, seeded from `data-cf-open`. Same shape as the bug #21 fixed. + - The navbar collapse uses `hide-for-small-only`, not `hide`: `hide` would + collapse the desktop menu too, and with Alpine off no class is emitted at + all, so the menu is simply visible. + +- **`aria-selected` on a tab is load-bearing for appearance here.** + `.tabs-title > a[aria-selected=true]` is the rule that restyles the selected + tab; `.is-active` on the `
  • ` alone changes nothing visually. Both are + rendered server-side and both keep their Alpine binding. The tab panel is + `.tabs-content` rather than `.tabs-panel`, because this widget has one + always-shown HTMX-swapped panel, and `.tabs-panel` is hidden until + `.is-active` picks one of several siblings. + +- **Variant vocabulary maps inside the partial**, as designed: Foundation's + callout uses bare `alert` / `success` / `warning` with no `is-` prefix, and has + no `info` variant — `type="info"` maps to `primary`. Public prop values are + unchanged. `progress` needs a `.progress-meter` child because 6.7.5 does not + style a native ``; a zero `max` yields 0% rather than a + `ZeroDivisionError` on an empty result set. + +- Tab ids reach Alpine through `$el.dataset.cfTab`, never as interpolated + expression source (#32) — applied here so the theme does not land with the bug + and need patching twice. + ### Added — Bootstrap JS decision record and a version tripwire (#33) - **`docs/bootstrap.md`.** The CSS-only, Alpine-driven stance was a decision 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..5df31d9 --- /dev/null +++ b/src/cf_ui/templates/cotton/_themes/foundation/tabs.html @@ -0,0 +1,32 @@ +{% 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 %} +
    + +
    {{ 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..8e09a7d --- /dev/null +++ b/src/cf_ui/templates/jinja/foundation/Tabs.jinja @@ -0,0 +1,38 @@ +{#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. #} +
    + +
    {{ 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 42cdde9..209e6d3 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") +THEMES = ("bulma", "daisy", "bootstrap", "foundation") DEFAULT_THEME = "bulma" diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 449a84b..add13e3 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -95,6 +95,25 @@ def bootstrap_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")) @@ -147,3 +166,21 @@ def bootstrap_cotton_page(request, browser, bootstrap_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 b2ffddf..6f42e2c 100644 --- a/tests/integration/jinja_app/main.py +++ b/tests/integration/jinja_app/main.py @@ -12,6 +12,9 @@ "bulma": "https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css", "daisy": "https://cdn.jsdelivr.net/npm/daisyui@4.7.2/dist/full.min.css", "bootstrap": "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css", + "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 @@ -22,9 +25,11 @@ _THEME_EXTRA_HEAD = { "bulma": "", "daisy": '', - # Bootstrap ships prebuilt CSS and cf-ui deliberately loads none of its - # JavaScript — Alpine owns modal, tab and panel state in every theme. + # Bootstrap 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": "", } diff --git a/tests/unit/cotton/test_foundation.py b/tests/unit/cotton/test_foundation.py new file mode 100644 index 0000000..7ba5423 --- /dev/null +++ b/tests/unit/cotton/test_foundation.py @@ -0,0 +1,267 @@ +"""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 + # The id reaches Alpine as data, never as expression text (#32). + assert 'data-cf-tab="one"' in html + assert "setActive($el.dataset.cfTab)" in html + assert "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 70f3269..3b008b1 100644 --- a/tests/unit/test_accessibility.py +++ b/tests/unit/test_accessibility.py @@ -25,13 +25,19 @@ TEMPLATES_DIR = Path(__file__).parent.parent.parent / "src" / "cf_ui" / "templates" JINJA_DIR = TEMPLATES_DIR / "jinja" -THEMES = ["bulma", "daisy", "bootstrap"] +THEMES = ["bulma", "daisy", "bootstrap", "foundation"] #: The class each theme puts on the *selected* tab, and the element it sits on. #: Bulma marks the ``
  • ``; DaisyUI and Bootstrap mark the ```` -#: itself. Matched as a whole class token, so Bootstrap's bare ``active`` does +#: itself; Foundation marks the ``
  • ``, reusing Bulma's +#: token. Matched as a whole class token, so Bootstrap's bare ``active`` does #: not collide with Bulma's ``is-active``. -ACTIVE_TAB_CLASS = {"bulma": "is-active", "daisy": "tab-active", "bootstrap": "active"} +ACTIVE_TAB_CLASS = { + "bulma": "is-active", + "daisy": "tab-active", + "bootstrap": "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 e4094c8..b28a2e5 100644 --- a/tests/unit/test_theme_dispatch.py +++ b/tests/unit/test_theme_dispatch.py @@ -37,7 +37,7 @@ "textarea", ] -IMPLEMENTED_THEMES = ["bulma", "daisy", "bootstrap"] +IMPLEMENTED_THEMES = ["bulma", "daisy", "bootstrap", "foundation"] # --- The theme registry ---------------------------------------------------- @@ -74,12 +74,18 @@ def test_resolve_theme_accepts_bootstrap(): assert resolve_theme("bootstrap") == "bootstrap" +def test_resolve_theme_accepts_foundation(): + from cf_ui.themes import resolve_theme + + assert resolve_theme("foundation") == "foundation" + + def test_resolve_theme_rejects_a_stub_theme_by_name(): - """foundation/fomantic are PLANNED.md stubs, not usable themes.""" + """fomantic is still a PLANNED.md stub, not a usable theme.""" from cf_ui.themes import ThemeError, resolve_theme - with pytest.raises(ThemeError, match="foundation"): - resolve_theme("foundation") + with pytest.raises(ThemeError, match="fomantic"): + resolve_theme("fomantic") def test_resolve_theme_error_names_the_available_themes():