Skip to content

Commit

Permalink
improve multiselect widget
Browse files Browse the repository at this point in the history
  • Loading branch information
madjid-asa committed Aug 20, 2024
1 parent 99b61d5 commit 38685bb
Show file tree
Hide file tree
Showing 3 changed files with 271 additions and 63 deletions.
96 changes: 56 additions & 40 deletions lemarche/static/itou_marche/itou_marche.scss
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,17 @@ $yellow-super-badge--borders-color: #fcc63a;
display: flex;
align-items: center;
position: relative;
flex-direction: column;
}
.fr-input-wrap input {
flex-grow: 1;
.fr-input {
width: 100%;
}
.fr-input-wrap button {
.fr-btn {
margin-left: 8px;
}
.fr-checkbox-list {
position: absolute;
position: relative;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
background: white;
border: 1px solid #ddd;
Expand All @@ -39,6 +38,23 @@ $yellow-super-badge--borders-color: #fcc63a;
.fr-checkbox-list.show {
display: block;
}
.fr-tags-group {
display: flex;
flex-wrap: wrap;
margin-top: 8px;
}
.fr-tag {
margin: 4px;
background-color: #e5e5e5;
padding: 5px 10px;
border-radius: 3px;
display: flex;
align-items: center;
}
.fr-tag .fr-tag__close {
margin-left: 8px;
cursor: pointer;
}

.list-unstyled {
padding-left: 0;
Expand All @@ -62,46 +78,46 @@ ul.summary-grid-list {

@media (min-width: 992px) {
ul.summary-grid-list {
grid-template-columns: repeat(2,1fr);
grid-template-columns: repeat(2, 1fr);
grid-column-gap: 2rem;
grid-row-gap: 1rem;
}
}

.s-inclusion-03 {
&__stats {
display: flex;
justify-content: center;
align-items: center;
&__stats {
display: flex;
justify-content: center;
align-items: center;

div {
z-index: 2;
position: relative;
display: flex;
justify-content: center;
align-items: center;
padding: 0.5rem;
margin: 1rem;
background-color: #fff;
min-height: 60px;
min-width: 240px;
border-bottom: 4px solid #FE5455;
text-align: center;
div {
z-index: 2;
position: relative;
display: flex;
justify-content: center;
align-items: center;
padding: 0.5rem;
margin: 1rem;
background-color: #fff;
min-height: 60px;
min-width: 240px;
border-bottom: 4px solid #fe5455;
text-align: center;

&.has-relation-between {
z-index: 1;
&.has-relation-between {
z-index: 1;

&::before {
content: '';
position: absolute;
bottom: -58px;
left: 50%;
transform: translateX(-50%);
background-image: url('/static/images/inclusion-06-illu-01.svg');
width: 164px;
height: 58px;
}
}
}
}
}
&::before {
content: "";
position: absolute;
bottom: -58px;
left: 50%;
transform: translateX(-50%);
background-image: url("/static/images/inclusion-06-illu-01.svg");
width: 164px;
height: 58px;
}
}
}
}
}
203 changes: 181 additions & 22 deletions lemarche/templates/includes/_widget_custom_multiselect.html
Original file line number Diff line number Diff line change
@@ -1,35 +1,194 @@
{% load static %}
<div x-data="multiselect()" class="fr-checkbox-group" {{ widget.attrs }}>
<div class="fr-input-wrap">
<div x-data="multiselect('{{ widget.attrs.id }}')" x-init="initOptions('{{ widget.groups_json|escapejs }}', '{{ widget.options_json|escapejs }}')" class="fr-checkbox-group" {{ widget.attrs }}>
<template x-for="value in selectedValues" :key="value">
<input type="hidden" :name="name" :value="value">
</template>
<div class="fr-input-group"
style="display: flex;
align-items: center;
margin-bottom: 0">
<input type="text"
id="{{ widget.attrs.id }}"
class="fr-input"
x-model="selectedLabels"
readonly
placeholder="Sélectionner des options">
<button type="button"
class="fr-btn fr-btn--icon-right fr-m-0"
@click="toggle"
:class="open ? ' fr-icon-arrow-up-s-line' : 'fr-icon-arrow-down-s-line'"></button>
x-model="searchQuery"
placeholder="Sélectionner des options"
@focus="openDropdown()"
@input="filterOptions()"
style="flex-grow: 1">
{% comment %} <button type="button" class="fr-btn fr-btn--icon-right fr-m-0" @click="openDropdown()" x-ref="button" :class="open ? 'fr-icon-arrow-up-s-line' : 'fr-icon-arrow-down-s-line'" style="margin-left: 8px"></button> {% endcomment %}
</div>
<div class="fr-tags-group">
<template x-for="(label, index) in selected" :key="index">
<div class="fr-tag">
<span x-text="label"></span>
<button type="button" class="fr-tag__close" @click="removeSelection(index)">&times;</button>
</div>
</template>
</div>
<div x-show="open"
x-ref="dropdown"
class="fr-checkbox-list"
:class="{ show: open }">
{% for group, options, index in widget.optgroups %}
{% if group %}<div class="fr-checkbox-group-title">{{ group }}</div>{% endif %}
{% for option in options %}
<template x-if="groups.length > 0">
<template x-for="group in filteredGroups" :key="group.name">
<div>
<template x-if="group.name">
<div class="fr-checkbox-group-title">
<b x-text="group.name"></b>
</div>
</template>
<template x-for="option in group.options" :key="option.value">
<div class="fr-checkbox fr-py-1w">
<input type="checkbox"
:id="option.id"
:value="option.value"
:checked="selectedValues.includes(option.value)"
@change="updateSelection(option.label, option.value)">
<label :for="option.id" x-text="option.label"></label>
</div>
</template>
</div>
</template>
</template>
<!-- Affichage des options sans groupe -->
<template x-if="groups.length === 0">
<template x-for="option in filteredOptions" :key="option.value">
<div class="fr-checkbox fr-py-1w">
<input type="checkbox"
id="{{ option.attrs.id }}"
name="{{ widget.name }}"
value="{{ option.value }}"
x-value="{{ option.label }}"
{% if option.selected %}checked{% endif %}
{% include "django/forms/widgets/attrs.html" %}
@click="updateSelection($event)">
<label for="{{ option.attrs.id }}">{{ option.label }}</label>
:id="option.id"
:value="option.value"
:checked="selectedValues.includes(option.value)"
@change="updateSelection(option.label, option.value)">
<label :for="option.id" x-text="option.label"></label>
</div>
{% endfor %}
{% endfor %}
</template>
</template>
</div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('multiselect', (id) => ({
open: false,
selected: [],
selectedValues: [],
searchQuery: '',
groups: [],
options: [],
filteredGroups: [],
filteredOptions: [],
name: id,

initOptions(groupsJson, optionsJson) {
if (groupsJson) {
this.groups = JSON.parse(groupsJson);
this.filteredGroups = this.groups;
}
if (optionsJson) {
this.options = JSON.parse(optionsJson);
this.filteredOptions = this.options;
}

// Initialiser les tags à partir des valeurs de l'URL
this.initSelectedValuesFromURL();

// Ajouter un écouteur global pour fermer le dropdown si on clique à l'extérieur
document.addEventListener('click', this.handleClickOutside.bind(this));
},

initSelectedValuesFromURL() {
const urlParams = new URLSearchParams(window.location.search);
const values = urlParams.getAll(this.name);

values.forEach(value => {
const option = this.findOptionByValue(value);
if (option) {
this.selectedValues.push(value);
this.selected.push(option.label);
}
});

this.updateInput();
},

handleClickOutside(event) {
if (this.open && !this.$refs.dropdown.contains(event.target) && !this.$refs.button.contains(event.target)) {
this.open = false;
}
},

findOptionByValue(value) {
let foundOption = null;

if (this.groups.length > 0) {
this.groups.forEach(group => {
group.options.forEach(option => {
if (option.value === value) {
foundOption = option;
}
});
});
}

if (!foundOption) {
this.options.forEach(option => {
if (option.value === value) {
foundOption = option;
}
});
}

return foundOption;
},

openDropdown() {
this.open = !this.open;
if (this.open) {
this.$refs.dropdown.style.display = 'block';
} else {
this.$refs.dropdown.style.display = 'none';
}
},

filterOptions() {
const searchQueryLower = this.searchQuery.toLowerCase();
if (this.groups.length > 0) {
this.filteredGroups = this.groups.map(group => ({
...group,
options: group.options.filter(option =>
option.label.toLowerCase().includes(searchQueryLower)
)
})).filter(group => group.options.length > 0);
} else {
this.filteredOptions = this.options.filter(option =>
option.label.toLowerCase().includes(searchQueryLower)
);
}
},

updateSelection(label, value) {
const valueIndex = this.selectedValues.indexOf(value);
if (valueIndex === -1) {
this.selectedValues.push(value);
this.selected.push(label);
} else {
this.selectedValues.splice(valueIndex, 1);
this.selected = this.selected.filter(item => item !== label);
}
this.updateInput();
},

removeSelection(index) {
const value = this.selectedValues[index];
this.selectedValues.splice(index, 1);
this.selected.splice(index, 1);
this.updateInput();
},

updateInput() {
this.searchQuery = '';
this.filterOptions();
}
}));
});

</script>
35 changes: 34 additions & 1 deletion lemarche/utils/widgets.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import json

from django import forms


Expand All @@ -9,7 +11,38 @@ def get_context(self, name, value, attrs):
attrs = {}
if "id" not in attrs:
attrs["id"] = f"id_{name}"

context = super().get_context(name, value, attrs)
context["widget"]["options"] = self.choices

# Sérialiser les groupes et les options
groups = []
options = []

for group_name, group_options, group_index in context["widget"]["optgroups"]:
if group_name:
group = {"name": str(group_name), "options": []}
for option in group_options:
_value = option["value"] if not hasattr(option["value"], "value") else option["value"].value
group["options"].append(
{
"id": f'{attrs["id"]}_{_value}',
"label": str(option["label"]),
"value": _value,
}
)
groups.append(group)
else:
for option in group_options:
_value = option["value"] if not hasattr(option["value"], "value") else option["value"].value
options.append(
{
"id": f'{attrs["id"]}_{_value}',
"label": str(option["label"]),
"value": _value,
}
)

context["widget"]["groups_json"] = json.dumps(groups)
context["widget"]["options_json"] = json.dumps(options)
context["widget"]["value"] = value
return context

0 comments on commit 38685bb

Please sign in to comment.