refactor(cms): replace custom icon comboboxes with standardized icon picker

Replace the legacy `cms-icon-combobox` implementation with a new, standardized `icon-picker` across multiple admin modules. This change simplifies the UI by using a Bootstrap input group with a dedicated "Pick Icon" button and a preview cell.

Affected areas:
- Policies block editor
- Accreditation, Admissions, History, and Policies admin editor scripts
- Admissions calculator options view
This removes redundant icon list generation and manual dropdown management in favor of a centralized icon picker utility.
This commit is contained in:
Tống Thành Đạt
2026-04-22 20:27:42 +07:00
parent c0ea0dcb4d
commit 2cee0172f2
6 changed files with 1043 additions and 740 deletions
+177 -104
View File
@@ -549,24 +549,24 @@
</div>
<div>
<label class="form-label fw-semibold">Icon</label>
<div class="icon-combobox" data-role="icon-combobox">
<input type="hidden" data-field="icon" value="${escapeAttribute(block.icon || "")}" />
<button type="button" class="icon-combobox-trigger">
<span class="icon-combobox-value">
<span class="icon-combobox-preview">
${block.icon ? `<i class="fa-solid ${escapeAttribute(block.icon)}"></i>` : '<i class="fas fa-icons"></i>'}
</span>
<span class="icon-combobox-text">
<strong>${escapeHtml(block.icon || "Select icon")}</strong>
</span>
</span>
<i class="fas fa-chevron-down icon-combobox-caret"></i>
<div class="input-group" data-role="icon-picker-group">
<span class="input-group-text icon-preview-cell">
${block.icon ? `<i class="${escapeAttribute(resolveCalloutIconPreviewClass(block.icon))}"></i>` : ""}
</span>
<input
type="text"
class="form-control"
data-field="icon"
value="${escapeAttribute(block.icon || "")}"
placeholder="Click to pick..."
readonly
style="cursor:pointer;background:#fff"
data-icon-picker-value-mode="icon-name"
data-icon-picker-preview-prefix="${escapeAttribute(resolveCalloutIconPreviewPrefix(block.icon))}"
/>
<button type="button" class="btn btn-outline-secondary" data-action="pick-icon">
<i class="fas fa-icons me-1"></i>Pick Icon
</button>
<div class="icon-combobox-panel is-hidden">
<input type="text" class="form-control icon-combobox-search" placeholder="Search icon name" value="${escapeAttribute(block.icon || "")}" />
<div class="icon-combobox-options"></div>
<div class="icon-combobox-empty is-hidden">No matching icons. Press Enter to use custom value.</div>
</div>
</div>
</div>
<div>
@@ -583,7 +583,7 @@
block.html = value;
});
initializeIconCombobox(body.querySelector('[data-role="icon-combobox"]'), block);
initializeIconPicker(body.querySelector('[data-role="icon-picker-group"]'), block);
body.querySelectorAll("input[data-field], select[data-field], textarea[data-field]").forEach((input) => {
const syncField = function () {
@@ -661,111 +661,184 @@
return editor;
}
function initializeIconCombobox(wrapper, block) {
function initializeIconPicker(wrapper, block) {
if (!wrapper) {
return;
}
const hiddenInput = wrapper.querySelector('input[data-field="icon"]');
const trigger = wrapper.querySelector(".icon-combobox-trigger");
const panel = wrapper.querySelector(".icon-combobox-panel");
const searchInput = wrapper.querySelector(".icon-combobox-search");
const optionsContainer = wrapper.querySelector(".icon-combobox-options");
const emptyState = wrapper.querySelector(".icon-combobox-empty");
const preview = wrapper.querySelector(".icon-combobox-preview");
const title = wrapper.querySelector(".icon-combobox-text strong");
const card = wrapper.closest(".block-card");
const input = wrapper.querySelector('input[data-field="icon"]');
const button = wrapper.querySelector('[data-action="pick-icon"]');
if (!hiddenInput || !trigger || !panel || !searchInput || !optionsContainer || !emptyState || !preview || !title) {
if (!input || !button) {
return;
}
const setOpen = function (isOpen) {
wrapper.classList.toggle("is-open", isOpen);
panel.classList.toggle("is-hidden", !isOpen);
if (card) {
card.classList.toggle("has-floating-ui", isOpen);
}
if (isOpen) {
searchInput.focus();
searchInput.select();
}
};
const syncIconValue = function (value) {
const nextValue = String(value || "").trim();
hiddenInput.value = nextValue;
searchInput.value = nextValue;
input.value = nextValue;
input.dataset.iconPickerPreviewPrefix = resolveCalloutIconPreviewPrefix(nextValue);
block.icon = nextValue;
preview.innerHTML = nextValue
? `<i class="fa-solid ${escapeAttribute(nextValue)}"></i>`
: '<i class="fas fa-icons"></i>';
title.textContent = nextValue || "Select icon";
syncCalloutIconPreview(input);
renderPreview();
renderValidation(validateState());
};
const renderOptions = function (query) {
const normalizedQuery = String(query || "").trim().toLowerCase();
const filteredOptions = iconOptions.filter((option) =>
option.toLowerCase().includes(normalizedQuery),
);
optionsContainer.innerHTML = "";
emptyState.classList.toggle("is-hidden", filteredOptions.length > 0);
filteredOptions.forEach((option) => {
const button = document.createElement("button");
button.type = "button";
button.className = "icon-combobox-option";
button.classList.toggle("is-active", option === hiddenInput.value);
button.innerHTML = `
<span class="icon-combobox-option-main">
<i class="fa-solid ${escapeAttribute(option)}"></i>
<span>${escapeHtml(option)}</span>
</span>
${option === hiddenInput.value ? '<i class="fas fa-check small"></i>' : ""}
`;
button.addEventListener("click", function () {
syncIconValue(option);
renderOptions(option);
setOpen(false);
});
optionsContainer.appendChild(button);
});
};
trigger.addEventListener("click", function () {
const nextIsOpen = panel.classList.contains("is-hidden");
setOpen(nextIsOpen);
if (nextIsOpen) {
renderOptions(searchInput.value || hiddenInput.value);
}
input.addEventListener("click", function () {
openSharedIconPicker(input, syncIconValue);
});
searchInput.addEventListener("input", function () {
renderOptions(searchInput.value);
});
searchInput.addEventListener("keydown", function (event) {
if (event.key === "Enter") {
event.preventDefault();
syncIconValue(searchInput.value);
renderOptions(searchInput.value);
setOpen(false);
}
});
wrapper.addEventListener("focusout", function () {
window.setTimeout(function () {
if (!wrapper.contains(document.activeElement)) {
setOpen(false);
}
}, 0);
button.addEventListener("click", function () {
openSharedIconPicker(input, syncIconValue);
});
syncIconValue(block.icon || "");
renderOptions(block.icon || "");
}
function openSharedIconPicker(input, onPicked) {
if (!window.IconPicker || typeof window.IconPicker.open !== "function") {
return;
}
patchSharedIconPicker();
window.__policyBlockIconPickerInput = input;
window.__policyBlockIconPickerOnPicked = onPicked;
window.IconPicker.open(input);
}
function patchSharedIconPicker() {
if (!window.IconPicker || window.IconPicker.__policyBlockPatched) {
return;
}
const originalPick = typeof window.IconPicker.pick === "function"
? window.IconPicker.pick.bind(window.IconPicker)
: null;
if (!originalPick) {
return;
}
const patchedPick = function (value) {
originalPick(value);
const activeInput = window.__policyBlockIconPickerInput;
const onPicked = window.__policyBlockIconPickerOnPicked;
if (!activeInput || typeof onPicked !== "function") {
return;
}
activeInput.dataset.iconPickerPreviewPrefix = extractIconStyle(value)
|| resolveCalloutIconPreviewPrefix(activeInput.value || value);
onPicked(extractIconName(value));
activeInput.dispatchEvent(new Event("input", { bubbles: true }));
activeInput.dispatchEvent(new Event("change", { bubbles: true }));
window.__policyBlockIconPickerInput = null;
window.__policyBlockIconPickerOnPicked = null;
};
window.IconPicker.pick = patchedPick;
window.IconPickerPick = patchedPick;
window.IconPicker.__policyBlockPatched = true;
}
function syncCalloutIconPreview(input) {
if (!input) {
return;
}
const previewCell = input.closest(".input-group")?.querySelector(".icon-preview-cell");
if (!previewCell) {
return;
}
const previewClass = resolveCalloutIconPreviewClass(input.value, input);
previewCell.innerHTML = previewClass ? `<i class="${escapeAttribute(previewClass)}"></i>` : "";
loadIconStyleLookup().then(function () {
const nextPrefix = resolveCalloutIconPreviewPrefix(input.value, input);
if (nextPrefix !== input.dataset.iconPickerPreviewPrefix) {
input.dataset.iconPickerPreviewPrefix = nextPrefix;
const refreshedPreviewClass = resolveCalloutIconPreviewClass(input.value, input);
previewCell.innerHTML = refreshedPreviewClass
? `<i class="${escapeAttribute(refreshedPreviewClass)}"></i>`
: "";
}
});
}
function resolveCalloutIconPreviewClass(iconName, input) {
const normalizedValue = String(iconName || "").trim();
if (!normalizedValue) {
return "";
}
return `${resolveCalloutIconPreviewPrefix(normalizedValue, input)} ${normalizedValue}`.trim();
}
function resolveCalloutIconPreviewPrefix(iconName, input) {
const explicitPrefix = String(input?.dataset?.iconPickerPreviewPrefix || "").trim();
if (explicitPrefix && explicitPrefix !== "fa-solid") {
return explicitPrefix;
}
const normalizedIconName = extractIconName(iconName);
const knownStyles = window.__policyBlockIconStyleLookup?.[normalizedIconName] || [];
if (knownStyles.includes("fa-brands")) return "fa-brands";
if (knownStyles.includes("fa-regular")) return "fa-regular";
if (knownStyles.includes("fa-solid")) return "fa-solid";
return explicitPrefix || "fa-solid";
}
function extractIconName(value) {
return String(value || "")
.trim()
.split(/\s+/)
.find(
(token) =>
/^fa-[a-z0-9-]+$/i.test(token) && !/^fa-(solid|regular|brands)$/i.test(token),
) || "";
}
function extractIconStyle(value) {
return String(value || "")
.trim()
.split(/\s+/)
.find((token) => /^fa-(solid|regular|brands)$/i.test(token)) || "";
}
function loadIconStyleLookup() {
if (window.__policyBlockIconStyleLookupPromise) {
return window.__policyBlockIconStyleLookupPromise;
}
window.__policyBlockIconStyleLookupPromise = fetch("/js/fa-icons.json")
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
})
.then((json) => {
const lookup = {};
Object.entries(json || {}).forEach(([name, meta]) => {
lookup[`fa-${name}`] = (meta.styles || [])
.filter((style) => ["solid", "regular", "brands"].includes(style))
.map((style) => `fa-${style}`);
});
window.__policyBlockIconStyleLookup = lookup;
return lookup;
})
.catch(() => {
window.__policyBlockIconStyleLookup = window.__policyBlockIconStyleLookup || {};
return window.__policyBlockIconStyleLookup;
});
return window.__policyBlockIconStyleLookupPromise;
}
function bindEditable(element, onChange, options) {
@@ -1150,7 +1223,7 @@
callout.className = "callout-block";
callout.dataset.tone = block.tone || "info";
callout.innerHTML = `
${block.title ? `<div class="fw-semibold mb-2"><i class="fa-solid ${escapeAttribute(block.icon || "fa-circle-info")} me-2"></i>${escapeHtml(block.title)}</div>` : ""}
${block.title ? `<div class="fw-semibold mb-2"><i class="${escapeAttribute(resolveCalloutIconPreviewClass(block.icon || "fa-circle-info"))} me-2"></i>${escapeHtml(block.title)}</div>` : ""}
<div>${block.html || ""}</div>
`;
node.appendChild(callout);