Skip to content

Commit f8f99a0

Browse files
Champ de formulaire pour les contrôles segmentés (#233)
* refactor(widgets): rename _RichChoiceWidget to something more generic Its content was actually not specific to RichRadioButtonChoices. This makes it possible to reuse this logic for another widget that uses ExtendedChoices * feat(widgets): new SegmentedControl widget for ChoiceFields * feat(example_app): add some examples in form for SegmentedControl widget * feat(example_app): update not-yet-implemented components doc * fixup! refactor(widgets): rename _RichChoiceWidget to something more generic
1 parent 5956d50 commit f8f99a0

File tree

10 files changed

+195
-44
lines changed

10 files changed

+195
-44
lines changed

doc/forms.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ from dsfr.utils import lazy_static
333333
from dsfr.enums import RichRadioButtonChoices
334334
from dsfr.widgets import RichRadioSelect
335335

336+
336337
class ExampleRichChoices(RichRadioButtonChoices, IntegerChoices):
337338
ITEM_1 = {
338339
"value": auto(),
@@ -353,13 +354,14 @@ class ExampleRichChoices(RichRadioButtonChoices, IntegerChoices):
353354
"pictogram": lazy_static("img/placeholder.1x1.png"),
354355
}
355356

357+
356358
class ExampleForm(DsfrBaseForm):
357359
sample_rich_radio = forms.ChoiceField(
358360
label="Cases à cocher",
359361
required=False,
360362
choices=ExampleRichChoices.choices,
361363
help_text="Exemple de boutons radios riches",
362-
widget=RichRadioSelect(rich_choices=ExampleRichChoices),
364+
widget=RichRadioSelect(extended_choices=ExampleRichChoices),
363365
)
364366
```
365367

dsfr/enums.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,3 +422,37 @@ def html_label(self):
422422
if hasattr(self, self.private_variable_name("html_label"))
423423
else self.label
424424
)
425+
426+
427+
class SegmentedControlChoices(ExtendedChoices):
428+
"""
429+
Version spécialisée de `ExtendedChoices` à utiliser avec
430+
`dsfr.widgets.SegmentedControl`. Cette version déclare en plus la propriété
431+
`icon` :
432+
433+
```python
434+
>>> from enum import auto
435+
>>> from django.db.models import IntegerChoices
436+
>>> class ExampleSegmentedControlChoices(IntegerChoices, SegmentedControlChoices):
437+
... ITEM_1 = {
438+
... "value": auto(),
439+
... "label": "Item 1",
440+
... "icon": "mail-line",
441+
... }
442+
... ITEM_2 = {
443+
... "value": auto(),
444+
... "label": "Item 2",
445+
... "icon": "calendar-line",
446+
... }
447+
... ITEM_3 = {
448+
... "value": auto(),
449+
... "label": "Item 3",
450+
... "icon": "archive-line",
451+
... }
452+
```
453+
454+
"""
455+
456+
@enum_property
457+
def icon(self):
458+
return getattr(self, self.private_variable_name("icon"), "")

dsfr/templates/dsfr/form_field_snippets/field_snippet.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
{% include "dsfr/form_field_snippets/richradioselect_snippet.html" %}
1212
{% elif field|widget_type == "numbercursor" %}
1313
{% include "dsfr/form_field_snippets/numbercursor_snippet.html" %}
14+
{% elif field|widget_type == "segmentedcontrol" %}
15+
{% include "dsfr/form_field_snippets/segmented_control_snippet.html" %}
1416
{% else %}
1517
{% include "dsfr/form_field_snippets/input_snippet.html" %}
1618
{% endif %}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{% load dsfr_tags %}
2+
3+
<div class="fr-input-group">
4+
<fieldset class="fr-segmented {{ field.field.widget.extra_classes }}">
5+
<legend class="fr-segmented__legend {% if field.field.widget.is_inline %}fr-segmented__legend--inline{% endif %}">
6+
{{ field.label }}
7+
</legend>
8+
{{ field }}
9+
</fieldset>
10+
</div>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{% load dsfr_tags %}
2+
3+
<div class="fr-segmented__elements">
4+
{% for group, options, index in widget.optgroups %}
5+
{% for option in options %}
6+
{% include option.template_name with widget=option %}
7+
{% endfor %}
8+
{% endfor %}
9+
</div>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<div class="fr-segmented__element">
2+
<input value="{{ widget.value }}"
3+
type="radio"
4+
id="segmented-{{ widget.attrs.id }}"
5+
name="{{ widget.name }}"
6+
{% include "django/forms/widgets/attrs.html" %}
7+
>
8+
<label class="{% if widget.icon %}fr-icon-{{ widget.icon }}{% endif %} fr-label" for="segmented-{{ widget.attrs.id }}">
9+
{{ widget.label }}
10+
</label>
11+
</div>

dsfr/test/test_form_field_snippets.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
from django import forms
2+
from django.db.models import IntegerChoices
23
from django.template import Context, Template
34
from django.test import SimpleTestCase
45

6+
from dsfr.enums import SegmentedControlChoices
57
from dsfr.forms import DsfrBaseForm
6-
from dsfr.widgets import InlineRadioSelect, InlineCheckboxSelectMultiple
8+
from dsfr.widgets import (
9+
InlineRadioSelect,
10+
InlineCheckboxSelectMultiple,
11+
SegmentedControl,
12+
)
713

814

915
class RadioSelectTestCase(SimpleTestCase):
@@ -68,3 +74,35 @@ def test_inline(self):
6874
self.assertTrue(
6975
'class="fr-fieldset__element fr-fieldset__element--inline"' in rendered
7076
)
77+
78+
79+
class SegmentedControlTestCase(SimpleTestCase):
80+
class DummyForm(DsfrBaseForm):
81+
class ExampleSegmentedControlChoices(IntegerChoices, SegmentedControlChoices):
82+
ITEM_1 = {
83+
"value": 1,
84+
"label": "Item 1",
85+
"icon": "table-line",
86+
}
87+
ITEM_2 = {
88+
"value": 2,
89+
"label": "Item 2",
90+
"icon": "list-unordered",
91+
}
92+
ITEM_3 = {
93+
"value": 3,
94+
"label": "Item 3",
95+
"icon": "layout-grid-line",
96+
}
97+
98+
checkbox_field = forms.ChoiceField(
99+
choices=ExampleSegmentedControlChoices.choices,
100+
widget=SegmentedControl(extended_choices=ExampleSegmentedControlChoices),
101+
)
102+
103+
def test_rendered(self):
104+
rendered = Template("{{form}}").render(
105+
Context({"form": SegmentedControlTestCase.DummyForm()})
106+
)
107+
self.assertTrue('class="fr-segmented__elements"' in rendered)
108+
self.assertTrue('<label class="fr-icon-table-line fr-label"' in rendered)

dsfr/widgets.py

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import warnings
12
from typing import Type
23

34
from django.forms.widgets import (
@@ -9,43 +10,56 @@
910
from django.http import QueryDict
1011
from django.utils.datastructures import MultiValueDict
1112

12-
from dsfr.enums import RichRadioButtonChoices
13+
from dsfr.enums import ExtendedChoices
1314

1415

1516
__all__ = [
1617
"RichRadioSelect",
1718
"InlineRadioSelect",
1819
"InlineCheckboxSelectMultiple",
1920
"NumberCursor",
21+
"SegmentedControl",
2022
]
2123

2224

23-
class _RichChoiceWidget(ChoiceWidget):
25+
class _ExtendedChoicesWidget(ChoiceWidget):
2426
inline = False
2527

2628
def __init__(
27-
self, rich_choices: Type[RichRadioButtonChoices], inline=None, attrs=None
29+
self,
30+
extended_choices: Type[ExtendedChoices],
31+
rich_choices: Type[ExtendedChoices] = None, # /!\ do not use, deprecated
32+
inline=None,
33+
attrs=None,
2834
):
2935
super().__init__(attrs)
30-
self.rich_choices = rich_choices
36+
if rich_choices:
37+
# TODO before v3.0, delete rich_choices argument and this block
38+
self.extended_choices = rich_choices
39+
warnings.warn(
40+
"Argument rich_choices is deprecated, it has been renamed extended_choices and will be removed by the next major release.",
41+
DeprecationWarning,
42+
stacklevel=3,
43+
)
44+
self.extended_choices = extended_choices
3145
if inline is not None:
3246
self.inline = inline
3347

3448
@property
3549
def choices(self):
36-
return self.rich_choices.choices
50+
return self.extended_choices.choices
3751

3852
@choices.setter
3953
def choices(self, value):
4054
"""
41-
Superseded by self.rich_choices;
55+
Superseded by self.extended_choices;
4256
kept for compatibility with ChoiceWidget.__init__
4357
"""
4458
...
4559

4660
def __deepcopy__(self, memo):
4761
obj = super().__deepcopy__(memo)
48-
obj.rich_choices = self.rich_choices
62+
obj.extended_choices = self.extended_choices
4963
return obj
5064

5165
def create_option(
@@ -60,15 +74,15 @@ def create_option(
6074

6175
opt.update(
6276
{
63-
k: getattr(self.rich_choices(value), k)
64-
for k in self.rich_choices.additional_attributes
77+
k: getattr(self.extended_choices(value), k)
78+
for k in self.extended_choices.additional_attributes
6579
}
6680
)
6781

6882
return opt
6983

7084

71-
class RichRadioSelect(_RichChoiceWidget, RadioSelect):
85+
class RichRadioSelect(_ExtendedChoicesWidget, RadioSelect):
7286
"""
7387
Widget permettant de produire des boutons radio riches. Ce widget fonctionne avec
7488
`dsfr.enums.RichRadioButtonChoices`.
@@ -82,6 +96,7 @@ class RichRadioSelect(_RichChoiceWidget, RadioSelect):
8296
>>> from enum import auto
8397
>>> from django.db.models import IntegerChoices
8498
>>> from django import forms
99+
>>> from dsfr.enums import RichRadioButtonChoices
85100
>>> from dsfr.forms import DsfrBaseForm
86101
>>> from dsfr.utils import lazy_static
87102
@@ -111,7 +126,7 @@ class RichRadioSelect(_RichChoiceWidget, RadioSelect):
111126
... required=False,
112127
... choices=ExampleRichChoices.choices,
113128
... help_text="Exemple de boutons radios riches",
114-
... widget=RichRadioSelect(rich_choices=ExampleRichChoices),
129+
... widget=RichRadioSelect(extended_choices=ExampleRichChoices),
115130
... )
116131
```
117132
@@ -192,3 +207,15 @@ def value_from_datadict(self, data: QueryDict, files: MultiValueDict, name: str)
192207

193208
def format_value(self, value):
194209
return value
210+
211+
212+
class SegmentedControl(_ExtendedChoicesWidget, ChoiceWidget):
213+
template_name = "dsfr/widgets/segmented_control.html"
214+
option_template_name = "dsfr/widgets/segmented_control_option.html"
215+
216+
def __init__(
217+
self, *args, extra_classes: str = "", is_inline: bool = False, **kwargs
218+
):
219+
super().__init__(*args, **kwargs)
220+
self.extra_classes = extra_classes
221+
self.is_inline = is_inline

example_app/dsfr_components.py

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,34 +1119,7 @@
11191119
sorted(unsorted_IMPLEMENTED_COMPONENTS.items(), key=lambda k: k[1]["title"])
11201120
)
11211121

