diff options
| author | Petri Hienonen <petri.hienonen@gmail.com> | 2025-10-29 15:18:30 +0200 |
|---|---|---|
| committer | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-03 10:54:48 +0200 |
| commit | b03ee7032b2ea2d4d22ab7ec1346b7c9331cfc17 (patch) | |
| tree | efc0ce6823ab8611d9c6a0bf27ecdbd124638b73 /app/dom.js | |
| download | housing-b03ee7032b2ea2d4d22ab7ec1346b7c9331cfc17.tar.zst | |
Initial commit
Diffstat (limited to 'app/dom.js')
| -rw-r--r-- | app/dom.js | 556 |
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); + } +} |
