Давай немного усовершенствуем функционал существующего компонента поиска в теме и создадим еще один для дропдауна, который будет полностью повторять логику дефолтного, но работать с нашей таилвинд разметкой. Главным усовершенствованием будет добавление настройки в теме для контроля количества символов после набора которых начинает срабатывать аякс запрос. То есть мы сделаем чтобы стало возможным чтобы аякс срабатывал после набора третьего символа, а не после первого. Хотя эту цифру можно быдет менять теперь из админки в общих настройках редактора темы.
Начнем сначала сделаем несколько дополнений и исправлений в существующем коде:
Шаг.1 Обновление config/settings_shema.json
Открываем файл, находим 1363 строку, увидишь “name”: “t:settings_schema.search_input.name”, и в settings сразу после квадратной скобки вставляем :
{
"type": "range",
"id": "predictive_search_min_chars",
"min": 1,
"max": 5,
"step": 1,
"label": "Minimum characters to search",
"default": 2
},
весь блок будет выглядеть вот так:
{
"name": "t:settings_schema.search_input.name",
"settings": [
{
"type": "range",
"id": "predictive_search_min_chars",
"min": 1,
"max": 5,
"step": 1,
"label": "Minimum characters to search",
"default": 2
},
{
"type": "checkbox",
"id": "predictive_search_enabled",
"default": true,
"label": "t:settings_schema.search_input.settings.predictive_search_enabled.label"
},
{
"type": "checkbox",
"id": "predictive_search_show_vendor",
"default": false,
"label": "t:settings_schema.search_input.settings.predictive_search_show_vendor.label",
"info": "t:settings_schema.search_input.settings.predictive_search_show_vendor.info"
},
{
"type": "checkbox",
"id": "predictive_search_show_price",
"default": false,
"label": "t:settings_schema.search_input.settings.predictive_search_show_price.label",
"info": "t:settings_schema.search_input.settings.predictive_search_show_price.info"
},
],
},
После данного действия в админке появится дополнительны рэнж слайдер для изменения значения от 1 до 5. В liquid компоненте мы будем его читать через аттрибут.
Шаг.2 Теперь лечим дефолтный predictive-search.js
Чтобы все заработало нужно в конструктор добавить дополнительное свойство this.minChars примерно на строке 14.
constructor() {
super();
this.cachedResults = {};
this.predictiveSearchResults = this.querySelector(
"[data-predictive-search]",
);
this.allPredictiveSearchInstances =
document.querySelectorAll("predictive-search");
this.isOpen = false;
this.abortController = new AbortController();
this.searchTerm = "";
// ДОБАВЛЕННАЯ СТРОКА ДЛЯ ЧТЕНИЯ АТТРИБУТА ИЗ ВЕРСТКИ
this.minChars = parseInt(this.getAttribute('data-min-chars')) || 2;
this.setupEventListeners();
}
Также на строке +-50 нужно в методе onChange добавить логику срабатывания:
onChange() {
super.onChange();
const newSearchTerm = this.getQuery();
if (!this.searchTerm || !newSearchTerm.startsWith(this.searchTerm)) {
// Remove the results when they are no longer relevant for the new search term
// so they don't show up when the dropdown opens again
this.querySelector("#predictive-search-results-groups-wrapper")?.remove();
}
// Update the term asap, don't wait for the predictive search query to finish loading
this.updateSearchForTerm(this.searchTerm, newSearchTerm);
this.searchTerm = newSearchTerm;
if (!this.searchTerm.length) {
this.close(true);
return;
}
// НОВОЕ ПРАВИЛО: Проверка на минимальное количество символов
if (this.searchTerm.length < this.minChars) {
this.close();
return;
}
this.getSearchResults(this.searchTerm);
}
В методе отправки , при нажатии на кнопку с лупой тоже нужно проверять кол-во символов. Меняем метод onFormSubmit:
onFormSubmit(event) {
// Получаем текущий запрос
const currentQuery = this.getQuery();
// ПОЯВИЛАСЬ ЛОГИКА ПРОВЕРКИ: Если запрос пустой ИЛИ его длина меньше minChars — отменяем отправку формы
if (!currentQuery.length || currentQuery.length < this.minChars || this.querySelector('[aria-selected="true"] a')) {
event.preventDefault();
}
}
И при фокусе тоже проверяем:
onFocus() {
const currentSearchTerm = this.getQuery();
if (!currentSearchTerm.length) return;
if (this.searchTerm !== currentSearchTerm) {
// Search term was changed from other search input, treat it as a user change
this.onChange();
} else if (this.getAttribute("results") === "true") {
this.open();
// ИЗМЕНЕННАЯ ЛОГИКА
} else if (currentSearchTerm.length >= this.minChars) {
// Запрашиваем результаты только если символов достаточно
this.getSearchResults(this.searchTerm);
}
}
Еще давай закроем недоработку-баг дефолтного поиска при наборе нестандартных символов в поиске, скорректируем метод updateSearchFormTerm:
updateSearchForTerm(previousTerm, newTerm) {
const searchForTextElement = this.querySelector(
"[data-predictive-search-search-for-text]",
);
const currentButtonText = searchForTextElement?.innerText;
if (currentButtonText) {
// ДОБАВЛЕНА ЗАЩИТА ОТ null: сначала проверяем, что match нашел совпадения
const matchResult = currentButtonText.match(new RegExp(previousTerm, "g"));
if (matchResult && matchResult.length > 1) {
// The new term matches part of the button text and not just the search term, do not replace to avoid mistakes
return;
}
const newButtonText = currentButtonText.replace(previousTerm, newTerm);
searchForTextElement.innerText = newButtonText;
}
}
Вот так теперь должен выглядеть весь файл целиком:
class PredictiveSearch extends SearchForm {
constructor() {
super();
this.cachedResults = {};
this.predictiveSearchResults = this.querySelector(
"[data-predictive-search]",
);
this.allPredictiveSearchInstances =
document.querySelectorAll("predictive-search");
this.isOpen = false;
this.abortController = new AbortController();
this.searchTerm = "";
this.minChars = parseInt(this.getAttribute('data-min-chars')) || 2;
this.setupEventListeners();
}
setupEventListeners() {
this.input.form.addEventListener("submit", this.onFormSubmit.bind(this));
this.input.addEventListener("focus", this.onFocus.bind(this));
this.addEventListener("focusout", this.onFocusOut.bind(this));
this.addEventListener("keyup", this.onKeyup.bind(this));
this.addEventListener("keydown", this.onKeydown.bind(this));
}
getQuery() {
return this.input.value.trim();
}
onChange() {
super.onChange();
const newSearchTerm = this.getQuery();
if (!this.searchTerm || !newSearchTerm.startsWith(this.searchTerm)) {
// Remove the results when they are no longer relevant for the new search term
// so they don't show up when the dropdown opens again
this.querySelector("#predictive-search-results-groups-wrapper")?.remove();
}
// Update the term asap, don't wait for the predictive search query to finish loading
this.updateSearchForTerm(this.searchTerm, newSearchTerm);
this.searchTerm = newSearchTerm;
if (!this.searchTerm.length) {
this.close(true);
return;
}
// ПРАВИЛО: Проверка на минимальное количество символов
if (this.searchTerm.length < this.minChars) {
this.close();
return;
}
this.getSearchResults(this.searchTerm);
}
onFormSubmit(event) {
// Получаем текущий запрос
const currentQuery = this.getQuery();
// Если запрос пустой ИЛИ его длина меньше minChars — отменяем отправку формы
if (!currentQuery.length || currentQuery.length < this.minChars || this.querySelector('[aria-selected="true"] a')) {
event.preventDefault();
}
}
onFormReset(event) {
super.onFormReset(event);
if (super.shouldResetForm()) {
this.searchTerm = "";
this.abortController.abort();
this.abortController = new AbortController();
this.closeResults(true);
}
}
onFocus() {
const currentSearchTerm = this.getQuery();
if (!currentSearchTerm.length) return;
if (this.searchTerm !== currentSearchTerm) {
// Search term was changed from other search input, treat it as a user change
this.onChange();
} else if (this.getAttribute("results") === "true") {
this.open();
} else if (currentSearchTerm.length >= this.minChars) {
// Запрашиваем результаты только если символов достаточно
this.getSearchResults(this.searchTerm);
}
}
onFocusOut() {
setTimeout(() => {
if (!this.contains(document.activeElement)) this.close();
});
}
onKeyup(event) {
if (!this.getQuery().length) this.close(true);
event.preventDefault();
switch (event.code) {
case "ArrowUp":
this.switchOption("up");
break;
case "ArrowDown":
this.switchOption("down");
break;
case "Enter":
this.selectOption();
break;
}
}
onKeydown(event) {
// Prevent the cursor from moving in the input when using the up and down arrow keys
if (event.code === "ArrowUp" || event.code === "ArrowDown") {
event.preventDefault();
}
}
updateSearchForTerm(previousTerm, newTerm) {
const searchForTextElement = this.querySelector(
"[data-predictive-search-search-for-text]",
);
const currentButtonText = searchForTextElement?.innerText;
if (currentButtonText) {
// Защита от null: сначала проверяем, что match нашел совпадения
const matchResult = currentButtonText.match(new RegExp(previousTerm, "g"));
if (matchResult && matchResult.length > 1) {
// The new term matches part of the button text and not just the search term, do not replace to avoid mistakes
return;
}
const newButtonText = currentButtonText.replace(previousTerm, newTerm);
searchForTextElement.innerText = newButtonText;
}
}
switchOption(direction) {
if (!this.getAttribute("open")) return;
const moveUp = direction === "up";
const selectedElement = this.querySelector('[aria-selected="true"]');
// Filter out hidden elements (duplicated page and article resources) thanks
// to this https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent
const allVisibleElements = Array.from(
this.querySelectorAll("li, button.predictive-search__item"),
).filter((element) => element.offsetParent !== null);
let activeElementIndex = 0;
if (moveUp && !selectedElement) return;
let selectedElementIndex = -1;
let i = 0;
while (selectedElementIndex === -1 && i <= allVisibleElements.length) {
if (allVisibleElements[i] === selectedElement) {
selectedElementIndex = i;
}
i++;
}
this.statusElement.textContent = "";
if (!moveUp && selectedElement) {
activeElementIndex =
selectedElementIndex === allVisibleElements.length - 1
? 0
: selectedElementIndex + 1;
} else if (moveUp) {
activeElementIndex =
selectedElementIndex === 0
? allVisibleElements.length - 1
: selectedElementIndex - 1;
}
if (activeElementIndex === selectedElementIndex) return;
const activeElement = allVisibleElements[activeElementIndex];
activeElement.setAttribute("aria-selected", true);
if (selectedElement) selectedElement.setAttribute("aria-selected", false);
this.input.setAttribute("aria-activedescendant", activeElement.id);
}
selectOption() {
const selectedOption = this.querySelector(
'[aria-selected="true"] a, button[aria-selected="true"]',
);
if (selectedOption) selectedOption.click();
}
getSearchResults(searchTerm) {
const queryKey = searchTerm.replace(" ", "-").toLowerCase();
this.setLiveRegionLoadingState();
if (this.cachedResults[queryKey]) {
this.renderSearchResults(this.cachedResults[queryKey]);
return;
}
fetch(
`${routes.predictive_search_url}?q=${encodeURIComponent(searchTerm)}§ion_id=predictive-search`,
{
signal: this.abortController.signal,
},
)
.then((response) => {
if (!response.ok) {
var error = new Error(response.status);
this.close();
throw error;
}
return response.text();
})
.then((text) => {
const resultsMarkup = new DOMParser()
.parseFromString(text, "text/html")
.querySelector("#shopify-section-predictive-search").innerHTML;
// Save bandwidth keeping the cache in all instances synced
this.allPredictiveSearchInstances.forEach(
(predictiveSearchInstance) => {
predictiveSearchInstance.cachedResults[queryKey] = resultsMarkup;
},
);
this.renderSearchResults(resultsMarkup);
})
.catch((error) => {
if (error?.code === 20) {
// Code 20 means the call was aborted
return;
}
this.close();
throw error;
});
}
setLiveRegionLoadingState() {
this.statusElement =
this.statusElement || this.querySelector(".predictive-search-status");
this.loadingText =
this.loadingText || this.getAttribute("data-loading-text");
this.setLiveRegionText(this.loadingText);
this.setAttribute("loading", true);
}
setLiveRegionText(statusText) {
this.statusElement.setAttribute("aria-hidden", "false");
this.statusElement.textContent = statusText;
setTimeout(() => {
this.statusElement.setAttribute("aria-hidden", "true");
}, 1000);
}
renderSearchResults(resultsMarkup) {
this.predictiveSearchResults.innerHTML = resultsMarkup;
this.setAttribute("results", true);
this.setLiveRegionResults();
this.open();
}
setLiveRegionResults() {
this.removeAttribute("loading");
this.setLiveRegionText(
this.querySelector("[data-predictive-search-live-region-count-value]")
.textContent,
);
}
getResultsMaxHeight() {
const calculatedHeight = window.innerHeight - (56 + 30 + 30 + 30);
// высоты экрана - search form
const maxHeight = calculatedHeight;
this.resultsMaxHeight = maxHeight;
return this.resultsMaxHeight;
}
open() {
this.predictiveSearchResults.style.maxHeight =
this.resultsMaxHeight || `${this.getResultsMaxHeight()}px`;
this.setAttribute("open", true);
this.input.setAttribute("aria-expanded", true);
this.isOpen = true;
}
close(clearSearchTerm = false) {
this.closeResults(clearSearchTerm);
this.isOpen = false;
}
closeResults(clearSearchTerm = false) {
if (clearSearchTerm) {
this.input.value = "";
this.removeAttribute("results");
}
const selected = this.querySelector('[aria-selected="true"]');
if (selected) selected.setAttribute("aria-selected", false);
this.input.setAttribute("aria-activedescendant", "");
this.removeAttribute("loading");
this.removeAttribute("open");
this.input.setAttribute("aria-expanded", false);
this.resultsMaxHeight = false;
this.predictiveSearchResults.removeAttribute("style");
}
}
customElements.define("predictive-search", PredictiveSearch);
ШАГ.3 Добавляем аттрибуты в дефолтные компоненты.
Давай сначала добавим в существующие компоненты аттрибуты, чтобы связать наши настройки со скриптом. Находим файл section/main-search.liquid и заменяем на строке +- 78:
<predictive-search data-loading-text="{{ 'accessibility.loading' | t }}"
data-min-chars="{{ settings.predictive_search_min_chars | default: 2 }}">
Находим файл snippets/header-search.liquid и заменяем на строке +- 39:
<predictive-search class="search-modal__form"
data-loading-text="{{ 'accessibility.loading' | t }}"
data-min-chars="{{ settings.predictive_search_min_chars | default: 2 }}">
Вот теперь наш дефолтный поиск (кол-во вводимых символов и срабатывание аякс) темы DAWN будет управляться через глобальные настройки в редакторе темы.
ШАГ.4 Создаем собственный компонент для хедера. Дропдаун-поиск.
Чтобы сделать полноценный компонент который будет стабильно работать с шопифай просто написать обычный fetch не получится, потому что будут проблемы с доступом. Мы повторим логику дефолтного поиска которую разработчики использую в теме DAWN. Для этого нам нужно создать 3 файла. Это будет новый custom-predictive-search.js с основной логикой, custom-predictive-search.liquid с нашей новой разметкой на выдаче, ну и компонент формы custom-header-search.liquid. Что это значит ?
custom-predictive-search.liquid это компонент который формирует разметку ответа для вывода результатов поиска. Мы решили написать новый ,потому что не можем использовать существующий дефолтный компонент, так как он выдает похожую разметку но с другой стилизацией для уже работающей мобильной версии.
custom-header-search.liquid это просто форма – инпут, кнопка и место для вывода custom-predictive-search.liquid
новый custom-predictive-search.js проще написать чем пытаться использовать существующий потому что он будет работать с новыми компонентами и у них свои аттрибуты. Напишем новый чтобы не ломать и не усложнять существующий.
добавь в assets файл custom-predictive-search.js :
(() => {
function debounce(fn, wait) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn.apply(this, args), wait);
};
}
class CustomSearchForm extends HTMLElement {
constructor() {
super();
this.input = this.querySelector('input[type="search"]');
this.resetButton = this.querySelector('button[type="reset"]');
if (this.input) {
this.input.form.addEventListener("reset", this.onFormReset.bind(this));
this.input.addEventListener(
"input",
debounce((event) => {
this.onChange(event);
}, 300).bind(this),
);
}
}
toggleResetButton() {
const resetIsHidden = this.resetButton.classList.contains("hidden");
if (this.input.value.length > 0 && resetIsHidden) {
this.resetButton.classList.remove("hidden");
} else if (this.input.value.length === 0 && !resetIsHidden) {
this.resetButton.classList.add("hidden");
}
}
onChange() {
this.toggleResetButton();
}
shouldResetForm() {
return !document.querySelector('[aria-selected="true"] a');
}
onFormReset(event) {
event.preventDefault();
if (this.shouldResetForm()) {
this.input.value = "";
this.input.focus();
this.toggleResetButton();
}
}
}
class CustomPredictiveSearch extends CustomSearchForm {
constructor() {
super();
this.cachedResults = {};
this.predictiveSearchResults = this.querySelector(
"[data-predictive-search]",
);
this.allPredictiveSearchInstances = document.querySelectorAll(
"custom-predictive-search",
);
this.isOpen = false;
this.abortController = new AbortController();
this.searchTerm = "";
this.minChars = parseInt(this.getAttribute("data-min-chars")) || 2;
this.setupEventListeners();
}
setupEventListeners() {
this.input.form.addEventListener("submit", this.onFormSubmit.bind(this));
this.input.addEventListener("focus", this.onFocus.bind(this));
this.addEventListener("focusout", this.onFocusOut.bind(this));
this.addEventListener("keyup", this.onKeyup.bind(this));
this.addEventListener("keydown", this.onKeydown.bind(this));
}
getQuery() {
return this.input.value.trim();
}
onChange() {
super.onChange();
const newSearchTerm = this.getQuery();
if (!this.searchTerm || !newSearchTerm.startsWith(this.searchTerm)) {
this.querySelector(
"#predictive-search-results-groups-wrapper",
)?.remove();
}
this.updateSearchForTerm(this.searchTerm, newSearchTerm);
this.searchTerm = newSearchTerm;
if (!this.searchTerm.length) {
this.close(true);
return;
}
// Используем динамическое значение из настроек темы
if (this.searchTerm.length < this.minChars) {
this.close();
return;
}
this.getSearchResults(this.searchTerm);
}
onFormSubmit(event) {
// Получаем текущий запрос
const currentQuery = this.getQuery();
// Если запрос пустой ИЛИ его длина меньше minChars — отменяем отправку формы
if (
!currentQuery.length ||
currentQuery.length < this.minChars ||
this.querySelector('[aria-selected="true"] a')
) {
event.preventDefault();
}
}
onFormReset(event) {
super.onFormReset(event);
if (super.shouldResetForm()) {
this.searchTerm = "";
this.abortController.abort();
this.abortController = new AbortController();
this.closeResults(true);
}
}
onFocus() {
const currentSearchTerm = this.getQuery();
if (!currentSearchTerm.length) return;
if (this.searchTerm !== currentSearchTerm) {
this.onChange();
} else if (this.getAttribute("results") === "true") {
this.open();
} else if (currentSearchTerm.length >= this.minChars) {
// Запрашиваем результаты только если символов достаточно
this.getSearchResults(this.searchTerm);
}
}
onFocusOut() {
setTimeout(() => {
if (!this.contains(document.activeElement)) this.close();
});
}
onKeyup(event) {
if (!this.getQuery().length) this.close(true);
event.preventDefault();
switch (event.code) {
case "ArrowUp":
this.switchOption("up");
break;
case "ArrowDown":
this.switchOption("down");
break;
case "Enter":
this.selectOption();
break;
}
}
onKeydown(event) {
if (event.code === "ArrowUp" || event.code === "ArrowDown") {
event.preventDefault();
}
}
updateSearchForTerm(previousTerm, newTerm) {
const searchForTextElement = this.querySelector(
"[data-predictive-search-search-for-text]",
);
const currentButtonText = searchForTextElement?.innerText;
if (currentButtonText) {
// Защита от null: сначала проверяем, что match нашел совпадения
const matchResult = currentButtonText.match(
new RegExp(previousTerm, "g"),
);
if (matchResult && matchResult.length > 1) {
// The new term matches part of the button text and not just the search term, do not replace to avoid mistakes
return;
}
const newButtonText = currentButtonText.replace(previousTerm, newTerm);
searchForTextElement.innerText = newButtonText;
}
}
switchOption(direction) {
if (!this.getAttribute('open')) return;
const moveUp = direction === 'up';
const selectedElement = this.querySelector('[aria-selected="true"]');
// Ищем все видимые пункты (товары и кнопку See all)
const allVisibleElements = Array.from(
this.querySelectorAll('li[role="option"]')
).filter((element) => element.offsetParent !== null);
let activeElementIndex = 0;
if (moveUp && !selectedElement) return;
let selectedElementIndex = -1;
let i = 0;
while (selectedElementIndex === -1 && i <= allVisibleElements.length) {
if (allVisibleElements[i] === selectedElement) {
selectedElementIndex = i;
}
i++;
}
this.statusElement.textContent = '';
if (!moveUp && selectedElement) {
activeElementIndex =
selectedElementIndex === allVisibleElements.length - 1
? 0
: selectedElementIndex + 1;
} else if (moveUp) {
activeElementIndex =
selectedElementIndex === 0
? allVisibleElements.length - 1
: selectedElementIndex - 1;
}
if (activeElementIndex === selectedElementIndex) return;
const activeElement = allVisibleElements[activeElementIndex];
activeElement.setAttribute('aria-selected', true);
if (selectedElement) selectedElement.setAttribute('aria-selected', false);
this.input.setAttribute('aria-activedescendant', activeElement.id);
// Автоматический скролл к элементу, если он скрыт за пределами видимой области
activeElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
selectOption() {
const selectedOption = this.querySelector(
'[aria-selected="true"] a, button[aria-selected="true"]',
);
if (selectedOption) selectedOption.click();
}
getSearchResults(searchTerm) {
const queryKey = searchTerm.replace(" ", "-").toLowerCase();
this.setLiveRegionLoadingState();
if (this.cachedResults[queryKey]) {
this.renderSearchResults(this.cachedResults[queryKey]);
return;
}
fetch(
`${routes.predictive_search_url}?q=${encodeURIComponent(searchTerm)}§ion_id=custom-predictive-search`,
{
signal: this.abortController.signal,
},
)
.then((response) => {
if (!response.ok) {
var error = new Error(response.status);
this.close();
throw error;
}
return response.text();
})
.then((text) => {
const resultsMarkup = new DOMParser()
.parseFromString(text, "text/html")
.querySelector(
"#shopify-section-custom-predictive-search",
).innerHTML;
this.allPredictiveSearchInstances.forEach(
(predictiveSearchInstance) => {
predictiveSearchInstance.cachedResults[queryKey] = resultsMarkup;
},
);
this.renderSearchResults(resultsMarkup);
})
.catch((error) => {
if (error?.code === 20) return;
this.close();
throw error;
});
}
setLiveRegionLoadingState() {
this.statusElement =
this.statusElement || this.querySelector(".predictive-search-status");
this.loadingText =
this.loadingText || this.getAttribute("data-loading-text");
this.setLiveRegionText(this.loadingText);
this.setAttribute("loading", true);
}
setLiveRegionText(statusText) {
this.statusElement.setAttribute("aria-hidden", "false");
this.statusElement.textContent = statusText;
setTimeout(() => {
this.statusElement.setAttribute("aria-hidden", "true");
}, 1000);
}
renderSearchResults(resultsMarkup) {
this.predictiveSearchResults.innerHTML = resultsMarkup;
this.setAttribute("results", true);
this.setLiveRegionResults();
this.open();
}
setLiveRegionResults() {
this.removeAttribute("loading");
this.setLiveRegionText(
this.querySelector("[data-predictive-search-live-region-count-value]")
.textContent,
);
}
getResultsMaxHeight() {
return null; // Отключаем JS расчет высоты
}
open() {
this.setAttribute("open", true);
this.input.setAttribute("aria-expanded", true);
this.isOpen = true;
}
close(clearSearchTerm = false) {
this.closeResults(clearSearchTerm);
this.isOpen = false;
}
closeResults(clearSearchTerm = false) {
if (clearSearchTerm) {
this.input.value = "";
this.removeAttribute("results");
}
const selected = this.querySelector('[aria-selected="true"]');
if (selected) selected.setAttribute("aria-selected", false);
this.input.setAttribute("aria-activedescendant", "");
this.removeAttribute("loading");
this.removeAttribute("open");
this.input.setAttribute("aria-expanded", false);
this.resultsMaxHeight = false;
}
}
customElements.define("custom-predictive-search", CustomPredictiveSearch);
})();
ШАГ.4 Создаем компоненты
Новый компонент для формирования разметки в нашем дроп-дауне. В папку sections создаем custom-predictive-search.liquid:
{% comment %}theme-check-disable ImgLazyLoading{% endcomment %}
{%- if predictive_search.performed -%}
{% assign first_column_results_size = predictive_search.resources.queries.size
| plus: predictive_search.resources.collections.size
| plus: predictive_search.resources.pages.size
| plus: predictive_search.resources.articles.size
%}
<div id="custom-predictive-search-results" role="listbox" class="tw:flex tw:flex-col">
{%- if first_column_results_size > 0 or predictive_search.resources.products.size > 0 -%}
<div
id="custom-predictive-search-results-groups-wrapper"
class="tw:max-h-[60dvh] tw:min-h-[10dvh] tw:overflow-y-auto tw:p-2"
>
{%- endif -%}
{%- if predictive_search.resources.queries.size > 0 or predictive_search.resources.collections.size > 0 -%}
<div class="tw:py-2">
<h2 class="tw:px-4 tw:py-2 tw:text-[1.2rem] tw:uppercase tw:text-gray-500 tw:font-semibold tw:tracking-wider">
{{- 'templates.search.suggestions' | t -}}
</h2>
<ul class="tw:flex tw:flex-col" role="group">
{%- for query in predictive_search.resources.queries -%}
<li id="custom-predictive-search-option-query-{{ forloop.index }}" role="option" aria-selected="false">
<a href="{{ query.url }}" class="predictive-search__item tw:block tw:px-4 tw:py-3 tw:text-[1.4rem] tw:text-gray-800 tw:hover:bg-gray-50 tw:rounded-md tw:transition-colors" tabindex="-1">
{{ query.styled_text }}
</a>
</li>
{%- endfor -%}
{%- for collection in predictive_search.resources.collections -%}
<li id="custom-predictive-search-option-collection-{{ forloop.index }}" role="option" aria-selected="false">
<a href="{{ collection.url }}" class="predictive-search__item tw:block tw:px-4 tw:py-3 tw:text-[1.4rem] tw:text-gray-800 tw:hover:bg-gray-50 tw:rounded-md tw:transition-colors" tabindex="-1">
{{ collection.title | escape }}
</a>
</li>
{%- endfor -%}
</ul>
</div>
{%- endif -%}
{%- if predictive_search.resources.products.size > 0 -%}
<div class="tw:py-2">
<h2 class="tw:px-4 tw:py-2 tw:text-[1.2rem] tw:uppercase tw:text-gray-500 tw:font-semibold tw:tracking-wider">
{{- 'templates.search.products' | t -}}
</h2>
<ul id="custom-predictive-search-results-products-list" class="tw:flex tw:flex-col" role="group">
{%- for product in predictive_search.resources.products -%}
<li id="custom-predictive-search-option-product-{{ forloop.index }}" role="option" aria-selected="false">
<a
href="{{ product.url }}"
class="predictive-search__item tw:flex tw:items-center tw:gap-4 tw:p-4 tw:hover:bg-gray-50 tw:transition-colors tw:rounded-md"
tabindex="-1"
>
{%- if product.featured_media != blank -%}
<img
class="tw:w-[5rem] tw:h-[5rem] tw:flex-shrink-0 tw:bg-gray-100 tw:rounded-md tw:object-cover"
src="{{ product.featured_media | image_url: width: 150 }}"
alt="{{ product.featured_media.alt | escape }}"
width="50"
height="{{ 50 | divided_by: product.featured_media.preview_image.aspect_ratio }}"
>
{%- endif -%}
<div class="tw:flex-1 tw:min-w-0">
{%- if settings.predictive_search_show_vendor -%}
<span class="visually-hidden">{{ 'accessibility.vendor' | t }}</span>
<div class="tw:text-[1.2rem] tw:text-gray-500 tw:mb-1">
{{ product.vendor }}
</div>
{%- endif -%}
<h3 class="tw:text-[1.4rem] tw:font-medium tw:text-grayscale-950 tw:leading-[1.33] tw:line-clamp-2">{{ product.title | escape }}</h3>
{%- if settings.predictive_search_show_price -%}
<div class="tw:mt-1">
{% render 'price', product: product, use_variant: true, show_badges: false %}
</div>
{%- endif -%}
</div>
</a>
</li>
{%- endfor -%}
</ul>
</div>
{%- endif -%}
{%- if predictive_search.resources.pages.size > 0 or predictive_search.resources.articles.size > 0 -%}
<div class="tw:py-2">
<h2 class="tw:px-4 tw:py-2 tw:text-[1.2rem] tw:uppercase tw:text-gray-500 tw:font-semibold tw:tracking-wider">
{{- 'templates.search.pages' | t -}}
</h2>
<ul class="tw:flex tw:flex-col" role="group">
{%- for page in predictive_search.resources.pages -%}
<li id="custom-predictive-search-option-page-{{ forloop.index }}" role="option" aria-selected="false">
<a href="{{ page.url }}" class="predictive-search__item tw:block tw:px-4 tw:py-3 tw:text-[1.4rem] tw:text-gray-800 tw:hover:bg-gray-50 tw:rounded-md tw:transition-colors" tabindex="-1">
{{ page.title | escape }}
</a>
</li>
{%- endfor -%}
{%- for article in predictive_search.resources.articles -%}
<li id="custom-predictive-search-option-article-{{ forloop.index }}" role="option" aria-selected="false">
<a href="{{ article.url }}" class="predictive-search__item tw:block tw:px-4 tw:py-3 tw:text-[1.4rem] tw:text-gray-800 tw:hover:bg-gray-50 tw:rounded-md tw:transition-colors" tabindex="-1">
{{ article.title | escape }}
</a>
</li>
{%- endfor -%}
</ul>
</div>
{%- endif -%}
{%- if first_column_results_size > 0 or predictive_search.resources.products.size > 0 -%}
</div>
{%- endif -%}
{%- render 'loading-spinner', class: 'predictive-search__loading-state' -%}
<div id="custom-predictive-search-option-search-keywords" class="tw:mt-1 tw:border-t tw:border-gray-100">
<a
href="{{ routes.search_url }}?q={{ predictive_search.terms | escape }}&type=product&options[prefix]=last"
class="predictive-search__item predictive-search__item--term tw:!py-8 tw:!block tw:w-full tw:!text-center tw:py-6 tw:text-golden tw:bg-gray-50 tw:hover:!text-goluboy tw:transition-colors tw:text-[1.4rem] tw:font-poppins tw:font-semibold tw:uppercase"
>
See all products
</a>
</div>
</div>
<span class="hidden" data-predictive-search-live-region-count-value>
{% liquid
assign total_results = predictive_search.resources.products.size | plus: first_column_results_size
if total_results == 0
echo 'templates.search.no_results' | t: terms: predictive_search.terms
else
echo 'templates.search.results_with_count' | t: count: total_results | append: ': '
if predictive_search.resources.queries.size > 0
assign count = predictive_search.resources.queries.size | plus: predictive_search.resources.collections.size
echo 'templates.search.results_suggestions_with_count' | t: count: count | append: ', '
endif
if predictive_search.resources.pages.size > 0
assign count = predictive_search.resources.pages.size | plus: predictive_search.resources.articles.size
echo 'templates.search.results_pages_with_count' | t: count: count | append: ', '
endif
if predictive_search.resources.products.size > 0
echo 'templates.search.results_products_with_count' | t: count: predictive_search.resources.products.size
endif
endif
%}
</span>
{%- endif -%}
Новый компонент для самой формы. В папку snippets создаем custom-header-search.liquid
{%- comment -%}
Параллельная реальность поиска.
Тег: <custom-predictive-search>, Скрипт: custom-predictive-search.js
{%- endcomment -%}
<style>
/* Убиваем дефолтные браузерные outline и рамки */
.custom-search-wrapper input,
.custom-search-wrapper button,
.custom-search-wrapper a {
outline: none !important;
box-shadow: none !important;
}
.custom-search-wrapper input[type="search"]::-webkit-search-decoration,
.custom-search-wrapper input[type="search"]::-webkit-search-cancel-button,
.custom-search-wrapper input[type="search"]::-webkit-search-results-button,
.custom-search-wrapper input[type="search"]::-webkit-search-results-decoration {
-webkit-appearance: none;
}
/* МАГИЯ ЗДЕСЬ: Управляем видимостью дропдауна и сбрасываем Dawn */
.custom-search-wrapper .predictive-search {
display: none !important;
position: absolute !important;
top: 100% !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
border: 0.0625rem solid #f3f4f6 !important; /* gray-100 */
background-color: #ffffff !important; /* bg-white */
border-radius: 0.5rem !important;
box-shadow: 0 0.625rem 1rem -2rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.45rem -0.125rem rgba(0, 0, 0, 0.05) !important;
overflow: hidden;
z-index: 50;
}
.custom-search-wrapper[open] .predictive-search {
display: block !important;
}
/* Стили для кастомного спиннера (скрываем по умолчанию) */
.custom-search-wrapper .predictive-search__loading-state {
display: none;
align-items: center;
justify-content: center;
padding: 3rem 0;
}
/* Показываем спиннер только когда есть атрибут loading */
.custom-search-wrapper[loading="true"] .predictive-search__loading-state {
display: flex;
}
/* Скрываем старые результаты, пока грузится новый запрос */
.custom-search-wrapper[loading="true"] #custom-predictive-search-results {
display: none;
}
/* Кастомный скроллбар */
.custom-search-wrapper #custom-predictive-search-results-groups-wrapper::-webkit-scrollbar {
width: 0.4rem;
}
.custom-search-wrapper #custom-predictive-search-results-groups-wrapper::-webkit-scrollbar-track {
background: transparent;
}
.custom-search-wrapper #custom-predictive-search-results-groups-wrapper::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 0.65rem;
}
/* Подсветка при навигации с клавиатуры (стрелочки) */
.custom-search-wrapper #custom-predictive-search-results li[aria-selected="true"] > a {
background-color: #F9FAFB !important; /* tw:bg-gray-50 */
}
.custom-search-wrapper #custom-predictive-search-results li[aria-selected="true"] > .predictive-search__item--term {
background-color: #F3F4F6 !important; /* tw:bg-gray-100 для кнопки */
color: #0ea5e9 !important; /* tw:text-goluboy */
}
</style>
<custom-predictive-search class="custom-search-wrapper tw:relative tw:w-full tw:max-w-[40rem] tw:hidden tw:lg:block"
data-loading-text="{{ 'accessibility.loading' | t }}"
data-min-chars="{{ settings.predictive_search_min_chars | default: 2 }}"
>
<form action="{{ routes.search_url }}" method="get" role="search" class="tw:flex tw:w-full">
<div class="tw:relative tw:flex-1">
<input
class="tw:!w-full tw:px-5 tw:h-[4rem] tw:text-[1.4rem] tw:text-grayscale-950 tw:placeholder-gray-400 tw:outline-none tw:focus:outline-none tw:appearance-none tw:border tw:border-r-0 tw:border-gray-200 tw:focus:border-gray-300 tw:transition-colors"
id="Header-Search-Input"
type="search"
name="q"
value="{{ search.terms | escape }}"
placeholder="Search..."
role="combobox"
aria-expanded="false"
aria-owns="predictive-search-results"
aria-controls="predictive-search-results"
aria-haspopup="listbox"
aria-autocomplete="list"
autocorrect="off"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
>
<button
type="reset"
class="reset__button tw:hidden tw:absolute tw:right-0 tw:top-0 tw:h-[4rem] tw:text-gray-400 tw:hover:text-grayscale-950 tw:transition-colors{% if search.terms == blank %} hidden{% endif %}"
aria-label="{{ 'general.search.reset' | t }}"
>
<svg class="tw:w-5 tw:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<input type="hidden" name="type" value="product">
<input type="hidden" name="options[prefix]" value="last">
<button type="submit" class="tw:bg-golden tw:text-white tw:px-5 tw:h-[4rem] tw:hover:bg-golden/80 tw:transition-colors tw:shrink-0 tw:outline-none tw:focus:outline-none" aria-label="{{ 'general.search.search' | t }}">
<svg class="tw:w-7 tw:h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</button>
<div class="predictive-search predictive-search--header" tabindex="-1" data-predictive-search>
<!-- Наш кастомный Tailwind-спиннер -->
<div class="predictive-search__loading-state">
<svg class="tw:animate-spin tw:h-10 tw:w-10 tw:text-golden" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="tw:opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="tw:opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</div>
<span class="predictive-search-status visually-hidden" role="status" aria-hidden="true"></span>
</form>
</custom-predictive-search>
Компоненты готовы и теперь осталось парочку штрихов чтобы все заработало как следует.
ШАГ.6 Подключаем и наслаждаемся
Добавляем в layout/theme.liquid перед закрывающим тегом body подключение нашего кастомного скрипта
<script src="{{ 'custom-predictive-search.js' | asset_url }}" defer="defer"></script>
Находим место для встаки нашего нового компонента в хедере:
{% comment %} вставляем после менюшки {% endcomment %}
{% render 'custom-header-search' %}
Учти что у него стоит класс tw:hidden tw:lg:block это значит что тебе нужно скрывать компонент для мобилки на этом брекпоинте чтобы не было сразу 2 формы поиска в хедере.
Открываем наш header-search.liquid или weblegko-header-search.liquid (какой используется в шапке), который лежит в папке snippets и добавляем tw:lg:!hidden примерно на строке 26 чтобы скрыть на десктопе:
<details-modal class="tw:lg:!hidden header__search">
Дроп-даун с настройкой кол-ва символов в редакторе админке готов.