Components
A commune picker for forms, in HTML or React.
Try it
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.
<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>| Attribute | What it does |
|---|---|
| api | Where the API lives. The script’s own origin when it’s left out. |
| value | A commune code to open on, such as 01.511.01.0. Its région and province follow. |
| lang | ar for Arabic names. French otherwise. |
| data-level | On each select: region, province or commune. |
To host the script yourself, copy the file and set api to this site’s address.
/**
* <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.
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.