Skip to content

Commit bdbf03d

Browse files
committed
use HtmlAttributes
1 parent 1a6a03a commit bdbf03d

21 files changed

Lines changed: 368 additions & 133 deletions

.docs/refactor/REFACTOR.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,54 @@ Damit sind B17.2 und B17.3 miterledigt, statt behandelt zu werden.
498498
steht derzeit `dev-main`. Sobald das Repository ein Tag hat, auf `^0.1`
499499
umstellen und `composer update` ausführen.
500500

501+
### B20 — Templates nutzten Contaos `HtmlAttributes` nicht — **ERLEDIGT**
502+
503+
Attribute wurden als Zeichenketten zusammengesetzt, bedingte Klassen als
504+
Ternaries im `class`-Attribut:
505+
506+
```twig
507+
class="qna-question{{ question.answered ? ' qna-question--answered' : '' }}"
508+
```
509+
510+
Damit gab es keinen Erweiterungspunkt: Ein Projekt, das ein Attribut ergänzen
511+
wollte, musste das Template kopieren.
512+
513+
**Umgesetzt am 15.09.2026** nach dem Core-Idiom
514+
(`vendor/contao/core-bundle/contao/templates/twig/form_row.html.twig`,
515+
`content_element/_base.html.twig`):
516+
517+
```twig
518+
{% set question_attributes = attrs()
519+
.addClass('qna-question')
520+
.addClass('qna-question--answered', question.answered)
521+
.mergeWith(question_attributes|default)
522+
%}
523+
<article{{ question_attributes }}>
524+
```
525+
526+
Das `mergeWith(…|default)` am Ende ist der Erweiterungspunkt. Belegte API:
527+
`ContaoExtension.php:197` (`attrs`), `String/HtmlAttributes.php` (`addClass`
528+
mit `condition`-Parameter, `set`, `mergeWith`, `setIfExists`).
529+
530+
**Abgrenzung:** Umgestellt wurden Elemente mit bedingten Attributen und die
531+
strukturellen Anker (Frames, Container, Formulare, Buttons). Rein statische
532+
Einzelklassen wie `<p class="qna-question__votes">` blieben unverändert — dort
533+
wäre der Builder Rauschen ohne Gewinn.
534+
535+
Zwei Nebenbefunde, die der Umbau ans Licht gebracht hat:
536+
537+
1. Die Test-Twig-Umgebung kannte `attrs()` nicht **und** registrierte
538+
`HtmlAttributes` nicht als *safe class*. Letzteres führte zu doppeltem
539+
Escaping — in Produktion macht `ContaoExtension` das über
540+
`addSafeClass(HtmlAttributes::class, ['html', 'contao_html'])`. Die Fixture
541+
bildet das jetzt nach; ohne diesen Fund hätte die Testumgebung stillschweigend
542+
anders gerendert als die Produktion.
543+
2. `QnaTurboAssetTest::testFrameResponsesDoNotReferenceTheirOwnSourceUrl` prüfte
544+
den Template-**Quelltext** auf die Abwesenheit von `src=`. Nach der Umstellung
545+
wäre der Test grün geblieben, selbst wenn `attrs()` ein `src` gesetzt hätte —
546+
er hätte also aufgehört zu testen, ohne rot zu werden. Beide Frame-Tests
547+
rendern jetzt und prüfen das DOM (vgl. B18).
548+
501549
---
502550

503551
## 2. Phasenübersicht

assets/css/qna.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,17 @@
8686
border-block-end: 0.0625rem solid currentColor;
8787
}
8888

