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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<li>` 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 `<progress>`; 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
Expand Down
15 changes: 15 additions & 0 deletions src/cf_ui/templates/cotton/_themes/foundation/breadcrumb.html
Original file line number Diff line number Diff line change
@@ -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 %}
<nav aria-label="breadcrumbs">
<ul class="breadcrumbs {{ class }}">
{% for item in items %}
<li>
{% if forloop.last %}
<a href="{{ item.url }}" aria-current="page">{{ item.label }}</a>
{% else %}
<a href="{{ item.url }}">{{ item.label }}</a>
{% endif %}
</li>
{% endfor %}
</ul>
</nav>
13 changes: 13 additions & 0 deletions src/cf_ui/templates/cotton/_themes/foundation/card.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{% comment %} Foundation has no `.card-footer`; a trailing `.card-divider` is the
documented footer convention. {% endcomment %}
<div class="card {{ class }}">
{% if header %}
<div class="card-divider">
<h3>{{ header }}</h3>
</div>
{% endif %}
<div class="card-section">{{ slot }}</div>
{% if footer %}
<div class="card-divider">{{ footer }}</div>
{% endif %}
</div>
17 changes: 17 additions & 0 deletions src/cf_ui/templates/cotton/_themes/foundation/checkbox-group.html
Original file line number Diff line number Diff line change
@@ -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 %}
<fieldset class="fieldset {{ class }}">
<legend>{{ label }}</legend>
<div class="{{ control_class }}">
{% for choice in choices %}
<input type="checkbox"
id="{{ name }}-{{ forloop.counter }}"
name="{{ name }}"
value="{{ choice.value }}"
{% if choice.value in selected %}checked{% endif %}>
<label for="{{ name }}-{{ forloop.counter }}">{{ choice.label }}</label>
{% endfor %}
</div>
{% if error %}<span class="form-error is-visible">{{ error }}</span>{% endif %}
</fieldset>
17 changes: 17 additions & 0 deletions src/cf_ui/templates/cotton/_themes/foundation/form-field.html
Original file line number Diff line number Diff line change
@@ -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 %}
<div class="{{ class }}">
<label for="{{ name }}"{% if error %} class="is-invalid-label"{% endif %}>{{ label }}</label>
<input class="{% if error %}is-invalid-input{% endif %}{% if input_class %} {{ input_class }}{% endif %}"
id="{{ name }}"
type="{{ type }}"
name="{{ name }}"
value="{{ value }}"
{% if error %}aria-invalid="true" aria-describedby="{{ name }}-error"{% endif %}
{% if required == "true" %}required{% endif %}>
{% if error %}
<span class="form-error is-visible" id="{{ name }}-error">{{ error }}</span>
{% endif %}
</div>
32 changes: 32 additions & 0 deletions src/cf_ui/templates/cotton/_themes/foundation/modal.html
Original file line number Diff line number Diff line change
@@ -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 %}
<div id="{{ id }}"
class="reveal-overlay {{ class }}"
role="dialog"
aria-modal="true"
{% if header %}aria-labelledby="{{ id }}-title"{% else %}aria-label="{{ label|default:'Dialog' }}"{% endif %}
x-data="cfModal"
x-init="initModal()"
:style="open ? 'display: block' : 'display: none'"
@click.self="close()">
<div class="reveal" style="display: block">
<button type="button" class="close-button" aria-label="close" @click="close()">
<span aria-hidden="true">&times;</span>
</button>
{% if header %}<h2 id="{{ id }}-title">{{ header }}</h2>{% endif %}
<div>{{ slot }}</div>
{% if footer %}
<div class="text-right">{{ footer }}</div>
{% endif %}
</div>
</div>
27 changes: 27 additions & 0 deletions src/cf_ui/templates/cotton/_themes/foundation/navbar.html
Original file line number Diff line number Diff line change
@@ -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 %}
<nav class="top-bar {{ class }}" role="navigation" aria-label="main navigation"
x-data="cfNavbar">
<div class="top-bar-left">
{% if brand %}
<ul class="menu">
<li class="menu-text">{{ brand }}</li>
</ul>
{% endif %}
<button type="button"
class="menu-icon dark hide-for-medium"
aria-label="menu"
:aria-expanded="menuOpen"
@click="toggle()"></button>
<ul class="menu" :class="{ 'hide-for-small-only': !menuOpen }">{{ start }}</ul>
</div>
<div class="top-bar-right" :class="{ 'hide-for-small-only': !menuOpen }">
<ul class="menu">{{ end }}</ul>
</div>
</nav>
17 changes: 17 additions & 0 deletions src/cf_ui/templates/cotton/_themes/foundation/notification.html
Original file line number Diff line number Diff line change
@@ -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 %}
<div role="alert"
class="callout {% if type == 'danger' or type == 'error' %}alert{% elif type == 'success' %}success{% elif type == 'warning' %}warning{% elif type == 'secondary' %}secondary{% else %}primary{% endif %} {{ class }}"
x-data="{ visible: true }"
x-show="visible">
<p>{{ message }}</p>
{% if dismissible == "true" %}
<button type="button" class="close-button" aria-label="dismiss" @click="visible = false">
<span aria-hidden="true">&times;</span>
</button>
{% endif %}
</div>
39 changes: 39 additions & 0 deletions src/cf_ui/templates/cotton/_themes/foundation/pagination.html
Original file line number Diff line number Diff line change
@@ -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 %}
<nav role="navigation" aria-label="pagination">
<ul class="pagination {{ class }}">
{% if page|add:"0" > 1 %}
<li class="pagination-previous">
<a hx-get="{{ hx_url }}?page={{ page|add:'-1' }}"
hx-target="{{ hx_target }}">Previous <span class="show-for-sr">page</span></a>
</li>
{% else %}
<li class="pagination-previous disabled">Previous <span class="show-for-sr">page</span></li>
{% endif %}

{% for p in total_pages|make_list_1_to_n %}
{% if p == page|add:'0' %}
<li class="current" aria-current="page">
<span class="show-for-sr">You're on page</span> {{ p }}
</li>
{% else %}
<li>
<a hx-get="{{ hx_url }}?page={{ p }}"
hx-target="{{ hx_target }}"
aria-label="Page {{ p }}">{{ p }}</a>
</li>
{% endif %}
{% endfor %}

{% if page|add:"0" < total_pages|add:"0" %}
<li class="pagination-next">
<a hx-get="{{ hx_url }}?page={{ page|add:'1' }}"
hx-target="{{ hx_target }}">Next <span class="show-for-sr">page</span></a>
</li>
{% else %}
<li class="pagination-next disabled">Next <span class="show-for-sr">page</span></li>
{% endif %}
</ul>
</nav>
30 changes: 30 additions & 0 deletions src/cf_ui/templates/cotton/_themes/foundation/panel.html
Original file line number Diff line number Diff line change
@@ -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 %}
<div class="card {{ class }}"
x-data="cfPanel"
x-init="initPanel()"
data-cf-open="{% if open and open != 'false' %}true{% else %}false{% endif %}">
<button type="button"
class="card-divider"
style="width:100%;text-align:left;cursor:pointer"
aria-controls="{{ id }}-body"
aria-expanded="{% if open and open != 'false' %}true{% else %}false{% endif %}"
:aria-expanded="open"
@click="toggle()">
<span>{{ title }}</span>
<span style="display:inline-block;transition:transform 200ms"
:style="open ? 'display:inline-block;transform:rotate(90deg)' : 'display:inline-block'"
aria-hidden="true">&#9654;</span>
</button>
<div id="{{ id }}-body"
class="card-section"
x-show="open"
{% if not open or open == 'false' %}x-cloak{% endif %}>
{{ slot }}
</div>
</div>
21 changes: 21 additions & 0 deletions src/cf_ui/templates/cotton/_themes/foundation/progress.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{% comment %} Not a native `<progress>`. Foundation 6.7.5's compiled CSS gives the bare
element only `vertical-align: baseline`; every colour rule is
`.progress.<variant> .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 %}
<div class="{{ class }}">
{% if label %}<p class="text-center">{{ label }}</p>{% endif %}
<div class="progress {% if type == 'danger' or type == 'error' %}alert{% elif type == 'success' %}success{% elif type == 'warning' %}warning{% elif type == 'secondary' %}secondary{% else %}primary{% endif %}"
role="progressbar"
aria-valuenow="{{ value }}"
aria-valuemin="0"
aria-valuemax="{{ max }}"
{% if label %}aria-label="{{ label }}"{% endif %}>
<span class="progress-meter" style="width: {% widthratio value max 100 %}%">
<span class="progress-meter-text">{% widthratio value max 100 %}%</span>
</span>
</div>
</div>
16 changes: 16 additions & 0 deletions src/cf_ui/templates/cotton/_themes/foundation/select.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<div class="{{ class }}">
<label for="{{ name }}"{% if error %} class="is-invalid-label"{% endif %}>{{ label }}</label>
<select class="{% if error %}is-invalid-input{% endif %}{% if input_class %} {{ input_class }}{% endif %}"
id="{{ name }}"
name="{{ name }}"
{% if error %}aria-invalid="true" aria-describedby="{{ name }}-error"{% endif %}>
{% for opt in options %}
<option value="{{ opt.value }}"{% if opt.value|stringformat:"s" == value|stringformat:"s" %} selected{% endif %}>
{{ opt.label }}
</option>
{% endfor %}
</select>
{% if error %}
<span class="form-error is-visible" id="{{ name }}-error">{{ error }}</span>
{% endif %}
</div>
23 changes: 23 additions & 0 deletions src/cf_ui/templates/cotton/_themes/foundation/table.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{% load cf_ui %}
{% comment %} Foundation styles the `<table>` element itself — there is no `.table` class.
`.table-scroll` is the 6.2+ wrapper; `table.scroll` is the legacy form. {% endcomment %}
<div class="table-scroll {{ class }}">
<table class="hover">
<thead>
<tr>
{% for col in columns %}
<th>{{ col.label }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
{% for col in columns %}
<td>{{ row|get_item:col.key }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
32 changes: 32 additions & 0 deletions src/cf_ui/templates/cotton/_themes/foundation/tabs.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{% comment %} `.tabs-title > a[aria-selected=true]` is what actually restyles the selected
tab — `.is-active` on the `<li>` 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 %}
<div class="{{ class }}"
x-data="cfTabs"
x-init="initTabs()"
data-cf-active="{{ active }}">
<ul class="tabs" role="tablist" @keydown="onKeydown($event)">
{% for tab in tabs %}
<li class="tabs-title{% if tab.id == active %} is-active{% endif %}"
data-cf-tab="{{ tab.id }}"
:class="{ 'is-active': active === $el.dataset.cfTab }">
<a role="tab"
data-cf-tab="{{ tab.id }}"
aria-controls="{{ hx_target }}"
aria-selected="{% if tab.id == active %}true{% else %}false{% endif %}"
tabindex="{% if tab.id == active or not active and forloop.first %}0{% else %}-1{% endif %}"
:aria-selected="active === $el.dataset.cfTab"
:tabindex="tabIndexFor($el.dataset.cfTab)"
@click.prevent="setActive($el.dataset.cfTab)"
hx-get="{{ tab.url }}"
hx-target="#{{ hx_target }}">{{ tab.id }}</a>
</li>
{% endfor %}
</ul>
<div id="{{ hx_target }}" class="tabs-content" role="tabpanel">{{ slot }}</div>
</div>
11 changes: 11 additions & 0 deletions src/cf_ui/templates/cotton/_themes/foundation/textarea.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<div class="{{ class }}">
<label for="{{ name }}"{% if error %} class="is-invalid-label"{% endif %}>{{ label }}</label>
<textarea class="{% if error %}is-invalid-input{% endif %}{% if input_class %} {{ input_class }}{% endif %}"
id="{{ name }}"
name="{{ name }}"
rows="{{ rows }}"
{% if error %}aria-invalid="true" aria-describedby="{{ name }}-error"{% endif %}>{{ value }}</textarea>
{% if error %}
<span class="form-error is-visible" id="{{ name }}-error">{{ error }}</span>
{% endif %}
</div>
3 changes: 0 additions & 3 deletions src/cf_ui/templates/cotton/foundation/PLANNED.md

This file was deleted.

18 changes: 18 additions & 0 deletions src/cf_ui/templates/jinja/foundation/Breadcrumb.jinja
Original file line number Diff line number Diff line change
@@ -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. #}
<nav aria-label="breadcrumbs">
<ul class="breadcrumbs {{ extra_class }}">
{% for item in items %}
<li>
{% if loop.last %}
<a href="{{ item.url }}" aria-current="page">{{ item.label }}</a>
{% else %}
<a href="{{ item.url }}">{{ item.label }}</a>
{% endif %}
</li>
{% endfor %}
</ul>
</nav>
Loading
Loading