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 @@

Modals

Modal создаётся из JS через `Modals.create`. - В `body` можно вернуть HTML-строку или DOM-элемент, а `actions` возвращает массив кнопок. + Для обычного текста используй `bodyText`; HTML-контент передавай явно через `bodyHtml` или DOM-элемент.

@@ -16,9 +16,9 @@ Modals JS
-
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 ` - - `; + 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 +};