diff --git a/demo/index.html b/demo/index.html index 19aa9dd..41d2d5e 100644 --- a/demo/index.html +++ b/demo/index.html @@ -51,6 +51,7 @@ @@include('./partials/alerts.html') @@include('./partials/modals.html') @@include('./partials/confirm-dialog.html') + @@include('./partials/data-patterns.html') @@include('./partials/drawer.html') @@include('./partials/toasts.html') @@include('./partials/tabs.html') diff --git a/demo/partials/data-patterns.html b/demo/partials/data-patterns.html new file mode 100644 index 0000000..a5cb83b --- /dev/null +++ b/demo/partials/data-patterns.html @@ -0,0 +1,36 @@ +
+

Data patterns

+ +

Editable string

+

+ Click the pencil to edit this text. +

+

+ Multi-line editable string. +

+ + +

Input clear

+
+ + +
+ +

Date picker trigger

+ + +

File upload with preview

+
+ + +
+
diff --git a/src/js/components/editable-string.js b/src/js/components/editable-string.js new file mode 100644 index 0000000..1779690 --- /dev/null +++ b/src/js/components/editable-string.js @@ -0,0 +1,116 @@ +/** + * editableString browser helper. + * + * Mirrors gnexus-ui-kit editableString public surface. + * + * @param {HTMLElement} stringContainer - Container to replace with editable component + * @param {boolean} [isMultiString=false] - Use textarea instead of input + * @returns {HTMLElement} Component root element + */ + +function template(originalText, isMultiString) { + const placeholder = "Write something"; + const input = !isMultiString + ? `` + : ``; + + return ` +
+ ${originalText} + +
+
+
+ +
+ + +
+ `; +} + +export default function editableString(stringContainer, isMultiString = false) { + const originalText = stringContainer.innerHTML; + + const component = document.createElement("div"); + component.className = "editable-string-component"; + component.innerHTML = template(originalText, isMultiString); + + stringContainer.innerHTML = ""; + stringContainer.append(component); + + const editBtn = component.querySelector(".edit-text-btn"); + const applyBtn = component.querySelector(".apply-changes-btn"); + const cancelBtn = component.querySelector(".cancel-changes-btn"); + const content = component.querySelector(".editable-string-content"); + const editableStringEl = component.querySelector(".editable-string"); + const form = component.querySelector(".editable-string-form"); + const input = component.querySelector(".input"); + + const api = { + formIsDisplaying: false, + value: originalText, + input, + eventsHandlers: { + onChange: [], + onSwitch: [] + }, + switch: () => { + if(api.formIsDisplaying) { + form.classList.add("d-none"); + content.classList.remove("d-none"); + editableStringEl.textContent = api.value; + } else { + form.classList.remove("d-none"); + content.classList.add("d-none"); + input.value = api.value; + input.focus(); + } + + api.formIsDisplaying = !api.formIsDisplaying; + api.runEventHandler("onSwitch"); + }, + setValue: val => { + api.value = val; + input.value = val; + editableStringEl.textContent = val; + }, + apply: () => { + const previousValue = api.value; + api.value = input.value; + api.switch(); + if(input.value !== previousValue) { + api.runEventHandler("onChange"); + } + }, + onChange: cb => { + api.eventsHandlers.onChange.push(cb); + }, + onSwitch: cb => { + api.eventsHandlers.onSwitch.push(cb); + }, + runEventHandler: evName => { + api.eventsHandlers[evName].forEach(handler => handler(api)); + } + }; + + component.editableString = api; + + editBtn.addEventListener("click", () => api.switch()); + cancelBtn.addEventListener("click", () => api.switch()); + applyBtn.addEventListener("click", () => api.apply()); + + input.addEventListener("keydown", event => { + if(event.key === "Enter" && !isMultiString) { + input.blur(); + api.apply(); + } + if(event.key === "Escape") { + api.switch(); + } + }); + + return component; +} diff --git a/src/js/components/input-patterns.js b/src/js/components/input-patterns.js new file mode 100644 index 0000000..e78653a --- /dev/null +++ b/src/js/components/input-patterns.js @@ -0,0 +1,265 @@ +/** + * InputPatterns browser helper. + * + * Mirrors gnexus-ui-kit InputPatterns public surface: + * init(root), updateFileUpload(input) + * + * Handles: + * - [data-input-clear] buttons inside input groups + * - [data-file-upload-input] with preview rendering + * - [data-date-picker] input picker trigger + */ + +const initializedRoots = new WeakSet(); +const fileUploadState = new WeakMap(); + +function getFileKey(file) { + return `${file.name}:${file.size}:${file.lastModified}`; +} + +function clearFilePreviews(previewNode) { + if(!previewNode) { + return; + } + + previewNode.querySelectorAll("img[data-object-url]").forEach(image => { + URL.revokeObjectURL(image.dataset.objectUrl); + }); + previewNode.innerHTML = ""; + previewNode.hidden = true; +} + +function getStoredFiles(input) { + return fileUploadState.get(input) || []; +} + +function setStoredFiles(input, files) { + fileUploadState.set(input, files); + + const transfer = new DataTransfer(); + files.forEach(file => transfer.items.add(file)); + input.files = transfer.files; +} + +function addStoredFiles(input, files) { + const storedFiles = getStoredFiles(input); + const knownKeys = new Set(storedFiles.map(getFileKey)); + const nextFiles = [...storedFiles]; + + files.forEach(file => { + const key = getFileKey(file); + + if(!knownKeys.has(key)) { + knownKeys.add(key); + nextFiles.push(file); + } + }); + + setStoredFiles(input, nextFiles); + return nextFiles; +} + +function removeStoredFile(input, index) { + const nextFiles = getStoredFiles(input).filter((file, fileIndex) => fileIndex !== index); + setStoredFiles(input, nextFiles); + return nextFiles; +} + +function getFileType(file) { + const nameParts = file.name.split("."); + const extension = nameParts.length > 1 ? nameParts.pop().trim() : ""; + + if(extension) { + return extension.slice(0, 6).toUpperCase(); + } + + if(file.type) { + return file.type.split("/").pop().slice(0, 6).toUpperCase(); + } + + return "FILE"; +} + +function formatBytes(bytes) { + if(!Number.isFinite(bytes)) { + return ""; + } + + if(bytes === 0) { + return "0 B"; + } + + const units = ["B", "KB", "MB", "GB"]; + const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const value = bytes / Math.pow(1024, index); + + return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`; +} + +function updateFileUpload(input) { + const container = input.closest(".file-upload-panel, .file-upload"); + const previewNode = container?.querySelector("[data-file-upload-preview]"); + + if(!container || !previewNode) { + return; + } + + const files = getStoredFiles(input); + + if(!files.length) { + clearFilePreviews(previewNode); + return; + } + + updateFilePreviews(previewNode, files); +} + +function updateFilePreviews(previewNode, files) { + if(!previewNode) { + return; + } + + clearFilePreviews(previewNode); + + files.forEach((file, index) => { + const figure = document.createElement("figure"); + figure.className = "file-upload-preview-item"; + figure.dataset.fileUploadIndex = String(index); + + const preview = document.createElement("div"); + preview.className = "file-upload-preview-visual"; + + if(file.type.startsWith("image/")) { + const image = document.createElement("img"); + const objectUrl = URL.createObjectURL(file); + image.src = objectUrl; + image.dataset.objectUrl = objectUrl; + image.alt = ""; + image.loading = "lazy"; + preview.append(image); + } else { + const type = document.createElement("span"); + type.className = "file-upload-preview-type"; + type.textContent = getFileType(file); + preview.append(type); + } + + const caption = document.createElement("figcaption"); + + const name = document.createElement("span"); + name.className = "file-upload-preview-name"; + name.textContent = file.name; + + const meta = document.createElement("span"); + meta.className = "file-upload-preview-meta"; + meta.textContent = `${getFileType(file)} / ${formatBytes(file.size)}`; + + const remove = document.createElement("button"); + remove.className = "file-upload-preview-remove"; + remove.type = "button"; + remove.dataset.fileUploadRemove = String(index); + remove.setAttribute("aria-label", `Remove ${file.name}`); + remove.innerHTML = ``; + + caption.append(name, meta); + figure.append(remove, preview, caption); + previewNode.append(figure); + }); + + previewNode.hidden = false; +} + +function init(root = document) { + if(initializedRoots.has(root)) { + return; + } + + root.addEventListener("click", event => { + const clearButton = event.target.closest("[data-input-clear]"); + + if(!clearButton) { + return; + } + + const group = clearButton.closest(".input-group"); + const input = group?.querySelector("input, textarea"); + + if(!input) { + return; + } + + input.value = ""; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.focus(); + }); + + root.addEventListener("click", event => { + const removeButton = event.target.closest("[data-file-upload-remove]"); + + if(!removeButton) { + return; + } + + const container = removeButton.closest(".file-upload-panel, .file-upload"); + const input = container?.querySelector("[data-file-upload-input]"); + + if(!input) { + return; + } + + removeStoredFile(input, Number(removeButton.dataset.fileUploadRemove)); + updateFileUpload(input); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + + root.addEventListener("click", event => { + const input = event.target.closest("[data-date-picker]"); + + if(!input) { + return; + } + + input.focus(); + + if(typeof input.showPicker === "function") { + try { + input.showPicker(); + } catch(error) { + // Some browsers restrict showPicker() to direct user gestures or supported input types. + } + } + }); + + root.addEventListener("change", event => { + const input = event.target.closest("[data-file-upload-input]"); + + if(!input) { + return; + } + + addStoredFiles(input, Array.from(input.files || [])); + updateFileUpload(input); + }); + + root.addEventListener("reset", event => { + const form = event.target.closest("form"); + + if(!form) { + return; + } + + setTimeout(() => { + form.querySelectorAll("[data-file-upload-input]").forEach(input => { + setStoredFiles(input, []); + updateFileUpload(input); + }); + }, 0); + }); + + initializedRoots.add(root); +} + +export default { + init, + updateFileUpload +}; diff --git a/src/js/index.js b/src/js/index.js index f20a3be..d7b0558 100644 --- a/src/js/index.js +++ b/src/js/index.js @@ -7,6 +7,8 @@ import Toasts from "./components/toasts.js"; import Modals from "./components/modals.js"; import confirmPopup from "./components/confirm-popup.js"; +import InputPatterns from "./components/input-patterns.js"; +import editableString from "./components/editable-string.js"; import Tabs from "./components/tabs.js"; import Progress from "./components/progress.js"; import Tables from "./components/tables.js"; @@ -26,6 +28,8 @@ Toasts, Modals, confirmPopup, + InputPatterns, + editableString, Tabs, Progress, Tables, @@ -55,8 +59,9 @@ Popover.init?.(); Loader.init?.(); NavigationShell.init?.(); + InputPatterns.init?.(); }); } -export { Icons as Helper, Toasts, Modals, confirmPopup, Tabs, Progress, Tables, Lists, Skeleton, Tooltips, Dropdowns, NavigationShell, Background, Breadcrumbs, Accordion, Popover, Loader }; +export { Icons as Helper, Toasts, Modals, confirmPopup, InputPatterns, editableString, Tabs, Progress, Tables, Lists, Skeleton, Tooltips, Dropdowns, NavigationShell, Background, Breadcrumbs, Accordion, Popover, Loader }; export default api; diff --git a/src/scss/components/_editable-string.scss b/src/scss/components/_editable-string.scss new file mode 100644 index 0000000..bcf4a79 --- /dev/null +++ b/src/scss/components/_editable-string.scss @@ -0,0 +1,64 @@ +/* ========================= + Editable string +========================= */ + +@use "../kit-deps" as *; + +.editable-string-component { + display: inline-flex; + align-items: center; + gap: $space-2; + + .editable-string-content { + display: inline-flex; + align-items: center; + gap: $space-2; + font-size: inherit; + + .editable-string { + font-size: inherit; + border-bottom: $border-width-base $border-style-base $border-color-muted; + min-width: 40px; + padding: 2px 0; + } + } + + .edit-text-btn, + .apply-changes-btn, + .cancel-changes-btn { + color: $neutral-700; + + &:hover:not(:disabled) { + color: $neutral-900; + background-color: $yellow-400; + } + } + + .apply-changes-btn { + color: $success-text; + } + + .cancel-changes-btn { + color: $danger-text; + } + + .editable-string-form { + display: inline-flex; + align-items: center; + gap: $space-2; + + .form-group { + max-width: 260px; + margin: 0; + + .input { + min-height: auto; + padding: $space-2 $space-4; + } + } + } + + .d-none { + display: none !important; + } +} diff --git a/src/scss/kit.scss b/src/scss/kit.scss index 22eb6ce..7f9de21 100644 --- a/src/scss/kit.scss +++ b/src/scss/kit.scss @@ -27,6 +27,7 @@ @use "components/divider"; @use "components/accordion"; @use "components/stepper"; +@use "components/editable-string"; @use "components/file-upload"; @use "components/popover"; @use "components/pagination";