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 @@
+
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";