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
59 changes: 59 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,65 @@

## [Unreleased]

### Added — Fomantic UI theme (#24)

All 14 components in both template sets, replacing the `PLANNED.md` stub.
`CF_UI_THEME = "fomantic"` (Django) or `theme="fomantic"` (FastAPI/Litestar)
now resolves; it was rejected at startup before. The CDN entry and pinned
version (`fomantic-ui@2.9.3`, `dist/semantic.min.css`) already existed and were
verified to still resolve.

- **CSS only — no jQuery, and no Fomantic JS.** Fomantic's Modal, Tab,
Accordion and Dropdown are jQuery plugins, which makes this the most
JS-dependent of the five themes. Loading them would mean a *styling* choice
silently changed a consuming app's dependency graph, so the theme uses
Fomantic's classes and markup structure only and Alpine drives every
behaviour, exactly as it does for Bulma and DaisyUI. A unit test scans all 28
templates and an E2E test checks the delivered page and the live `window`.

- Consequences of that, where they are not obvious from the markup:
- **Modal** renders its own `.ui.dimmer`. Fomantic normally injects it from
`$('.ui.modal').modal('show')`; here the dimmer and the modal each take
`active` from the same Alpine `open`, since both are `display:none` without
it.
- **Panel** binds `active` as a class rather than relying on `x-show` alone.
`.ui.accordion .title ~ .content:not(.active)` is `display:none`, so Alpine
merely dropping its inline style would leave the panel hidden.
- **Select** is a plain `<select class="ui fluid selection dropdown">`.
Fomantic's `ui dropdown` is a JS-built widget over a hidden select;
reproducing its DOM by hand would be a combobox nobody could drive.
- **Tabs** puts its panel in a `ui bottom attached segment`, not `.ui.tab` —
`.ui.tab` is `display:none` until Fomantic's Tab module adds `active`.
- **Navbar** uses `ui stackable menu`, Fomantic's CSS-only responsive
collapse. Its burger reports state through `aria-expanded` and the `active`
class but hides nothing, because Fomantic's collapse is a Sidebar/Dropdown
JS module that cf-ui does not load.
- **Field errors** use `ui basic red pointing prompt label`, not
`ui error message` — the latter is `display:none` until the *form* itself
carries `.error`.
- **Progress** is a div, so `role="progressbar"` and `aria-valuenow` /
`aria-valuemin` / `aria-valuemax` are written out, and the bar width and
`data-percent` are rendered server-side. The percentage is also clamped to
`0..100`: Bulma and DaisyUI render a real `<progress>`, which the browser
clamps for both painting and the accessibility tree, whereas a div would
take `width: 150%` and report `aria-valuenow="150"` against a max of 100.
The ARIA is stated in percent so it agrees with the visible label.

- Accessibility is at parity with every other theme, not a reduced subset: the
parametrized cases in `tests/unit/test_accessibility.py` now run against five
themes rather than four, with no assertion weakened.

