@agence-zef/astro 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/components/Form.astro +56 -8
- package/components/Multiselect.astro +149 -0
- package/components/Popover.astro +74 -5
- package/dist/ui.d.ts +14 -0
- package/dist/ui.js +123 -2
- package/package.json +4 -3
- package/styles.css +162 -0
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# @agence-zef/astro
|
|
2
2
|
|
|
3
|
-
Intégration Astro du CMS de l'agence ZEF : route des pages, aperçu de l'éditeur, composants `Button`, `Image`, `Video`, `RichText`, `Form`, `Popups`, `CookieBanner`, et comptes du site (option `accounts`, `AccountForm`, `getAccount`).
|
|
3
|
+
Intégration Astro du CMS de l'agence ZEF : route des pages, aperçu de l'éditeur, composants `Button`, `Image`, `Video`, `RichText`, `Form`, `Popups`, `CookieBanner`, composants d'interface (`Dialog`, `Accordion`, `Popover`, `Multiselect`, champs), et comptes du site (option `accounts`, `AccountForm`, `getAccount`).
|
|
4
4
|
|
|
5
5
|
Paquet du CMS de l'agence ZEF (dépôt privé). Licence MIT.
|
package/components/Form.astro
CHANGED
|
@@ -5,19 +5,40 @@
|
|
|
5
5
|
* champ piège, envoi en JavaScript au CMS avec message de confirmation ou redirection ;
|
|
6
6
|
* sans JavaScript, envoi HTML classique. Classes `zef-form__*` et attributs `data-*`
|
|
7
7
|
* pour le CSS du site.
|
|
8
|
+
*
|
|
9
|
+
* Mise en page sur mesure, sans réécrire l'envoi :
|
|
10
|
+
* - slot `field-<nom technique>` : remplace le contrôle d'un champ (libellé, erreur et logique
|
|
11
|
+
* conditionnelle gardés), par exemple `<Multiselect slot="field-autres_decors" … />` ou un
|
|
12
|
+
* `<input type="hidden">` rempli par le site ; plusieurs valeurs partent une par ligne ;
|
|
13
|
+
* - slots `start` (avant les champs) et `end` (après le bouton) ;
|
|
14
|
+
* - `submitLabel` : remplace le libellé du bouton saisi dans le CMS.
|
|
15
|
+
*
|
|
16
|
+
* Après l'envoi, le formulaire est masqué et le message de confirmation s'affiche à la suite
|
|
17
|
+
* (`.zef-form__done`) ; l'évènement `zef-form:success` part du formulaire, et `resetForm`
|
|
18
|
+
* (`@agence-zef/astro/ui`) le remet à zéro pour une nouvelle saisie.
|
|
8
19
|
*/
|
|
9
20
|
import type { PublicForm, Reference } from "@agence-zef/schema";
|
|
10
21
|
|
|
11
22
|
interface Props {
|
|
12
23
|
/** Référence renvoyée par l'API (`{ type: "form", form }`) ou formulaire seul. */
|
|
13
24
|
form: Reference | PublicForm | null | undefined;
|
|
25
|
+
/** Libellé du bouton d'envoi (sinon celui du CMS). */
|
|
26
|
+
submitLabel?: string;
|
|
14
27
|
class?: string;
|
|
15
28
|
}
|
|
16
29
|
|
|
17
|
-
const { form: input, class: className } = Astro.props;
|
|
30
|
+
const { form: input, submitLabel, class: className } = Astro.props;
|
|
18
31
|
const form: PublicForm | null =
|
|
19
32
|
input && "fields" in input ? input : ((input as Reference | null)?.form ?? null);
|
|
20
33
|
const locale = Astro.currentLocale ?? "";
|
|
34
|
+
// Contrôles fournis par le site (slot `field-<nom>`), rendus d'avance.
|
|
35
|
+
const custom: Record<string, string> = Object.fromEntries(
|
|
36
|
+
await Promise.all(
|
|
37
|
+
(form?.fields ?? [])
|
|
38
|
+
.filter((f) => Astro.slots.has(`field-${f.name}`))
|
|
39
|
+
.map(async (f) => [f.name, await Astro.slots.render(`field-${f.name}`)]),
|
|
40
|
+
),
|
|
41
|
+
);
|
|
21
42
|
const id = form ? `zef-form-${form.id.slice(0, 8)}` : "";
|
|
22
43
|
const COUNTRIES = [
|
|
23
44
|
"France",
|
|
@@ -61,6 +82,7 @@ const inputType = {
|
|
|
61
82
|
</label>
|
|
62
83
|
</div>
|
|
63
84
|
)}
|
|
85
|
+
<slot name="start" />
|
|
64
86
|
{form.fields.map((f) => {
|
|
65
87
|
const fieldId = `${id}-${f.name}`;
|
|
66
88
|
const attrs = {
|
|
@@ -69,6 +91,18 @@ const inputType = {
|
|
|
69
91
|
"data-width": f.width,
|
|
70
92
|
"data-conditions": f.conditions ? JSON.stringify(f.conditions) : undefined,
|
|
71
93
|
};
|
|
94
|
+
if (f.name in custom) {
|
|
95
|
+
return (
|
|
96
|
+
<div class="zef-form__field" role="group" aria-labelledby={`${fieldId}-label`} {...attrs}>
|
|
97
|
+
<span class="zef-form__label" id={`${fieldId}-label`}>
|
|
98
|
+
{f.label}
|
|
99
|
+
{f.required && <span aria-hidden="true"> *</span>}
|
|
100
|
+
</span>
|
|
101
|
+
<Fragment set:html={custom[f.name]} />
|
|
102
|
+
<p class="zef-form__error" role="alert" hidden />
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
72
106
|
if (f.type === "message") {
|
|
73
107
|
return (
|
|
74
108
|
<p class="zef-form__message" {...attrs}>
|
|
@@ -155,8 +189,9 @@ const inputType = {
|
|
|
155
189
|
{form.turnstileSiteKey && <div class="cf-turnstile zef-form__turnstile" data-sitekey={form.turnstileSiteKey} data-size="flexible" />}
|
|
156
190
|
<p class="zef-form__status" role="status" hidden />
|
|
157
191
|
<button type="submit" class="zef-form__submit">
|
|
158
|
-
{form.submitLabel}
|
|
192
|
+
{submitLabel ?? form.submitLabel}
|
|
159
193
|
</button>
|
|
194
|
+
<slot name="end" />
|
|
160
195
|
<template data-zef-confirmation={form.confirmation.type} data-url={form.confirmation.type === "redirect" ? (form.confirmation.url ?? "") : undefined}>
|
|
161
196
|
{form.confirmation.type === "message" && <div class="zef-form__confirmation" set:html={form.confirmation.html} />}
|
|
162
197
|
</template>
|
|
@@ -176,10 +211,15 @@ const inputType = {
|
|
|
176
211
|
const prev = values[key];
|
|
177
212
|
values[key] = prev === undefined ? value : [...(Array.isArray(prev) ? prev : [prev]), value];
|
|
178
213
|
}
|
|
179
|
-
for (const
|
|
180
|
-
const name =
|
|
181
|
-
if (
|
|
182
|
-
|
|
214
|
+
for (const field of form.querySelectorAll<HTMLElement>("[data-field]")) {
|
|
215
|
+
const name = field.dataset.field ?? "";
|
|
216
|
+
if (field.dataset.type === "checkboxes") {
|
|
217
|
+
if (!(name in values)) values[name] = [];
|
|
218
|
+
else if (!Array.isArray(values[name])) values[name] = [values[name] as string];
|
|
219
|
+
} else if (Array.isArray(values[name])) {
|
|
220
|
+
// Plusieurs valeurs pour un champ texte (contrôle sur mesure) : une par ligne.
|
|
221
|
+
values[name] = (values[name] as string[]).join("\n");
|
|
222
|
+
}
|
|
183
223
|
}
|
|
184
224
|
return values;
|
|
185
225
|
}
|
|
@@ -208,6 +248,12 @@ const inputType = {
|
|
|
208
248
|
error.hidden = true;
|
|
209
249
|
error.textContent = "";
|
|
210
250
|
}
|
|
251
|
+
if (submit) submit.disabled = true;
|
|
252
|
+
// Anti-spam : si le visiteur envoie avant que Turnstile ait rendu son jeton, on l'attend (5 s au plus).
|
|
253
|
+
const token = () => form.querySelector<HTMLInputElement>('[name="cf-turnstile-response"]')?.value;
|
|
254
|
+
if (form.querySelector(".cf-turnstile")) {
|
|
255
|
+
for (let i = 0; i < 25 && !token(); i++) await new Promise((r) => setTimeout(r, 200));
|
|
256
|
+
}
|
|
211
257
|
const values = valuesOf(form);
|
|
212
258
|
const meta = (key: string) => {
|
|
213
259
|
const value = values[key];
|
|
@@ -235,10 +281,10 @@ const inputType = {
|
|
|
235
281
|
error.textContent = message;
|
|
236
282
|
error.hidden = false;
|
|
237
283
|
}
|
|
284
|
+
if (submit) submit.disabled = false;
|
|
238
285
|
return;
|
|
239
286
|
}
|
|
240
287
|
}
|
|
241
|
-
if (submit) submit.disabled = true;
|
|
242
288
|
form.setAttribute("aria-busy", "true");
|
|
243
289
|
// Avec un champ fichier : envoi multipart (les champs masqués, désactivés, n'en font pas partie).
|
|
244
290
|
const multipart = form.querySelector('input[type="file"]:not(:disabled)') !== null;
|
|
@@ -259,7 +305,9 @@ const inputType = {
|
|
|
259
305
|
done.className = "zef-form__done";
|
|
260
306
|
done.setAttribute("role", "status");
|
|
261
307
|
done.append(template?.content.cloneNode(true) ?? "");
|
|
262
|
-
form.
|
|
308
|
+
form.hidden = true;
|
|
309
|
+
form.after(done);
|
|
310
|
+
form.dispatchEvent(new CustomEvent("zef-form:success", { bubbles: true }));
|
|
263
311
|
return;
|
|
264
312
|
}
|
|
265
313
|
for (const [name, message] of Object.entries<string>(result.fields ?? {})) {
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Choix multiple dans une liste fermée (puces dans le champ, liste à cases dessous), classe
|
|
4
|
+
* `zef-multiselect`. Ce sont de vraies cases à cocher `name` : les valeurs cochées partent avec
|
|
5
|
+
* le formulaire (`<Form>` les envoie au CMS, une par ligne pour un champ texte).
|
|
6
|
+
*
|
|
7
|
+
* Options : `value`, `label`, et en option `description` (ligne grise) et `image` (vignette,
|
|
8
|
+
* reprise dans la puce). `max` limite le nombre de choix (les autres cases sont désactivées) ;
|
|
9
|
+
* `counter` affiche le compte (`{n}` choisis, `{max}`). Avec `label`, rendu dans un `<Field>`.
|
|
10
|
+
*
|
|
11
|
+
* Comportement : clic sur le champ ou Entrée pour ouvrir, Échap ou clic en dehors pour fermer
|
|
12
|
+
* (Échap ne ferme pas la `<Dialog>` qui le contient). En JavaScript : `updateMultiselect`,
|
|
13
|
+
* `resetMultiselect`, `toggleMultiselect` de `@agence-zef/astro/ui` (après avoir masqué une
|
|
14
|
+
* option avec `hidden`, par exemple).
|
|
15
|
+
*/
|
|
16
|
+
import Field from "./Field.astro";
|
|
17
|
+
|
|
18
|
+
type Option = { value: string; label: string; description?: string | null; image?: string | null };
|
|
19
|
+
interface Props {
|
|
20
|
+
name: string;
|
|
21
|
+
options: Option[];
|
|
22
|
+
label?: string;
|
|
23
|
+
help?: string;
|
|
24
|
+
error?: string;
|
|
25
|
+
/** Nombre de choix au plus. */
|
|
26
|
+
max?: number;
|
|
27
|
+
/** Texte du champ vide (« Choisir… »). */
|
|
28
|
+
placeholder?: string;
|
|
29
|
+
/** Compteur sous le champ : `{n}` choisis, `{max}` au plus (« {n} sur {max} sélectionnés. »). */
|
|
30
|
+
counter?: string;
|
|
31
|
+
/** Valeurs cochées au départ. */
|
|
32
|
+
value?: string[];
|
|
33
|
+
/** Libellé accessible du bouton qui retire une puce (suivi du nom de l'option). */
|
|
34
|
+
removeLabel?: string;
|
|
35
|
+
id?: string;
|
|
36
|
+
class?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const {
|
|
40
|
+
name,
|
|
41
|
+
options,
|
|
42
|
+
label,
|
|
43
|
+
help,
|
|
44
|
+
error,
|
|
45
|
+
max,
|
|
46
|
+
placeholder = "",
|
|
47
|
+
counter,
|
|
48
|
+
value = [],
|
|
49
|
+
removeLabel = "Retirer",
|
|
50
|
+
id: givenId,
|
|
51
|
+
class: className,
|
|
52
|
+
} = Astro.props;
|
|
53
|
+
const id = givenId ?? `zef-${name}-${Math.random().toString(36).slice(2, 8)}`;
|
|
54
|
+
const full = max !== undefined && value.length >= max;
|
|
55
|
+
// Avec un libellé : dans un `<Field>` (libellé, aide, erreur) ; sinon le champ seul.
|
|
56
|
+
const Wrapper = label ? Field : Fragment;
|
|
57
|
+
const wrapperProps = label ? { label, for: id, help, error } : {};
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
<Wrapper {...wrapperProps}>
|
|
61
|
+
<div
|
|
62
|
+
class:list={["zef-multiselect", className]}
|
|
63
|
+
data-zef-multiselect
|
|
64
|
+
data-max={max}
|
|
65
|
+
data-counter={counter}
|
|
66
|
+
data-remove-label={removeLabel}
|
|
67
|
+
>
|
|
68
|
+
<div class="zef-multiselect__control">
|
|
69
|
+
<ul class="zef-multiselect__chips"></ul>
|
|
70
|
+
<button type="button" id={id} class="zef-multiselect__toggle" aria-expanded="false" aria-controls={`${id}-list`}>
|
|
71
|
+
<span class="zef-multiselect__placeholder">{placeholder}</span>
|
|
72
|
+
<svg class="zef-multiselect__chevron" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true">
|
|
73
|
+
<path d="m6 9 6 6 6-6" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"></path>
|
|
74
|
+
</svg>
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
<div id={`${id}-list`} class="zef-multiselect__list" hidden>
|
|
78
|
+
{
|
|
79
|
+
options.map((o) => (
|
|
80
|
+
<label class="zef-multiselect__option">
|
|
81
|
+
<input
|
|
82
|
+
type="checkbox"
|
|
83
|
+
name={name}
|
|
84
|
+
value={o.value}
|
|
85
|
+
checked={value.includes(o.value)}
|
|
86
|
+
disabled={full && !value.includes(o.value)}
|
|
87
|
+
/>
|
|
88
|
+
{o.image && <img src={o.image} alt="" loading="lazy" />}
|
|
89
|
+
<span class="zef-multiselect__text">
|
|
90
|
+
<span class="zef-multiselect__name">{o.label}</span>
|
|
91
|
+
{o.description && <span class="zef-multiselect__description">{o.description}</span>}
|
|
92
|
+
</span>
|
|
93
|
+
</label>
|
|
94
|
+
))
|
|
95
|
+
}
|
|
96
|
+
</div>
|
|
97
|
+
<p class="zef-multiselect__count" aria-live="polite" hidden></p>
|
|
98
|
+
</div>
|
|
99
|
+
</Wrapper>
|
|
100
|
+
|
|
101
|
+
<script>
|
|
102
|
+
import { toggleMultiselect, updateMultiselect } from "@agence-zef/astro/ui";
|
|
103
|
+
|
|
104
|
+
const roots = () => document.querySelectorAll<HTMLElement>("[data-zef-multiselect]");
|
|
105
|
+
for (const root of roots()) updateMultiselect(root);
|
|
106
|
+
|
|
107
|
+
document.addEventListener("click", (event) => {
|
|
108
|
+
const target = event.target as Element;
|
|
109
|
+
const control = target.closest(".zef-multiselect__control");
|
|
110
|
+
const current = control?.closest<HTMLElement>("[data-zef-multiselect]");
|
|
111
|
+
if (current && !target.closest(".zef-multiselect__remove")) toggleMultiselect(current);
|
|
112
|
+
// Clic en dehors : les listes ouvertes se ferment.
|
|
113
|
+
for (const root of roots()) {
|
|
114
|
+
if (root !== current && root.hasAttribute("data-open") && !root.contains(target)) {
|
|
115
|
+
toggleMultiselect(root, false);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
document.addEventListener("change", (event) => {
|
|
120
|
+
const root = (event.target as Element).closest?.("[data-zef-multiselect]");
|
|
121
|
+
if (root) updateMultiselect(root);
|
|
122
|
+
});
|
|
123
|
+
// Échap ferme la liste (et pas la fenêtre qui la contient) ; flèche bas l'ouvre.
|
|
124
|
+
document.addEventListener(
|
|
125
|
+
"keydown",
|
|
126
|
+
(event) => {
|
|
127
|
+
const root = (event.target as Element).closest?.<HTMLElement>("[data-zef-multiselect]");
|
|
128
|
+
if (!root) return;
|
|
129
|
+
if (event.key === "Escape" && root.hasAttribute("data-open")) {
|
|
130
|
+
event.preventDefault();
|
|
131
|
+
event.stopPropagation();
|
|
132
|
+
toggleMultiselect(root, false);
|
|
133
|
+
root.querySelector<HTMLElement>(".zef-multiselect__toggle")?.focus();
|
|
134
|
+
} else if (event.key === "ArrowDown" && (event.target as Element).matches(".zef-multiselect__toggle")) {
|
|
135
|
+
event.preventDefault();
|
|
136
|
+
toggleMultiselect(root, true);
|
|
137
|
+
root.querySelector<HTMLInputElement>(".zef-multiselect__option:not([hidden]) input:not(:disabled)")?.focus();
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
true,
|
|
141
|
+
);
|
|
142
|
+
// Formulaire vidé (`reset`) : puces et compteur suivent.
|
|
143
|
+
document.addEventListener("reset", (event) => {
|
|
144
|
+
const form = event.target as HTMLFormElement;
|
|
145
|
+
requestAnimationFrame(() => {
|
|
146
|
+
for (const root of form.querySelectorAll("[data-zef-multiselect]")) updateMultiselect(root);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
</script>
|
package/components/Popover.astro
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* Popover natif (attribut `popover`), sans
|
|
4
|
-
* `<button popovertarget="<id>">`,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* Popover natif (attribut `popover`), sans style imposé : ouvert par un ou plusieurs boutons
|
|
4
|
+
* `<button popovertarget="<id>">`, placé contre le bouton qui l'a ouvert par le positionnement
|
|
5
|
+
* d'ancre CSS (`styles.css`, au centre de l'écran en repli). Classe `zef-popover`.
|
|
6
|
+
*
|
|
7
|
+
* Comportement (script ci-dessous, partagé par toute la page) :
|
|
8
|
+
* - un autre bouton du même popover pendant qu'il est ouvert : le popover se déplace vers lui
|
|
9
|
+
* tout de suite, sans se fermer ni se rouvrir (le site met son contenu à jour dans son propre
|
|
10
|
+
* `click`) ; le même bouton : il se ferme ;
|
|
11
|
+
* - clic en dehors ou Échap : il se ferme (sauf `manual`).
|
|
7
12
|
*/
|
|
8
13
|
interface Props {
|
|
9
14
|
id: string;
|
|
15
|
+
/** Ne se ferme ni au clic en dehors ni par Échap (seulement par son bouton). */
|
|
10
16
|
manual?: boolean;
|
|
11
17
|
/** Position par rapport au bouton : en dessous (défaut), au-dessus, à droite, à gauche. */
|
|
12
18
|
placement?: "bottom" | "top" | "right" | "left";
|
|
@@ -16,6 +22,69 @@ interface Props {
|
|
|
16
22
|
const { id, manual = false, placement = "bottom", class: className } = Astro.props;
|
|
17
23
|
---
|
|
18
24
|
|
|
19
|
-
<div
|
|
25
|
+
<div
|
|
26
|
+
id={id}
|
|
27
|
+
popover="manual"
|
|
28
|
+
class:list={["zef-popover", className]}
|
|
29
|
+
data-placement={placement}
|
|
30
|
+
data-zef-popover={manual ? "manual" : "auto"}
|
|
31
|
+
>
|
|
20
32
|
<slot />
|
|
21
33
|
</div>
|
|
34
|
+
|
|
35
|
+
<script>
|
|
36
|
+
/** Bouton qui a ouvert chaque popover (ancre CSS). */
|
|
37
|
+
const opener = new WeakMap<HTMLElement, HTMLElement>();
|
|
38
|
+
const popovers = () => document.querySelectorAll<HTMLElement>("[data-zef-popover]");
|
|
39
|
+
const isOpen = (pop: HTMLElement) => pop.matches(":popover-open");
|
|
40
|
+
|
|
41
|
+
/** Accroche le popover à ce bouton : l'ancre suit le bouton, même pendant l'ouverture. */
|
|
42
|
+
function anchorTo(pop: HTMLElement, trigger: HTMLElement) {
|
|
43
|
+
const name = `--zef-popover-${pop.id}`;
|
|
44
|
+
const previous = opener.get(pop);
|
|
45
|
+
if (previous && previous !== trigger) {
|
|
46
|
+
previous.style.removeProperty("anchor-name");
|
|
47
|
+
previous.setAttribute("aria-expanded", "false");
|
|
48
|
+
}
|
|
49
|
+
trigger.style.setProperty("anchor-name", name);
|
|
50
|
+
trigger.setAttribute("aria-expanded", "true");
|
|
51
|
+
pop.style.setProperty("position-anchor", name);
|
|
52
|
+
opener.set(pop, trigger);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
document.addEventListener("click", (event) => {
|
|
56
|
+
const target = event.target as Element;
|
|
57
|
+
const trigger = target.closest<HTMLElement>("[popovertarget]");
|
|
58
|
+
const pop = trigger ? document.getElementById(trigger.getAttribute("popovertarget") ?? "") : null;
|
|
59
|
+
if (trigger && pop?.hasAttribute("data-zef-popover")) {
|
|
60
|
+
// Le comportement natif (basculer) est remplacé : déplacer plutôt que fermer.
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
if (isOpen(pop) && opener.get(pop) === trigger) return pop.hidePopover();
|
|
63
|
+
anchorTo(pop, trigger);
|
|
64
|
+
if (!isOpen(pop)) pop.showPopover();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Clic en dehors : les popovers ouverts se ferment.
|
|
68
|
+
for (const open of popovers()) {
|
|
69
|
+
if (isOpen(open) && open.dataset.zefPopover !== "manual" && !open.contains(target)) {
|
|
70
|
+
open.hidePopover();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
document.addEventListener("keydown", (event) => {
|
|
75
|
+
if (event.key !== "Escape") return;
|
|
76
|
+
for (const open of popovers()) {
|
|
77
|
+
if (isOpen(open) && open.dataset.zefPopover !== "manual") {
|
|
78
|
+
open.hidePopover();
|
|
79
|
+
opener.get(open)?.focus();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
for (const pop of popovers()) {
|
|
84
|
+
pop.addEventListener("toggle", (event) => {
|
|
85
|
+
if ((event as ToggleEvent).newState === "closed") {
|
|
86
|
+
opener.get(pop)?.setAttribute("aria-expanded", "false");
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
</script>
|
package/dist/ui.d.ts
CHANGED
|
@@ -5,3 +5,17 @@ export declare function openDialog(id: string): void;
|
|
|
5
5
|
* ferme vraiment ; tout de suite si le visiteur réduit les animations ou sans animation CSS.
|
|
6
6
|
*/
|
|
7
7
|
export declare function closeDialog(target: HTMLDialogElement | string): void;
|
|
8
|
+
/** Ouvre ou ferme la liste d'un `<Multiselect>`. */
|
|
9
|
+
export declare function toggleMultiselect(target: Element | string, open?: boolean): void;
|
|
10
|
+
/**
|
|
11
|
+
* Met à jour un `<Multiselect>` d'après ses cases : puces, maximum (les autres cases désactivées),
|
|
12
|
+
* compteur. À appeler après avoir coché, décoché ou masqué (`hidden`) des options en JavaScript.
|
|
13
|
+
*/
|
|
14
|
+
export declare function updateMultiselect(target: Element | string): void;
|
|
15
|
+
/** Décoche tout, referme la liste et remet le `<Multiselect>` à zéro. */
|
|
16
|
+
export declare function resetMultiselect(target: Element | string): void;
|
|
17
|
+
/**
|
|
18
|
+
* Remet un `<Form>` à son état de départ (après un envoi réussi, pour une nouvelle demande) :
|
|
19
|
+
* formulaire vidé et réaffiché, message de confirmation retiré, erreurs effacées.
|
|
20
|
+
*/
|
|
21
|
+
export declare function resetForm(target: HTMLFormElement | string): void;
|
package/dist/ui.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Comportements des composants d'interface (`<Dialog>`, `<
|
|
3
|
-
* scripts : exportés pour qu'un site les déclenche lui-même.
|
|
2
|
+
* Comportements des composants d'interface (`<Dialog>`, `<Multiselect>`, `<Form>`) partagés
|
|
3
|
+
* par leurs scripts : exportés pour qu'un site les déclenche lui-même.
|
|
4
4
|
*/
|
|
5
5
|
const reduced = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
6
6
|
/** Ouvre une fenêtre `<Dialog>` par son identifiant. */
|
|
@@ -31,3 +31,124 @@ export function closeDialog(target) {
|
|
|
31
31
|
// Filet : si l'animation ne se termine pas (onglet en arrière-plan), on ferme quand même.
|
|
32
32
|
setTimeout(() => dialog.hasAttribute("data-closing") && finish(), 400);
|
|
33
33
|
}
|
|
34
|
+
/* ---------- Multiselect ---------- */
|
|
35
|
+
const REMOVE_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
|
|
36
|
+
/** Racine d'un `<Multiselect>` : l'élément, un de ses descendants, son conteneur (le premier) ou un identifiant. */
|
|
37
|
+
function multiselectOf(target) {
|
|
38
|
+
const el = typeof target === "string" ? document.getElementById(target) : target;
|
|
39
|
+
return (el?.closest("[data-zef-multiselect]") ??
|
|
40
|
+
el?.querySelector("[data-zef-multiselect]") ??
|
|
41
|
+
null);
|
|
42
|
+
}
|
|
43
|
+
/** Ouvre ou ferme la liste d'un `<Multiselect>`. */
|
|
44
|
+
export function toggleMultiselect(target, open) {
|
|
45
|
+
const root = multiselectOf(target);
|
|
46
|
+
const list = root?.querySelector(".zef-multiselect__list");
|
|
47
|
+
const toggle = root?.querySelector(".zef-multiselect__toggle");
|
|
48
|
+
if (!root || !list || !toggle)
|
|
49
|
+
return;
|
|
50
|
+
const next = open ?? list.hasAttribute("hidden");
|
|
51
|
+
list.hidden = !next;
|
|
52
|
+
toggle.setAttribute("aria-expanded", String(next));
|
|
53
|
+
root.toggleAttribute("data-open", next);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Met à jour un `<Multiselect>` d'après ses cases : puces, maximum (les autres cases désactivées),
|
|
57
|
+
* compteur. À appeler après avoir coché, décoché ou masqué (`hidden`) des options en JavaScript.
|
|
58
|
+
*/
|
|
59
|
+
export function updateMultiselect(target) {
|
|
60
|
+
const root = multiselectOf(target);
|
|
61
|
+
if (!root)
|
|
62
|
+
return;
|
|
63
|
+
const max = Number(root.dataset.max) || Number.POSITIVE_INFINITY;
|
|
64
|
+
const options = [...root.querySelectorAll(".zef-multiselect__option")];
|
|
65
|
+
// Une option masquée n'est jamais envoyée.
|
|
66
|
+
for (const option of options) {
|
|
67
|
+
const box = option.querySelector("input");
|
|
68
|
+
if (option.hidden && box)
|
|
69
|
+
box.checked = false;
|
|
70
|
+
}
|
|
71
|
+
const checked = options.filter((o) => o.querySelector("input")?.checked);
|
|
72
|
+
const full = checked.length >= max;
|
|
73
|
+
for (const option of options) {
|
|
74
|
+
const box = option.querySelector("input");
|
|
75
|
+
if (box)
|
|
76
|
+
box.disabled = full && !box.checked;
|
|
77
|
+
}
|
|
78
|
+
const chips = root.querySelector(".zef-multiselect__chips");
|
|
79
|
+
chips?.replaceChildren(...checked.map((option) => {
|
|
80
|
+
const box = option.querySelector("input");
|
|
81
|
+
const label = option.querySelector(".zef-multiselect__name")?.textContent ?? box?.value ?? "";
|
|
82
|
+
const chip = document.createElement("li");
|
|
83
|
+
chip.className = "zef-multiselect__chip";
|
|
84
|
+
const image = option.querySelector("img");
|
|
85
|
+
if (image) {
|
|
86
|
+
const copy = image.cloneNode();
|
|
87
|
+
copy.alt = "";
|
|
88
|
+
copy.loading = "eager";
|
|
89
|
+
chip.append(copy);
|
|
90
|
+
}
|
|
91
|
+
const text = document.createElement("span");
|
|
92
|
+
text.textContent = label;
|
|
93
|
+
const remove = document.createElement("button");
|
|
94
|
+
remove.type = "button";
|
|
95
|
+
remove.className = "zef-multiselect__remove";
|
|
96
|
+
remove.setAttribute("aria-label", `${root.dataset.removeLabel ?? "Retirer"} ${label}`);
|
|
97
|
+
remove.innerHTML = REMOVE_ICON;
|
|
98
|
+
remove.addEventListener("click", (event) => {
|
|
99
|
+
event.stopPropagation();
|
|
100
|
+
if (box)
|
|
101
|
+
box.checked = false;
|
|
102
|
+
updateMultiselect(root);
|
|
103
|
+
box?.dispatchEvent(new Event("change", { bubbles: true }));
|
|
104
|
+
});
|
|
105
|
+
chip.append(text, remove);
|
|
106
|
+
return chip;
|
|
107
|
+
}));
|
|
108
|
+
const placeholder = root.querySelector(".zef-multiselect__placeholder");
|
|
109
|
+
if (placeholder)
|
|
110
|
+
placeholder.hidden = checked.length > 0;
|
|
111
|
+
const counter = root.querySelector(".zef-multiselect__count");
|
|
112
|
+
if (counter) {
|
|
113
|
+
const text = checked.length ? (root.dataset.counter ?? "") : "";
|
|
114
|
+
counter.textContent = text
|
|
115
|
+
.replaceAll("{n}", String(checked.length))
|
|
116
|
+
.replaceAll("{max}", Number.isFinite(max) ? String(max) : "");
|
|
117
|
+
counter.hidden = !counter.textContent;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Décoche tout, referme la liste et remet le `<Multiselect>` à zéro. */
|
|
121
|
+
export function resetMultiselect(target) {
|
|
122
|
+
const root = multiselectOf(target);
|
|
123
|
+
if (!root)
|
|
124
|
+
return;
|
|
125
|
+
for (const box of root.querySelectorAll(".zef-multiselect__option input")) {
|
|
126
|
+
box.checked = false;
|
|
127
|
+
}
|
|
128
|
+
toggleMultiselect(root, false);
|
|
129
|
+
updateMultiselect(root);
|
|
130
|
+
}
|
|
131
|
+
/* ---------- Formulaires du CMS (<Form>) ---------- */
|
|
132
|
+
/**
|
|
133
|
+
* Remet un `<Form>` à son état de départ (après un envoi réussi, pour une nouvelle demande) :
|
|
134
|
+
* formulaire vidé et réaffiché, message de confirmation retiré, erreurs effacées.
|
|
135
|
+
*/
|
|
136
|
+
export function resetForm(target) {
|
|
137
|
+
const form = typeof target === "string"
|
|
138
|
+
? document.querySelector(`#${CSS.escape(target)}, form[data-zef-form="${target}"]`)
|
|
139
|
+
: target;
|
|
140
|
+
if (!form)
|
|
141
|
+
return;
|
|
142
|
+
const done = form.nextElementSibling;
|
|
143
|
+
if (done?.classList.contains("zef-form__done"))
|
|
144
|
+
done.remove();
|
|
145
|
+
form.hidden = false;
|
|
146
|
+
form.reset();
|
|
147
|
+
for (const el of form.querySelectorAll(".zef-form__error, .zef-form__status")) {
|
|
148
|
+
el.hidden = true;
|
|
149
|
+
el.textContent = "";
|
|
150
|
+
}
|
|
151
|
+
for (const root of form.querySelectorAll("[data-zef-multiselect]"))
|
|
152
|
+
resetMultiselect(root);
|
|
153
|
+
window.turnstile?.reset(form.querySelector(".cf-turnstile") ?? undefined);
|
|
154
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agence-zef/astro",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Intégration Astro du CMS ZEF : routes, rendu des sections, aperçu, composants réutilisables sans style.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"./Input.astro": "./components/Input.astro",
|
|
60
60
|
"./Select.astro": "./components/Select.astro",
|
|
61
61
|
"./Checkbox.astro": "./components/Checkbox.astro",
|
|
62
|
+
"./Multiselect.astro": "./components/Multiselect.astro",
|
|
62
63
|
"./styles.css": "./styles.css",
|
|
63
64
|
"./ui": {
|
|
64
65
|
"types": "./dist/ui.d.ts",
|
|
@@ -84,8 +85,8 @@
|
|
|
84
85
|
},
|
|
85
86
|
"dependencies": {
|
|
86
87
|
"jiti": "^2.6.1",
|
|
87
|
-
"@agence-zef/schema": "0.
|
|
88
|
-
"@agence-zef/sdk": "0.
|
|
88
|
+
"@agence-zef/schema": "0.7.0",
|
|
89
|
+
"@agence-zef/sdk": "0.7.0"
|
|
89
90
|
},
|
|
90
91
|
"homepage": "https://agence-zef.fr",
|
|
91
92
|
"scripts": {
|
package/styles.css
CHANGED
|
@@ -188,6 +188,168 @@
|
|
|
188
188
|
border: 0;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/* Choix multiple (<Multiselect>) : puces dans le champ, liste à cases dessous. */
|
|
192
|
+
:where(.zef-multiselect) {
|
|
193
|
+
position: relative;
|
|
194
|
+
display: flex;
|
|
195
|
+
flex-direction: column;
|
|
196
|
+
gap: 0.375rem;
|
|
197
|
+
font-family: var(--zef-font);
|
|
198
|
+
}
|
|
199
|
+
:where(.zef-multiselect__control) {
|
|
200
|
+
box-sizing: border-box;
|
|
201
|
+
display: flex;
|
|
202
|
+
flex-wrap: wrap;
|
|
203
|
+
align-items: center;
|
|
204
|
+
gap: 0.375rem;
|
|
205
|
+
min-height: var(--zef-input-height);
|
|
206
|
+
padding: 0.3125rem 0.5rem 0.3125rem 0.3125rem;
|
|
207
|
+
border: 1px solid var(--zef-border-strong);
|
|
208
|
+
border-radius: var(--zef-radius);
|
|
209
|
+
background: var(--zef-surface);
|
|
210
|
+
cursor: pointer;
|
|
211
|
+
transition:
|
|
212
|
+
border-color var(--zef-duration),
|
|
213
|
+
box-shadow var(--zef-duration);
|
|
214
|
+
}
|
|
215
|
+
:where(
|
|
216
|
+
.zef-multiselect[data-open] .zef-multiselect__control,
|
|
217
|
+
.zef-multiselect__control:has(:focus-visible)
|
|
218
|
+
) {
|
|
219
|
+
border-color: var(--zef-accent);
|
|
220
|
+
box-shadow: var(--zef-focus);
|
|
221
|
+
}
|
|
222
|
+
:where(.zef-multiselect__chips) {
|
|
223
|
+
display: contents;
|
|
224
|
+
margin: 0;
|
|
225
|
+
padding: 0;
|
|
226
|
+
list-style: none;
|
|
227
|
+
}
|
|
228
|
+
:where(.zef-multiselect__chip) {
|
|
229
|
+
display: inline-flex;
|
|
230
|
+
align-items: center;
|
|
231
|
+
gap: 0.375rem;
|
|
232
|
+
min-height: 2rem;
|
|
233
|
+
padding: 0 0.25rem;
|
|
234
|
+
border-radius: var(--zef-radius-small);
|
|
235
|
+
background: var(--zef-surface-muted);
|
|
236
|
+
}
|
|
237
|
+
:where(.zef-multiselect__chip img) {
|
|
238
|
+
width: 1.5rem;
|
|
239
|
+
height: 1.5rem;
|
|
240
|
+
object-fit: cover;
|
|
241
|
+
border-radius: var(--zef-radius-small);
|
|
242
|
+
}
|
|
243
|
+
:where(.zef-multiselect__remove) {
|
|
244
|
+
display: grid;
|
|
245
|
+
place-items: center;
|
|
246
|
+
width: 1.25rem;
|
|
247
|
+
height: 1.25rem;
|
|
248
|
+
padding: 0;
|
|
249
|
+
border: 0;
|
|
250
|
+
border-radius: var(--zef-radius-small);
|
|
251
|
+
background: none;
|
|
252
|
+
color: inherit;
|
|
253
|
+
cursor: pointer;
|
|
254
|
+
}
|
|
255
|
+
:where(.zef-multiselect__toggle) {
|
|
256
|
+
display: flex;
|
|
257
|
+
flex: 1 1 4rem;
|
|
258
|
+
align-items: center;
|
|
259
|
+
justify-content: space-between;
|
|
260
|
+
gap: 0.5rem;
|
|
261
|
+
align-self: stretch;
|
|
262
|
+
min-width: 4rem;
|
|
263
|
+
padding: 0 0 0 0.4375rem;
|
|
264
|
+
border: 0;
|
|
265
|
+
background: none;
|
|
266
|
+
color: inherit;
|
|
267
|
+
font: inherit;
|
|
268
|
+
text-align: left;
|
|
269
|
+
cursor: pointer;
|
|
270
|
+
}
|
|
271
|
+
:where(.zef-multiselect__toggle):focus-visible {
|
|
272
|
+
outline: none;
|
|
273
|
+
}
|
|
274
|
+
:where(.zef-multiselect__placeholder) {
|
|
275
|
+
color: var(--zef-muted);
|
|
276
|
+
}
|
|
277
|
+
:where(.zef-multiselect__chevron) {
|
|
278
|
+
flex-shrink: 0;
|
|
279
|
+
margin-left: auto;
|
|
280
|
+
transition: transform var(--zef-duration);
|
|
281
|
+
}
|
|
282
|
+
:where(.zef-multiselect[data-open] .zef-multiselect__chevron) {
|
|
283
|
+
transform: rotate(180deg);
|
|
284
|
+
}
|
|
285
|
+
:where(.zef-multiselect__list) {
|
|
286
|
+
position: absolute;
|
|
287
|
+
z-index: 10;
|
|
288
|
+
top: calc(100% + 0.25rem);
|
|
289
|
+
left: 0;
|
|
290
|
+
right: 0;
|
|
291
|
+
box-sizing: border-box;
|
|
292
|
+
max-height: 18rem;
|
|
293
|
+
padding: 0.375rem;
|
|
294
|
+
overflow: auto;
|
|
295
|
+
border: 1px solid var(--zef-border);
|
|
296
|
+
border-radius: var(--zef-radius);
|
|
297
|
+
background: var(--zef-surface);
|
|
298
|
+
box-shadow: var(--zef-shadow);
|
|
299
|
+
}
|
|
300
|
+
/* Compteur affiché : la liste part sous le champ, pas sous le compteur. */
|
|
301
|
+
:where(.zef-multiselect:has(.zef-multiselect__count:not([hidden])) .zef-multiselect__list) {
|
|
302
|
+
top: calc(var(--zef-input-height) + 0.25rem);
|
|
303
|
+
}
|
|
304
|
+
:where(.zef-multiselect__option) {
|
|
305
|
+
display: flex;
|
|
306
|
+
align-items: center;
|
|
307
|
+
gap: 0.75rem;
|
|
308
|
+
padding: 0.375rem 0.5rem;
|
|
309
|
+
border-radius: var(--zef-radius-small);
|
|
310
|
+
cursor: pointer;
|
|
311
|
+
}
|
|
312
|
+
:where(.zef-multiselect__option:hover, .zef-multiselect__option:has(input:checked)) {
|
|
313
|
+
background: var(--zef-surface-muted);
|
|
314
|
+
}
|
|
315
|
+
:where(.zef-multiselect__option:has(input:disabled)) {
|
|
316
|
+
opacity: 0.45;
|
|
317
|
+
background: none;
|
|
318
|
+
cursor: not-allowed;
|
|
319
|
+
}
|
|
320
|
+
:where(.zef-multiselect__option input) {
|
|
321
|
+
flex-shrink: 0;
|
|
322
|
+
width: 1.125rem;
|
|
323
|
+
height: 1.125rem;
|
|
324
|
+
margin: 0;
|
|
325
|
+
accent-color: var(--zef-accent);
|
|
326
|
+
}
|
|
327
|
+
:where(.zef-multiselect__option img) {
|
|
328
|
+
flex-shrink: 0;
|
|
329
|
+
width: 3rem;
|
|
330
|
+
height: 2rem;
|
|
331
|
+
object-fit: cover;
|
|
332
|
+
border-radius: var(--zef-radius-small);
|
|
333
|
+
}
|
|
334
|
+
:where(.zef-multiselect__text) {
|
|
335
|
+
display: flex;
|
|
336
|
+
flex-direction: column;
|
|
337
|
+
min-width: 0;
|
|
338
|
+
line-height: 1.3;
|
|
339
|
+
}
|
|
340
|
+
:where(.zef-multiselect__name) {
|
|
341
|
+
font-weight: 600;
|
|
342
|
+
}
|
|
343
|
+
:where(.zef-multiselect__description, .zef-multiselect__count) {
|
|
344
|
+
margin: 0;
|
|
345
|
+
color: var(--zef-muted);
|
|
346
|
+
font-size: 0.875rem;
|
|
347
|
+
}
|
|
348
|
+
:where(.zef-multiselect [hidden]) {
|
|
349
|
+
/* biome-ignore lint/complexity/noImportantStyles: `hidden` doit gagner sur tout `display` du site. */
|
|
350
|
+
display: none !important;
|
|
351
|
+
}
|
|
352
|
+
|
|
191
353
|
/* Aide, erreurs, messages. */
|
|
192
354
|
:where(.zef-help, .zef-account__help, .zef-account__optional, .zef-checkout__optional) {
|
|
193
355
|
color: var(--zef-muted);
|