From 8e0a7c5e31a6c6ca68937eaa45d6b0724730b9e6 Mon Sep 17 00:00:00 2001 From: Ihor Masechko Date: Wed, 25 Feb 2026 19:29:36 +0200 Subject: [PATCH 1/3] feat(case-studies): add search with shared SearchService and safe regex - Add search bar UI and client handler (form submit, clear, placeholder) - Add SearchService for getSearchTerm and buildSearchRegexPattern (ReDoS-safe) - Use SearchService in index query and NavigationService.applySearchFilter - Preserve filters in search form; include search in clear-all and active state - Respect reduced motion for search bar; fix clear button a11y and template comment --- website/modules/asset/ui/src/scss/_cases.scss | 112 +++++++++++++++ website/modules/case-studies-page/index.js | 92 +++++++++---- .../services/NavigationService.js | 25 +++- .../services/SearchService.js | 43 ++++++ .../case-studies-page/views/index.html | 71 +++++++++- website/public/images/search.svg | 3 + .../case-studies-page/search-handler.js | 127 ++++++++++++++++++ 7 files changed, 437 insertions(+), 36 deletions(-) create mode 100644 website/modules/case-studies-page/services/SearchService.js create mode 100644 website/public/images/search.svg create mode 100644 website/public/js/modules/case-studies-page/search-handler.js diff --git a/website/modules/asset/ui/src/scss/_cases.scss b/website/modules/asset/ui/src/scss/_cases.scss index 65de7094..972d4f87 100644 --- a/website/modules/asset/ui/src/scss/_cases.scss +++ b/website/modules/asset/ui/src/scss/_cases.scss @@ -78,9 +78,114 @@ } } +// New search bar (separate from .cs_search-form to avoid conflicts) +.cs_search-bar-wrapper { + width: 100%; + margin-bottom: 20px; + @include breakpoint-medium { + margin-bottom: 0; + } +} + +.cs_search-bar-form { + width: 100%; + max-width: 1200px; + margin: 0 auto; +} + +.cs_search-bar-input-wrapper { + position: relative; + display: flex; + align-items: center; + width: 100%; + height: 56px; + background: $white; + border: 1px solid rgba($gray-200, 0.4); + box-sizing: border-box; + transition: border-color 0.2s ease; + + &:focus-within { + outline: 0; + border: none; + border-bottom: 1px solid rgba($gray-200, 0.4); + + .cs_search-bar-icon { + display: none; + } + } +} + +.cs_search-bar-icon { + position: absolute; + left: 1rem; + width: 24px; + height: 24px; + pointer-events: none; + z-index: 1; +} + +.cs_search-bar-input { + flex: 1; + width: 100%; + height: 100%; + padding: 0 1rem 0 3rem; + border: none; + background: transparent; + font-size: 14px; + font-weight: $font-weight-medium; + color: $gray-500; + outline: none; + box-shadow: none; + + &::placeholder { + color: $gray-300; + } + + &:focus { + border: none; + outline: none; + box-shadow: none; + } +} + +.cs_search-bar-clear { + display: none; + position: absolute; + right: 1rem; + width: 16px; + height: 16px; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + z-index: 2; + align-items: center; + justify-content: center; + + img { + width: 16px; + height: 16px; + } + + &:hover { + opacity: 0.7; + } +} + +.cs_search-bar-input:not(:placeholder-shown) ~ .cs_search-bar-clear, +.cs_search-bar-input:focus ~ .cs_search-bar-clear, +.cs_search-bar-clear--visible { + display: flex; +} + +.cs_search-bar-input-wrapper:focus-within .cs_search-bar-input::placeholder { + color: $gray-300; +} + // Tags styling .tags-filter { border: 1px solid $gray-border; + flex-shrink: 0; font-style: $font-style-normal; max-width: 262px; width: 100%; @@ -468,6 +573,7 @@ align-items: flex-start; justify-content: flex-start; top: $desktop-header-height; + margin-top: 20px; } } @@ -630,6 +736,7 @@ grid-template-columns: 1fr; gap: 32px; margin: 0 auto; + min-width: 0; opacity: 0; transform: translateY(30px); filter: blur(2px); @@ -1190,6 +1297,11 @@ filter: none; } + .cs_search-bar-input-wrapper, + .cs_search-bar-clear { + transition: none; + } + .tag-item { transition: none; diff --git a/website/modules/case-studies-page/index.js b/website/modules/case-studies-page/index.js index 29daa7f8..b6760dfe 100644 --- a/website/modules/case-studies-page/index.js +++ b/website/modules/case-studies-page/index.js @@ -1,8 +1,65 @@ const mainWidgets = require('../../lib/mainWidgets'); -const TagCountService = require('./services/TagCountService'); const NavigationService = require('./services/NavigationService'); +const SearchService = require('./services/SearchService'); +const TagCountService = require('./services/TagCountService'); const UrlService = require('./services/UrlService'); +const buildIndexQuery = function (self, req) { + const queryParams = { ...req.query }; + const searchTerm = SearchService.getSearchTerm(queryParams); + delete queryParams.search; + + const query = self.pieces + .find(req, {}) + .applyBuildersSafely(queryParams) + .perPage(self.perPage); + self.filterByIndexPage(query, req.data.page); + + const regexPattern = SearchService.buildSearchRegexPattern(searchTerm); + if (regexPattern) { + query.and({ + $or: [ + { title: { $regex: regexPattern, $options: 'i' } }, + { portfolioTitle: { $regex: regexPattern, $options: 'i' } }, + ], + }); + } + return query; +}; + +const runSetupIndexData = async function (self, req) { + try { + const tagCounts = await TagCountService.calculateTagCounts( + req, + self.apos.modules, + self.options, + ); + UrlService.attachIndexData(req, tagCounts); + } catch (error) { + self.apos.util.error('Error calculating tag counts:', error); + UrlService.attachIndexData(req, { + industry: {}, + stack: {}, + caseStudyType: {}, + partner: {}, + }); + } +}; + +const runSetupShowData = async function (self, req) { + try { + const navigation = await NavigationService.getNavigationDataForPage( + req, + self.apos, + self, + ); + UrlService.attachShowData(req, navigation); + } catch (error) { + self.apos.util.error('Error calculating navigation data:', error); + UrlService.attachShowData(req, { prev: null, next: null }); + } +}; + module.exports = { extend: '@apostrophecms/piece-page-type', options: { @@ -46,37 +103,14 @@ module.exports = { methods(self) { return { + indexQuery(req) { + return buildIndexQuery(self, req); + }, async setupIndexData(req) { - try { - const tagCounts = await TagCountService.calculateTagCounts( - req, - self.apos.modules, - self.options, - ); - UrlService.attachIndexData(req, tagCounts); - } catch (error) { - self.apos.util.error('Error calculating tag counts:', error); - UrlService.attachIndexData(req, { - industry: {}, - stack: {}, - caseStudyType: {}, - partner: {}, - }); - } + return await runSetupIndexData(self, req); }, - async setupShowData(req) { - try { - const navigation = await NavigationService.getNavigationDataForPage( - req, - self.apos, - self, - ); - UrlService.attachShowData(req, navigation); - } catch (error) { - self.apos.util.error('Error calculating navigation data:', error); - UrlService.attachShowData(req, { prev: null, next: null }); - } + return await runSetupShowData(self, req); }, }; }, diff --git a/website/modules/case-studies-page/services/NavigationService.js b/website/modules/case-studies-page/services/NavigationService.js index 28e2f26e..d8f82d48 100644 --- a/website/modules/case-studies-page/services/NavigationService.js +++ b/website/modules/case-studies-page/services/NavigationService.js @@ -1,3 +1,5 @@ +const SearchService = require('./SearchService'); + /** * NavigationService - Single Responsibility: Case study navigation * @@ -68,6 +70,27 @@ class NavigationService { ); } + /** + * Applies search filter to query when search param is present + * Uses SearchService for safe regex (ReDoS prevention) and array handling + * @param {Object} filteredQuery - Query object + * @param {Object} req - Request object + * @returns {Object} Modified query + */ + static applySearchFilter(filteredQuery, req) { + const searchTerm = SearchService.getSearchTerm(req.query || {}); + const regexPattern = SearchService.buildSearchRegexPattern(searchTerm); + if (!regexPattern) { + return filteredQuery; + } + return filteredQuery.and({ + $or: [ + { title: { $regex: regexPattern, $options: 'i' } }, + { portfolioTitle: { $regex: regexPattern, $options: 'i' } }, + ], + }); + } + /** * Applies filters to a query based on request parameters * @param {Object} query - ApostropheCMS query object @@ -115,7 +138,7 @@ class NavigationService { }); } } - return filteredQuery; + return NavigationService.applySearchFilter(filteredQuery, req); } /** diff --git a/website/modules/case-studies-page/services/SearchService.js b/website/modules/case-studies-page/services/SearchService.js new file mode 100644 index 00000000..77c22d88 --- /dev/null +++ b/website/modules/case-studies-page/services/SearchService.js @@ -0,0 +1,43 @@ +/** + * SearchService - Shared search term normalization and safe regex building + * + * Used by case-studies-page index query and NavigationService so search + * behavior and escaping stay consistent and safe (ReDoS prevention). + */ + +const REGEX_ESCAPE = /[$()*+.?[\\\]^{|}]/gu; + +/** + * Normalizes search param from query (handles missing, array, non-string) + * @param {Object} queryParams - Request query object (e.g. req.query) + * @returns {string} Trimmed search string, or empty string + */ +function getSearchTerm(queryParams) { + if (!queryParams || !queryParams.search) { + return ''; + } + const raw = queryParams.search; + const value = Array.isArray(raw) ? raw[0] : raw; + if (typeof value !== 'string') { + return ''; + } + return value.trim(); +} + +/** + * Builds a safe MongoDB regex pattern from search term (escape + word match) + * @param {string} searchTerm - User search string + * @returns {string|null} Pattern for $regex, or null if no search + */ +function buildSearchRegexPattern(searchTerm) { + if (!searchTerm || !searchTerm.trim()) { + return null; + } + const escaped = searchTerm.trim().replace(REGEX_ESCAPE, '\\$&'); + return escaped.split(/\s+/u).join('.*'); +} + +module.exports = { + getSearchTerm, + buildSearchRegexPattern, +}; diff --git a/website/modules/case-studies-page/views/index.html b/website/modules/case-studies-page/views/index.html index 23738a1d..c730a032 100644 --- a/website/modules/case-studies-page/views/index.html +++ b/website/modules/case-studies-page/views/index.html @@ -1,9 +1,12 @@ -{# modules/case-studies-page/views/index.new.html #} {% extends "layout.html" %} -{% import '@apostrophecms/pager:macros.html' as pager with context %} {% block -main %} +{# modules/case-studies-page/views/index.html #} +{% extends "layout.html" %} +{% import '@apostrophecms/pager:macros.html' as pager with context %} +{% block main %}
+
+ +
+
Filter Case Studies
{% set hasActiveFilters = data.query.industry or data.query.stack or - data.query.caseStudyType or data.query.partner %} {% if hasActiveFilters %} + data.query.caseStudyType or data.query.partner or data.query.search %} {% if hasActiveFilters %}

