المكونات
مكون لاختيار الجماعة في الاستمارات، بـHTML أو React.
جرّبه
HTML
اكتب قوائم الاختيار الـ3 بنفسك، بتسمياتك وتنسيقك، ثم حمّل الـscript. قائمة الجماعات ترسل رمز المندوبية مع الاستمارة، تحت name الخاص بها.
<form>
<commune-picker>
<label>الجهة <select data-level="region"><option value="">اختر</option></select></label>
<label>العمالة أو الإقليم <select data-level="province"><option value="">اختر</option></select></label>
<label>الجماعة <select data-level="commune" name="commune" required><option value="">اختر</option></select></label>
</commune-picker>
</form>
<script type="module" src="https://communes.pages.dev/components/commune-picker.js"></script>| الخاصية (attribute) | وظيفتها |
|---|---|
| api | عنوان API. عند غياب هذه الخاصية، يستعمل عنوان الـscript نفسه. |
| value | رمز جماعة يفتح عليها المكون، مثل 01.511.01.0. فتتحدد جهتها وإقليمها تلقائيا. |
| lang | ar لعرض الأسماء بالعربية. وبالفرنسية في غير ذلك. |
| data-level | على كل قائمة اختيار: region أو province أو commune. |
لاستضافة الـscript بنفسك، انسخ الملف واجعل api عنوان هذا الموقع.
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
الاختيار نفسه في شكل مكون React. value وonChange يحملان رمز الجماعة.
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: "الجهة", province: "العمالة أو الإقليم", commune: "الجماعة", 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>
</>
);
}
طريقة عمله
- يقرأ ملفات ثابتة فقط، فلا يكلف تشغيله شيئا مهما كثر استعمال الاستمارة.
- الرمز المحفوظ يفتح المكون على الجهة والإقليم الصحيحين، لأن الرمز نفسه يتضمنهما.
- عمالات المقاطعات مستثناة: فهي تضم مقاطعات لا جماعات.
- ترتب الأسماء حسب اللغة المعروضة بها.