Composants

Un sélecteur de commune pour les formulaires, en HTML ou en React.

L’essayer

Le formulaire envoie rien pour l’instant

HTML

Écrivez vous-même les 3 listes, avec vos libellés et vos styles, puis chargez le script. La liste des communes envoie le code HCP avec le formulaire, sous son name.

HTML
<form>
  <commune-picker>
    <label>Région <select data-level="region"><option value="">Choisir</option></select></label>
    <label>Province ou préfecture <select data-level="province"><option value="">Choisir</option></select></label>
    <label>Commune <select data-level="commune" name="commune" required><option value="">Choisir</option></select></label>
  </commune-picker>
</form>
<script type="module" src="https://communes.pages.dev/components/commune-picker.js"></script>
AttributRôle
apiL’adresse de l’API. Par défaut, celle d’où le script est chargé.
valueUn code de commune à l’ouverture, comme 01.511.01.0. Sa région et sa province suivent.
langar pour les noms en arabe. Le français sinon.
data-levelSur chaque liste : region, province ou commune.

Pour héberger le script vous-même, copiez le fichier et donnez à api l’adresse de ce site.

commune-picker.js
/**
 * <commune-picker>: région, then province or préfecture, then commune, filled in from the
 * Morocco communes API. It fills 3 <select> elements you write yourself, so the labels,
 * the language and the styling stay yours, and the commune's HCP code is posted with the
 * form like any other field.
 *
 *   <commune-picker>
 *     <label>Région <select data-level="region"></select></label>
 *     <label>Province <select data-level="province"></select></label>
 *     <label>Commune <select data-level="commune" name="commune" required></select></label>
 *   </commune-picker>
 *   <script type="module" src="https://<this site>/components/commune-picker.js"></script>
 *
 * Attributes:
 *   api    where the API lives. Defaults to the origin this file was loaded from.
 *   value  a commune code to start on, such as 01.511.01.0. Its région and province follow.
 *   lang   "ar" for Arabic names. French otherwise.
 *
 * It reads only the static files of the API, which cost nothing to serve.
 */
const DEFAULT_API = new URL(import.meta.url).origin;

class CommunePicker extends HTMLElement {
  #selects = {};
  #pending = { province: 0, commune: 0 };

  connectedCallback() {
    if (this.#selects.region) return;
    for (const level of ["region", "province", "commune"]) {
      const select = this.querySelector(`select[data-level="${level}"]`);
      if (!select) throw new Error(`commune-picker needs a <select data-level="${level}">`);
      // An option with an empty value that the page wrote itself is kept as the prompt.
      const prompt = select.querySelector('option[value=""]');
      this.#selects[level] = { select, prompt: prompt ? prompt.textContent : "" };
    }
    this.#selects.region.select.addEventListener("change", () => this.#region());
    this.#selects.province.select.addEventListener("change", () => this.#province());
    this.#start();
  }

  get #api() {
    return (this.getAttribute("api") || DEFAULT_API).replace(/\/$/, "");
  }

  get #lang() {
    return this.getAttribute("lang") === "ar" ? "ar" : "fr";
  }

  async #get(path) {
    const response = await fetch(this.#api + path);
    if (!response.ok) throw new Error(`${path} answered ${response.status}`);
    return response.json();
  }

  /** Every commune in a province. The lists are paged, and a province runs to 2 pages at most. */
  async #communes(province) {
    const base = `/api/provinces/${province}/communes/page`;
    const first = await this.#get(`${base}/1.json`);
    const rest = [];
    for (let page = 2; page <= first.meta.totalPages; page++) rest.push(this.#get(`${base}/${page}.json`));
    return [first, ...(await Promise.all(rest))].flatMap((body) => body.data);
  }

  #fill(level, rows, selected = "") {
    const { select, prompt } = this.#selects[level];
    const lang = this.#lang;
    const options = rows.map((row) => new Option(row.name[lang], row.code, false, row.code === selected));
    select.replaceChildren(new Option(prompt, ""), ...options);
    select.disabled = rows.length === 0;
    select.dir = lang === "ar" ? "rtl" : "";
  }

  #clear(...levels) {
    for (const level of levels) this.#fill(level, []);
  }

  #sorted(rows) {
    const lang = this.#lang;
    return rows.slice().sort((a, b) => a.name[lang].localeCompare(b.name[lang], lang));
  }

  async #start() {
    // A commune's code carries its région and province: 01.511.01.0 is in 01 and 01.511.
    const value = this.getAttribute("value") || "";
    const [region = "", province = ""] = value ? [value.slice(0, 2), value.slice(0, 6)] : [];
    this.#clear("province", "commune");
    try {
      const regions = await this.#get("/api/regions.json");
      this.#fill("region", regions.data, region);
      if (region) await this.#region(province, value);
    } catch (error) {
      this.#failed(error);
    }
  }

  async #region(province = "", commune = "") {
    const region = this.#selects.region.select.value;
    const ticket = ++this.#pending.province;
    this.#clear("province", "commune");
    if (!region) return;
    try {
      const body = await this.#get(`/api/regions/${region}/provinces.json`);
      // A later choice has already replaced this one.
      if (ticket !== this.#pending.province) return;
      // Préfectures d'arrondissements hold no communes of their own, so they're left out.
      const provinces = this.#sorted(body.data.filter((p) => p.communeCount > 0));
      this.#fill("province", provinces, province);
      if (province) await this.#province(commune);
    } catch (error) {
      this.#failed(error);
    }
  }

  async #province(commune = "") {
    const province = this.#selects.province.select.value;
    const ticket = ++this.#pending.commune;
    this.#clear("commune");
    if (!province) return;
    try {
      const communes = await this.#communes(province);
      if (ticket !== this.#pending.commune) return;
      this.#fill("commune", this.#sorted(communes), commune);
    } catch (error) {
      this.#failed(error);
    }
  }

  #failed(error) {
    this.dataset.state = "error";
    this.dispatchEvent(new CustomEvent("commune-picker-error", { detail: error, bubbles: true }));
  }
}