- Tab ids reach Alpine through `$el.dataset.cfTab`, never as interpolated
expression source (#32). The tree-wide guard that shipped with that fix reads
its theme list from `THEMES`, so it covered this theme the moment the registry
entry landed — and did not pass until the templates were corrected.

- **The last `PLANNED.md` stub is gone.** Every directory under `templates/` is
now a real theme, which retires the "a directory exists but is not selectable"
test that has existed in some form since #6. What it protected is stated
directly instead: `resolve_theme` and `CfUiConfig.ready()` answer from
`THEMES`, not from the filesystem.

### Security — tab ids no longer reach Alpine as expression source (#32)

- **A request-controlled `tab.id` executed as JavaScript on page load.** Each
Expand Down
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,16 @@ pip install "cf-ui[bulma]"
# Tailwind + DaisyUI
pip install "cf-ui[daisy]"

# All themes (Foundation and Fomantic are still stubs)
# Bootstrap 5
pip install "cf-ui[bootstrap]"

# Foundation 6
pip install "cf-ui[foundation]"

# Fomantic UI
pip install "cf-ui[fomantic]"

# All themes
pip install "cf-ui[all]"
```

Expand Down Expand Up @@ -291,8 +300,8 @@ from cf_ui import JINJA_TEMPLATES_DIR, COTTON_TEMPLATES_DIR
| Bulma | ✅ v0.1.0 |
| Tailwind + DaisyUI | ✅ — see [docs/daisyui.md](docs/daisyui.md) |
| Bootstrap 5 | ✅ — CSS only, no `bootstrap.bundle.js`; see [docs/bootstrap.md](docs/bootstrap.md) |
| Foundation | 📋 Planned |
| Fomantic UI | 📋 Planned |
| Foundation 6 | ✅ — CSS only, no jQuery |
| Fomantic UI | ✅ — CSS only, no jQuery |

Switching is one line — `CF_UI_THEME = "daisy"` on Django, `theme="daisy"` on
FastAPI/Litestar — and needs no template edits in the consuming app. An
Expand Down
10 changes: 10 additions & 0 deletions src/cf_ui/templates/cotton/_themes/fomantic/breadcrumb.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<nav class="ui breadcrumb {{ class }}" aria-label="breadcrumbs">
{% for item in items %}
{% if not forloop.first %}<span class="divider">/</span>{% endif %}
{% if forloop.last %}
<a class="active section" href="{{ item.url }}" aria-current="page">{{ item.label }}</a>
{% else %}
<a class="section" href="{{ item.url }}">{{ item.label }}</a>
{% endif %}
{% endfor %}
</nav>
9 changes: 9 additions & 0 deletions src/cf_ui/templates/cotton/_themes/fomantic/card.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<div class="ui card {{ class }}">
<div class="content">
{% if header %}<div class="header">{{ header }}</div>{% endif %}
<div class="description">{{ slot }}</div>
</div>
{% if footer %}
<div class="extra content">{{ footer }}</div>
{% endif %}
</div>
22 changes: 22 additions & 0 deletions src/cf_ui/templates/cotton/_themes/fomantic/checkbox-group.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<div class="ui form {{ class }}">
<div class="grouped fields{% if control_class %} {{ control_class }}{% endif %}{% if error %} error{% endif %}">
<label>{{ label }}</label>
{% for choice in choices %}
<div class="field">
{% comment %} Fomantic's CSS-only checkbox works because the real input is stretched
invisibly over the box, so a click anywhere hits it. That is a mouse
affordance, not an accessible name — the label is wired up with
for/id so a screen reader announces it too. {% endcomment %}
<div class="ui checkbox">
<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>
</div>
</div>
{% endfor %}
{% if error %}<div class="ui basic red pointing prompt label">{{ error }}</div>{% endif %}
</div>
</div>
21 changes: 21 additions & 0 deletions src/cf_ui/templates/cotton/_themes/fomantic/form-field.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{% comment %} Every Fomantic form rule is scoped `.ui.form .field`, so a standalone field
with no `.ui.form` ancestor is unstyled. The component carries its own, which
nests harmlessly inside a consumer's `<form class="ui form">`. `input_class`
lands on the `.ui.input` wrapper because that is where Fomantic's size and
state modifiers live (`ui mini input`, `ui icon input`). {% endcomment %}
<div class="ui form {{ class }}">
<div class="field{% if error %} error{% endif %}">
<label for="{{ name }}">{{ label }}</label>
<div class="ui fluid input{% if input_class %} {{ input_class }}{% endif %}">
<input id="{{ name }}"
type="{{ type }}"
name="{{ name }}"
value="{{ value }}"
{% if required == "true" %}required{% endif %}>
</div>
{% comment %} Not `<div class="ui error message">`: `.ui.form .error.message` is
display:none until the *form* itself carries `.error`. The prompt label
is what Fomantic actually shows for a single field. {% endcomment %}
{% if error %}<div class="ui basic red pointing prompt label">{{ error }}</div>{% endif %}
</div>
</div>
23 changes: 23 additions & 0 deletions src/cf_ui/templates/cotton/_themes/fomantic/modal.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{% comment %} Fomantic's modal is normally positioned and revealed by
`$('.ui.modal').modal('show')`, which also injects the `.ui.dimmer` wrapper.
cf-ui loads no Fomantic JS, so the dimmer is part of the markup and both
`.ui.dimmer` and `.ui.modal` — each `display:none` until `.active` — get the
class from the same Alpine `open`. {% endcomment %}
<div id="{{ id }}"
class="ui page dimmer {{ 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()"
:class="{ 'active': open }"
@click.self="close()">
<div class="ui modal" :class="{ 'active': open }">
<button type="button" class="close" aria-label="close" @click="close()">&times;</button>
{% if header %}<div class="header" id="{{ id }}-title">{{ header }}</div>{% endif %}
<div class="content">{{ slot }}</div>
{% if footer %}
<div class="actions">{{ footer }}</div>
{% endif %}
</div>
</div>
23 changes: 23 additions & 0 deletions src/cf_ui/templates/cotton/_themes/fomantic/navbar.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{% comment %} Fomantic's responsive nav collapse is its Sidebar/Dropdown *JS* module, which
cf-ui does not load. `ui stackable menu` is Fomantic's CSS-only equivalent:
below 768px the menu becomes a full-width column, so nothing is hidden that a
burger would have to reveal. The toggle is kept because `cfNavbar` is the
cross-theme contract — it reports its state through `aria-expanded` and
Fomantic's `active` item class rather than through a display utility that
this framework does not ship. {% endcomment %}
<nav class="ui stackable menu {{ class }}"
role="navigation"
aria-label="main navigation"
x-data="cfNavbar">
<div class="header item">{{ brand }}</div>
<button type="button"
class="item"
aria-label="menu"
:aria-expanded="menuOpen"
:class="{ 'active': menuOpen }"
@click="toggle()">&#9776;</button>
<div class="item">{{ start }}</div>
<div class="right menu">
<div class="item">{{ end }}</div>
</div>
</nav>
12 changes: 12 additions & 0 deletions src/cf_ui/templates/cotton/_themes/fomantic/notification.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<div role="alert"
class="ui {% if type == 'danger' or type == 'error' %}negative{% elif type == 'success' %}positive{% elif type == 'warning' %}warning{% else %}info{% endif %} message {{ class }}"
x-data="{ visible: true }"
x-show="visible">
{% if dismissible == "true" %}
{% comment %} `.ui.message > .close.icon` positions this; the glyph rules are scoped to
`i.icon`, so a `<button>` keeps the placement and renders its own text —
which is what makes it a real, focusable control rather than an `<i>`. {% endcomment %}
<button type="button" class="close icon" aria-label="dismiss" @click="visible = false">&times;</button>
{% endif %}
<div class="content">{{ message }}</div>
</div>
26 changes: 26 additions & 0 deletions src/cf_ui/templates/cotton/_themes/fomantic/pagination.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{% load cf_ui %}
<nav class="ui pagination menu {{ class }}" role="navigation" aria-label="pagination">
{% if page|add:"0" > 1 %}
<a class="item"
hx-get="{{ hx_url }}?page={{ page|add:"-1" }}"
hx-target="{{ hx_target }}">Previous</a>
{% else %}
<a class="disabled item" aria-disabled="true">Previous</a>
{% endif %}
{% for p in total_pages|make_list_1_to_n %}
<a class="{% if p == page|add:'0' %}active {% endif %}item"
{% if p != page|add:"0" %}
hx-get="{{ hx_url }}?page={{ p }}"
hx-target="{{ hx_target }}"
{% endif %}
aria-label="Page {{ p }}"
{% if p == page|add:'0' %}aria-current="page"{% endif %}>{{ p }}</a>
{% endfor %}
{% if page|add:"0" < total_pages|add:"0" %}
<a class="item"
hx-get="{{ hx_url }}?page={{ page|add:"1" }}"
hx-target="{{ hx_target }}">Next</a>
{% else %}
<a class="disabled item" aria-disabled="true">Next</a>
{% endif %}
</nav>
25 changes: 25 additions & 0 deletions src/cf_ui/templates/cotton/_themes/fomantic/panel.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{% comment %} `.ui.accordion .title ~ .content:not(.active)` is `display:none`, so the open
state has to be a *class*, not only `x-show`: Alpine dropping its inline
`display:none` would leave the stylesheet's rule in force. Both are bound so
the panel is right with Alpine off (class, server-rendered) and with it on. {% endcomment %}
<div class="ui styled fluid accordion {{ class }}"
x-data="cfPanel"
x-init="initPanel()"
data-cf-open="{% if open and open != 'false' %}true{% else %}false{% endif %}">
<button type="button"
class="title{% if open and open != 'false' %} active{% endif %}"
:class="{ 'active': open }"
aria-controls="{{ id }}-body"
aria-expanded="{% if open and open != 'false' %}true{% else %}false{% endif %}"
:aria-expanded="open"
@click="toggle()">
<span class="dropdown icon" aria-hidden="true">&#9654;</span>{{ title }}
</button>
<div id="{{ id }}-body"
class="content{% if open and open != 'false' %} active{% endif %}"
:class="{ 'active': open }"
x-show="open"
{% if not open or open == 'false' %}x-cloak{% endif %}>
{{ slot }}
</div>
</div>
28 changes: 28 additions & 0 deletions src/cf_ui/templates/cotton/_themes/fomantic/progress.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{% comment %} Fomantic's progress is a div whose `.bar` is sized by its Progress module.
With no JS the width is written server-side, and `data-percent` has to be
present — `.ui.progress:not([data-percent]) .bar` is given a transparent
background. A div is also not a `<progress>`, so the ARIA is explicit.

The percentage is clamped to 0..100 to match the Jinja side. Bulma and Daisy
render a real `<progress>`, which the browser clamps for both painting and
the accessibility tree; a plain div would happily take `width: 150%` and
report `aria-valuenow="150"` against a max of 100. Django has no min/max
filter, so the clamp is three `widthratio` branches: `1 1 100` is 100 and
`0 1 100` is 0. The ARIA is stated in percent so it agrees with the visible
"40%" text. {% endcomment %}
{% widthratio value max 100 as cf_raw %}
{% if cf_raw|add:"0" > 100 %}{% widthratio 1 1 100 as cf_pct %}{% elif cf_raw|add:"0" < 0 %}{% widthratio 0 1 100 as cf_pct %}{% else %}{% widthratio value max 100 as cf_pct %}{% endif %}
<div class="{{ class }}">
<div class="ui {% if type == 'danger' or type == 'error' %}error{% elif type == 'success' %}success{% elif type == 'warning' %}warning{% elif type == 'info' %}teal{% else %}blue{% endif %} progress"
data-percent="{{ cf_pct }}"
role="progressbar"
aria-valuenow="{{ cf_pct }}"
aria-valuemin="0"
aria-valuemax="100"
{% if label %}aria-label="{{ label }}"{% endif %}>
<div class="bar" style="width: {{ cf_pct }}%">
<div class="progress">{{ cf_pct }}%</div>
</div>
{% if label %}<div class="label">{{ label }}</div>{% endif %}
</div>
</div>
20 changes: 20 additions & 0 deletions src/cf_ui/templates/cotton/_themes/fomantic/select.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{% comment %} A real `<select>`, not Fomantic's `ui dropdown` widget. That widget is built
at runtime by a jQuery module cf-ui does not load, over a hidden select;
reproducing its DOM by hand would be a combobox nobody can drive. The
component's contract is a form control, and `select.ui.dropdown` /
`.ui.selection.dropdown` style a native select acceptably with no JS. {% endcomment %}
<div class="ui form {{ class }}">
<div class="field{% if error %} error{% endif %}">
<label for="{{ name }}">{{ label }}</label>
<select class="ui fluid selection dropdown{% if input_class %} {{ input_class }}{% endif %}"
id="{{ name }}"
name="{{ name }}">
{% 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 %}<div class="ui basic red pointing prompt label">{{ error }}</div>{% endif %}
</div>
</div>
21 changes: 21 additions & 0 deletions src/cf_ui/templates/cotton/_themes/fomantic/table.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{% load cf_ui %}
<div class="{{ class }}">
<table class="ui celled striped table">
<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>
25 changes: 25 additions & 0 deletions src/cf_ui/templates/cotton/_themes/fomantic/tabs.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{% comment %} The panel is a plain attached segment, not `.ui.tab`: `.ui.tab` is
`display:none` until Fomantic's Tab module adds `.active`, and this one panel
is always the visible one — HTMX swaps its contents. {% endcomment %}
<div class="{{ class }}"
x-data="cfTabs"
x-init="initTabs()"
data-cf-active="{{ active }}">
<div role="tablist" class="ui top attached tabular menu" @keydown="onKeydown($event)">
{% for tab in tabs %}
<a role="tab"
data-cf-tab="{{ tab.id }}"
class="{% if tab.id == active %}active {% endif %}item"
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 %}"
:class="{ 'active': active === $el.dataset.cfTab }"
: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>
{% endfor %}
</div>
<div id="{{ hx_target }}" role="tabpanel" class="ui bottom attached segment">{{ slot }}</div>
</div>
9 changes: 9 additions & 0 deletions src/cf_ui/templates/cotton/_themes/fomantic/textarea.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<div class="ui form {{ class }}">
<div class="field{% if error %} error{% endif %}">
<label for="{{ name }}">{{ label }}</label>
<textarea id="{{ name }}"
name="{{ name }}"
rows="{{ rows }}"{% if input_class %} class="{{ input_class }}"{% endif %}>{{ value }}</textarea>
{% if error %}<div class="ui basic red pointing prompt label">{{ error }}</div>{% endif %}
</div>
</div>
3 changes: 0 additions & 3 deletions src/cf_ui/templates/cotton/fomantic/PLANNED.md

This file was deleted.

Loading
Loading