Add an Onglets (Tabs) widget

New widget where each tab is a real "conteneur" element posed as a child
(see screens/elements/add_tab.py) — this reuses everything that already
exists for a normal container (adding a Répéteur/Conteneur/etc. inside via
"Ajouter DANS ce conteneur", renaming to change the tab's visible label,
deleting via the standard trash icon) instead of inventing a separate
storage format for tabs.

The widget's own properties panel gets a dedicated "Onglets" section to
add a tab, rename one, jump to its content, or delete it. Rendering
(render_onglets.py) builds a tab bar + one panel per tab, switched
client-side (forgeShowTab, in both screen_edit.html and play.html) with
only one panel visible at a time.

Distinct from the existing "activer_onglet" flow action (2.3, manual
show-one/hide-siblings) — that stays available for custom show/hide
wiring; this widget is the turnkey version with tab management built in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-23 21:42:39 +02:00
co-authored by Claude Sonnet 5
parent 2b2619d1c4
commit 17fa4cf087
11 changed files with 299 additions and 0 deletions
+1
View File
@@ -6,4 +6,5 @@ from . import (
element_delete,
element_duplicate,
element_rename,
element_tab_add,
)
+14
View File
@@ -0,0 +1,14 @@
from flask import redirect, url_for, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/tabs/add", methods=["POST"])
def element_tab_add(slug, element_id):
onglets = screens.get_element(slug, element_id)
if not onglets:
abort(404)
screens.add_tab(slug, element_id, onglets["screen_id"])
return redirect(url_for("screen_edit", slug=slug, screen_id=onglets["screen_id"], selected=element_id))
+1
View File
@@ -73,6 +73,7 @@ from .screens_repo.set_screen_aspect import set_screen_aspect
from .elements.list_elements import list_elements
from .elements.get_element import get_element
from .elements.add_element import add_element
from .elements.add_tab import add_tab
from .elements.update_element_geometry import update_element_geometry
from .elements.save_element_controls import save_element_controls
from .elements.delete_element import delete_element
+19
View File
@@ -0,0 +1,19 @@
import db
from .add_element import add_element
from .rename_element import rename_element
def add_tab(slug, onglets_id, screen_id):
"""Ajoute un nouvel onglet à un élément "onglets" : un simple conteneur
posé comme enfant (voir render_onglets.py), nommé "Onglet N" par défaut
(N = son rang) — ce nom est directement ce qui s'affiche sur l'onglet,
modifiable ensuite comme celui de n'importe quel élément."""
conn = db.connect(slug)
count = conn.execute(
"SELECT COUNT(*) AS c FROM _screen_elements WHERE parent_id = ?", (onglets_id,)
).fetchone()["c"]
conn.close()
tab_id = add_element(slug, screen_id, "conteneur", parent_id=onglets_id)
rename_element(slug, tab_id, f"Onglet {count + 1}")
return tab_id
+3
View File
@@ -13,6 +13,7 @@ from .render_checkbox_or_radio import _render_checkbox_or_radio
from .render_repeater import _render_repeater
from .render_jauge import _render_jauge
from .render_overlay import _render_overlay
from .render_onglets import _render_onglets
def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
@@ -43,6 +44,8 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_
return _render_jauge(el, meta, slug, children_map, ctx, parent_flex_direction)
if special == "overlay":
return _render_overlay(el, meta, slug, children_map, ctx, parent_flex_direction)
if special == "onglets":
return _render_onglets(el, meta, slug, children_map, ctx, parent_flex_direction)
tag = meta.get("tag")
if tag is None: # widget à balise dynamique (ex: "titre" -> h1/h2/h3)
+57
View File
@@ -0,0 +1,57 @@
import html as html_lib
from .visible_attrs import _visible_attrs
from .style_string import _style_string
from .parent_flex_direction import _parent_flex_direction
from .auto_id import _auto_id
from .attr_string import _attr_string
def _render_onglets(el, meta, slug, children_map=None, ctx=None, parent_flex_direction=None):
"""Élément à onglets : chaque onglet est un vrai conteneur posé comme
enfant (voir screens/elements/add_tab.py) — ça réutilise tel quel tout
ce qui existe déjà pour un conteneur normal (ajouter du contenu dedans
via "Ajouter DANS ce conteneur", le renommer pour changer le libellé
affiché sur l'onglet, le supprimer via la poubelle standard) plutôt que
d'inventer un stockage séparé pour les onglets. Un seul panneau visible
à la fois, basculé par un petit script embarqué qui marche aussi bien
dans l'éditeur qu'en jeu (voir forgeShowTab dans screen_edit.html et
play.html)."""
from .render_element_html import render_element_html
tabs = (children_map or {}).get(el["id"], [])
attrs = _visible_attrs(el, meta, ctx)
style = _style_string(el, " display:flex; flex-direction:column;", parent_flex_direction)
group_id = _auto_id(el)
if not tabs:
return (
f'<div {_attr_string(attrs, style)}>'
f'<em style="opacity:.6;">Onglets — ajoute au moins un onglet dans ses réglages.</em></div>'
)
flex_dir = _parent_flex_direction(el)
bar_items = []
panels = []
for i, tab in enumerate(tabs):
label = tab.get("name") or f"Onglet {i + 1}"
active = i == 0
tab_html = render_element_html(tab, slug, children_map, ctx, parent_flex_direction=flex_dir)
target_id = f"tabpanel-{tab['id']}"
bar_items.append(
f'<button type="button" class="forgeTabBtn{" active" if active else ""}" '
f'data-tab-btn="{group_id}" data-tab-target="{target_id}" '
f'onclick="forgeShowTab(this)">{html_lib.escape(str(label))}</button>'
)
hidden_style = "" if active else ' style="display:none;"'
panels.append(
f'<div class="forgeTabPanel" id="{target_id}" data-tab-group="{group_id}"{hidden_style}>'
f'{tab_html}</div>'
)
return (
f'<div {_attr_string(attrs, style)}>'
f'<div class="forgeTabBar">{"".join(bar_items)}</div>'
f'<div class="forgeTabPanels" style="flex:1 1 auto; min-height:0;">{"".join(panels)}</div>'
f"</div>"
)
+10
View File
@@ -277,4 +277,14 @@ WIDGETS = {
c_slider("arrondi", "Arrondi des coins de la boîte", "attr:_arrondi", unit="", min=0, max=40, default=12, group="Apparence"),
],
},
"onglets": {
# Chaque onglet est un vrai élément "conteneur" posé comme enfant
# (voir screens/elements/add_tab.py et render_onglets.py) — pas de
# gestion dédiée ici : ajouter/renommer/supprimer un onglet se fait
# via le bloc "Onglets" du panneau de propriétés (screen_edit.html),
# qui réutilise les routes existantes (element_rename, element_delete).
"label": "Onglets", "icon": "🗂️", "tag": "div", "has_content": False, "special_render": "onglets",
"w": 60, "h": 40, "default_content": "",
"controls": [],
},
}
+11
View File
@@ -206,6 +206,17 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
}
.contextMenu button:hover{ background:var(--panel2); }
/* Élément à onglets (voir render_onglets.py) */
.forgeTabBar{ display:flex; flex-wrap:wrap; gap:2px; border-bottom:1px solid var(--border); }
.forgeTabBtn{
background:none; border:none; border-bottom:2px solid transparent; color:var(--text-dim);
padding:8px 14px; font-size:14px; font-family:inherit; cursor:pointer; white-space:nowrap;
}
.forgeTabBtn:hover{ color:var(--text); }
.forgeTabBtn.active{ color:var(--text); border-bottom-color:var(--accent); font-weight:600; }
.forgeTabPanels{ min-width:0; }
.forgeTabPanel{ padding-top:10px; }
.propGroup{ border:1px solid var(--border); border-radius:8px; margin-bottom:8px; background:var(--panel); }
.propGroup > summary{
list-style:none; cursor:pointer; padding:9px 12px; font-size:13px; color:var(--accent);
+14
View File
@@ -74,6 +74,20 @@
let gameData = {{ payload_json|safe }};
let screensData = gameData.screens;
// Élément à onglets (voir render_onglets.py) : bascule le panneau visible
// sans aller-retour serveur — même fonction que dans screen_edit.html,
// pour un comportement identique éditeur/jeu.
function forgeShowTab(btn) {
var groupId = btn.dataset.tabBtn;
var targetId = btn.dataset.tabTarget;
document.querySelectorAll('[data-tab-btn="' + groupId + '"]').forEach(function (b) {
b.classList.toggle('active', b === btn);
});
document.querySelectorAll('[data-tab-group="' + groupId + '"]').forEach(function (p) {
p.style.display = (p.id === targetId) ? '' : 'none';
});
}
function showScreen(id) {
document.querySelectorAll('.playScreen').forEach(s => s.style.display = 'none');
const el = document.querySelector('.playScreen[data-screen-id="' + id + '"]');
+48
View File
@@ -186,6 +186,38 @@
</details>
{% endif %}
{% if el.widget == 'onglets' %}
{% set tabs = children_of.get(el.id, []) %}
<details class="propGroup">
<summary>🗂️ Onglets{{ ' (' ~ tabs|length ~ ')' if tabs else '' }}</summary>
<div class="propGroupBody">
{% if tabs %}
<div class="list">
{% for tab in tabs %}
<div class="listRow" style="display:flex; flex-direction:column; gap:6px;">
<form method="post" action="{{ url_for('element_rename', slug=game.slug, element_id=tab.id) }}" class="fieldRow" style="align-items:center; margin:0;">
<input type="text" name="name" value="{{ tab.name or ('Onglet ' ~ loop.index) }}" placeholder="Nom de l'onglet" style="flex:1;">
<button type="submit" class="smallBtn">Renommer</button>
</form>
<div class="fieldRow" style="align-items:center; margin:0;">
<a class="smallBtn" style="flex:1; text-align:center;" href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=tab.id) }}">📥 Contenu de l'onglet</a>
<form method="post" action="{{ url_for('element_delete', slug=game.slug, element_id=tab.id) }}" onsubmit="return confirm('Supprimer cet onglet et tout son contenu ?');">
<button type="submit" class="smallBtn danger">🗑️</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="hint">Aucun onglet pour l'instant.</p>
{% endif %}
<form method="post" action="{{ url_for('element_tab_add', slug=game.slug, element_id=el.id) }}" style="margin-top:8px;">
<button type="submit" class="smallBtn primary" style="width:100%;">➕ Ajouter un onglet</button>
</form>
</div>
</details>
{% endif %}
{% set size_ctrl_keys = ['child-width', 'child-height'] %}
{% set size_ctrls = {} %}
{% for c in selected_controls if c.key in size_ctrl_keys %}{% set _ = size_ctrls.update({c.key: c}) %}{% endfor %}
@@ -675,6 +707,22 @@ function applySelectionHighlight(id) {
if (node) node.classList.add('elementHighlighted');
}
// ---------- Élément à onglets : bascule d'onglet côté client ----------
//
// Un seul panneau visible à la fois (voir render_onglets.py) — basculé ici
// plutôt que par un aller-retour serveur, pour un rendu instantané aussi
// bien dans l'éditeur qu'en jeu (voir la même fonction dans play.html).
function forgeShowTab(btn) {
var groupId = btn.dataset.tabBtn;
var targetId = btn.dataset.tabTarget;
document.querySelectorAll('[data-tab-btn="' + groupId + '"]').forEach(function (b) {
b.classList.toggle('active', b === btn);
});
document.querySelectorAll('[data-tab-group="' + groupId + '"]').forEach(function (p) {
p.style.display = (p.id === targetId) ? '' : 'none';
});
}
if (!window.__forgeTreeToggleBound) {
window.__forgeTreeToggleBound = true;
document.addEventListener('click', function (e) {
+121
View File
@@ -0,0 +1,121 @@
"""Tests du widget Onglets — ajouter/renommer/supprimer un onglet, contenu
par onglet (un vrai conteneur enfant, voir screens/elements/add_tab.py).
À ne pas confondre avec tests/test_onglets.py, qui teste l'action de flux
existante "activer_onglet" (2.3, montrer un élément et masquer ses frères) —
un mécanisme manuel plus bas niveau, toujours disponible séparément, que ce
widget ne remplace pas."""
import re
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
def _create_onglets(client, slug, screen_id):
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": "onglets"}, follow_redirects=False)
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
def _tab_rename_ids(html):
"""Ids des formulaires de renommage DANS le bloc "Onglets" du panneau de
propriétés — pas le premier /elements/<id>/rename de la page, qui est le
champ "Nommer" générique de l'élément actuellement sélectionné (pas
forcément un onglet, ex. l'élément Onglets lui-même)."""
block = html.split("Onglets (", 1)[-1]
return [int(x) for x in re.findall(r"/elements/(\d+)/rename", block)]
def test_new_tabs_widget_has_no_tabs_yet(client, game):
screen_id = _create_screen(client, game)
_create_onglets(client, game, screen_id)
html = client.get(f"/game/{game}/play").data.decode()
assert "forgeTabBtn" not in html
assert "ajoute au moins un onglet" in html
def test_add_tab_creates_a_named_child_container(client, game):
screen_id = _create_screen(client, game)
onglets_id = _create_onglets(client, game, screen_id)
resp = client.post(f"/game/{game}/elements/{onglets_id}/tabs/add", follow_redirects=False)
assert resp.status_code == 302
assert f"selected={onglets_id}" in resp.headers["Location"] # reste sur le widget Onglets, pas le nouvel onglet
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert ">Onglet 1<" in html # nom par défaut, affiché tel quel comme libellé de l'onglet
def test_second_tab_is_named_onglet_2_and_starts_hidden(client, game):
screen_id = _create_screen(client, game)
onglets_id = _create_onglets(client, game, screen_id)
client.post(f"/game/{game}/elements/{onglets_id}/tabs/add")
client.post(f"/game/{game}/elements/{onglets_id}/tabs/add")
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert ">Onglet 1<" in html
assert ">Onglet 2<" in html
# Seul le premier onglet est visible par défaut (le second est en display:none).
assert 'style="display:none;"' in html
def test_renaming_a_tab_changes_the_visible_label(client, game):
screen_id = _create_screen(client, game)
onglets_id = _create_onglets(client, game, screen_id)
client.post(f"/game/{game}/elements/{onglets_id}/tabs/add")
html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={onglets_id}").data.decode()
tab_id = _tab_rename_ids(html)[0]
client.post(f"/game/{game}/elements/{tab_id}/rename", data={"name": "Statistiques"})
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert ">Statistiques<" in html
assert ">Onglet 1<" not in html
def test_deleting_a_tab_removes_it_and_reselects_the_tabs_widget(client, game):
screen_id = _create_screen(client, game)
onglets_id = _create_onglets(client, game, screen_id)
client.post(f"/game/{game}/elements/{onglets_id}/tabs/add")
client.post(f"/game/{game}/elements/{onglets_id}/tabs/add", follow_redirects=False)
html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={onglets_id}").data.decode()
tab_ids = _tab_rename_ids(html)
assert len(tab_ids) == 2
resp = client.post(f"/game/{game}/elements/{tab_ids[1]}/delete", follow_redirects=False)
assert resp.status_code == 302
assert f"selected={onglets_id}" in resp.headers["Location"]
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert html.count("forgeTabBtn") == 1
def test_tab_content_holds_a_real_container_children_can_be_added_to(client, game):
"""Régression : chaque onglet doit se comporter comme un vrai conteneur
— on doit pouvoir y poser un Répéteur/Conteneur/etc. via "Ajouter DANS
ce conteneur", exactement comme pour n'importe quel autre conteneur."""
screen_id = _create_screen(client, game)
onglets_id = _create_onglets(client, game, screen_id)
client.post(f"/game/{game}/elements/{onglets_id}/tabs/add")
html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={onglets_id}").data.decode()
tab_id = _tab_rename_ids(html)[0]
resp = client.post(f"/game/{game}/elements/{tab_id}/children/add", data={"widget": "titre"}, follow_redirects=False)
assert resp.status_code == 302
child_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
client.post(f"/game/{game}/elements/{child_id}/save", data={"ctrl_content": "Bienvenue dans cet onglet"})
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert "Bienvenue dans cet onglet" in html
def test_deleting_the_tabs_widget_cascades_to_its_tabs(client, game):
screen_id = _create_screen(client, game)
onglets_id = _create_onglets(client, game, screen_id)
client.post(f"/game/{game}/elements/{onglets_id}/tabs/add")
client.post(f"/game/{game}/elements/{onglets_id}/delete")
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert "forgeTabBtn" not in html
assert ">Onglet 1<" not in html