{{ data.totalPieces }} Ite{% if data.totalPieces == 1 %}m{% else %}ms{% endif %} Found @@ -32,7 +87,7 @@

+ {% endblock main %} diff --git a/website/public/images/search.svg b/website/public/images/search.svg new file mode 100644 index 00000000..14e84602 --- /dev/null +++ b/website/public/images/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/website/public/js/modules/case-studies-page/search-handler.js b/website/public/js/modules/case-studies-page/search-handler.js new file mode 100644 index 00000000..b4b9622c --- /dev/null +++ b/website/public/js/modules/case-studies-page/search-handler.js @@ -0,0 +1,127 @@ +/** + * Case Studies Search Handler + * Triggers search on Enter (form submit) and clear button click + */ + +(function () { + 'use strict'; + + // Build URL with search and existing filters + function buildSearchUrl(searchValue) { + const url = new URL(window.location.href); + const params = new URLSearchParams(url.search); + + // Update or remove search parameter + if (searchValue && searchValue.trim()) { + params.set('search', searchValue.trim()); + } else { + params.delete('search'); + } + + // Remove page parameter when searching (reset to page 1) + params.delete('page'); + + // Build new URL + const newSearch = params.toString(); + const newUrl = + url.pathname + (newSearch ? '?' + newSearch : '') + url.hash; + + return newUrl; + } + + // Update URL and reload page + function performSearch(searchValue) { + const newUrl = buildSearchUrl(searchValue); + history.pushState({ clientSideFilter: true, url: newUrl }, '', newUrl); + window.location.reload(); + } + + // Update clear button visibility + function updateClearButtonVisibility(searchInput, clearButton) { + if (searchInput && clearButton) { + if (searchInput.value && searchInput.value.trim()) { + clearButton.style.display = 'flex'; + } else { + clearButton.style.display = 'none'; + } + } + } + + // Handle search input (only update clear button; search runs on Enter) + function handleSearchInput(event) { + const clearButton = document.querySelector('.cs_search-bar-clear'); + updateClearButtonVisibility(event.target, clearButton); + } + + // Handle clear button click + function handleClearClick(event) { + event.preventDefault(); + event.stopPropagation(); + + const searchInput = document.getElementById('case-studies-search'); + const clearButton = event.target.closest('.cs_search-bar-clear'); + if (searchInput) { + searchInput.value = ''; + updateClearButtonVisibility(searchInput, clearButton); + searchInput.focus(); + performSearch(''); + } + } + + // Handle form submission (Enter key) – triggers search + function handleFormSubmit(event) { + event.preventDefault(); + const searchInput = event.target.querySelector('.cs_search-bar-input'); + if (searchInput) { + performSearch(searchInput.value); + } + } + + function handleSearchFocus(event) { + event.target.setAttribute('placeholder', 'Try a title or description'); + } + + function handleSearchBlur(event) { + if (!event.target.value) { + event.target.setAttribute('placeholder', 'Search case studies'); + } + } + + // Initialize search handler + function initSearchHandler() { + const searchForm = document.querySelector('.cs_search-bar-form'); + const searchInput = document.getElementById('case-studies-search'); + const clearButton = document.querySelector('.cs_search-bar-clear'); + + if (!searchForm || !searchInput) { + return; + } + + // Add input event listener + searchInput.addEventListener('input', handleSearchInput); + + // Add form submit handler + searchForm.addEventListener('submit', handleFormSubmit); + + // Add clear button handler + if (clearButton) { + clearButton.addEventListener('click', handleClearClick); + } + + // Update placeholder on focus/blur + searchInput.addEventListener('focus', handleSearchFocus); + searchInput.addEventListener('blur', handleSearchBlur); + + // Initial clear button visibility + if (clearButton) { + updateClearButtonVisibility(searchInput, clearButton); + } + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initSearchHandler); + } else { + initSearchHandler(); + } +})(); From 7bf3fc7ca146d883e0d99ada5703b227f6d44cff Mon Sep 17 00:00:00 2001 From: Ihor Masechko Date: Wed, 25 Feb 2026 20:12:55 +0200 Subject: [PATCH 2/3] fix(case-studies): search UX, history, and piece-page req handling - Drive clear button visibility by class only; remove dead CSS/placeholder rule - Use type=text to avoid native search clear conflicting with custom clear - performSearch: use location.assign only (no pushState+reload phantom history) - handleSearchInput: close over clearButton; remove no-op focus() before navigate - SearchService: buildSearchCondition, 200-char cap, Prettier format - Chain parent beforeIndex/beforeShow so req.data is set before getFilters - Do not reassign query from filterByIndexPage (it returns undefined; fixes query.req) --- website/modules/asset/ui/src/scss/_cases.scss | 4 -- website/modules/case-studies-page/index.js | 19 ++++---- .../services/NavigationService.js | 11 ++--- .../services/SearchService.js | 45 ++++++++++++++++--- .../case-studies-page/views/index.html | 2 +- .../case-studies-page/search-handler.js | 36 +++++++-------- 6 files changed, 71 insertions(+), 46 deletions(-) diff --git a/website/modules/asset/ui/src/scss/_cases.scss b/website/modules/asset/ui/src/scss/_cases.scss index 972d4f87..151f1ca0 100644 --- a/website/modules/asset/ui/src/scss/_cases.scss +++ b/website/modules/asset/ui/src/scss/_cases.scss @@ -178,10 +178,6 @@ display: flex; } -.cs_search-bar-input-wrapper:focus-within .cs_search-bar-input::placeholder { - color: $gray-300; -} - // Tags styling .tags-filter { border: 1px solid $gray-border; diff --git a/website/modules/case-studies-page/index.js b/website/modules/case-studies-page/index.js index b6760dfe..d3a070fe 100644 --- a/website/modules/case-studies-page/index.js +++ b/website/modules/case-studies-page/index.js @@ -15,14 +15,9 @@ const buildIndexQuery = function (self, req) { .perPage(self.perPage); self.filterByIndexPage(query, req.data.page); - const regexPattern = SearchService.buildSearchRegexPattern(searchTerm); - if (regexPattern) { - query.and({ - $or: [ - { title: { $regex: regexPattern, $options: 'i' } }, - { portfolioTitle: { $regex: regexPattern, $options: 'i' } }, - ], - }); + const searchCondition = SearchService.buildSearchCondition(searchTerm); + if (searchCondition) { + query.and(searchCondition); } return query; }; @@ -92,11 +87,19 @@ module.exports = { }, init(self) { + const superBeforeIndex = self.beforeIndex; self.beforeIndex = async (req) => { + if (superBeforeIndex) { + await superBeforeIndex(req); + } await self.setupIndexData(req); }; + const superBeforeShow = self.beforeShow; self.beforeShow = async (req) => { + if (superBeforeShow) { + await superBeforeShow(req); + } await self.setupShowData(req); }; }, diff --git a/website/modules/case-studies-page/services/NavigationService.js b/website/modules/case-studies-page/services/NavigationService.js index d8f82d48..74af551c 100644 --- a/website/modules/case-studies-page/services/NavigationService.js +++ b/website/modules/case-studies-page/services/NavigationService.js @@ -79,16 +79,11 @@ class NavigationService { */ static applySearchFilter(filteredQuery, req) { const searchTerm = SearchService.getSearchTerm(req.query || {}); - const regexPattern = SearchService.buildSearchRegexPattern(searchTerm); - if (!regexPattern) { + const searchCondition = SearchService.buildSearchCondition(searchTerm); + if (!searchCondition) { return filteredQuery; } - return filteredQuery.and({ - $or: [ - { title: { $regex: regexPattern, $options: 'i' } }, - { portfolioTitle: { $regex: regexPattern, $options: 'i' } }, - ], - }); + return filteredQuery.and(searchCondition); } /** diff --git a/website/modules/case-studies-page/services/SearchService.js b/website/modules/case-studies-page/services/SearchService.js index 77c22d88..3062c090 100644 --- a/website/modules/case-studies-page/services/SearchService.js +++ b/website/modules/case-studies-page/services/SearchService.js @@ -7,37 +7,68 @@ const REGEX_ESCAPE = /[$()*+.?[\\\]^{|}]/gu; +const MAX_SEARCH_TERM_LENGTH = 200; + /** * Normalizes search param from query (handles missing, array, non-string) * @param {Object} queryParams - Request query object (e.g. req.query) * @returns {string} Trimmed search string, or empty string */ -function getSearchTerm(queryParams) { +const getSearchTerm = function (queryParams) { if (!queryParams || !queryParams.search) { return ''; } const raw = queryParams.search; - const value = Array.isArray(raw) ? raw[0] : raw; + let value = raw; + if (Array.isArray(raw)) { + [value] = raw; + } if (typeof value !== 'string') { return ''; } return value.trim(); -} +}; /** * Builds a safe MongoDB regex pattern from search term (escape + word match) + * Search term is capped at MAX_SEARCH_TERM_LENGTH to avoid pathologically long patterns * @param {string} searchTerm - User search string * @returns {string|null} Pattern for $regex, or null if no search */ -function buildSearchRegexPattern(searchTerm) { +const buildSearchRegexPattern = function (searchTerm) { if (!searchTerm || !searchTerm.trim()) { return null; } - const escaped = searchTerm.trim().replace(REGEX_ESCAPE, '\\$&'); + const trimmed = searchTerm.trim(); + if (!trimmed) { + return null; + } + let capped = trimmed; + if (trimmed.length > MAX_SEARCH_TERM_LENGTH) { + capped = trimmed.slice(0, MAX_SEARCH_TERM_LENGTH); + } + const escaped = capped.replace(REGEX_ESCAPE, '\\$&'); return escaped.split(/\s+/u).join('.*'); -} +}; + +/** + * Builds MongoDB $or condition for case study search (single source of truth for searchable fields) + * @param {string} searchTerm - User search string + * @returns {Object|null} Condition to pass to query.and(), or null if no search + */ +const buildSearchCondition = function (searchTerm) { + const regexPattern = buildSearchRegexPattern(searchTerm); + if (!regexPattern) { + return null; + } + const regexOpts = { $regex: regexPattern, $options: 'i' }; + return { + $or: [{ title: regexOpts }, { portfolioTitle: regexOpts }], + }; +}; module.exports = { - getSearchTerm, + buildSearchCondition, buildSearchRegexPattern, + getSearchTerm, }; diff --git a/website/modules/case-studies-page/views/index.html b/website/modules/case-studies-page/views/index.html index c730a032..31bc353f 100644 --- a/website/modules/case-studies-page/views/index.html +++ b/website/modules/case-studies-page/views/index.html @@ -29,7 +29,7 @@ aria-hidden="true" /> Date: Wed, 25 Feb 2026 20:35:45 +0200 Subject: [PATCH 3/3] fix: normalize filter params and improve input validation - Ensure filter params are always arrays for safe template iteration - Guard window.totalPages against NaN - Skip navigation when clearing already-empty search input --- .../case-studies-page/services/UrlService.js | 28 ++++++++++++++++++- .../case-studies-page/views/index.html | 3 +- .../case-studies-page/search-handler.js | 10 +++++-- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/website/modules/case-studies-page/services/UrlService.js b/website/modules/case-studies-page/services/UrlService.js index 4376be3e..f18963a9 100644 --- a/website/modules/case-studies-page/services/UrlService.js +++ b/website/modules/case-studies-page/services/UrlService.js @@ -74,6 +74,31 @@ class UrlService { return [value]; } + /** + * Filter param keys that must be arrays when present (for safe iteration in + * templates). + */ + static get filterParamKeys() { + return ['industry', 'stack', 'caseStudyType', 'partner']; + } + + /** + * Returns a copy of query with filter params normalized to arrays so + * templates can safely iterate (avoids single-string params iterating as + * characters). + * @param {Object} query - Raw request query object + * @returns {Object} New object with filter params as arrays when present + */ + static normalizeFilterParams(query) { + const result = { ...query }; + UrlService.filterParamKeys.forEach((key) => { + if (result[key] !== null && result[key] !== undefined) { + result[key] = UrlService.ensureArray(result[key]); + } + }); + return result; + } + /** * Attaches index page data to request * @param {Object} req - Request object @@ -85,6 +110,7 @@ class UrlService { reqCopy.data = {}; } reqCopy.data.tagCounts = tagCounts; + reqCopy.data.query = UrlService.normalizeFilterParams(req.query || {}); // Set default visible tags count reqCopy.data.defaultVisibleTagsCount = 5; reqCopy.data.buildCaseStudyUrl = (caseStudyUrl) => @@ -102,7 +128,7 @@ class UrlService { reqCopy.data = {}; } - const queryParams = req.query || {}; + const queryParams = UrlService.normalizeFilterParams(req.query || {}); // Build complete URLs server-side to avoid template encoding issues reqCopy.data.prev = navigation.prev; diff --git a/website/modules/case-studies-page/views/index.html b/website/modules/case-studies-page/views/index.html index 31bc353f..dcf98a91 100644 --- a/website/modules/case-studies-page/views/index.html +++ b/website/modules/case-studies-page/views/index.html @@ -385,7 +385,8 @@

{{ article.portfolioTitle }}

diff --git a/website/public/js/modules/case-studies-page/search-handler.js b/website/public/js/modules/case-studies-page/search-handler.js index 83975c7d..6e87281a 100644 --- a/website/public/js/modules/case-studies-page/search-handler.js +++ b/website/public/js/modules/case-studies-page/search-handler.js @@ -57,9 +57,13 @@ const searchInput = document.getElementById('case-studies-search'); const clearButton = event.target.closest('.cs_search-bar-clear'); - if (searchInput) { - searchInput.value = ''; - updateClearButtonVisibility(searchInput, clearButton); + if (!searchInput) { + return; + } + const hadValue = searchInput.value && searchInput.value.trim(); + searchInput.value = ''; + updateClearButtonVisibility(searchInput, clearButton); + if (hadValue) { performSearch(''); } }