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

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>
</div>
</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="control-panel" aria-label="Настройки панели" data-settings-panel>
@ -95,6 +102,14 @@
<input type="checkbox" data-show-numbers />
</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">
<span>Акцент</span>
<button

116
script.js
View File

@ -138,6 +138,8 @@ const defaultPreferences = {
view: "cards",
accent: "violet",
showNumbers: false,
showTreeTags: true,
sidebarWidth: 390,
};
const availableViews = ["cards", "list", "compact-cards", "compact-list"];
@ -145,6 +147,7 @@ const preferences = { ...defaultPreferences, ...readPreferences() };
const collapseState = readCollapseState();
const catalog = document.querySelector("[data-catalog-root]");
const navTree = document.querySelector("[data-nav-tree]");
const sidebarResizer = document.querySelector("[data-sidebar-resizer]");
const searchInput = document.querySelector("[data-search-input]");
const settingsToggle = document.querySelector("[data-settings-toggle]");
const settingsPanel = document.querySelector("[data-settings-panel]");
@ -154,13 +157,17 @@ const themeButtons = document.querySelectorAll("[data-theme]");
const viewButtons = document.querySelectorAll("[data-view]");
const accentButtons = document.querySelectorAll("[data-accent]");
const showNumbersInput = document.querySelector("[data-show-numbers]");
const showTreeTagsInput = document.querySelector("[data-show-tree-tags]");
const emptyTemplate = document.querySelector("#empty-template");
renderNavigationTree();
applyPreferences(preferences);
renderCatalog();
searchInput.addEventListener("input", renderCatalog);
searchInput.addEventListener("input", () => {
renderNavigationTree();
renderCatalog();
});
navTree.addEventListener("click", (event) => {
const button = event.target.closest("[data-focus-target]");
@ -234,6 +241,36 @@ showNumbersInput.addEventListener("change", () => {
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 = "#") {
return { number, title, type, tags, url };
}
@ -245,7 +282,7 @@ function renderCatalog() {
let visibleItems = 0;
ecosystemSections.forEach((section) => {
const sectionElement = createSectionElement(section, forceOpen);
const sectionElement = createSectionElement(section, forceOpen, query);
let visibleGroups = 0;
let sectionVisibleItems = 0;
@ -258,7 +295,7 @@ function renderCatalog() {
visibleGroups += 1;
sectionVisibleItems += filteredItems.length;
sectionElement.querySelector(".section-groups").append(createGroupElement(group, filteredItems, forceOpen));
sectionElement.querySelector(".section-groups").append(createGroupElement(group, filteredItems, forceOpen, query));
});
if (visibleGroups) {
@ -279,7 +316,7 @@ function renderCatalog() {
}
function createSectionElement(section, forceOpen = false) {
function createSectionElement(section, forceOpen = false, query = "") {
const sectionElement = document.createElement("details");
sectionElement.className = "catalog-section";
sectionElement.id = getSectionId(section);
@ -289,9 +326,9 @@ function createSectionElement(section, forceOpen = false) {
sectionElement.innerHTML = `
<summary class="section-head">
<div>
<p class="section-number">${section.number} раздел</p>
<h2>${section.title}</h2>
<p>${section.description}</p>
<p class="section-number">${highlightText(section.number, query)} раздел</p>
<h2>${highlightText(section.title, query)}</h2>
<p>${highlightText(section.description, query)}</p>
</div>
<span class="section-meta">
<span class="section-count"><span data-section-count>0</span> элементов</span>
@ -303,7 +340,7 @@ function createSectionElement(section, forceOpen = false) {
return sectionElement;
}
function createGroupElement(group, items, forceOpen = false) {
function createGroupElement(group, items, forceOpen = false, query = "") {
const groupElement = document.createElement("details");
groupElement.className = "catalog-group";
groupElement.id = getGroupId(group);
@ -311,13 +348,13 @@ function createGroupElement(group, items, forceOpen = false) {
groupElement.tabIndex = -1;
groupElement.open = forceOpen || getSavedOpenState(groupElement.dataset.collapseKey);
const cards = items.map(createCardElement);
const cards = items.map((item) => createCardElement(item, query));
groupElement.innerHTML = `
<summary class="group-head">
<div>
<p class="group-number">${group.number}</p>
<h3>${group.title}</h3>
<p>${group.description}</p>
<p class="group-number">${highlightText(group.number, query)}</p>
<h3>${highlightText(group.title, query)}</h3>
<p>${highlightText(group.description, query)}</p>
</div>
<span class="group-meta">
<span class="group-count">${items.length}</span>
@ -334,7 +371,7 @@ function createGroupElement(group, items, forceOpen = false) {
return groupElement;
}
function createCardElement(item) {
function createCardElement(item, query = "") {
const meta = serviceMeta[item.type] || serviceMeta.config;
const link = document.createElement("a");
link.className = "nav-card";
@ -354,10 +391,10 @@ function createCardElement(item) {
<span class="shine"></span>
<img src="${meta.icon}" alt="" loading="lazy" />
<div class="card-content">
<p class="item-number">${item.number}</p>
<h4>${item.title}</h4>
<p class="item-type">${meta.label}</p>
<div class="tags">${item.tags.map((tag) => `<span>${tag}</span>`).join("")}</div>
<p class="item-number">${highlightText(item.number, query)}</p>
<h4>${highlightText(item.title, query)}</h4>
<p class="item-type">${highlightText(meta.label, query)}</p>
<div class="tags">${item.tags.map((tag) => `<span>${highlightText(tag, query)}</span>`).join("")}</div>
</div>
<span class="arrow" aria-hidden="true">→</span>
`;
@ -387,6 +424,7 @@ function matchesQuery(item, group, section, query) {
}
function renderNavigationTree() {
const query = normalize(searchInput.value);
const fragment = document.createDocumentFragment();
ecosystemSections.forEach((section) => {
@ -399,7 +437,7 @@ function renderNavigationTree() {
sectionSummary.innerHTML = `
<span class="tree-caret" aria-hidden="true"></span>
<button class="tree-node tree-folder" type="button" data-focus-target="${getSectionId(section)}">
<span>${section.title}</span>
<span>${highlightText(section.title, query)}</span>
</button>
`;
sectionNode.append(sectionSummary);
@ -414,7 +452,7 @@ function renderNavigationTree() {
groupSummary.innerHTML = `
<span class="tree-caret" aria-hidden="true"></span>
<button class="tree-node tree-folder" type="button" data-focus-target="${getGroupId(group)}">
<span>${group.title}</span>
<span>${highlightText(group.title, query)}</span>
</button>
`;
groupNode.append(groupSummary);
@ -427,7 +465,7 @@ function renderNavigationTree() {
itemButton.type = "button";
itemButton.className = "tree-node tree-file";
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);
});
@ -528,10 +566,16 @@ function applyPreferences(nextPreferences) {
document.documentElement.dataset.theme = nextPreferences.theme;
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.add(`${nextPreferences.view}-view`);
document.documentElement.dataset.showNumbers = String(Boolean(nextPreferences.showNumbers));
document.documentElement.dataset.showTreeTags = String(Boolean(nextPreferences.showTreeTags));
showNumbersInput.checked = Boolean(nextPreferences.showNumbers);
showTreeTagsInput.checked = Boolean(nextPreferences.showTreeTags);
setActiveButton(themeButtons, "theme", nextPreferences.theme);
setActiveButton(viewButtons, "view", nextPreferences.view);
setActiveButton(accentButtons, "accent", nextPreferences.accent);
@ -549,6 +593,38 @@ function normalize(value) {
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) {
return `section-${section.id}`;
}

View File

@ -14,6 +14,7 @@
--accent-rgb: 139, 92, 246;
--card-x: 50%;
--card-y: 50%;
--sidebar-width: 390px;
}
html[data-theme="light"] {
@ -109,11 +110,42 @@ a {
.app-layout {
display: grid;
grid-template-columns: minmax(320px, 390px) minmax(0, 1fr);
gap: 12px;
grid-template-columns: var(--sidebar-width) 8px minmax(0, 1fr);
gap: 8px;
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 {
position: sticky;
top: 20px;
@ -187,6 +219,17 @@ html:not([data-show-numbers="true"]) .item-number {
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 {
position: fixed;
z-index: 1001;
@ -604,6 +647,10 @@ html:not([data-show-numbers="true"]) .item-number {
}
.tree-node small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--accent);
font-size: 0.68rem;
font-weight: 800;
@ -1024,7 +1071,7 @@ html:not([data-show-numbers="true"]) .item-number {
@media (max-width: 1120px) {
.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 {
@ -1046,6 +1093,10 @@ html:not([data-show-numbers="true"]) .item-number {
grid-template-columns: 1fr;
}
.sidebar-resizer {
display: none;
}
.side-panel {
position: static;
}