if (!customElements.get("commune-picker")) customElements.define("commune-picker", CommunePicker);

React

Le même sélecteur en composant. value et onChange portent le code de la commune.

CommunePicker.jsx
import { useEffect, useState } from "react";

// Where the Morocco communes API lives, and what the form calls things.
const API = "https://communes.pages.dev";
const LABELS = {region: "Région", province: "Province ou préfecture", commune: "Commune", choose: "Choisir"};

async function get(path) {
  const response = await fetch(API + path);
  if (!response.ok) throw new Error(`${path} answered ${response.status}`);
  return response.json();
}

// A province's communes come in pages of 50, and no province has more than 2.
async function communesOf(province) {
  const base = `/api/provinces/${province}/communes/page`;
  const first = await get(`${base}/1.json`);
  const rest = [];
  for (let page = 2; page <= first.meta.totalPages; page++) rest.push(get(`${base}/${page}.json`));
  return [first, ...(await Promise.all(rest))].flatMap((body) => body.data);
}

const byName = (a, b) => a.name.fr.localeCompare(b.name.fr, "fr");

/**
 * Région, then province or préfecture, then commune. `value` and `onChange` carry the
 * commune's HCP code, such as 01.511.01.0, and the code's first digits already name its
 * région and province, so a saved value opens on the right lists.
 */
export function CommunePicker({ value = "", onChange, name = "commune" }) {
  const [region, setRegion] = useState(value.slice(0, 2));
  const [province, setProvince] = useState(value.slice(0, 6));
  const [regions, setRegions] = useState([]);
  const [provinces, setProvinces] = useState([]);
  const [communes, setCommunes] = useState([]);

  useEffect(() => {
    get("/api/regions.json").then((body) => setRegions(body.data), console.error);
  }, []);

  useEffect(() => {
    setProvinces([]);
    if (!region) return;
    let current = true;
    get(`/api/regions/${region}/provinces.json`).then((body) => {
      // Préfectures d'arrondissements hold no communes of their own, so they're left out.
      if (current) setProvinces(body.data.filter((p) => p.communeCount > 0).sort(byName));
    }, console.error);
    return () => (current = false);
  }, [region]);

  useEffect(() => {
    setCommunes([]);
    if (!province) return;
    let current = true;
    communesOf(province).then((rows) => current && setCommunes(rows.sort(byName)), console.error);
    return () => (current = false);
  }, [province]);

  return (
    <>
      <label>
        {LABELS.region}
        <select
          value={region}
          onChange={(e) => {
            setRegion(e.target.value);
            setProvince("");
            onChange?.("");
          }}
        >
          <option value="">{LABELS.choose}</option>
          {regions.map((r) => (
            <option key={r.code} value={r.code}>{r.name.fr}</option>
          ))}
        </select>
      </label>
      <label>
        {LABELS.province}
        <select
          value={province}
          disabled={provinces.length === 0}
          onChange={(e) => {
            setProvince(e.target.value);
            onChange?.("");
          }}
        >
          <option value="">{LABELS.choose}</option>
          {provinces.map((p) => (
            <option key={p.code} value={p.code}>{p.name.fr}</option>
          ))}
        </select>
      </label>
      <label>
        {LABELS.commune}
        <select name={name} value={value} disabled={communes.length === 0} onChange={(e) => onChange?.(e.target.value)}>
          <option value="">{LABELS.choose}</option>
          {communes.map((c) => (
            <option key={c.code} value={c.code}>{c.name.fr}</option>
          ))}
        </select>
      </label>
    </>
  );
}

Comment il se comporte

  • Il ne lit que des fichiers statiques : il ne coûte rien, quel que soit le trafic du formulaire.
  • Un code enregistré rouvre sur la bonne région et la bonne province, puisque le code les nomme déjà.
  • Les préfectures d’arrondissements sont écartées : elles contiennent des arrondissements, pas des communes.
  • Les noms sont triés dans la langue où ils s’affichent.