Skip to content

Instantly share code, notes, and snippets.

@jasondavis
Last active June 11, 2025 01:21
Show Gist options
  • Save jasondavis/09408bea303271f4fbbae58440e41475 to your computer and use it in GitHub Desktop.
Save jasondavis/09408bea303271f4fbbae58440e41475 to your computer and use it in GitHub Desktop.
Multi-Tag Selector with Alphabetical Tag Lists and Tag Sets by Category
<div id="tagSelector"></div>

Multi-Tag Selector with Alphabetical Tag Lists and Tag Sets by Category

A Pen by Jason Davis on CodePen.

License.

🏷️ 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 tag list or loads dynamically from an API
  • ✅ Alphabetical filtering (A–Z, 0–9, All)
  • ✅ Custom tag sets (group tags into categories)
  • ✅ Recent tags (stored via localStorage, up to 50)
  • ✅ Search tab:
    • Full-text search across all tags
    • Case-insensitive by default
    • Optional text highlighting
    • Debounced input to improve performance
  • ✅ Tab-based navigation: Alphabetical, Tag Sets, Search, Recent
  • ✅ Multi-select support with toggle selection
  • ✅ Tag count summary:
    • Total tags
    • Filtered tags
    • Selected tags
    • Pluralized display
  • ✅ Submit button returns selected tag objects:
    • { id, name } format
  • ✅ Auto-submit to iOS Scriptable via URL (optional)
  • ✅ Keyboard + touch friendly

⚙️ Configuration Options

Pass these options as the 4th argument to TagPicker.init(...):

{
  defaultView: 'alphabet', // or '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 API URL for loading tags
}

📥 Loading Tags from an API

To fetch tag data remotely:

TagPicker.init("#tagSelector", null, tagSets, {
  tagSourceUrl: "https://your-api.com/tags"
});
  • Expects:
    [
      { "id": 1, "name": "Vue" },
      { "id": 2, "name": "Tailwind" }
    ]
  • Displays a loading message
  • Shows retry button if fetch fails

📤 Output Format

When the user submits:

[
  { "id": 1, "name": "Vue" },
  { "id": 2, "name": "Tailwind" }
]

If autoSubmit is enabled, the tags are passed to Scriptable via:

scriptable:///run?scriptName=ApplyTags&input=ENCODED_JSON

If autoSubmit is false, the tags are shown in an alert instead.


🧪 Usage Example

HTML

<div id="tagSelector"></div>

JavaScript

TagPicker.init("#tagSelector", tagList, tagSets);

or

TagPicker.init("#tagSelector", null, tagSets, {
  tagSourceUrl: "https://your-api.com/tags"
});

📁 Suggested Project Structure

/tag-picker/
├── dist/
│   └── tag-picker.umd.js
├── demo/
│   └── demo.html
├── README.md

📅 Version

v1.0.0


🔧 Author

Jason Davis + ChatGPT


📄 License

MIT

/**
* ===========================================
* 🏷️ 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"
});
body {
font-family: sans-serif;
padding: 1em;
margin: 0;
}
h2 {
margin-top: 0;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 10px;
}
.toolbar button {
padding: 6px 12px;
border: none;
background-color: #eee;
border-radius: 4px;
cursor: pointer;
}
.toolbar button.active {
background-color: #007aff;
color: white;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag {
padding: 6px 10px;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
background-color: #f5f5f5;
}
.tag.selected {
background-color: #d0ebff;
font-weight: bold;
}
.view-toggle {
margin-bottom: 10px;
}
.view-toggle label {
margin-right: 10px;
}
#submitBtn {
margin-top: 20px;
padding: 10px 15px;
background-color: #28a745;
color: white;
border: none;
border-radius: 5px;
font-size: 16px;
}
.view-toggle label {
margin-right: 10px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 10px;
}
.toolbar button {
padding: 6px 12px;
background: #eee;
border: none;
border-radius: 4px;
cursor: pointer;
}
.toolbar button.active {
background: #007aff;
color: white;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag {
padding: 6px 10px;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
background: #f5f5f5;
}
.tag.selected {
background: #d0ebff;
font-weight: bold;
}
#submitBtn {
margin-top: 20px;
padding: 10px 15px;
background-color: #28a745;
color: white;
border: none;
border-radius: 5px;
}
#searchInput {
width: 100%;
margin-bottom: 10px;
padding: 6px 10px;
border-radius: 5px;
border: 1px solid #ccc;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment