aboutsummaryrefslogtreecommitdiffstats
path: root/app/dom.js
diff options
context:
space:
mode:
authorPetri Hienonen <petri.hienonen@gmail.com>2025-10-29 15:18:30 +0200
committerPetri Hienonen <petri.hienonen@gmail.com>2025-11-03 10:54:48 +0200
commitb03ee7032b2ea2d4d22ab7ec1346b7c9331cfc17 (patch)
treeefc0ce6823ab8611d9c6a0bf27ecdbd124638b73 /app/dom.js
downloadhousing-b03ee7032b2ea2d4d22ab7ec1346b7c9331cfc17.tar.zst
Initial commit
Diffstat (limited to 'app/dom.js')
-rw-r--r--app/dom.js556
1 files changed, 556 insertions, 0 deletions
diff --git a/app/dom.js b/app/dom.js
new file mode 100644
index 0000000..39d9436
--- /dev/null
+++ b/app/dom.js
@@ -0,0 +1,556 @@
+/**
+ * @typedef {Object} BaseElementOptions
+ * @property {Partial<CSSStyleDeclaration>} [styles]
+ * @property {string} [id]
+ * @property {string[]} [classes]
+ * @property {string} [textContent]
+ * @property {HTMLElement[]} [children]
+ * @property {Record<string, string>} [attributes]
+ */
+
+/**
+ * Toast notification types
+ * @enum {string}
+ */
+export const ToastType = {
+ error: "error",
+ success: "success",
+ warning: "warning",
+};
+
+/**
+ * DOM element creation class – every creator applies its own options.
+ * @class
+ */
+export class Dom {
+ /**
+ * Create a `<div>`
+ * @param {BaseElementOptions} [options={}]
+ * @returns {HTMLDivElement}
+ */
+ static div(options = {}) {
+ const {
+ styles = {},
+ id = "",
+ classes = [],
+ textContent = "",
+ children = [],
+ attributes = {},
+ } = options;
+
+ const div = document.createElement("div");
+ Object.assign(div.style, styles);
+ if (id) div.id = id;
+ for (const cls of classes) div.classList.add(cls);
+ if (textContent) div.textContent = textContent;
+ for (const [k, v] of Object.entries(attributes)) div.setAttribute(k, v);
+ Dom.appendChildren(div, children);
+ return div;
+ }
+
+ /**
+ * Create a `<span>`
+ * @param {BaseElementOptions} [options={}]
+ * @returns {HTMLSpanElement}
+ */
+ static span(options = {}) {
+ const {
+ styles = {},
+ id = "",
+ classes = [],
+ textContent = "",
+ children = [],
+ attributes = {},
+ } = options;
+
+ const span = document.createElement("span");
+ Object.assign(span.style, styles);
+ if (id) span.id = id;
+ for (const cls of classes) span.classList.add(cls);
+ if (textContent) span.textContent = textContent;
+ for (const [k, v] of Object.entries(attributes)) span.setAttribute(k, v);
+ Dom.appendChildren(span, children);
+ return span;
+ }
+
+ /**
+ * Create a `<button>`
+ * @param {BaseElementOptions & { onClick?: (e: MouseEvent) => void }} [options={}]
+ * @returns {HTMLButtonElement}
+ */
+ static button(options = {}) {
+ const {
+ styles = {},
+ id = "",
+ classes = [],
+ textContent = "",
+ children = [],
+ attributes = {},
+ onClick,
+ } = options;
+
+ const button = document.createElement("button");
+ Object.assign(button.style, styles);
+ if (id) button.id = id;
+ for (const cls of classes) button.classList.add(cls);
+ if (textContent) button.textContent = textContent;
+ for (const [k, v] of Object.entries(attributes)) button.setAttribute(k, v);
+ Dom.appendChildren(button, children);
+ if (onClick) button.addEventListener("click", onClick);
+ return button;
+ }
+
+ /**
+ * @typedef {BaseElementOptions & {
+ * type?: string,
+ * placeholder?: string,
+ * value?: string,
+ * onInput?: (e: InputEvent) => void,
+ * onChange?: (e: Event) => void
+ * }} InputOptions
+ */
+
+ /**
+ * Create an `<input>`
+ * @param {InputOptions} [options={}]
+ * @returns {HTMLInputElement}
+ */
+ static input(options = {}) {
+ const {
+ styles = {},
+ id = "",
+ classes = [],
+ textContent = "",
+ attributes = {},
+ type = "text",
+ placeholder = "",
+ value = "",
+ onInput,
+ onChange,
+ } = options;
+
+ const input = document.createElement("input");
+ Object.assign(input.style, styles);
+ if (id) input.id = id;
+ for (const cls of classes) input.classList.add(cls);
+ if (textContent) input.textContent = textContent;
+ for (const [k, v] of Object.entries(attributes)) input.setAttribute(k, v);
+
+ input.type = type;
+ input.placeholder = placeholder;
+ input.value = value;
+
+ if (onInput) {
+ input.addEventListener(
+ "input",
+ /** @param {InputEvent} e */ (e) => {
+ const _target = /** @type {HTMLInputElement} */ (e.target);
+ onInput(e);
+ },
+ );
+ }
+ if (onChange) {
+ input.addEventListener("change", onChange);
+ }
+ return input;
+ }
+
+ /**
+ * @typedef {BaseElementOptions & { for?: string }} LabelOptions
+ */
+
+ /**
+ * Create a `<label>`
+ * @param {LabelOptions} [options={}]
+ * @returns {HTMLLabelElement}
+ */
+ static label(options = {}) {
+ const {
+ styles = {},
+ id = "",
+ classes = [],
+ textContent = "",
+ children = [],
+ attributes = {},
+ for: htmlFor = "",
+ } = options;
+
+ const label = document.createElement("label");
+ Object.assign(label.style, styles);
+ if (id) label.id = id;
+ for (const cls of classes) label.classList.add(cls);
+ if (textContent) label.textContent = textContent;
+ for (const [k, v] of Object.entries(attributes)) label.setAttribute(k, v);
+ Dom.appendChildren(label, children);
+ label.htmlFor = htmlFor;
+ return label;
+ }
+
+ /**
+ * @typedef {BaseElementOptions & { level: 1|2|3|4|5|6 }} HeadingOptions
+ */
+
+ /**
+ * Create a heading `<h1>`–`<h6>`
+ * @param {HeadingOptions["level"]} level
+ * @param {Omit<HeadingOptions, "level">} [options={}]
+ * @returns {HTMLHeadingElement}
+ */
+ static heading(level, options = {}) {
+ const {
+ styles = {},
+ id = "",
+ classes = [],
+ textContent = "",
+ children = [],
+ attributes = {},
+ } = options;
+
+ const heading = document.createElement(`h${level}`);
+ Object.assign(heading.style, styles);
+ if (id) heading.id = id;
+ for (const cls of classes) heading.classList.add(cls);
+ if (textContent) heading.textContent = textContent;
+ for (const [k, v] of Object.entries(attributes)) heading.setAttribute(k, v);
+ Dom.appendChildren(heading, children);
+ return heading;
+ }
+
+ /**
+ * @typedef {BaseElementOptions & { src?: string }} ImgOptions
+ */
+
+ /**
+ * Create an `<img>`
+ * @param {ImgOptions} [options={}]
+ * @returns {HTMLImageElement}
+ */
+ static img(options = {}) {
+ const { styles = {}, id = "", classes = [], attributes = {}, src = "" } = options;
+
+ const img = document.createElement("img");
+ Object.assign(img.style, styles);
+ if (id) img.id = id;
+ for (const cls of classes) img.classList.add(cls);
+ for (const [k, v] of Object.entries(attributes)) img.setAttribute(k, v);
+ img.src = src;
+ return img;
+ }
+
+ /**
+ * @typedef {BaseElementOptions & { onChange?: (e: Event) => void }} SelectOptions
+ */
+
+ /**
+ * Create a `<select>`
+ * @param {SelectOptions} [options={}]
+ * @returns {HTMLSelectElement}
+ */
+ static select(options = {}) {
+ const {
+ styles = {},
+ id = "",
+ classes = [],
+ children = [],
+ attributes = {},
+ onChange,
+ } = options;
+
+ const select = document.createElement("select");
+ Object.assign(select.style, styles);
+ if (id) select.id = id;
+ for (const cls of classes) select.classList.add(cls);
+ for (const [k, v] of Object.entries(attributes)) select.setAttribute(k, v);
+ Dom.appendChildren(select, children);
+ if (onChange) select.addEventListener("change", onChange);
+ return select;
+ }
+
+ /**
+ * Create an `<option>`
+ * @param {string} value
+ * @param {string} text
+ * @param {boolean} [selected=false]
+ * @returns {HTMLOptionElement}
+ */
+ static option(value, text, selected = false) {
+ const opt = document.createElement("option");
+ opt.value = value;
+ opt.textContent = text;
+ opt.selected = selected;
+ return opt;
+ }
+
+ /**
+ * Create a `<p>`
+ * @param {BaseElementOptions} [options={}]
+ * @returns {HTMLParagraphElement}
+ */
+ static p(options = {}) {
+ const {
+ styles = {},
+ id = "",
+ classes = [],
+ textContent = "",
+ children = [],
+ attributes = {},
+ } = options;
+
+ const p = document.createElement("p");
+ Object.assign(p.style, styles);
+ if (id) p.id = id;
+ for (const cls of classes) p.classList.add(cls);
+ if (textContent) p.textContent = textContent;
+ for (const [k, v] of Object.entries(attributes)) p.setAttribute(k, v);
+ Dom.appendChildren(p, children);
+ return p;
+ }
+
+ /**
+ * Build a modal dialog
+ * @param {() => void} onClose
+ * @returns {HTMLDialogElement}
+ */
+ static buildModal(onClose) {
+ const modal = document.createElement("dialog");
+ Object.assign(modal.style, {
+ background: "white",
+ border: "none",
+ borderRadius: "8px",
+ boxShadow: "0 4px 20px rgba(0,0,0,0.2)",
+ maxHeight: "80vh",
+ maxWidth: "600px",
+ overflowY: "auto",
+ padding: "20px",
+ position: "fixed",
+ top: "50%",
+ transform: "translateY(-50%)",
+ width: "90%",
+ zIndex: "1000",
+ });
+
+ const closeBtn = Dom.button({
+ onClick: onClose,
+ styles: {
+ background: "none",
+ border: "none",
+ color: "#666",
+ cursor: "pointer",
+ fontSize: "24px",
+ position: "absolute",
+ right: "10px",
+ top: "10px",
+ },
+ textContent: "×",
+ });
+
+ modal.appendChild(closeBtn);
+ modal.addEventListener("close", onClose);
+ return modal;
+ }
+
+ /**
+ * Build modal content for a house
+ * @param {import("./models.js").House} house
+ * @returns {DocumentFragment}
+ */
+ static buildModalContent(house) {
+ const frag = document.createDocumentFragment();
+
+ /* Header */
+ const header = Dom.div({
+ styles: {
+ alignItems: "center",
+ display: "flex",
+ justifyContent: "space-between",
+ marginBottom: "20px",
+ },
+ });
+ const title = Dom.heading(2, {
+ styles: { color: "#333", fontSize: "20px", margin: "0" },
+ textContent: house.location.address,
+ });
+ const score = Dom.span({
+ styles: {
+ background: "#e8f5e9",
+ borderRadius: "4px",
+ color: "#2e7d32",
+ fontSize: "16px",
+ fontWeight: "bold",
+ padding: "4px 8px",
+ },
+ textContent: `Score: ${house.scores.current.toFixed(1)}`,
+ });
+ Dom.appendChildren(header, [title, score]);
+ frag.appendChild(header);
+
+ /* Details grid */
+ const grid = Dom.div({
+ styles: {
+ display: "grid",
+ gap: "15px",
+ gridTemplateColumns: "repeat(2,1fr)",
+ marginBottom: "20px",
+ },
+ });
+ const details = [
+ { label: "Price", value: `${house.property.price} €` },
+ { label: "Building Type", value: house.property.buildingType },
+ { label: "Construction Year", value: house.property.constructionYear?.toString() ?? "N/A" },
+ { label: "Living Area", value: `${house.property.livingArea} m²` },
+ { label: "District", value: house.location.district },
+ { label: "Rooms", value: house.property.rooms?.toString() ?? "N/A" },
+ ];
+ for (const { label, value } of details) {
+ const item = Dom.div({
+ children: [
+ Dom.div({
+ styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "4px" },
+ textContent: label,
+ }),
+ Dom.div({ styles: { color: "#333", fontSize: "14px" }, textContent: value }),
+ ],
+ });
+ grid.appendChild(item);
+ }
+ frag.appendChild(grid);
+
+ /* Description */
+ const descSect = Dom.div({ styles: { marginBottom: "20px" } });
+ const descTitle = Dom.div({
+ styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "5px" },
+ textContent: "Description",
+ });
+ const descText = Dom.p({
+ styles: { color: "#333", fontSize: "14px", lineHeight: "1.4", marginTop: "5px" },
+ textContent: house.property.description ?? "No description available.",
+ });
+ Dom.appendChildren(descSect, [descTitle, descText]);
+ frag.appendChild(descSect);
+
+ /* Images */
+ if (house.images?.length) {
+ const imgSect = Dom.div({ styles: { marginBottom: "20px" } });
+ const imgTitle = Dom.div({
+ styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "10px" },
+ textContent: "Images",
+ });
+ const imgCont = Dom.div({
+ styles: { display: "flex", gap: "10px", overflowX: "auto", paddingBottom: "5px" },
+ });
+ for (const src of house.images.slice(0, 3)) {
+ imgCont.appendChild(
+ Dom.img({
+ attributes: { loading: "lazy" },
+ src,
+ styles: { borderRadius: "4px", flexShrink: "0", height: "100px" },
+ }),
+ );
+ }
+ Dom.appendChildren(imgSect, [imgTitle, imgCont]);
+ frag.appendChild(imgSect);
+ }
+
+ return frag;
+ }
+
+ /**
+ * Show toast notification
+ * @param {string} message
+ * @param {ToastType} [type=ToastType.error]
+ * @param {number} [duration=5000]
+ */
+ static showToast(message, type = ToastType.error, duration = 5000) {
+ document.getElementById("app-toast")?.remove();
+
+ const bg =
+ type === ToastType.error ? "#f44336" : type === ToastType.warning ? "#ff9800" : "#4caf50";
+
+ const toast = Dom.div({
+ classes: ["toast", `toast-${type}`],
+ id: "app-toast",
+ styles: {
+ background: bg,
+ borderRadius: "4px",
+ boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
+ color: "white",
+ fontSize: "14px",
+ fontWeight: "500",
+ maxWidth: "300px",
+ padding: "12px 20px",
+ position: "fixed",
+ right: "20px",
+ top: "20px",
+ transition: "all .3s ease",
+ zIndex: "10000",
+ },
+ textContent: message,
+ });
+
+ document.body.appendChild(toast);
+
+ setTimeout(() => {
+ if (toast.parentNode) {
+ toast.style.opacity = "0";
+ setTimeout(() => toast.remove(), 300);
+ }
+ }, duration);
+ }
+
+ /**
+ * Loading spinner
+ * @returns {HTMLDivElement}
+ */
+ static loadingIndicator() {
+ const spinner = Dom.div({
+ classes: ["loading-indicator"],
+ styles: {
+ animation: "spin 1s linear infinite",
+ border: "2px solid #f3f3f3",
+ borderRadius: "50%",
+ borderTop: "2px solid #3498db",
+ display: "inline-block",
+ height: "16px",
+ width: "16px",
+ },
+ });
+
+ if (!document.querySelector("#loading-styles")) {
+ const style = document.createElement("style");
+ style.id = "loading-styles";
+ style.textContent = `
+ @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
+ `;
+ document.head.appendChild(style);
+ }
+ return spinner;
+ }
+
+ /**
+ * Remove all children
+ * @param {HTMLElement} el
+ */
+ static clear(el) {
+ while (el.firstChild) el.removeChild(el.firstChild);
+ }
+
+ /**
+ * Append many children
+ * @param {HTMLElement} parent
+ * @param {HTMLElement[]} children
+ */
+ static appendChildren(parent, children) {
+ for (const child of children) {
+ if (child) parent.appendChild(child);
+ }
+ }
+
+ /**
+ * Replace an element
+ * @param {HTMLElement} oldEl
+ * @param {HTMLElement} newEl
+ */
+ static replace(oldEl, newEl) {
+ oldEl.parentNode?.replaceChild(newEl, oldEl);
+ }
+}