feat(icons): add the FA5 catalog, REST endpoint and unified icon helpers

Mirror the reference theme's universal picker on FA5: a generated full
catalog of the bundled FA5 Free 5.15.4 fonts (src/Icons/fa5-icons.php,
generated from the shipped CSS + SVG fonts), a zibsvg group parsed from
the runtime sprite so it cannot drift, and GET /aksrt/v1/icons/catalog.

Shared admin pieces: aksrt_icon_markup()/aksrt_sanitize_icon_value()
with an SVG allowlist, the picker field/modal assets, and one canonical
aksrt-font-awesome handle registered through aksrt_enqueue_fontawesome().
This commit is contained in:
kate522 committed 2026-09-06 12:59:03 +08:00
1 parent 9496db5171
commit f629a4f373
12 files changed
+2541 -18

No files matched your search

+195
View File
@@ -0,0 +1,195 @@
/* AKSRT universal icon picker (admin) */
#aksrt-modal-icon.aksrt-icon-modal {
display: none;
}
#aksrt-modal-icon.aksrt-icon-modal:not(.hidden) {
display: flex;
position: fixed;
inset: 0;
z-index: 100100;
align-items: center;
justify-content: center;
}
#aksrt-modal-icon .aksrt-icon-modal__overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
}
#aksrt-modal-icon .aksrt-icon-modal__inner {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
width: min(720px, calc(100vw - 32px));
max-height: min(76vh, 640px);
background: #fff;
border-radius: 8px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.28);
overflow: hidden;
}
#aksrt-modal-icon .aksrt-icon-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #e2e4e7;
font-size: 14px;
}
#aksrt-modal-icon .aksrt-icon-modal__close {
font-size: 20px;
line-height: 1;
text-decoration: none;
cursor: pointer;
}
#aksrt-modal-icon .aksrt-icon-modal__toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 10px 16px;
border-bottom: 1px solid #e2e4e7;
}
#aksrt-modal-icon .aksrt-icon-modal__tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
#aksrt-modal-icon .aksrt-icon-modal__tab.is-active {
border-color: #2271b1;
color: #2271b1;
}
#aksrt-modal-icon .aksrt-icon-modal__body {
position: relative;
overflow-y: auto;
padding: 12px 16px 16px;
}
#aksrt-modal-icon .aksrt-icon-modal__loading {
padding: 24px 0;
text-align: center;
}
#aksrt-modal-icon .aksrt-icon-modal__error {
color: #b32d2e;
}
#aksrt-modal-icon .aksrt-icon-modal__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(42px, 1fr));
gap: 4px;
margin-top: 8px;
}
#aksrt-modal-icon .aksrt-icon-picker__item {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
padding: 0;
border: 1px solid transparent;
border-radius: 6px;
background: #f6f7f7;
font-size: 18px;
color: #3c434a;
cursor: pointer;
}
#aksrt-modal-icon .aksrt-icon-picker__item:hover {
border-color: #2271b1;
color: #2271b1;
}
#aksrt-modal-icon .aksrt-icon-picker__item .icon {
width: 20px;
height: 20px;
fill: currentColor;
}
#aksrt-modal-icon .aksrt-icon-picker__empty {
grid-column: 1 / -1;
padding: 20px 0;
color: #787c82;
text-align: center;
}
/* Custom SVG tab */
#aksrt-modal-icon .aksrt-icon-modal__custom {
margin-top: 4px;
}
#aksrt-modal-icon .aksrt-icon-modal__custom textarea {
margin-top: 4px;
}
#aksrt-modal-icon .aksrt-icon-modal__custom-actions {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
}
#aksrt-modal-icon .aksrt-icon-modal__custom-preview {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px dashed #c3c4c7;
border-radius: 4px;
font-size: 18px;
}
#aksrt-modal-icon .aksrt-icon-modal__custom-preview svg {
width: 20px;
height: 20px;
}
#aksrt-modal-icon .aksrt-icon-modal__grid--session {
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid #e2e4e7;
}
/* Field markup (widgets, nav menus, meta boxes) */
.aksrt-icon-picker {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.aksrt-icon-picker .aksrt-icon-picker__preview {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: 1px solid #dcdcde;
border-radius: 4px;
background: #f6f7f7;
font-size: 16px;
}
.aksrt-icon-picker .aksrt-icon-picker__preview svg {
width: 18px;
height: 18px;
fill: currentColor;
}
.aksrt-icon-picker .aksrt-icon-picker__value {
flex: 1 1 160px;
min-width: 0;
}
+238
View File
@@ -0,0 +1,238 @@
/**
* AKSRT universal icon picker — shared admin modal for FA5, the built-in
* SVG sprite and inline custom SVG values ("fa fas fa-x" / "zibsvg-key" /
* "customsvg-<svg>").
*
* Any admin screen: call aksrt_icon_picker_assets() (PHP) and render fields
* via aksrt_icon_picker_field(); interaction is fully delegated, so lazy
* loaded widget forms and repeater rows work without re-init.
*/
(function ($) {
'use strict';
const config = window.aksrtIconPicker || {};
const i18n = config.i18n || {};
const state = {
loaded: false,
loading: false,
activeTab: 'fas',
tabs: [],
grids: {},
sessionCustom: [],
$target: null,
};
function modal() {
return $('#aksrt-modal-icon');
}
function setBusy(busy) {
modal().find('.aksrt-icon-modal__loading').each((_i, el) => { el.hidden = !busy; });
}
/* Custom SVG cleanup — strip authoring leftovers, keep pure vector markup. */
function cleanSvg(raw) {
const text = String(raw || '').trim();
if (!text) return '';
let doc;
try {
doc = new window.DOMParser().parseFromString(text, 'image/svg+xml');
} catch (e) {
return '';
}
if (doc.querySelector('parsererror')) return '';
const svg = doc.querySelector('svg');
if (!svg || svg.querySelector('script, foreignObject, animate')) return '';
['class', 'id', 'style', 't', 'version', 'p-id'].forEach((attr) => svg.removeAttribute(attr));
Array.prototype.slice.call(svg.attributes).forEach((attr) => {
if (attr.name.indexOf('data-') === 0) svg.removeAttribute(attr.name);
});
const keep = new window.XMLSerializer().serializeToString(svg);
return /<path|<use|<g\b|<circle|<rect|<polygon|<ellipse|<line/i.test(keep) ? keep : '';
}
function escapeText(text) {
return String(text).replace(/[&<>"']/g, (ch) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;',
}[ch]));
}
function previewMarkup(value) {
value = String(value || '').trim();
if (!value) return '';
if (value.indexOf('customsvg-') === 0) return cleanSvg(value.slice(10));
if (value.indexOf('zibsvg-') === 0) {
const key = value.slice(7).replace(/[^a-zA-Z0-9_-]/g, '');
return key ? `<svg class="icon" aria-hidden="true"><use xlink:href="#icon-${key}"></use></svg>` : '';
}
const el = document.createElement('i');
el.className = !/\bfa\b/.test(value) && value.indexOf('fa-') !== -1 ? `fa ${value}` : value;
el.setAttribute('aria-hidden', 'true');
return el.outerHTML;
}
function seedSessionCustom() {
$('input.aksrt-icon-picker__value').each(function () {
const value = String(this.value || '').trim();
if (value.indexOf('customsvg-') !== 0) return;
const svg = value.slice(10);
if (svg && state.sessionCustom.indexOf(svg) === -1) state.sessionCustom.push(svg);
});
}
function itemButton(value, name, innerHtml) {
return $('<button type="button" class="aksrt-icon-picker__item" role="option">')
.attr({ 'data-value': value, 'data-name': name, title: name })
.html(innerHtml);
}
function buildGrids(groups) {
state.tabs = [];
state.grids = {};
const $wrap = modal().find('.aksrt-icon-modal__grids').empty();
groups.forEach((group) => {
state.tabs.push(group);
const $grid = $('<div class="aksrt-icon-modal__grid" data-grid="' + escapeText(group.id) + '" hidden></div>');
(group.icons || []).forEach((name) => {
if (group.type === 'fa') {
const value = `fa ${group.id} fa-${name}`;
$grid.append(itemButton(value, name, `<i class="fa ${group.id} fa-${escapeText(name)}" aria-hidden="true"></i>`));
} else if (group.type === 'svg') {
const value = `zibsvg-${name}`;
const key = escapeText(name).replace(/[^a-zA-Z0-9_-]/g, '');
$grid.append(itemButton(value, name, `<svg class="icon" aria-hidden="true"><use xlink:href="#icon-${key}"></use></svg>`));
}
});
$wrap.append($grid);
state.grids[group.id] = $grid;
const $tab = $('<button type="button" class="button aksrt-icon-modal__tab" role="tab">').attr('data-tab', group.id).text(group.title || group.id);
modal().find('.aksrt-icon-modal__tabs').append($tab);
});
}
function buildSessionGrid() {
const $session = modal().find('.aksrt-icon-modal__grid--session');
$session.empty();
state.sessionCustom.forEach((svg) => {
$session.append(itemButton('customsvg-' + svg, 'customsvg', cleanSvg(svg)));
});
}
function showTab(tabId) {
state.activeTab = tabId;
modal().find('.aksrt-icon-modal__tab').each(function () {
this.classList.toggle('is-active', this.dataset.tab === tabId);
});
$.each(state.grids, (id, $grid) => { $grid[0].hidden = id !== tabId; });
const isCustom = tabId === 'custom';
modal().find('.aksrt-icon-modal__custom').each((_i, el) => { el.hidden = !isCustom; });
modal().find('.aksrt-icon-modal__search').each((_i, el) => { el.hidden = isCustom; });
applySearch();
}
function applySearch() {
if (state.activeTab === 'custom') return;
const needle = String(modal().find('.aksrt-icon-modal__search').val() || '').trim().toLowerCase();
const $grid = state.grids[state.activeTab];
if (!$grid) return;
let visible = 0;
$grid.find('.aksrt-icon-picker__item').each(function () {
const show = !needle || String(this.dataset.name || '').toLowerCase().indexOf(needle) !== -1;
this.hidden = !show;
if (show) visible++;
});
$grid.find('.aksrt-icon-picker__empty').remove();
if (!visible) $grid.append('<div class="aksrt-icon-picker__empty">' + escapeText(i18n.empty || 'No icons found.') + '</div>');
}
function refreshPicker($picker, value) {
const $preview = $picker.find('.aksrt-icon-picker__preview').first();
$preview.html(previewMarkup(value)).toggleClass('hidden', !value);
$picker.find('.aksrt-icon-picker__remove').first().toggleClass('hidden', !value);
}
function select(value) {
modal().addClass('hidden');
const $picker = state.$target;
if (!$picker || !$picker.length) return;
$picker.find('input.aksrt-icon-picker__value').val(value).trigger('change');
refreshPicker($picker, value);
}
function ensureCatalog() {
if (state.loaded || state.loading) return;
state.loading = true;
setBusy(true);
window.fetch(config.restUrl, { headers: { 'X-WP-Nonce': config.nonce } })
.then((r) => r.json())
.then((json) => {
if (!json || !json.success || !Array.isArray(json.groups)) throw new Error('bad payload');
buildGrids(json.groups);
seedSessionCustom();
buildSessionGrid();
state.loaded = true;
setBusy(false);
showTab('fas');
})
.catch(() => {
state.loading = false;
setBusy(false);
modal().find('.aksrt-icon-modal__error').each((_i, el) => {
el.hidden = false;
el.textContent = i18n.loadError || 'Failed to load icons.';
});
});
}
function open($picker) {
state.$target = $picker;
modal().removeClass('hidden');
seedSessionCustom();
buildSessionGrid();
ensureCatalog();
if (state.loaded) showTab(state.activeTab);
modal().find('.aksrt-icon-modal__search').trigger('focus');
}
$(document)
.on('click', '.aksrt-icon-picker__choose', function () {
open($(this).closest('[data-aksrt-icon-picker]'));
})
.on('click', '.aksrt-icon-picker__remove', function () {
const $picker = $(this).closest('[data-aksrt-icon-picker]');
$picker.find('input.aksrt-icon-picker__value').val('').trigger('change');
refreshPicker($picker, '');
})
.on('change', 'input.aksrt-icon-picker__value', function () {
refreshPicker($(this).closest('[data-aksrt-icon-picker]'), String(this.value || '').trim());
})
.on('click', '[data-aksrt-icon-close]', () => modal().addClass('hidden'))
.on('keydown', (e) => { if (e.key === 'Escape') modal().addClass('hidden'); })
.on('click', '.aksrt-icon-modal__tab', function () { showTab(this.dataset.tab); })
.on('input', '.aksrt-icon-modal__search', applySearch)
.on('click', '.aksrt-icon-picker__item', function () {
const value = String(this.dataset.value || '');
if (value) select(value);
})
.on('input', '.aksrt-icon-modal__custom textarea', function () {
modal().find('.aksrt-icon-modal__custom-preview').html(cleanSvg(this.value));
})
.on('click', '.aksrt-icon-modal__custom-actions .button', function () {
const $custom = modal().find('.aksrt-icon-modal__custom');
const svg = cleanSvg($custom.find('textarea').val());
if (!svg) {
window.alert(i18n.customEmpty || 'Invalid SVG.');
return;
}
if (state.sessionCustom.indexOf(svg) === -1) {
state.sessionCustom.push(svg);
buildSessionGrid();
}
select('customsvg-' + svg);
$custom.find('textarea').val('');
$custom.find('.aksrt-icon-modal__custom-preview').html('');
});
})(jQuery);