aboutsummaryrefslogtreecommitdiffstats
path: root/app/dom.js
diff options
context:
space:
mode:
authorPetri Hienonen <petri.hienonen@gmail.com>2025-11-04 17:07:24 +0200
committerPetri Hienonen <petri.hienonen@gmail.com>2025-11-09 22:48:55 +0200
commitbe7ec90b500ac68e053f2b58feb085247ef95817 (patch)
treeaef7732ce0bbe505c6bc8486e1d0da2c06990e6a /app/dom.js
parenta4ed99a370930b1a0c0f065906ed99c15a015fd4 (diff)
downloadhousing-be7ec90b500ac68e053f2b58feb085247ef95817.tar.zst
Refactor application to use couchbase
Diffstat (limited to 'app/dom.js')
-rw-r--r--app/dom.js920
1 files changed, 467 insertions, 453 deletions
diff --git a/app/dom.js b/app/dom.js
index 5f94132..ab43570 100644
--- a/app/dom.js
+++ b/app/dom.js
@@ -1,12 +1,29 @@
-/**
- * @typedef {Object} BaseElementOptions
- * @property {Partial<CSSStyleDeclaration>} [styles]
- * @property {string} [id]
- * @property {string[]} [classes]
- * @property {string} [textContent]
- * @property {HTMLElement[]} [children]
- * @property {Record<string, string>} [attributes]
- */
+// dom.js
+import { House } from "models";
+
+export class DomOptions {
+ attributes;
+ children;
+ classes;
+ id;
+ styles;
+
+ /**
+ * @param {Object} [options]
+ * @param {Partial<CSSStyleDeclaration>} [options.styles]
+ * @param {string|null} [options.id]
+ * @param {string[]} [options.classes]
+ * @param {HTMLElement[]} [options.children]
+ * @param {Record<string, string>} [options.attributes]
+ */
+ constructor({ id = "", styles = {}, classes = [], children = [], attributes = {} } = {}) {
+ this.attributes = attributes;
+ this.children = children;
+ this.classes = classes;
+ this.id = id;
+ this.styles = styles;
+ }
+}
/**
* Toast notification types
@@ -18,136 +35,77 @@ export const ToastType = {
warning: "warning",
};
-/**
- * DOM element creation class – every creator applies its own options.
- * @class
- */
export class Dom {
/**
* Create a `<div>`
- * @param {BaseElementOptions} [options={}]
+ * @param {DomOptions} options
* @returns {HTMLDivElement}
*/
- static div(options = {}) {
- const {
- styles = {},
- id = "",
- classes = [],
- textContent = "",
- children = [],
- attributes = {},
- } = options;
-
+ static div(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);
+ Object.assign(div.style, options.styles);
+ if (options.id) div.id = options.id;
+ for (const cls of options.classes) div.classList.add(cls);
+ for (const [k, v] of Object.entries(options.attributes)) div.setAttribute(k, v);
+ if (options.children) div.append(...options.children);
return div;
}
/**
* Create a `<span>`
- * @param {BaseElementOptions} [options={}]
+ * @param {string} text
+ * @param {DomOptions} options
* @returns {HTMLSpanElement}
*/
- static span(options = {}) {
- const {
- styles = {},
- id = "",
- classes = [],
- textContent = "",
- children = [],
- attributes = {},
- } = options;
-
+ static span(text, options = new DomOptions()) {
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);
+ Object.assign(span.style, options.styles);
+ if (options.id) span.id = options.id;
+ for (const cls of options.classes) span.classList.add(cls);
+ span.textContent = text;
+ for (const [k, v] of Object.entries(options.attributes)) span.setAttribute(k, v);
+ if (options.children) span.append(...options.children);
return span;
}
/**
* Create a `<button>`
- * @param {BaseElementOptions & { onClick?: (e: MouseEvent) => void }} [options={}]
+ * @param { string} text
+ * @param { (e: Event) => void } onClick
+ * @param {DomOptions} o
* @returns {HTMLButtonElement}
*/
- static button(options = {}) {
- const {
- styles = {},
- id = "",
- classes = [],
- textContent = "",
- children = [],
- attributes = {},
- onClick,
- } = options;
-
+ static button(text, onClick, o = new DomOptions()) {
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);
+ Object.assign(button.style, o.styles);
+ if (o.id) button.id = o.id;
+ for (const cls of o.classes) button.classList.add(cls);
+ if (text) button.textContent = text;
+ for (const [k, v] of Object.entries(o.attributes)) button.setAttribute(k, v);
+ if (o.children) button.append(...o.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={}]
+ * @param { string} type
+ * @param { (e: Event) => void } onChange
+ * @param { string} value
+ * @param { string} placeholder
+ * @param {DomOptions} [o]
* @returns {HTMLInputElement}
*/
- static input(options = {}) {
- const {
- styles = {},
- id = "",
- classes = [],
- textContent = "",
- attributes = {},
- type = "text",
- placeholder = "",
- value = "",
- onInput,
- onChange,
- } = options;
-
+ static input(type, onChange, value = "", placeholder = "", o = new DomOptions()) {
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);
+ Object.assign(input.style, o.styles);
+ if (o.id) input.id = o.id;
+ for (const cls of o.classes) input.classList.add(cls);
+ for (const [k, v] of Object.entries(o.attributes)) input.setAttribute(k, v);
input.type = type;
input.placeholder = placeholder;
input.value = value;
-
- if (onInput) {
- input.addEventListener(
- "input",
- /** @param {Event} e */ (e) => {
- onInput(/** @type {InputEvent} */ (e));
- },
- );
- }
if (onChange) {
input.addEventListener("change", onChange);
}
@@ -155,112 +113,71 @@ export class Dom {
}
/**
- * @typedef {BaseElementOptions & { for?: string }} LabelOptions
- */
-
- /**
* Create a `<label>`
- * @param {LabelOptions} [options={}]
+ * @param {string} to
+ * @param {string} text
+ * @param {DomOptions} o
* @returns {HTMLLabelElement}
*/
- static label(options = {}) {
- const {
- styles = {},
- id = "",
- classes = [],
- textContent = "",
- children = [],
- attributes = {},
- for: htmlFor = "",
- } = options;
-
+ static label(to, text, o = new DomOptions()) {
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;
+ Object.assign(label.style, o.styles);
+ if (o.id) label.id = o.id;
+ for (const cls of o.classes) label.classList.add(cls);
+ for (const [k, v] of Object.entries(o.attributes)) label.setAttribute(k, v);
+ if (o.children) label.append(...o.children);
+ label.textContent = text;
+ label.htmlFor = to;
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={}]
+ * @param {1|2|3|4|5|6} level
+ * @param {string} text
+ * @param {DomOptions} o
* @returns {HTMLHeadingElement}
*/
- static heading(level, options = {}) {
- const {
- styles = {},
- id = "",
- classes = [],
- textContent = "",
- children = [],
- attributes = {},
- } = options;
-
+ static heading(level, text, o = new DomOptions()) {
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);
+ Object.assign(heading.style, o.styles);
+ if (o.id) heading.id = o.id;
+ for (const cls of o.classes) heading.classList.add(cls);
+ if (text) heading.textContent = text;
+ for (const [k, v] of Object.entries(o.attributes)) heading.setAttribute(k, v);
+ if (o.children) heading.append(...o.children);
return heading;
}
/**
- * @typedef {BaseElementOptions & { src?: string }} ImgOptions
- */
-
- /**
* Create an `<img>`
- * @param {ImgOptions} [options={}]
+ * @param {string} src
+ * @param {DomOptions} o
* @returns {HTMLImageElement}
*/
- static img(options = {}) {
- const { styles = {}, id = "", classes = [], attributes = {}, src = "" } = options;
-
+ static img(src, o = new DomOptions()) {
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);
+ Object.assign(img.style, o.styles);
+ if (o.id) img.id = o.id;
+ for (const cls of o.classes) img.classList.add(cls);
+ for (const [k, v] of Object.entries(o.attributes)) img.setAttribute(k, v);
img.src = src;
return img;
}
/**
- * @typedef {BaseElementOptions & { onChange?: (e: Event) => void }} SelectOptions
- */
-
- /**
* Create a `<select>`
- * @param {SelectOptions} [options={}]
+ * @param {DomOptions} o
+ * @param { (e: Event) => void } onChange
* @returns {HTMLSelectElement}
*/
- static select(options = {}) {
- const {
- styles = {},
- id = "",
- classes = [],
- children = [],
- attributes = {},
- onChange,
- } = options;
-
+ static select(onChange, o = new DomOptions()) {
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);
+ Object.assign(select.style, o.styles);
+ if (o.id) select.id = o.id;
+ for (const cls of o.classes) select.classList.add(cls);
+ for (const [k, v] of Object.entries(o.attributes)) select.setAttribute(k, v);
+ if (o.children) select.append(...o.children);
if (onChange) select.addEventListener("change", onChange);
return select;
}
@@ -282,123 +199,248 @@ export class Dom {
/**
* Create a `<p>`
- * @param {BaseElementOptions} [options={}]
+ * @param {string} text
+ * @param {DomOptions} o
* @returns {HTMLParagraphElement}
*/
- static p(options = {}) {
- const {
- styles = {},
- id = "",
- classes = [],
- textContent = "",
- children = [],
- attributes = {},
- } = options;
-
+ static p(text, o = new DomOptions()) {
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);
+ Object.assign(p.style, o.styles);
+ if (o.id) p.id = o.id;
+ for (const cls of o.classes) p.classList.add(cls);
+ if (text) p.textContent = text;
+ for (const [k, v] of Object.entries(o.attributes)) p.setAttribute(k, v);
+ if (o.children) p.append(...o.children);
return p;
}
+}
+export class Widgets {
/**
- * Build a modal dialog
- * @param {() => void} onClose
- * @returns {HTMLDialogElement}
+ * Show toast notification
+ * @param {string} message
+ * @param {ToastType} [type=ToastType.error]
*/
- 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",
- });
+ static toast(message, type = ToastType.error) {
+ return Dom.div(
+ new DomOptions({
+ children: [Dom.p(message)],
+ classes: ["toast", `toast-${type}`],
+ id: "app-toast",
+ styles: {
+ background:
+ type === ToastType.error
+ ? "#f44336"
+ : type === ToastType.warning
+ ? "#ff9800"
+ : "#4caf50",
+ 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",
+ },
+ }),
+ );
+ }
- const closeBtn = Dom.button({
- onClick: onClose,
- styles: {
- background: "none",
- border: "none",
- color: "#666",
- cursor: "pointer",
- fontSize: "24px",
- position: "absolute",
- right: "10px",
- top: "10px",
- },
- textContent: "×",
- });
+ /**
+ * Remove all children
+ * @param {HTMLElement} el
+ */
+ static clear(el) {
+ while (el.firstChild) el.removeChild(el.firstChild);
+ }
- modal.appendChild(closeBtn);
- modal.addEventListener("close", onClose);
- return modal;
+ /**
+ * Create a weight slider
+ * @param {string} id
+ * @param {string} labelText
+ * @param {string} weightKey
+ * @param {number} initialValue
+ * @param {(key: string, value: number) => void} onChange
+ * @returns {HTMLElement}
+ */
+ static slider(id, labelText, weightKey, initialValue, onChange) {
+ const output = Dom.span(
+ initialValue.toFixed(1),
+ new DomOptions({
+ id: `${id}-value`,
+ styles: { color: "#0066cc", fontSize: "0.85rem", fontWeight: "bold", textAlign: "center" },
+ }),
+ );
+
+ return Dom.div(
+ new DomOptions({
+ children: [
+ Dom.label(
+ id,
+ labelText,
+ new DomOptions({
+ children: [
+ Dom.span(
+ labelText,
+ new DomOptions({
+ styles: { fontSize: "0.85rem" },
+ }),
+ ),
+ Dom.span(" "),
+ output,
+ ],
+ }),
+ ),
+ Dom.input(
+ "range",
+ (e) => {
+ const target = /** @type {HTMLInputElement} */ (e.target);
+ const val = Number(target.value);
+ output.textContent = val.toFixed(1);
+ onChange(weightKey, val);
+ },
+ "",
+ "",
+ new DomOptions({
+ attributes: { max: "1", min: "0", step: "0.1", value: initialValue.toString() },
+ id,
+ styles: {
+ margin: "0.5rem 0",
+ width: "100%",
+ },
+ }),
+ ),
+ ],
+ styles: { display: "flex", flexDirection: "column", marginBottom: "1rem" },
+ }),
+ );
+ }
+
+ /**
+ * Create a number filter input
+ * @param {string} id
+ * @param {string} labelText
+ * @param {(value: number | null) => void} onChange
+ * @returns {HTMLElement}
+ */
+ static numberFilter(id, labelText, onChange) {
+ return Dom.div(
+ new DomOptions({
+ children: [
+ Dom.label(
+ id,
+ labelText,
+ new DomOptions({
+ styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem" },
+ }),
+ ),
+ Dom.input(
+ "number",
+ (e) => {
+ const target = /** @type {HTMLInputElement} */ (e.target);
+ const raw = target.value.trim();
+ onChange(raw === "" ? null : Number(raw));
+ },
+ "any",
+ "",
+ new DomOptions({
+ id,
+ styles: {
+ border: "1px solid #ddd",
+ borderRadius: "4px",
+ fontSize: "0.9rem",
+ padding: "0.5rem",
+ },
+ }),
+ ),
+ ],
+ styles: { display: "flex", flexDirection: "column", marginBottom: "1.75rem" },
+ }),
+ );
}
+}
+
+export class Modal {
+ /** @type {HTMLDialogElement} */
+ #dialog;
+ /** @type {AbortController} */
+ #abortController;
+ /** @type {number | undefined} */
+ #timer;
+ /** @type {boolean} */
+ #persistent;
+ /** @type {House} */
+ #house;
+ /** @type {() => void} */
+ #onHide;
+ /** @type {() => void} */
+ #onClearMapTimer;
/**
* Build modal content for a house
- * @param {import("./models.js").House} house
+ * @param {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.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",
- },
- });
+ frag.appendChild(
+ Dom.div(
+ new DomOptions({
+ children: [
+ Dom.heading(
+ 2,
+ house.address,
+ new DomOptions({
+ styles: { color: "#333", fontSize: "20px", margin: "0" },
+ }),
+ ),
+ Dom.span(
+ `Score: ${house.scores.current.toFixed(1)}`,
+ new DomOptions({
+ styles: {
+ background: "#e8f5e9",
+ borderRadius: "4px",
+ color: "#2e7d32",
+ fontSize: "16px",
+ fontWeight: "bold",
+ padding: "4px 8px",
+ },
+ }),
+ ),
+ ],
+ id: "modal-header",
+ styles: {
+ alignItems: "center",
+ display: "flex",
+ justifyContent: "space-between",
+ marginBottom: "20px",
+ },
+ }),
+ ),
+ );
+
+ const grid = Dom.div(
+ new DomOptions({
+ 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" },
+ { label: "Price", value: `${house.price} €` },
+ { label: "Building Type", value: house.buildingType },
+ { label: "Construction Year", value: house.constructionYear?.toString() ?? "N/A" },
+ { label: "Living Area", value: `${house.livingArea} m²` },
+ { label: "District", value: house.district },
+ { label: "Rooms", value: house.rooms?.toString() ?? "N/A" },
{ label: "Price", value: `${house.price} €` },
{ label: "Building Type", value: house.buildingType },
{ label: "Construction Year", value: house.constructionYear?.toString() ?? "N/A" },
@@ -407,52 +449,73 @@ export class Dom {
{ label: "Rooms", value: house.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 }),
- ],
- });
+ const item = Dom.div(
+ new DomOptions({
+ children: [
+ Dom.span(
+ label,
+ new DomOptions({
+ styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "4px" },
+ }),
+ ),
+ Dom.span(value, new DomOptions({ styles: { color: "#333", fontSize: "14px" } })),
+ ],
+ }),
+ );
grid.appendChild(item);
}
frag.appendChild(grid);
+ frag.appendChild(
+ Dom.div(
+ new DomOptions({
+ children: [
+ Dom.span(
+ "Description",
+ new DomOptions({
+ styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "5px" },
+ }),
+ ),
+ Dom.p(
+ house.description ?? "No description available.",
+ new DomOptions({
+ styles: { color: "#333", fontSize: "14px", lineHeight: "1.4", marginTop: "5px" },
+ }),
+ ),
+ ],
+ styles: { marginBottom: "20px" },
+ }),
+ ),
+ );
- /* 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.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]);
+ const imgSect = Dom.div(
+ new DomOptions({
+ children: [
+ Dom.div(
+ new DomOptions({
+ id: "img_title",
+ styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "10px" },
+ }),
+ ),
+ Dom.div(
+ new DomOptions({
+ children: house.images.slice(0, 3).map((src) => {
+ return Dom.img(
+ src,
+ new DomOptions({
+ attributes: { loading: "lazy" },
+ styles: { borderRadius: "4px", flexShrink: "0", height: "100px" },
+ }),
+ );
+ }),
+
+ styles: { display: "flex", gap: "10px", overflowX: "auto", paddingBottom: "5px" },
+ }),
+ ),
+ ],
+ styles: { marginBottom: "20px" },
+ }),
+ );
frag.appendChild(imgSect);
}
@@ -460,150 +523,101 @@ export class Dom {
}
/**
- * Show toast notification
- * @param {string} message
- * @param {ToastType} [type=ToastType.error]
- * @param {number} [duration=5000]
+ * @param {House} house
+ * @param {boolean} persistent
+ * @param {object} positionStyles
+ * @param {() => void} onHide
+ * @param {() => void} onClearMapTimer
*/
- 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",
+ constructor(house, persistent, positionStyles, onHide, onClearMapTimer) {
+ this.#house = house;
+ this.#persistent = persistent;
+ this.#onHide = onHide;
+ this.#onClearMapTimer = onClearMapTimer;
+ this.#abortController = new AbortController();
+ this.#dialog = document.createElement("dialog");
+
+ Object.assign(
+ this.#dialog.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",
- right: "20px",
- top: "20px",
- transition: "all .3s ease",
- zIndex: "10000",
+ top: "50%",
+ transform: "translateY(-50%)",
+ width: "90%",
+ zIndex: "1000",
},
- textContent: message,
+ positionStyles,
+ );
+
+ this.#dialog.append(
+ Dom.button(
+ "x",
+ () => this.hide(),
+ new DomOptions({
+ id: "close-modal-btn",
+ styles: {
+ background: "none",
+ border: "none",
+ color: "#666",
+ cursor: "pointer",
+ fontSize: "24px",
+ position: "absolute",
+ right: "10px",
+ top: "10px",
+ },
+ }),
+ ),
+ Modal.buildModalContent(house),
+ );
+
+ // Add event listeners with AbortController
+ this.#dialog.addEventListener("close", () => this.hide(), {
+ signal: this.#abortController.signal,
});
-
- 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",
+ this.#dialog.addEventListener(
+ "mouseenter",
+ () => {
+ clearTimeout(this.#timer);
+ this.#onClearMapTimer();
},
- });
-
- 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;
+ { signal: this.#abortController.signal },
+ );
+ this.#dialog.addEventListener(
+ "mouseleave",
+ () => {
+ if (!this.#persistent) {
+ this.#timer = setTimeout(() => this.hide(), 200);
+ }
+ },
+ { signal: this.#abortController.signal },
+ );
}
- /**
- * Remove all children
- * @param {HTMLElement} el
- */
- static clear(el) {
- while (el.firstChild) el.removeChild(el.firstChild);
+ render() {
+ return this.#dialog;
}
- /**
- * Append many children
- * @param {HTMLElement} parent
- * @param {HTMLElement[]} children
- */
- static appendChildren(parent, children) {
- for (const child of children) {
- if (child) parent.appendChild(child);
+ show() {
+ if (this.#persistent) {
+ this.#dialog.showModal();
+ } else {
+ this.#dialog.show();
}
}
- /**
- * Replace an element
- * @param {HTMLElement} oldEl
- * @param {HTMLElement} newEl
- */
- static replace(oldEl, newEl) {
- oldEl.parentNode?.replaceChild(newEl, oldEl);
- }
-
- /**
- * Create a weight slider
- * @param {string} id
- * @param {string} labelText
- * @param {string} weightKey
- * @param {number} initialValue
- * @param {(key: string, value: number) => void} onChange
- * @returns {HTMLElement}
- */
- static slider(id, labelText, weightKey, initialValue, onChange) {
- const group = Dom.div({
- styles: { display: "flex", flexDirection: "column", marginBottom: "1rem" },
- });
-
- const label = Dom.label({ for: id });
- const output = Dom.span({
- id: `${id}-value`,
- styles: { color: "#0066cc", fontSize: "0.85rem", fontWeight: "bold", textAlign: "center" },
- textContent: initialValue.toFixed(1),
- });
-
- const labelTextSpan = Dom.span({
- styles: { fontSize: "0.85rem" },
- textContent: labelText,
- });
-
- label.append(labelTextSpan, " ", output);
-
- const slider = Dom.input({
- attributes: { max: "1", min: "0", step: "0.1", value: initialValue.toString() },
- id,
- onInput: /** @param {Event} e */ (e) => {
- const target = /** @type {HTMLInputElement} */ (e.target);
- const val = Number(target.value);
- output.textContent = val.toFixed(1);
- onChange(weightKey, val);
- },
- styles: {
- margin: "0.5rem 0",
- width: "100%",
- },
- type: "range",
- });
-
- group.append(label, slider);
- return group;
+ hide() {
+ clearTimeout(this.#timer);
+ this.#dialog.close();
+ this.#dialog.remove();
+ this.#abortController.abort();
+ this.#onHide();
}
}