|
/** |
|
* =========================================== |
|
* 🏷️ TagPicker JavaScript Library |
|
* =========================================== |
|
* A modular, mobile-friendly tag selector UI component |
|
* for websites, iOS Shortcuts, and bookmark tagging. |
|
* |
|
* 📦 Features: |
|
* ------------------------------------------- |
|
* ✅ Modular initialization via `TagPicker.init()` |
|
* ✅ Accepts static JavaScript array OR loads from API |
|
* ✅ Alphabetical filtering (A–Z, 0–9, All) |
|
* ✅ Custom Tag Set filtering (group tags by purpose) |
|
* ✅ Recent tags (based on localStorage, up to 50) |
|
* ✅ Full-text search across ALL tags (not just current view) |
|
* ✅ Optional tabbed navigation between views |
|
* ✅ Tag selection with toggles (multiple select) |
|
* ✅ Returns `{ id, name }` of selected tags on submit |
|
* ✅ Configurable auto-submit via `scriptable://` URI |
|
* ✅ Pluralized tag count summary: |
|
* - Total tag count |
|
* - Filtered tag count |
|
* - Selected tag count |
|
* ✅ Search enhancements: |
|
* - Case-insensitive by default |
|
* - Highlight matching text in results |
|
* - Debounce typing (default: 200ms) |
|
* ✅ Live view updates on tab/filter change |
|
* ✅ Responsive & touch-friendly layout |
|
* |
|
* ⚙️ Configuration Options: |
|
* ------------------------------------------- |
|
* { |
|
* defaultView: 'alphabet' | 'sets' | 'search' | 'recent', |
|
* enableAlphabetView: true, |
|
* enableSetView: true, |
|
* enableSearchView: true, |
|
* enableRecentView: true, |
|
* showTotalTagCount: true, |
|
* showFilteredTagCount: true, |
|
* showSelectedTagCount: true, |
|
* debounceDelay: 200, |
|
* autoSubmit: false, |
|
* scriptName: 'ApplyTags', |
|
* tagSourceUrl: null // ← optional fetch URL for tag list |
|
* } |
|
* |
|
* 📥 API Tag Loading: |
|
* ------------------------------------------- |
|
* - If `tagList` is null and `tagSourceUrl` is set, |
|
* the tag data will be fetched from the endpoint. |
|
* - Expected format: JSON array of objects with `{ id, name }` |
|
* - Shows loading message while fetching |
|
* - Displays retry button on failure |
|
* |
|
* 📤 Output: |
|
* ------------------------------------------- |
|
* - On submit, returns JSON-encoded array of selected tags: |
|
* [{ id: 1, name: "Vue" }, { id: 3, name: "Laravel" }] |
|
* - Sent via Scriptable URI or alert, depending on `autoSubmit` |
|
* |
|
* 🧪 Usage: |
|
* ------------------------------------------- |
|
* HTML: |
|
* <div id="tagSelector"></div> |
|
* |
|
* JS (Static Tags): |
|
* TagPicker.init("#tagSelector", tagList, tagSets); |
|
* |
|
* JS (API Tags): |
|
* TagPicker.init("#tagSelector", null, tagSets, { |
|
* tagSourceUrl: "https://your-api.com/tags" |
|
* }); |
|
* |
|
* 📅 Version: 1.0.0 |
|
* 🔧 Author: Jason Davis + ChatGPT |
|
* 💻 License: MIT (optional) |
|
*/ |
|
|
|
window.TagPicker = { |
|
async init(selector, tagList, tagSets, options = {}) { |
|
const config = Object.assign( |
|
{ |
|
enableAlphabetView: true, |
|
enableSetView: true, |
|
enableSearchView: true, |
|
enableRecentView: true, |
|
defaultView: "alphabet", |
|
autoSubmit: false, |
|
scriptName: "ApplyTags", |
|
debounceDelay: 200, |
|
showTotalTagCount: true, |
|
showFilteredTagCount: true, |
|
showSelectedTagCount: true, |
|
tagSourceUrl: null |
|
}, |
|
options |
|
); |
|
|
|
const container = |
|
typeof selector === "string" ? document.querySelector(selector) : selector; |
|
if (!container) throw new Error("Container not found"); |
|
|
|
// Show loading |
|
if (!tagList && config.tagSourceUrl) { |
|
container.innerHTML = "<p>Loading tags...</p>"; |
|
try { |
|
const res = await fetch(config.tagSourceUrl); |
|
if (!res.ok) throw new Error(`HTTP ${res.status}`); |
|
tagList = await res.json(); |
|
} catch (err) { |
|
container.innerHTML = ` |
|
<div style="color:red;"> |
|
Failed to load tags: ${err.message}<br> |
|
<button id="retryTags">Retry</button> |
|
</div>`; |
|
document.getElementById("retryTags").onclick = () => { |
|
TagPicker.init(selector, null, tagSets, options); |
|
}; |
|
return; |
|
} |
|
} |
|
|
|
const selected = new Set(); |
|
const maxRecent = 50; |
|
let currentView = config.defaultView; |
|
let debounceTimer = null; |
|
let lastFiltered = []; |
|
|
|
const createEl = (tag, props = {}, ...children) => { |
|
const el = document.createElement(tag); |
|
Object.entries(props).forEach(([k, v]) => { |
|
if (k.startsWith("on")) el.addEventListener(k.substring(2), v); |
|
else if (k === "className") el.className = v; |
|
else el.setAttribute(k, v); |
|
}); |
|
children.forEach((child) => { |
|
if (typeof child === "string") el.innerHTML += child; |
|
else if (child) el.appendChild(child); |
|
}); |
|
return el; |
|
}; |
|
|
|
const plural = (count, word) => `${count} ${word}${count !== 1 ? "s" : ""}`; |
|
const highlightMatch = (text, query) => { |
|
if (!query) return text; |
|
const re = new RegExp( |
|
`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, |
|
"gi" |
|
); |
|
return text.replace(re, "<mark>$1</mark>"); |
|
}; |
|
|
|
const tagListEl = createEl("div", { className: "tag-list", id: "tagList" }); |
|
const letterToolbar = createEl("div", { |
|
className: "toolbar", |
|
id: "letterToolbar" |
|
}); |
|
const setToolbar = createEl("div", { |
|
className: "toolbar", |
|
id: "setToolbar", |
|
style: "display:none;" |
|
}); |
|
const countSummary = createEl("div", { |
|
id: "tagCountSummary", |
|
style: "margin-bottom: 10px;" |
|
}); |
|
|
|
const searchInputBox = createEl( |
|
"div", |
|
{ id: "searchBox", style: "display:none;" }, |
|
createEl("input", { |
|
id: "searchInput", |
|
type: "text", |
|
placeholder: "Search tags...", |
|
oninput: (e) => { |
|
clearTimeout(debounceTimer); |
|
debounceTimer = setTimeout(() => { |
|
const query = e.target.value.trim().toLowerCase(); |
|
const matches = tagList.filter((tag) => |
|
tag.name.toLowerCase().includes(query) |
|
); |
|
updateTags(matches, query); |
|
}, config.debounceDelay); |
|
} |
|
}) |
|
); |
|
|
|
const updateCountSummary = () => { |
|
const parts = []; |
|
if (config.showTotalTagCount) |
|
parts.push(`All ${plural(tagList.length, "Tag")}`); |
|
if (config.showFilteredTagCount) |
|
parts.push(`Shown: ${plural(lastFiltered.length, "Tag")}`); |
|
if (config.showSelectedTagCount) |
|
parts.push(`Selected: ${plural(selected.size, "Tag")}`); |
|
countSummary.innerText = parts.join(" | "); |
|
}; |
|
|
|
const updateTags = (tagSubset, highlightQuery = "") => { |
|
lastFiltered = tagSubset; |
|
tagListEl.innerHTML = ""; |
|
tagSubset.forEach((tag) => { |
|
const tagEl = createEl("div", { |
|
className: "tag" + (selected.has(tag.id) ? " selected" : ""), |
|
onclick: () => { |
|
if (selected.has(tag.id)) { |
|
selected.delete(tag.id); |
|
tagEl.classList.remove("selected"); |
|
} else { |
|
selected.add(tag.id); |
|
tagEl.classList.add("selected"); |
|
updateRecentTags(tag.id); |
|
} |
|
updateCountSummary(); |
|
} |
|
}); |
|
tagEl.innerHTML = highlightQuery |
|
? highlightMatch(tag.name, highlightQuery) |
|
: tag.name; |
|
tagListEl.appendChild(tagEl); |
|
}); |
|
updateCountSummary(); |
|
}; |
|
|
|
const updateRecentTags = (id) => { |
|
let recent = JSON.parse(localStorage.getItem("recentTags") || "[]"); |
|
recent = [id, ...recent.filter((tid) => tid !== id)].slice(0, maxRecent); |
|
localStorage.setItem("recentTags", JSON.stringify(recent)); |
|
}; |
|
|
|
const getRecentTags = () => { |
|
const recent = JSON.parse(localStorage.getItem("recentTags") || "[]"); |
|
return recent.map((id) => tagList.find((t) => t.id === id)).filter(Boolean); |
|
}; |
|
|
|
const initAlphabetToolbar = () => { |
|
const letters = [..."ABCDEFGHIJKLMNOPQRSTUVWXYZ"]; |
|
["All", "0-9", ...letters].forEach((letter) => { |
|
const btn = createEl( |
|
"button", |
|
{ |
|
onclick: () => { |
|
[...letterToolbar.children].forEach((b) => b.classList.remove("active")); |
|
btn.classList.add("active"); |
|
let filtered = tagList; |
|
if (letter === "0-9") { |
|
filtered = tagList.filter((tag) => /^[0-9]/.test(tag.name)); |
|
} else if (letter !== "All") { |
|
filtered = tagList.filter((tag) => |
|
tag.name.toUpperCase().startsWith(letter) |
|
); |
|
} |
|
updateTags(filtered); |
|
} |
|
}, |
|
letter |
|
); |
|
letterToolbar.appendChild(btn); |
|
}); |
|
}; |
|
|
|
const initSetToolbar = () => { |
|
Object.keys(tagSets).forEach((setName) => { |
|
const btn = createEl( |
|
"button", |
|
{ |
|
onclick: () => { |
|
[...setToolbar.children].forEach((b) => b.classList.remove("active")); |
|
btn.classList.add("active"); |
|
const filtered = tagList.filter((tag) => |
|
tagSets[setName].includes(tag.name) |
|
); |
|
updateTags(filtered); |
|
} |
|
}, |
|
setName |
|
); |
|
setToolbar.appendChild(btn); |
|
}); |
|
}; |
|
|
|
const viewToggle = createEl( |
|
"div", |
|
{ className: "view-toggle" }, |
|
config.enableAlphabetView && |
|
createEl( |
|
"label", |
|
{}, |
|
createEl("input", { |
|
type: "radio", |
|
name: "view", |
|
value: "alphabet", |
|
checked: config.defaultView === "alphabet" |
|
}), |
|
" Alphabetical" |
|
), |
|
config.enableSetView && |
|
createEl( |
|
"label", |
|
{}, |
|
createEl("input", { |
|
type: "radio", |
|
name: "view", |
|
value: "sets", |
|
checked: config.defaultView === "sets" |
|
}), |
|
" Tag Sets" |
|
), |
|
config.enableSearchView && |
|
createEl( |
|
"label", |
|
{}, |
|
createEl("input", { |
|
type: "radio", |
|
name: "view", |
|
value: "search", |
|
checked: config.defaultView === "search" |
|
}), |
|
" Search" |
|
), |
|
config.enableRecentView && |
|
createEl( |
|
"label", |
|
{}, |
|
createEl("input", { |
|
type: "radio", |
|
name: "view", |
|
value: "recent", |
|
checked: config.defaultView === "recent" |
|
}), |
|
" Recent" |
|
) |
|
); |
|
|
|
const submitBtn = createEl( |
|
"button", |
|
{ |
|
id: "submitBtn", |
|
onclick: () => { |
|
const result = Array.from(selected).map((id) => { |
|
const tag = tagList.find((t) => t.id === id); |
|
return { id: tag.id, name: tag.name }; |
|
}); |
|
const json = encodeURIComponent(JSON.stringify(result)); |
|
if (config.autoSubmit) { |
|
location.href = `scriptable:///run?scriptName=${config.scriptName}&input=${json}`; |
|
} else { |
|
alert("Selected Tags:\n" + decodeURIComponent(json)); |
|
} |
|
} |
|
}, |
|
"Submit" |
|
); |
|
|
|
container.innerHTML = ""; |
|
container.appendChild(viewToggle); |
|
container.appendChild(letterToolbar); |
|
container.appendChild(setToolbar); |
|
container.appendChild(searchInputBox); |
|
container.appendChild(countSummary); |
|
container.appendChild(tagListEl); |
|
container.appendChild(submitBtn); |
|
|
|
initAlphabetToolbar(); |
|
initSetToolbar(); |
|
|
|
container.querySelectorAll('input[name="view"]').forEach((radio) => { |
|
radio.onchange = (e) => { |
|
currentView = e.target.value; |
|
letterToolbar.style.display = currentView === "alphabet" ? "" : "none"; |
|
setToolbar.style.display = currentView === "sets" ? "" : "none"; |
|
searchInputBox.style.display = currentView === "search" ? "" : "none"; |
|
|
|
if (currentView === "alphabet") letterToolbar.children[0].click(); |
|
if (currentView === "sets") setToolbar.children[0].click(); |
|
if (currentView === "search") { |
|
document.getElementById("searchInput").value = ""; |
|
updateTags([]); |
|
} |
|
if (currentView === "recent") updateTags(getRecentTags()); |
|
}; |
|
}); |
|
|
|
if (currentView === "alphabet") letterToolbar.children[0].click(); |
|
if (currentView === "sets") setToolbar.children[0].click(); |
|
if (currentView === "search") updateTags([]); |
|
if (currentView === "recent") updateTags(getRecentTags()); |
|
} |
|
}; |
|
|
|
// Example data (use this below the TagPicker code in your CodePen) |
|
const tags = [ |
|
{ id: 1, name: "Vue" }, |
|
{ id: 2, name: "React" }, |
|
{ id: 3, name: "Laravel" }, |
|
{ id: 4, name: "MySQL" }, |
|
{ id: 5, name: "Tailwind CSS" }, |
|
{ id: 6, name: "CSS3" }, |
|
{ id: 7, name: "Node.js" }, |
|
{ id: 8, name: "Python" } |
|
]; |
|
|
|
const tagSets = { |
|
Frontend: ["Vue", "React", "Tailwind CSS"], |
|
Backend: ["Laravel", "Node.js"], |
|
Database: ["MySQL"], |
|
Styling: ["CSS3", "Tailwind CSS"], |
|
Scripting: ["Python"] |
|
}; |
|
|
|
TagPicker.init("#tagSelector", tags, tagSets, { |
|
defaultView: "alphabet" |
|
}); |