89+
/* Deliberately subtle: the modifiers are hooks for project themes, the bundle
90+
only makes them visible enough to be recognisable without any theming. */
91+
.qna-question--answered {
92+
opacity: 0.75;
93+
}
94+
95+
.qna-question--own {
96+
border-inline-start: 0.1875rem solid currentColor;
97+
padding-inline-start: 0.5rem;
98+
}
99+
89100
.qna-question__answered {
90101
display: inline-block;
91102
padding: 0.25rem 0.5rem;

contao/templates/content_element/qna_session_list.html.twig

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,19 @@
33
{% block wrapper_tag %}section{% endblock %}
44

55
{% block content %}
6-
<div class="qna-session-list">
6+
<div{{ attrs().addClass('qna-session-list').mergeWith(list_attributes|default) }}>
77
{% if sessions is empty %}
88
<p class="qna-empty">{{ 'qna.session_list.empty'|trans({}, 'contao_default') }}</p>
99
{% else %}
1010
<ul class="qna-session-list__items">
1111
{% for session in sessions %}
1212
<li class="qna-session-list__item">
13-
<a
14-
class="qna-session-list__link"
15-
href="{{ session.url }}"
16-
aria-label="{{ 'qna.session_list.open'|trans([session.title], 'contao_default') }}"
17-
>{{ session.title }}</a>
13+
<a{{ attrs()
14+
.addClass('qna-session-list__link')
15+
.set('href', session.url)
16+
.set('aria-label', 'qna.session_list.open'|trans([session.title], 'contao_default'))
17+
.mergeWith(link_attributes|default)
18+
}}>{{ session.title }}</a>
1819
</li>
1920
{% endfor %}
2021
</ul>

contao/templates/content_element/qna_session_reader.html.twig

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,28 @@
88
{% else %}
99
<div class="qna-reader">
1010
<h2 class="qna-reader__title">{{ view.title }}</h2>
11-
<turbo-frame
12-
id="{{ view.frameId }}"
13-
class="qna-reader__frame"
14-
src="{{ controls_frame_src }}"
15-
loading="lazy"
16-
data-qna-frame="reader-controls"
17-
>
11+
<turbo-frame{{ attrs()
12+
.set('id', view.frameId)
13+
.addClass('qna-reader__frame')
14+
.set('src', controls_frame_src)
15+
.set('loading', 'lazy')
16+
.set('data-qna-frame', 'reader-controls')
17+
.mergeWith(controls_frame_attributes|default)
18+
}}>
1819
<p class="qna-reader__loading">{{ 'qna.reader.loading'|trans({}, 'contao_default') }}</p>
1920
</turbo-frame>
20-
<turbo-frame
21-
id="{{ view.questionsFrameId }}"
22-
class="qna-reader__questions-frame"
23-
src="{{ questions_frame_src }}"
24-
loading="lazy"
25-
refresh="morph"
26-
data-qna-frame="reader-questions"
27-
data-qna-poll
28-
data-qna-poll-interval="{{ polling_interval }}"
29-
data-qna-poll-max-interval="{{ polling_max_interval }}"
30-
>
21+
<turbo-frame{{ attrs()
22+
.set('id', view.questionsFrameId)
23+
.addClass('qna-reader__questions-frame')
24+
.set('src', questions_frame_src)
25+
.set('loading', 'lazy')
26+
.set('refresh', 'morph')
27+
.set('data-qna-frame', 'reader-questions')
28+
.set('data-qna-poll')
29+
.set('data-qna-poll-interval', polling_interval)
30+
.set('data-qna-poll-max-interval', polling_max_interval)
31+
.mergeWith(questions_frame_attributes|default)
32+
}}>
3133
<p class="qna-reader__loading">{{ 'qna.reader.loading'|trans({}, 'contao_default') }}</p>
3234
</turbo-frame>
3335
</div>

contao/templates/qna/question.html.twig

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
1-
<li id="qna-question-{{ question.id }}" class="qna-question-list__item">
2-
<article class="qna-question" aria-labelledby="qna-question-{{ question.id }}-text">
1+
{% set item_attributes = attrs()
2+
.set('id', "qna-question-#{question.id}")
3+
.addClass('qna-question-list__item')
4+
.mergeWith(item_attributes|default)
5+
%}
6+
{% set question_attributes = attrs()
7+
.addClass('qna-question')
8+
.addClass('qna-question--answered', question.answered)
9+
.addClass('qna-question--own', question.isOwn)
10+
.set('aria-labelledby', "qna-question-#{question.id}-text")
11+
.mergeWith(question_attributes|default)
12+
%}
13+
14+
<li{{ item_attributes }}>
15+
<article{{ question_attributes }}>
316
<p id="qna-question-{{ question.id }}-text" class="qna-question__text">{{ question.question }}</p>
417
<p class="qna-question__votes">
518
{{ 'qna.question.vote_count'|trans([question.voteCount], 'contao_default') }}
@@ -10,24 +23,30 @@
1023
{% endif %}
1124

1225
{% if answer_url|default(null) %}
13-
<form class="qna-answer-form" action="{{ answer_url }}" method="post">
26+
<form{{ attrs().addClass('qna-answer-form').set('action', answer_url).set('method', 'post') }}>
1427
<input type="hidden" name="REQUEST_TOKEN" value="{{ request_token }}">
15-
<button id="qna-question-{{ question.id }}-answer" class="qna-button" type="submit" aria-describedby="qna-question-{{ question.id }}-text">
28+
<button{{ attrs()
29+
.set('id', "qna-question-#{question.id}-answer")
30+
.addClass('qna-button')
31+
.set('type', 'submit')
32+
.set('aria-describedby', "qna-question-#{question.id}-text")
33+
}}>
1634
{{ (question.answered ? 'qna.question.mark_unanswered' : 'qna.question.mark_answered')|trans({}, 'contao_default') }}
1735
</button>
1836
</form>
1937
{% endif %}
2038

2139
{% if show_vote_button and not question.answered %}
22-
<form class="qna-vote-form" action="{{ vote_url }}" method="post">
40+
<form{{ attrs().addClass('qna-vote-form').set('action', vote_url).set('method', 'post') }}>
2341
<input type="hidden" name="REQUEST_TOKEN" value="{{ request_token }}">
24-
<button
25-
id="qna-question-{{ question.id }}-vote"
26-
class="qna-button qna-vote-button{{ question.hasVoted ? ' qna-vote-button--selected' : '' }}"
27-
type="submit"
28-
aria-label="{{ (question.hasVoted ? 'qna.vote.selected_label' : 'qna.vote.label')|trans([question.question], 'contao_default') }}"
29-
aria-pressed="{{ question.hasVoted ? 'true' : 'false' }}"
30-
>
42+
<button{{ attrs()
43+
.set('id', "qna-question-#{question.id}-vote")
44+
.addClass(['qna-button', 'qna-vote-button'])
45+
.addClass('qna-vote-button--selected', question.hasVoted)
46+
.set('type', 'submit')
47+
.set('aria-label', (question.hasVoted ? 'qna.vote.selected_label' : 'qna.vote.label')|trans([question.question], 'contao_default'))
48+
.set('aria-pressed', question.hasVoted ? 'true' : 'false')
49+
}}>
3150
{{ (question.hasVoted ? 'qna.vote.selected' : 'qna.vote.submit')|trans({}, 'contao_default') }}
3251
</button>
3352
</form>

contao/templates/qna/question_list.html.twig

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1-
<div id="{{ frame_id }}" class="qna-questions">
1+
{% set list_attributes = attrs()
2+
.set('id', frame_id)
3+
.addClass('qna-questions')
4+
.mergeWith(list_attributes|default)
5+
%}
6+
7+
<div{{ list_attributes }}>
28
{% if questions is empty %}
39
<p class="qna-empty">{{ (empty_translation_key|default('qna.questions.empty'))|trans({}, 'contao_default') }}</p>
410
{% else %}
5-
<ol class="qna-question-list">
11+
<ol{{ attrs().addClass('qna-question-list').mergeWith(items_attributes|default) }}>
612
{% for question in questions %}
713
{% include '@Contao/qna/question.html.twig' with {
814
question: question,

contao/templates/qna/reader_controls.html.twig

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,45 @@
1-
<div
2-
id="{{ view.controlsContentId }}"
3-
class="qna-reader__controls qna-reader__controls--{{ view.state }}"
4-
data-qna-state="{{ view.state }}"
5-
>
1+
{% set controls_attributes = attrs()
2+
.set('id', view.controlsContentId)
3+
.addClass(['qna-reader__controls', "qna-reader__controls--#{view.state}"])
4+
.set('data-qna-state', view.state)
5+
.mergeWith(controls_attributes|default)
6+
%}
7+
8+
<div{{ controls_attributes }}>
69
{% if error_translation_key %}
710
<p class="qna-message qna-message--error" role="alert">
811
{{ error_translation_key|trans({}, 'contao_default') }}
912
</p>
1013
{% endif %}
1114

12-
<p class="qna-reader__status qna-reader__status--{{ view.state }}" role="status" aria-live="polite">
15+
<p{{ attrs()
16+
.addClass(['qna-reader__status', "qna-reader__status--#{view.state}"])
17+
.set('role', 'status')
18+
.set('aria-live', 'polite')
19+
.mergeWith(status_attributes|default)
20+
}}>
1321
{{ view.statusTranslationKey|trans({}, 'contao_default') }}
1422
</p>
1523

1624
{% if view.showQuestionForm %}
17-
<form
18-
class="qna-question-form"
19-
action="{{ question_form_action }}"
20-
method="post"
21-
data-turbo-frame="{{ view.questionsFrameId }}"
22-
>
25+
<form{{ attrs()
26+
.addClass('qna-question-form')
27+
.set('action', question_form_action)
28+
.set('method', 'post')
29+
.set('data-turbo-frame', view.questionsFrameId)
30+
.mergeWith(form_attributes|default)
31+
}}>
2332
<label class="qna-question-form__label" for="qna-session-{{ view.sessionId }}-question">
2433
{{ 'qna.question.label'|trans({}, 'contao_default') }}
2534
</label>
26-
<textarea
27-
class="qna-question-form__input"
28-
id="qna-session-{{ view.sessionId }}-question"
29-
name="question"
30-
maxlength="{{ max_question_length }}"
31-
required
32-
>{{ question_value }}</textarea>
35+
<textarea{{ attrs()
36+
.addClass('qna-question-form__input')
37+
.set('id', "qna-session-#{view.sessionId}-question")
38+
.set('name', 'question')
39+
.set('maxlength', max_question_length)
40+
.set('required')
41+
.mergeWith(input_attributes|default)
42+
}}>{{ question_value }}</textarea>
3343
<input type="hidden" name="REQUEST_TOKEN" value="{{ request_token }}">
3444
<button class="qna-button qna-question-form__submit" type="submit">
3545
{{ 'qna.question.submit'|trans({}, 'contao_default') }}
Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
<turbo-frame
2-
id="{{ view.frameId }}"
3-
class="qna-reader__frame"
4-
data-qna-frame="reader-controls"
5-
data-qna-state="{{ view.state }}"
6-
>
1+
{% set frame_attributes = attrs()
2+
.set('id', view.frameId)
3+
.addClass('qna-reader__frame')
4+
.set('data-qna-frame', 'reader-controls')
5+
.set('data-qna-state', view.state)
6+
.mergeWith(frame_attributes|default)
7+
%}
8+
9+
<turbo-frame{{ frame_attributes }}>
710
{% include '@Contao/qna/reader_controls.html.twig' %}
811
</turbo-frame>

contao/templates/qna/reader_questions.html.twig

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
<div class="qna-reader__questions" data-qna-poll-interval="{{ polling_interval }}" data-qna-state="{{ view.state }}">
1+
{% set questions_attributes = attrs()
2+
.addClass('qna-reader__questions')
3+
.set('data-qna-poll-interval', polling_interval)
4+
.set('data-qna-state', view.state)
5+
.mergeWith(questions_attributes|default)
6+
%}
7+
8+
<div{{ questions_attributes }}>
29
{% if error_translation_key %}
310
<p class="qna-message qna-message--error" role="alert">
411
{{ error_translation_key|trans({}, 'contao_default') }}
Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
<turbo-frame
2-
id="{{ view.questionsFrameId }}"
3-
class="qna-reader__questions-frame"
4-
data-qna-frame="reader-questions"
5-
data-qna-state="{{ view.state }}"
6-
>
1+
{% set frame_attributes = attrs()
2+
.set('id', view.questionsFrameId)
3+
.addClass('qna-reader__questions-frame')
4+
.set('data-qna-frame', 'reader-questions')
5+
.set('data-qna-state', view.state)
6+
.mergeWith(frame_attributes|default)
7+
%}
8+
9+
<turbo-frame{{ frame_attributes }}>
710
{% include '@Contao/qna/reader_questions.html.twig' %}
811
{% include '@Contao/qna/reader_controls_update.html.twig' with {reset_question_form: false} %}
912
</turbo-frame>

0 commit comments

Comments
 (0)