переключение тэгов поиска в боковом меню, подсветка найденных элементов

This commit is contained in:
Илья Братских 2026-06-28 14:40:24 +03:00 committed by KBAHTNET
parent 1e8919e33c
commit 45fedcd359
3 changed files with 165 additions and 23 deletions

View File

@ -48,6 +48,13 @@
<nav class="nav-tree" aria-label="Дерево навигации" data-nav-tree></nav> <nav class="nav-tree" aria-label="Дерево навигации" data-nav-tree></nav>
</div> </div>
</aside> </aside>
<div
class="sidebar-resizer"
role="separator"
aria-orientation="vertical"
aria-label="Изменить ширину бокового меню"
data-sidebar-resizer
></div>
<div class="settings-overlay" data-settings-overlay></div> <div class="settings-overlay" data-settings-overlay></div>
<div class="control-panel" aria-label="Настройки панели" data-settings-panel> <div class="control-panel" aria-label="Настройки панели" data-settings-panel>
@ -95,6 +102,14 @@
<input type="checkbox" data-show-numbers /> <input type="checkbox" data-show-numbers />
</label> </label>
<label class="toggle-row">
<span>
<strong>Показывать теги дерева</strong>
<small>Тип сервиса рядом с элементом</small>
</span>
<input type="checkbox" data-show-tree-tags />
</label>
<div class="control-group accent-group"> <div class="control-group accent-group">
<span>Акцент</span> <span>Акцент</span>
<button <button

116
script.js
View File

