diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/.codex diff --git a/PROJECT_NOTES.md b/PROJECT_NOTES.md index 52c1d0a..185071b 100644 --- a/PROJECT_NOTES.md +++ b/PROJECT_NOTES.md @@ -84,16 +84,9 @@ - typography: `$font-family-base`, weights, sizes `xs..xl`, headings `h1..h6`, line heights. - breakpoints: `xs 360`, `sm 480`, `md 768`, `lg 1024`, `xl 1280`, `xxl 1440`. -## JS замечания +## JS замечания из первичного анализа -Компонентный JS сейчас не полностью модульный: - -- `editable-string.js` использует глобальный `Helper`, но не импортирует его. -- `confirm-popup.js` использует глобальные `Modals` и `Helper`. -- `toasts.js` опционально обращается к глобальному `Screens`. -- `index.js` экспортирует компоненты в `window`, но одновременно инициализирует app routing и SmartHome API. - -Для UI-kit нужен отдельный entry, который экспортирует только UI-компоненты и не запускает приложение. +На момент анализа `webclient` компонентный JS был не полностью модульным: часть компонентов зависела от глобальных `Helper`, `Modals`, `Screens`, а app entry запускал routing и SmartHome API. В текущем root UI-kit entry эти app-зависимости убраны: компоненты импортируются модульно, экспортируются в `window` и `window.GNexusUIKit`, приложение не запускается. ## Риски переноса @@ -190,6 +183,8 @@ Технический backlog: +- Закрыты JS-долги из ревью: toast title/text, confirm text и advanced-select options больше не строятся через HTML-строки; `Overlays.init()` и `InputPatterns.init()` идемпотентны для повторных roots; advanced-select устойчивее при пустых результатах; app-хвост `Screens` и production `console.log` удалены из toasts. +- В `Modals` сохранен явный HTML escape hatch через `bodyHtml` / `bodyMode: "html"` для сложной разметки; для обычных строк использовать `bodyText`. - Мигрировать Sass с `@import` на `@use`. - Заменить deprecated Sass functions на `sass:map` и `sass:color`. - Пересмотреть Gulp-зависимости или заменить сборку на более свежий простой pipeline. diff --git a/README.md b/README.md index dc13375..a89d361 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ const modal = Modals.create("example-modal", { title: "Modal", - body: () => "
Content
" + bodyText: "Content" }); modal.show(); diff --git a/demo/partials/modals.html b/demo/partials/modals.html index 48b3667..f7513e6 100644 --- a/demo/partials/modals.html +++ b/demo/partials/modals.html @@ -2,7 +2,7 @@Modal создаётся из JS через `Modals.create`. - В `body` можно вернуть HTML-строку или DOM-элемент, а `actions` возвращает массив кнопок. + Для обычного текста используй `bodyText`; HTML-контент передавай явно через `bodyHtml` или DOM-элемент.
Modals.create("demo-modal", {
+Modals.create("demo-modal", {
title: "Demo modal",
- body: modal => "<p class=\"text\">Modal content</p>",
+ bodyText: "Modal content",
actions: modal => {
const close = document.createElement("button");
close.className = "btn btn-primary";
@@ -40,7 +40,7 @@
const buttonCancel = document.createElement("button");
buttonCancel.classList.add("btn");
buttonCancel.classList.add("btn-primary");
- buttonCancel.innerHTML = "Cancel";
+ buttonCancel.textContent = "Cancel";
buttonCancel.addEventListener("click", e => {
modal.close();
@@ -49,7 +49,7 @@
const buttonApply = document.createElement("button");
buttonApply.classList.add("btn");
buttonApply.classList.add("btn-success");
- buttonApply.innerHTML = "Apply";
+ buttonApply.textContent = "Apply";
buttonApply.addEventListener("click", e => {
modal.close();
@@ -64,8 +64,7 @@
return [ buttonCancel, buttonApply ];
},
- body: modal => {
- return `
+ bodyHtml: `
Любой контент: текст, формы, списки.
@@ -81,8 +80,7 @@
- `;
- }
+ `
}).show();
}
});
diff --git a/src/js/components/advanced-select.js b/src/js/components/advanced-select.js
index a9fbc3e..3fa6e8b 100644
--- a/src/js/components/advanced-select.js
+++ b/src/js/components/advanced-select.js
@@ -21,23 +21,52 @@
}
}
+function firstVisibleOption(container) {
+ return container.querySelector(".option:not(.hide)");
+}
+
+function lastVisibleOption(container) {
+ return container.querySelector(".option:not(.hide):last-child");
+}
+
+function selectOption(input, container, option) {
+ if(!option) {
+ return;
+ }
+
+ input.value = option.dataset.displayValue;
+ input.blur();
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ container.advancedSelect.dispatchEvent("selected");
+ container.advancedSelect.closeList();
+}
+
export default function advancedSelect(input, options, notFoundText) {
const container = document.createElement("div");
container.classList.add("advanced-select");
- let optionsList = ``;
+ const popup = document.createElement("div");
+ popup.className = "popup-options-container";
+
+ const notFound = document.createElement("div");
+ notFound.className = "not-found";
+ notFound.textContent = notFoundText ?? "Nothing found";
+
+ const optionsContainer = document.createElement("div");
+ optionsContainer.className = "options";
+
for(let optionValue in options) {
- optionsList += `${options[optionValue]}`;
+ const option = document.createElement("div");
+ option.className = "option";
+ option.dataset.value = optionValue;
+ option.dataset.displayValue = options[optionValue];
+ option.textContent = options[optionValue];
+ optionsContainer.append(option);
}
- let html = `
-
- `;
-
- container.innerHTML = html;
+ popup.append(notFound, optionsContainer);
+ container.append(popup);
const existsOption = (value, options) => {
for(let optionValue in options) {
@@ -120,11 +149,16 @@
});
input.addEventListener("blur", e => {
- setTimeout(() => container.advancedSelect.closeList(), 20);
+ requestAnimationFrame(() => {
+ if(!container.matches(":hover")) {
+ container.advancedSelect.closeList();
+ }
+ });
});
input.addEventListener("keydown", e => {
- if(e.keyCode == 38) {
+ if(e.key === "ArrowUp") {
+ e.preventDefault();
// up
const current = container.querySelector(".option.focus");
if(current) {
@@ -139,16 +173,17 @@
}
if(!prev) {
- prev = container.querySelector(".option:not(.hide)");
+ prev = firstVisibleOption(container);
}
- prev.classList.add("focus");
+ prev?.classList.add("focus");
} else {
- container.querySelector(".option:not(.hide):last-child").classList.add("focus");
+ lastVisibleOption(container)?.classList.add("focus");
}
scrollToElementInFocus(container);
- } else if(e.keyCode == 40) {
+ } else if(e.key === "ArrowDown") {
+ e.preventDefault();
// down
const current = container.querySelector(".option.focus");
if(current) {
@@ -163,23 +198,22 @@
}
if(!next) {
- next = container.querySelector(".option:not(.hide)");
+ next = firstVisibleOption(container);
}
- next.classList.add("focus");
+ next?.classList.add("focus");
} else {
- container.querySelector(".option:not(.hide)").classList.add("focus");
+ firstVisibleOption(container)?.classList.add("focus");
}
scrollToElementInFocus(container);
- } else if(e.keyCode == 13) {
+ } else if(e.key === "Enter") {
+ e.preventDefault();
let selected = container.querySelector(".option.focus");
- if(!selected) return;
- input.value = selected.dataset.displayValue;
+ selectOption(input, container, selected);
+ } else if(e.key === "Escape") {
+ container.advancedSelect.closeList();
input.blur();
- input.dispatchEvent(new Event("input", { bubbles: true }));
- input.dispatchEvent(new Event("change", { bubbles: true }));
- container.advancedSelect.dispatchEvent("selected");
}
});
@@ -207,14 +241,11 @@
});
[ ...container.advancedSelect.optionsElements ].forEach(option => {
- option.addEventListener("click", e => {
- input.value = e.currentTarget.dataset.displayValue;
- input.blur();
- input.dispatchEvent(new Event("input", { bubbles: true }));
- input.dispatchEvent(new Event("change", { bubbles: true }));
- container.advancedSelect.dispatchEvent("selected");
+ option.addEventListener("pointerdown", e => {
+ e.preventDefault();
+ selectOption(input, container, e.currentTarget);
});
});
return container;
-}
\ No newline at end of file
+}
diff --git a/src/js/components/confirm-popup.js b/src/js/components/confirm-popup.js
index 4718a85..88338ae 100644
--- a/src/js/components/confirm-popup.js
+++ b/src/js/components/confirm-popup.js
@@ -4,10 +4,10 @@
export default function confirmPopup(text, confirmedCb, canceledCb) {
Modals.create("confirm-popup", {
title: `Requires confirmation`,
- body: modal => {
- return `
- ${text}
- `;
+ body: () => {
+ const paragraph = document.createElement("p");
+ paragraph.textContent = text ?? "";
+ return paragraph;
},
actions: modal => {
const buttonNO = Helper.template.createElement("button", { class: "btn btn-primary" }, "NO");
@@ -15,12 +15,12 @@
buttonNO.addEventListener("click", e => {
modal.close();
- canceledCb();
+ canceledCb?.();
});
buttonYES.addEventListener("click", e => {
modal.close();
- confirmedCb();
+ confirmedCb?.();
});
return [ buttonNO, buttonYES ];
diff --git a/src/js/components/editable-string.js b/src/js/components/editable-string.js
index 677d484..ff91e6c 100644
--- a/src/js/components/editable-string.js
+++ b/src/js/components/editable-string.js
@@ -101,7 +101,7 @@
});
input.addEventListener("keydown", e => {
- if(e.keyCode == 13) {
+ if(e.key === "Enter") {
input.blur();
component.editableString.apply();
}
diff --git a/src/js/components/input-patterns.js b/src/js/components/input-patterns.js
index 037b68d..cab3bd5 100644
--- a/src/js/components/input-patterns.js
+++ b/src/js/components/input-patterns.js
@@ -1,7 +1,7 @@
-let initialized = false;
+const initializedRoots = new WeakSet();
function init(root = document) {
- if(root === document && initialized) {
+ if(initializedRoots.has(root)) {
return;
}
@@ -24,9 +24,7 @@
input.focus();
});
- if(root === document) {
- initialized = true;
- }
+ initializedRoots.add(root);
}
export default {
diff --git a/src/js/components/modals.js b/src/js/components/modals.js
index e148dd0..b31ac54 100644
--- a/src/js/components/modals.js
+++ b/src/js/components/modals.js
@@ -1,19 +1,59 @@
-function template(id, title, footer) {
- return `
-
- `;
+function template(id, title, footer, props = {}) {
+ const modal = document.createElement("div");
+ modal.className = "modal";
+ modal.setAttribute("aria-hidden", "true");
+ modal.id = id;
+
+ const backdrop = document.createElement("div");
+ backdrop.className = "modal-backdrop";
+
+ const panel = document.createElement("div");
+ panel.className = "modal-panel";
+ panel.setAttribute("role", "dialog");
+ panel.setAttribute("aria-modal", "true");
+ panel.setAttribute("aria-labelledby", `${id}-title`);
+
+ const header = document.createElement("header");
+ header.className = "modal-header";
+
+ const modalTitle = document.createElement("h4");
+ modalTitle.className = "modal-title";
+ modalTitle.id = `${id}-title`;
+ modalTitle.textContent = title;
+
+ const close = document.createElement("button");
+ close.className = "btn-icon modal-close";
+ close.type = "button";
+ close.setAttribute("aria-label", "Close");
+ close.textContent = "✕";
+
+ const body = document.createElement("div");
+ body.className = "modal-body";
+
+ const modalFooter = document.createElement("footer");
+ modalFooter.className = "modal-footer";
+ appendContent(modalFooter, footer, props.footerMode ?? "html");
+
+ header.append(modalTitle, close);
+ panel.append(header, body, modalFooter);
+ modal.append(backdrop, panel);
+
+ return modal;
}
function init(modal, onready) {
@@ -51,12 +91,11 @@
* @return {object} DOM object
*/
function create(id, props) {
+ props = props ?? {};
const title = props.title || "";
const footer = props.footer || "";
- const div = document.createElement("div");
- div.innerHTML = template(id, title, footer);
- const modal = div.childNodes[1];
+ const modal = template(id, title, footer, props);
const modalBody = modal.querySelector(".modal-body");
const modalFooter = modal.querySelector(".modal-footer");
@@ -64,7 +103,7 @@
if(typeof props.actions == "function") {
const actionsResult = props.actions(modal);
- if(typeof actionsResult[0] == "object") {
+ if(Array.isArray(actionsResult) && actionsResult[0] instanceof Node) {
const actions = document.createElement("div");
actions.classList.add("actions");
for(let actionElement of actionsResult) {
@@ -72,17 +111,19 @@
}
modalFooter.append(actions);
+ } else if(actionsResult instanceof Node) {
+ modalFooter.append(actionsResult);
}
}
if(typeof props.body == "function") {
const bodyResult = props.body(modal);
- if(typeof bodyResult == "object") {
- modalBody.append(bodyResult);
- } else if(typeof bodyResult == "string") {
- modalBody.innerHTML = bodyResult;
- }
+ appendContent(modalBody, bodyResult, props.bodyMode ?? "html");
+ } else if(typeof props.bodyText != "undefined") {
+ appendContent(modalBody, props.bodyText, "text");
+ } else if(typeof props.bodyHtml != "undefined") {
+ appendContent(modalBody, props.bodyHtml, "html");
}
return init(modal, props?.onready);
@@ -90,4 +131,4 @@
export default {
create
-}
\ No newline at end of file
+}
diff --git a/src/js/components/overlays.js b/src/js/components/overlays.js
index 9f3db8d..5621242 100644
--- a/src/js/components/overlays.js
+++ b/src/js/components/overlays.js
@@ -1,4 +1,5 @@
-let initialized = false;
+const initializedRoots = new WeakSet();
+let keyboardDismissInitialized = false;
function closeNode(node) {
node.classList.remove("is-open");
@@ -111,15 +112,19 @@
closeAll();
});
- document.addEventListener("keydown", event => {
- if(event.key === "Escape") {
- closeAll();
- }
- });
+ if(!keyboardDismissInitialized) {
+ document.addEventListener("keydown", event => {
+ if(event.key === "Escape") {
+ closeAll();
+ }
+ });
+
+ keyboardDismissInitialized = true;
+ }
}
function init(root = document) {
- if(root === document && initialized) {
+ if(initializedRoots.has(root)) {
return;
}
@@ -128,10 +133,7 @@
initPopovers(root);
initTooltips(root);
initDismiss(root);
-
- if(root === document) {
- initialized = true;
- }
+ initializedRoots.add(root);
}
export default {
diff --git a/src/js/components/toasts.js b/src/js/components/toasts.js
index 817de69..410e20d 100644
--- a/src/js/components/toasts.js
+++ b/src/js/components/toasts.js
@@ -1,18 +1,46 @@
+function appendIcon(container, icon) {
+ if(icon instanceof Node) {
+ container.append(icon);
+ return;
+ }
+
+ const iconWrap = document.createElement("span");
+ iconWrap.innerHTML = icon;
+ container.append(...iconWrap.childNodes);
+}
+
function template(type, icon, title, text) {
- return `
-
-
- ${icon} ${title}
- ${text}
-
-
-
- `;
+ const toast = document.createElement("div");
+ toast.className = `toast toast-${type}`;
+ toast.setAttribute("role", "alert");
+
+ const content = document.createElement("div");
+ content.className = "toast-content";
+
+ const toastTitle = document.createElement("h4");
+ toastTitle.className = "toast-title";
+ appendIcon(toastTitle, icon);
+ toastTitle.append(document.createTextNode(` ${title ?? ""}`));
+
+ const toastText = document.createElement("p");
+ toastText.className = "toast-text";
+ toastText.textContent = text ?? "";
+
+ const close = document.createElement("button");
+ close.className = "btn-icon toast-close";
+ close.type = "button";
+ close.setAttribute("aria-label", "Close");
+ close.textContent = "✕";
+
+ content.append(toastTitle, toastText);
+ toast.append(content, close);
+
+ return toast;
}
function init(toast, props) {
if(props?.alone) {
- document.querySelectorAll(".toast").forEach(i => i.close());
+ document.querySelectorAll(".toast").forEach(i => i.close?.());
}
toast.close = function() {
@@ -34,19 +62,10 @@
}, 10);
}
- if(typeof Screens != "undefined") {
- Screens.onSwitch((scr, alias) => {
- setTimeout(() => {
- toast?.close();
- }, 10000);
- });
- }
-
toast.addEventListener("mouseover", e => toast.ishovered = true);
toast.addEventListener("mouseout", e => toast.ishovered = false);
if(props?.lifetime) {
- console.log(props);
const lifetimeInterval = setInterval(() => {
if(!toast.ishovered) {
toast.close();
@@ -59,10 +78,7 @@
}
function create(type, icon, title, text, props) {
- const div = document.createElement("div");
- div.innerHTML = template(type, icon, title, text);
-
- return init(div.childNodes[1], props);
+ return init(template(type, icon, title, text), props);
}
function createSuccess(title, text, props) {
@@ -124,4 +140,4 @@
createWarning,
createError,
"createDanger": createError
-};
\ No newline at end of file
+};