From b4bbfc252671601eebf6a0897a80d1f536324ee4 Mon Sep 17 00:00:00 2001 From: Pranesh Jha Date: Tue, 12 May 2026 23:02:11 +0530 Subject: [PATCH] Improve skill input UX with shared skill dataset and autocomplete dropdown --- static/data/skills.js | 41 +++++ static/script.js | 345 +++++++++++++++++++++++++++++++++--------- static/style.css | 64 ++++++++ templates/index.html | 5 + 4 files changed, 386 insertions(+), 69 deletions(-) create mode 100644 static/data/skills.js diff --git a/static/data/skills.js b/static/data/skills.js new file mode 100644 index 00000000..7f61c673 --- /dev/null +++ b/static/data/skills.js @@ -0,0 +1,41 @@ +var skills = [ + {id: "python", label: "Python"}, + {id: "javascript", label: "JavaScript"}, + {id: "java", label: "Java"}, + {id: "c++", label: "C++"}, + {id: "html", label: "HTML"}, + {id: "css", label: "CSS"}, + {id: "react", label: "React"}, + {id: "node.js", label: "Node.js"}, + {id: "django", label: "Django"}, + {id: "flask", label: "Flask"}, + {id: "sql", label: "SQL"}, + {id: "mongodb", label: "MongoDB"}, + {id: "aws", label: "AWS"}, + {id: "docker", label: "Docker"}, + {id: "kubernetes", label: "Kubernetes"}, + {id: "git", label: "Git"}, + {id: "c#", label: "C#"}, + {id: "ruby", label: "Ruby"}, + {id: "php", label: "PHP"}, + {id: "go", label: "Go"}, + {id: "swift", label: "Swift"}, + {id: "typescript", label: "TypeScript"}, + {id: "angular", label: "Angular"}, + {id: "vue.js", label: "Vue.js"}, + {id: "spring", label: "Spring"}, + {id: "flutter", label: "Flutter"}, + {id: "tensorflow", label: "TensorFlow"}, + {id: "pytorch", label: "PyTorch"}, + {id: "data science", label: "Data Science"}, + {id: "machine learning", label: "Machine Learning"}, + {id: "artificial intelligence", label: "Artificial Intelligence"}, + {id: "devops", label: "DevOps"}, + {id: "cybersecurity", label: "Cybersecurity"}, + {id: "blockchain", label: "Blockchain"}, + {id: "ui/ux design", label: "UI/UX Design"}, + {id: "game development", label: "Game Development"}, + {id: "ci/cd", label: "CI/CD"}, + {id: "restapi", label: "REST API"}, + {id: "graphql", label: "GraphQL"}, +]; diff --git a/static/script.js b/static/script.js index 1ceb1e63..8553ff91 100644 --- a/static/script.js +++ b/static/script.js @@ -11,7 +11,7 @@ // ============================================================ // Detect which page we are on // ============================================================ -var isIndexPage = !!document.getElementById("recommend-form"); +var isIndexPage = !!document.getElementById("recommend-form"); var isDetailPage = typeof PROJECT_ID !== "undefined"; @@ -20,7 +20,7 @@ var isDetailPage = typeof PROJECT_ID !== "undefined"; // ============================================================ (function initMobileNav() { var toggle = document.getElementById("nav-mobile-toggle"); - var menu = document.getElementById("nav-mobile-menu"); + var menu = document.getElementById("nav-mobile-menu"); if (!toggle || !menu) return; @@ -46,19 +46,19 @@ var isDetailPage = typeof PROJECT_ID !== "undefined"; if (isIndexPage) { // DOM references - var form = document.getElementById("recommend-form"); - var submitBtn = document.getElementById("submit-btn"); - var btnLabel = document.getElementById("btn-label"); - var btnLoading = document.getElementById("btn-loading"); - var resultsSection = document.getElementById("results-section"); - var resultsGrid = document.getElementById("results-grid"); - var resultsLoadingEl = document.getElementById("results-loading"); - var resultsEmptyEl = document.getElementById("results-empty"); - var emptyMessageEl = document.getElementById("empty-message"); - var skillsHidden = document.getElementById("skills"); - var skillsTextInput = document.getElementById("skills-input"); - var chipsSelectedEl = document.getElementById("skill-chips-selected"); - var quickPickChips = document.querySelectorAll(".skill-chip"); + var form = document.getElementById("recommend-form"); + var submitBtn = document.getElementById("submit-btn"); + var btnLabel = document.getElementById("btn-label"); + var btnLoading = document.getElementById("btn-loading"); + var resultsSection = document.getElementById("results-section"); + var resultsGrid = document.getElementById("results-grid"); + var resultsLoadingEl = document.getElementById("results-loading"); + var resultsEmptyEl = document.getElementById("results-empty"); + var emptyMessageEl = document.getElementById("empty-message"); + var skillsHidden = document.getElementById("skills"); + var skillsTextInput = document.getElementById("skills-input"); + var chipsSelectedEl = document.getElementById("skill-chips-selected"); + var quickPickChips = document.querySelectorAll(".skill-chip"); // Tracks currently selected skills to prevent duplicates var selectedSkills = []; @@ -68,59 +68,258 @@ if (isIndexPage) { // Skill chip manager // ---------------------------------------------------------- + // Skills list for autocomplete (from skills.js) + var availableSkills = []; + if (typeof skills !== "undefined" && Array.isArray(skills) && skills.length > 0) { + availableSkills = skills.map(function (s) { return s.label; }); + } else { + // Fallback if skills.js doesn't load + availableSkills = [ + "Python", "JavaScript", "Java", "C++", "HTML", "CSS", "React", "Node.js", + "Django", "Flask", "SQL", "MongoDB", "AWS", "Docker", "Kubernetes", "Git", + "C#", "Ruby", "PHP", "Go", "Swift", "TypeScript", "Angular", "Vue.js", + "Spring", "Flutter", "TensorFlow", "PyTorch", "Data Science", + "Machine Learning", "Artificial Intelligence", "DevOps", "Cybersecurity", + "Blockchain", "UI/UX Design", "Game Development", "CI/CD", "REST API", "GraphQL" + ]; + } + + var suggestionsDiv = document.getElementById("skills-suggestions"); + var skillWrap = document.getElementById("skill-input-wrap"); + var visibleSuggestions = []; + var activeSuggestionIndex = -1; + + availableSkills = availableSkills.filter(function (skill, index, list) { + return typeof skill === "string" && skill.trim() && + list.findIndex(function (item) { + return item.toLowerCase() === skill.toLowerCase(); + }) === index; + }); + + if (suggestionsDiv) { + suggestionsDiv.setAttribute("role", "listbox"); + } + + function normalizeSkill(skill) { + return skill.trim().toLowerCase(); + } + + function isSkillSelected(skill) { + var normalizedSkill = normalizeSkill(skill); + return selectedSkills.some(function (selectedSkill) { + return normalizeSkill(selectedSkill) === normalizedSkill; + }); + } + + function getCanonicalSkill(rawSkill) { + var normalizedSkill = normalizeSkill(rawSkill); + var matchedSkill = availableSkills.find(function (skill) { + return normalizeSkill(skill) === normalizedSkill; + }); + + return matchedSkill || rawSkill.trim(); + } + + function getFilteredSkills(query) { + var normalizedQuery = normalizeSkill(query); + + return availableSkills.filter(function (skill) { + return normalizeSkill(skill).includes(normalizedQuery) && !isSkillSelected(skill); + }).slice(0, 8); + } + + function syncSuggestionsA11yState() { + skillsTextInput.setAttribute("aria-expanded", visibleSuggestions.length > 0 ? "true" : "false"); + } + + function renderActiveSuggestion() { + if (!suggestionsDiv) return; + + suggestionsDiv.querySelectorAll(".suggestion-item").forEach(function (item, index) { + var isActive = index === activeSuggestionIndex; + item.classList.toggle("suggestion-item--active", isActive); + item.setAttribute("aria-selected", isActive ? "true" : "false"); + }); + } + + function hideSuggestions() { + visibleSuggestions = []; + activeSuggestionIndex = -1; + + if (suggestionsDiv) { + suggestionsDiv.style.display = "none"; + suggestionsDiv.innerHTML = ""; + } + + syncSuggestionsA11yState(); + } + + function selectSuggestion(skill) { + addSkill(skill); + skillsTextInput.value = ""; + hideSuggestions(); + skillsTextInput.focus(); + } + + function displaySuggestions(items) { + if (!suggestionsDiv) return; + + visibleSuggestions = items; + activeSuggestionIndex = -1; + + if (items.length === 0) { + hideSuggestions(); + return; + } + + suggestionsDiv.innerHTML = ""; + items.forEach(function (skill, index) { + var item = document.createElement("div"); + item.className = "suggestion-item"; + item.textContent = skill; + item.setAttribute("role", "option"); + item.setAttribute("id", "skills-suggestion-" + index); + item.setAttribute("aria-selected", "false"); + + // Prevent the input blur handler from closing the menu before click runs. + item.addEventListener("mousedown", function (evt) { + evt.preventDefault(); + }); + + item.addEventListener("mouseenter", function () { + activeSuggestionIndex = index; + renderActiveSuggestion(); + }); + + item.addEventListener("click", function () { + selectSuggestion(skill); + }); + + suggestionsDiv.appendChild(item); + }); + + suggestionsDiv.style.display = "block"; + syncSuggestionsA11yState(); + } + + function updateQuickPickState() { + quickPickChips.forEach(function (chip) { + var isActive = isSkillSelected(chip.getAttribute("data-skill") || ""); + chip.classList.toggle("active", isActive); + chip.setAttribute("aria-pressed", isActive ? "true" : "false"); + }); + } + // Add skill on Enter key in the text input skillsTextInput.addEventListener("keydown", function (evt) { + if (evt.key === "ArrowDown" || evt.key === "ArrowUp") { + if (visibleSuggestions.length === 0) { + displaySuggestions(getFilteredSkills(skillsTextInput.value)); + } + + if (visibleSuggestions.length === 0) return; + + evt.preventDefault(); + if (evt.key === "ArrowDown") { + activeSuggestionIndex = (activeSuggestionIndex + 1) % visibleSuggestions.length; + } else { + activeSuggestionIndex = activeSuggestionIndex <= 0 + ? visibleSuggestions.length - 1 + : activeSuggestionIndex - 1; + } + + renderActiveSuggestion(); + return; + } + + if (evt.key === "Escape") { + hideSuggestions(); + return; + } + if (evt.key === "Enter") { evt.preventDefault(); - var value = skillsTextInput.value.trim(); - if (value) { - addSkill(value); + + if (activeSuggestionIndex >= 0 && visibleSuggestions[activeSuggestionIndex]) { + selectSuggestion(visibleSuggestions[activeSuggestionIndex]); + return; + } + + if (skillsTextInput.value.trim()) { + addSkill(skillsTextInput.value); skillsTextInput.value = ""; } + + hideSuggestions(); + } + }); + + // Show suggestions on input + skillsTextInput.addEventListener("input", function (evt) { + var typedValue = evt.target.value.trim(); + + if (typedValue.length === 0) { + hideSuggestions(); + return; + } + + displaySuggestions(getFilteredSkills(typedValue)); + }); + + skillsTextInput.addEventListener("focus", function () { + if (skillsTextInput.value.trim()) { + displaySuggestions(getFilteredSkills(skillsTextInput.value)); } }); + // Hide suggestions when input loses focus + skillsTextInput.addEventListener("blur", function () { + setTimeout(function () { hideSuggestions(); }, 150); + }); + + if (skillWrap) { + skillWrap.addEventListener("click", function () { + skillsTextInput.focus(); + }); + } + // Add skill on quick-pick chip click quickPickChips.forEach(function (chip) { chip.addEventListener("click", function () { addSkill(chip.getAttribute("data-skill")); - chip.classList.add("active"); + hideSuggestions(); + skillsTextInput.value = ""; }); }); - // Focus the text input when clicking anywhere in the chip wrap - var skillWrap = document.getElementById("skill-input-wrap"); - if (skillWrap) { - skillWrap.addEventListener("click", function () { skillsTextInput.focus(); }); - } + document.addEventListener("click", function (evt) { + if (skillWrap && !skillWrap.contains(evt.target)) { + hideSuggestions(); + } + }); function addSkill(rawSkill) { - var skill = rawSkill.trim(); + var skill = getCanonicalSkill(rawSkill); if (!skill) return; // Block duplicate entries (case-insensitive) - var isDuplicate = selectedSkills.some(function (s) { - return s.toLowerCase() === skill.toLowerCase(); - }); - if (isDuplicate) return; + if (isSkillSelected(skill)) return; selectedSkills.push(skill); renderSelectedChips(); syncSkillsHiddenInput(); + updateQuickPickState(); clearFieldError("skills-error"); } function removeSkill(skill) { - selectedSkills = selectedSkills.filter(function (s) { return s !== skill; }); + selectedSkills = selectedSkills.filter(function (selectedSkill) { + return normalizeSkill(selectedSkill) !== normalizeSkill(skill); + }); + renderSelectedChips(); syncSkillsHiddenInput(); - - // Un-highlight the quick-pick button if it matches the removed skill - quickPickChips.forEach(function (chip) { - if (chip.getAttribute("data-skill") === skill) { - chip.classList.remove("active"); - } - }); + updateQuickPickState(); } function renderSelectedChips() { @@ -151,6 +350,8 @@ if (isIndexPage) { skillsHidden.value = selectedSkills.join(", "); } + updateQuickPickState(); + // ---------------------------------------------------------- // Form validation @@ -204,21 +405,27 @@ if (isIndexPage) { evt.preventDefault(); clearAllErrors(); + if (skillsTextInput.value.trim()) { + addSkill(skillsTextInput.value); + skillsTextInput.value = ""; + hideSuggestions(); + } + if (!validateForm()) return; setLoadingState(true); var payload = { - skills: skillsHidden.value.trim() || skillsTextInput.value.trim(), - level: document.getElementById("level").value, + skills: skillsHidden.value.trim() || skillsTextInput.value.trim(), + level: document.getElementById("level").value, interest: document.getElementById("interest").value, - time: document.getElementById("time").value + time: document.getElementById("time").value }; fetch("/api/recommend", { - method: "POST", + method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) + body: JSON.stringify(payload) }) .then(function (res) { return res.json(); }) .then(function (data) { @@ -242,19 +449,19 @@ if (isIndexPage) { function setLoadingState(isLoading) { submitBtn.disabled = isLoading; - btnLabel.style.display = isLoading ? "none" : "inline"; + btnLabel.style.display = isLoading ? "none" : "inline"; btnLoading.style.display = isLoading ? "inline" : "none"; if (isLoading) { // Show the results section with only the loading indicator visible - resultsSection.style.display = "block"; - resultsLoadingEl.style.display = "block"; - resultsGrid.style.display = "none"; - resultsEmptyEl.style.display = "none"; + resultsSection.style.display = "block"; + resultsLoadingEl.style.display = "block"; + resultsGrid.style.display = "none"; + resultsEmptyEl.style.display = "none"; resultsSection.scrollIntoView({ behavior: "smooth" }); } else { - resultsLoadingEl.style.display = "none"; - resultsGrid.style.display = "grid"; + resultsLoadingEl.style.display = "none"; + resultsGrid.style.display = "grid"; } } @@ -264,20 +471,20 @@ if (isIndexPage) { // ---------------------------------------------------------- function renderResults(projects, message) { - resultsSection.style.display = "block"; - resultsLoadingEl.style.display = "none"; - resultsGrid.innerHTML = ""; + resultsSection.style.display = "block"; + resultsLoadingEl.style.display = "none"; + resultsGrid.innerHTML = ""; if (!projects || projects.length === 0) { - resultsGrid.style.display = "none"; - resultsEmptyEl.style.display = "block"; + resultsGrid.style.display = "none"; + resultsEmptyEl.style.display = "block"; if (message && emptyMessageEl) emptyMessageEl.textContent = message; resultsSection.scrollIntoView({ behavior: "smooth" }); return; } - resultsEmptyEl.style.display = "none"; - resultsGrid.style.display = "grid"; + resultsEmptyEl.style.display = "none"; + resultsGrid.style.display = "grid"; projects.forEach(function (project) { resultsGrid.appendChild(buildProjectCard(project)); @@ -292,12 +499,12 @@ if (isIndexPage) { // Title var title = document.createElement("h3"); - title.className = "project-card-title"; + title.className = "project-card-title"; title.textContent = project.title; // Description (truncated for visual consistency) var desc = document.createElement("p"); - desc.className = "project-card-desc"; + desc.className = "project-card-desc"; desc.textContent = truncate(project.description, 120); // Tags row @@ -321,9 +528,9 @@ if (isIndexPage) { footer.className = "project-card-footer"; var link = document.createElement("a"); - link.className = "btn-details"; + link.className = "btn-details"; link.textContent = "View Full Project"; - link.href = "/project/" + project.id; + link.href = "/project/" + project.id; footer.appendChild(link); @@ -337,7 +544,7 @@ if (isIndexPage) { function createTag(text, type) { var span = document.createElement("span"); - span.className = "project-tag project-tag--" + type; + span.className = "project-tag project-tag--" + type; span.textContent = text; return span; } @@ -355,13 +562,13 @@ if (isIndexPage) { // ============================================================ if (isDetailPage) { - var codePanel = document.getElementById("code-panel"); - var codePanelOverlay = document.getElementById("code-panel-overlay"); - var codeContentEl = document.getElementById("code-content"); + var codePanel = document.getElementById("code-panel"); + var codePanelOverlay = document.getElementById("code-panel-overlay"); + var codeContentEl = document.getElementById("code-content"); var codePanelFilename = document.getElementById("code-panel-filename"); - var btnViewCode = document.getElementById("btn-view-code"); - var btnViewCodeSm = document.getElementById("btn-view-code-sm"); - var btnClosePanel = document.getElementById("code-panel-close"); + var btnViewCode = document.getElementById("btn-view-code"); + var btnViewCodeSm = document.getElementById("btn-view-code-sm"); + var btnClosePanel = document.getElementById("code-panel-close"); // Cache flag so code is only fetched once per page load var codeFetched = false; @@ -393,7 +600,7 @@ if (isDetailPage) { return; } if (codePanelFilename) codePanelFilename.textContent = data.filename; - if (codeContentEl) codeContentEl.textContent = data.code; + if (codeContentEl) codeContentEl.textContent = data.code; codeFetched = true; }) .catch(function () { @@ -404,7 +611,7 @@ if (isDetailPage) { } // Attach open/close handlers - if (btnViewCode) btnViewCode.addEventListener("click", openCodePanel); + if (btnViewCode) btnViewCode.addEventListener("click", openCodePanel); if (btnViewCodeSm) btnViewCodeSm.addEventListener("click", openCodePanel); if (btnClosePanel) btnClosePanel.addEventListener("click", closeCodePanel); diff --git a/static/style.css b/static/style.css index 188419b8..3903302e 100644 --- a/static/style.css +++ b/static/style.css @@ -669,6 +669,7 @@ label { /* Skill input wrap */ .skill-input-wrap { + position: relative; border: 1.5px solid var(--border); border-radius: var(--r-sm); background: var(--gray-50); @@ -688,6 +689,13 @@ label { } /* An added skill chip inside the wrap */ +.skill-chips-selected { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + .skill-chip-selected { display: inline-flex; align-items: center; @@ -731,6 +739,62 @@ label { box-shadow: none; } +/*suggestions dropdown*/ +.skills-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--white); + border: 1.5px solid var(--indigo-200, #c7d2fe); + border-top: none; + border-radius: 0 0 var(--r-sm) var(--r-sm); + margin-top: -1px; + max-height: 280px; + overflow-y: auto; + z-index: 1000; + display: none; + box-shadow: 0 8px 24px rgba(79, 110, 247, 0.15); +} + +.suggestion-item { + padding: 12px 14px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + font-size: 0.9rem; + color: var(--gray-700); + border-bottom: 1px solid var(--gray-100); +} + +.suggestion-item:last-child { + border-bottom: none; +} + +.suggestion-item:hover, +.suggestion-item--active { + background: var(--indigo-50); + color: var(--indigo-700); + font-weight: 500; +} + +/* Scrollbar styling for suggestions dropdown */ +.skills-suggestions::-webkit-scrollbar { + width: 6px; +} + +.skills-suggestions::-webkit-scrollbar-track { + background: var(--gray-50); +} + +.skills-suggestions::-webkit-scrollbar-thumb { + background: var(--indigo-300); + border-radius: 3px; +} + +.skills-suggestions::-webkit-scrollbar-thumb:hover { + background: var(--indigo-400); +} + /* Quick-select chips row */ .skill-chips-row { display: flex; diff --git a/templates/index.html b/templates/index.html index b4533b9e..dc350d04 100644 --- a/templates/index.html +++ b/templates/index.html @@ -260,7 +260,11 @@

Find Your Next Project

id="skills-input" placeholder="Type a skill and press Enter..." autocomplete="off" + aria-haspopup="listbox" + aria-expanded="false" + aria-controls="skills-suggestions" /> +
@@ -437,6 +441,7 @@ +