@ -138,6 +138,8 @@ const defaultPreferences = {
view: "cards", view: "cards",
accent: "violet", accent: "violet",
showNumbers: false, showNumbers: false,
showTreeTags: true,
sidebarWidth: 390,
}; };
const availableViews = ["cards", "list", "compact-cards", "compact-list"]; const availableViews = ["cards", "list", "compact-cards", "compact-list"];
@ -145,6 +147,7 @@ const preferences = { ...defaultPreferences, ...readPreferences() };
const collapseState = readCollapseState(); const collapseState = readCollapseState();
const catalog = document.querySelector("[data-catalog-root]"); const catalog = document.querySelector("[data-catalog-root]");
const navTree = document.querySelector("[data-nav-tree]"); const navTree = document.querySelector("[data-nav-tree]");
const sidebarResizer = document.querySelector("[data-sidebar-resizer]");
const searchInput = document.querySelector("[data-search-input]"); const searchInput = document.querySelector("[data-search-input]");
const settingsToggle = document.querySelector("[data-settings-toggle]"); const settingsToggle = document.querySelector("[data-settings-toggle]");
const settingsPanel = document.querySelector("[data-settings-panel]"); const settingsPanel = document.querySelector("[data-settings-panel]");
@ -154,13 +157,17 @@ const themeButtons = document.querySelectorAll("[data-theme]");
const viewButtons = document.querySelectorAll("[data-view]"); const viewButtons = document.querySelectorAll("[data-view]");
const accentButtons = document.querySelectorAll("[data-accent]"); const accentButtons = document.querySelectorAll("[data-accent]");
const showNumbersInput = document.querySelector("[data-show-numbers]"); const showNumbersInput = document.querySelector("[data-show-numbers]");
const showTreeTagsInput = document.querySelector("[data-show-tree-tags]");
const emptyTemplate = document.querySelector("#empty-template"); const emptyTemplate = document.querySelector("#empty-template");
renderNavigationTree(); renderNavigationTree();
applyPreferences(preferences); applyPreferences(preferences);
renderCatalog(); renderCatalog();
searchInput.addEventListener("input", renderCatalog); searchInput.addEventListener("input", () => {
renderNavigationTree();
renderCatalog();
});
navTree.addEventListener("click", (event) => { navTree.addEventListener("click", (event) => {
const button = event.target.closest("[data-focus-target]"); const button = event.target.closest("[data-focus-target]");
@ -234,6 +241,36 @@ showNumbersInput.addEventListener("change", () => {
savePreferences(preferences); savePreferences(preferences);
}); });
showTreeTagsInput.addEventListener("change", () => {
preferences.showTreeTags = showTreeTagsInput.checked;
applyPreferences(preferences);
savePreferences(preferences);
});
sidebarResizer.addEventListener("pointerdown", (event) => {
event.preventDefault();
document.body.classList.add("is-resizing-sidebar");
sidebarResizer.setPointerCapture(event.pointerId);
const handlePointerMove = (moveEvent) => {
const layoutLeft = document.querySelector(".app-layout").getBoundingClientRect().left;
preferences.sidebarWidth = Math.round(clamp(moveEvent.clientX - layoutLeft, 260, 560));
applyPreferences(preferences);
};
const handlePointerUp = () => {
document.body.classList.remove("is-resizing-sidebar");
savePreferences(preferences);
sidebarResizer.removeEventListener("pointermove", handlePointerMove);
sidebarResizer.removeEventListener("pointerup", handlePointerUp);
sidebarResizer.removeEventListener("pointercancel", handlePointerUp);
};
sidebarResizer.addEventListener("pointermove", handlePointerMove);
sidebarResizer.addEventListener("pointerup", handlePointerUp);
sidebarResizer.addEventListener("pointercancel", handlePointerUp);
});
function createItem(number, title, type, tags = [], url = "#") { function createItem(number, title, type, tags = [], url = "#") {
return { number, title, type, tags, url }; return { number, title, type, tags, url };
} }
@ -245,7 +282,7 @@ function renderCatalog() {
let visibleItems = 0; let visibleItems = 0;
ecosystemSections.forEach((section) => { ecosystemSections.forEach((section) => {
const sectionElement = createSectionElement(section, forceOpen); const sectionElement = createSectionElement(section, forceOpen, query);
let visibleGroups = 0; let visibleGroups = 0;
let sectionVisibleItems = 0; let sectionVisibleItems = 0;
@ -258,7 +295,7 @@ function renderCatalog() {
visibleGroups += 1; visibleGroups += 1;
sectionVisibleItems += filteredItems.length; sectionVisibleItems += filteredItems.length;
sectionElement.querySelector(".section-groups").append(createGroupElement(group, filteredItems, forceOpen)); sectionElement.querySelector(".section-groups").append(createGroupElement(group, filteredItems, forceOpen, query));
}); });
if (visibleGroups) { if (visibleGroups) {
@ -279,7 +316,7 @@ function renderCatalog() {
} }
function createSectionElement(section, forceOpen = false) { function createSectionElement(section, forceOpen = false, query = "") {
const sectionElement = document.createElement("details"); const sectionElement = document.createElement("details");
sectionElement.className = "catalog-section"; sectionElement.className = "catalog-section";
sectionElement.id = getSectionId(section); sectionElement.id = getSectionId(section);
@ -289,9 +326,9 @@ function createSectionElement(section, forceOpen = false) {
sectionElement.innerHTML = ` sectionElement.innerHTML = `
<summary class="section-head"> <summary class="section-head">
<div> <div>
<p class="section-number">${section.number} раздел</p> <p class="section-number">${highlightText(section.number, query)} раздел</p>
<h2>${section.title}</h2> <h2>${highlightText(section.title, query)}</h2>
<p>${section.description}</p> <p>${highlightText(section.description, query)}</p>
</div> </div>
<span class="section-meta"> <span class="section-meta">
<span class="section-count"><span data-section-count>0</span> элементов</span> <span class="section-count"><span data-section-count>0</span> элементов</span>
@ -303,7 +340,7 @@ function createSectionElement(section, forceOpen = false) {
return sectionElement; return sectionElement;
} }
function createGroupElement(group, items, forceOpen = false) { function createGroupElement(group, items, forceOpen = false, query = "") {
const groupElement = document.createElement("details"); const groupElement = document.createElement("details");
groupElement.className = "catalog-group"; groupElement.className = "catalog-group";
groupElement.id = getGroupId(group); groupElement.id = getGroupId(group);
@ -311,13 +348,13 @@ function createGroupElement(group, items, forceOpen = false) {
groupElement.tabIndex = -1; groupElement.tabIndex = -1;
groupElement.open = forceOpen || getSavedOpenState(groupElement.dataset.collapseKey); groupElement.open = forceOpen || getSavedOpenState(groupElement.dataset.collapseKey);
const cards = items.map(createCardElement); const cards = items.map((item) => createCardElement(item, query));
groupElement.innerHTML = ` groupElement.innerHTML = `
<summary class="group-head"> <summary class="group-head">
<div> <div>
<p class="group-number">${group.number}</p> <p class="group-number">${highlightText(group.number, query)}</p>
<h3>${group.title}</h3> <h3>${highlightText(group.title, query)}</h3>
<p>${group.description}</p> <p>${highlightText(group.description, query)}</p>
</div> </div>
<span class="group-meta"> <span class="group-meta">
<span class="group-count">${items.length}</span> <span class="group-count">${items.length}</span>
@ -334,7 +371,7 @@ function createGroupElement(group, items, forceOpen = false) {
return groupElement; return groupElement;
} }
function createCardElement(item) { function createCardElement(item, query = "") {
const meta = serviceMeta[item.type] || serviceMeta.config; const meta = serviceMeta[item.type] || serviceMeta.config;
const link = document.createElement("a"); const link = document.createElement("a");
link.className = "nav-card"; link.className = "nav-card";
@ -354,10 +391,10 @@ function createCardElement(item) {
<span class="shine"></span> <span class="shine"></span>
<img src="${meta.icon}" alt="" loading="lazy" /> <img src="${meta.icon}" alt="" loading="lazy" />
<div class="card-content"> <div class="card-content">
<p class="item-number">${item.number}</p> <p class="item-number">${highlightText(item.number, query)}</p>
<h4>${item.title}</h4> <h4>${highlightText(item.title, query)}</h4>
<p class="item-type">${meta.label}</p> <p class="item-type">${highlightText(meta.label, query)}</p>
<div class="tags">${item.tags.map((tag) => `<span>${tag}</span>`).join("")}</div> <div class="tags">${item.tags.map((tag) => `<span>${highlightText(tag, query)}</span>`).join("")}</div>
</div> </div>
<span class="arrow" aria-hidden="true">→</span> <span class="arrow" aria-hidden="true">→</span>
`; `;
@ -387,6 +424,7 @@ function matchesQuery(item, group, section, query) {
} }
function renderNavigationTree() { function renderNavigationTree() {
const query = normalize(searchInput.value);
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
ecosystemSections.forEach((section) => { ecosystemSections.forEach((section) => {
@ -399,7 +437,7 @@ function renderNavigationTree() {
sectionSummary.innerHTML = ` sectionSummary.innerHTML = `
<span class="tree-caret" aria-hidden="true"></span> <span class="tree-caret" aria-hidden="true"></span>
<button class="tree-node tree-folder" type="button" data-focus-target="${getSectionId(section)}"> <button class="tree-node tree-folder" type="button" data-focus-target="${getSectionId(section)}">
<span>${section.title}</span> <span>${highlightText(section.title, query)}</span>
</button> </button>
`; `;
sectionNode.append(sectionSummary); sectionNode.append(sectionSummary);
@ -414,7 +452,7 @@ function renderNavigationTree() {
groupSummary.innerHTML = ` groupSummary.innerHTML = `
<span class="tree-caret" aria-hidden="true"></span> <span class="tree-caret" aria-hidden="true"></span>
<button class="tree-node tree-folder" type="button" data-focus-target="${getGroupId(group)}"> <button class="tree-node tree-folder" type="button" data-focus-target="${getGroupId(group)}">
<span>${group.title}</span> <span>${highlightText(group.title, query)}</span>
</button> </button>
`; `;
groupNode.append(groupSummary); groupNode.append(groupSummary);
@ -427,7 +465,7 @@ function renderNavigationTree() {
itemButton.type = "button"; itemButton.type = "button";
itemButton.className = "tree-node tree-file"; itemButton.className = "tree-node tree-file";
itemButton.dataset.focusTarget = getItemId(item); itemButton.dataset.focusTarget = getItemId(item);
itemButton.innerHTML = `<span>${item.title}</span><small>${serviceMeta[item.type]?.label || item.type}</small>`; itemButton.innerHTML = `<span>${highlightText(item.title, query)}</span><small>${highlightText(item.tags.join(", "), query)}</small>`;
itemList.append(itemButton); itemList.append(itemButton);
}); });
@ -528,10 +566,16 @@ function applyPreferences(nextPreferences) {
document.documentElement.dataset.theme = nextPreferences.theme; document.documentElement.dataset.theme = nextPreferences.theme;
document.documentElement.dataset.accent = nextPreferences.accent; document.documentElement.dataset.accent = nextPreferences.accent;
document.documentElement.style.setProperty(
"--sidebar-width",
`${clamp(Number(nextPreferences.sidebarWidth) || defaultPreferences.sidebarWidth, 260, 560)}px`,
);
catalog.classList.remove("cards-view", "list-view", "compact-cards-view", "compact-list-view"); catalog.classList.remove("cards-view", "list-view", "compact-cards-view", "compact-list-view");
catalog.classList.add(`${nextPreferences.view}-view`); catalog.classList.add(`${nextPreferences.view}-view`);
document.documentElement.dataset.showNumbers = String(Boolean(nextPreferences.showNumbers)); document.documentElement.dataset.showNumbers = String(Boolean(nextPreferences.showNumbers));
document.documentElement.dataset.showTreeTags = String(Boolean(nextPreferences.showTreeTags));
showNumbersInput.checked = Boolean(nextPreferences.showNumbers); showNumbersInput.checked = Boolean(nextPreferences.showNumbers);
showTreeTagsInput.checked = Boolean(nextPreferences.showTreeTags);
setActiveButton(themeButtons, "theme", nextPreferences.theme); setActiveButton(themeButtons, "theme", nextPreferences.theme);
setActiveButton(viewButtons, "view", nextPreferences.view); setActiveButton(viewButtons, "view", nextPreferences.view);
setActiveButton(accentButtons, "accent", nextPreferences.accent); setActiveButton(accentButtons, "accent", nextPreferences.accent);
@ -549,6 +593,38 @@ function normalize(value) {
return value.toLowerCase().replaceAll("ё", "е").trim(); return value.toLowerCase().replaceAll("ё", "е").trim();
} }
function highlightText(value, query) {
const text = String(value);
const normalizedQuery = normalize(query);
if (!normalizedQuery) {
return escapeHTML(text);
}
const normalizedText = normalize(text);
let cursor = 0;
let result = "";
let matchIndex = normalizedText.indexOf(normalizedQuery, cursor);
while (matchIndex !== -1) {
result += escapeHTML(text.slice(cursor, matchIndex));
result += `<mark class="search-mark">${escapeHTML(text.slice(matchIndex, matchIndex + normalizedQuery.length))}</mark>`;
cursor = matchIndex + normalizedQuery.length;
matchIndex = normalizedText.indexOf(normalizedQuery, cursor);
}
return result + escapeHTML(text.slice(cursor));
}
function escapeHTML(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function getSectionId(section) { function getSectionId(section) {
return `section-${section.id}`; return `section-${section.id}`;
} }

View File

@ -14,6 +14,7 @@
--accent-rgb: 139, 92, 246; --accent-rgb: 139, 92, 246;
--card-x: 50%; --card-x: 50%;
--card-y: 50%; --card-y: 50%;
--sidebar-width: 390px;
} }
html[data-theme="light"] { html[data-theme="light"] {
@ -109,11 +110,42 @@ a {
.app-layout { .app-layout {
display: grid; display: grid;
grid-template-columns: minmax(320px, 390px) minmax(0, 1fr); grid-template-columns: var(--sidebar-width) 8px minmax(0, 1fr);
gap: 12px; gap: 8px;
align-items: start; align-items: start;
} }
.sidebar-resizer {
position: sticky;
top: 20px;
width: 8px;
height: calc(100vh - 40px);
border-radius: 999px;
cursor: col-resize;
background: transparent;
transition: background 160ms ease;
}
.sidebar-resizer::before {
content: "";
display: block;
width: 2px;
height: 100%;
margin: 0 auto;
border-radius: inherit;
background: var(--border);
}
.sidebar-resizer:hover::before,
body.is-resizing-sidebar .sidebar-resizer::before {
background: var(--accent);
}
body.is-resizing-sidebar {
cursor: col-resize;
user-select: none;
}
.side-panel { .side-panel {
position: sticky; position: sticky;
top: 20px; top: 20px;
@ -187,6 +219,17 @@ html:not([data-show-numbers="true"]) .item-number {
display: none; display: none;
} }
html[data-show-tree-tags="false"] .tree-node small {
display: none;
}
.search-mark {
border-radius: 4px;
padding: 0 2px;
color: white;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
}
.control-panel { .control-panel {
position: fixed; position: fixed;
z-index: 1001; z-index: 1001;
@ -604,6 +647,10 @@ html:not([data-show-numbers="true"]) .item-number {
} }
.tree-node small { .tree-node small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--accent); color: var(--accent);
font-size: 0.68rem; font-size: 0.68rem;
font-weight: 800; font-weight: 800;
@ -1024,7 +1071,7 @@ html:not([data-show-numbers="true"]) .item-number {
@media (max-width: 1120px) { @media (max-width: 1120px) {
.app-layout { .app-layout {
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); grid-template-columns: minmax(240px, min(var(--sidebar-width), 320px)) 8px minmax(0, 1fr);
} }
.items-grid { .items-grid {
@ -1046,6 +1093,10 @@ html:not([data-show-numbers="true"]) .item-number {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.sidebar-resizer {
display: none;
}
.side-panel { .side-panel {
position: static; position: static;
} }