1122-
NOT_YET_IMPLEMENTED_COMPONENTS = {
1123-
"radio_rich": {
1124-
"title": "Bouton radio riche",
1125-
"doc_url": "https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/bouton-radio-riche",
1126-
"example_url": "https://main--ds-gouv.netlify.app/example/component/radio/",
1127-
"note": """À implémenter au sein des formulaires et non comme une balise.
1128-
cf. [#126](https://github.com/numerique-gouv/django-dsfr/issues/126)
1129-
""",
1130-
},
1131-
"segmented_control": {
1132-
"title": "Contrôle segmenté",
1133-
"doc_url": "https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/controle-segmente",
1134-
"example_url": "https://main--ds-gouv.netlify.app/example/component/segmented/",
1135-
"storybook_url": "https://storybook.systeme-de-design.gouv.fr/?path=/docs/segmented--docs",
1136-
"note": """À implémenter au sein des formulaires et non comme une balise.
1137-
cf. [#128](https://github.com/numerique-gouv/django-dsfr/issues/128)
1138-
""",
1139-
},
1140-
"range": {
1141-
"title": "Curseur",
1142-
"doc_url": "https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/curseur-range",
1143-
"example_url": "https://main--ds-gouv.netlify.app/example/component/range/",
1144-
"storybook_url": "https://storybook.systeme-de-design.gouv.fr/?path=/docs/range--docs",
1145-
"note": """À implémenter au sein des formulaires et non comme une balise.
1146-
cf. [#129](https://github.com/numerique-gouv/django-dsfr/issues/129)
1147-
""",
1148-
},
1149-
}
1122+
NOT_YET_IMPLEMENTED_COMPONENTS = {}
11501123

11511124
# There is no need for specific tags for these
11521125
# (either because the DSFR is implemented globally or because they are

0 commit comments

Comments
 (0)