Components

A commune picker for forms, in HTML or React.

Try it

The form sends nothing yet

HTML

Write the 3 selects yourself, with your own labels and styles, then load the script. The commune’s select sends its HCP code with the form, under its name.

HTML
<form>
  <commune-picker>
    <label>Région <select data-level="region"><option value="">Choose</option></select></label>
    <label>Province or préfecture <select data-level="province"><option value="">Choose</option></select></label>
    <label>Commune <select data-level="commune" name="commune" required><option value="">Choose</option></select></label>
  </commune-picker>
</form>
<script type="module" src="https://communes.pages.dev/components/commune-picker.js"></script>
AttributeWhat it does
apiWhere the API lives. The script’s own origin when it’s left out.
valueA commune code to open on, such as 01.511.01.0. Its région and province follow.
langar for Arabic names. French otherwise.
data-levelOn each select: region, province or commune.

To host the script yourself, copy the file and set api to this site’s address.

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

The same picker as a component. value and onChange carry the commune’s code.

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 or préfecture", commune: "Commune", choose: "Choose"};

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>
    </>
  );
}

How it behaves

  • It reads only static files, so it costs nothing to run however busy the form gets.
  • A saved code opens on the right région and province, because the code already names both.
  • Préfectures d’arrondissements are left out: they hold arrondissements, not communes.
  • Names sort in the language they’re shown in.