Commit 26670221 authored by DevPilot's avatar DevPilot

feat(sidebar): add smart search bar to filter menu entries

Filters sidebar items/submenus live as the user types, normalizing
Arabic orthographic variants (hamza forms, ta marbuta, alef maksura,
Arabic-Indic digits) and doing typo-tolerant approximate matching
against label_ar, label_en, and the route path.
Co-Authored-By: 's avatarClaude Sonnet 5 <noreply@anthropic.com>
parent a946db53
...@@ -130,6 +130,15 @@ $isActive = function(string $route) use ($currentPath): bool { ...@@ -130,6 +130,15 @@ $isActive = function(string $route) use ($currentPath): bool {
return str_starts_with($currentPath, $route); return str_starts_with($currentPath, $route);
}; };
$searchText = function(array $entry): string {
$parts = [
$entry['label_ar'] ?? '',
$entry['label_en'] ?? '',
str_replace(['/', '-', '_'], ' ', $entry['route'] ?? ''),
];
return trim(implode(' ', array_filter($parts, fn($p) => $p !== '')));
};
// Get menu items from registry // Get menu items from registry
$menuItems = MenuRegistry::getAll(); $menuItems = MenuRegistry::getAll();
usort($menuItems, fn($a, $b) => ($a['order'] ?? 999) <=> ($b['order'] ?? 999)); usort($menuItems, fn($a, $b) => ($a['order'] ?? 999) <=> ($b['order'] ?? 999));
...@@ -159,7 +168,16 @@ if (empty($menuItems)) { ...@@ -159,7 +168,16 @@ if (empty($menuItems)) {
<?php endif; ?> <?php endif; ?>
</div> </div>
<div class="sidebar-search">
<i data-lucide="search" class="sidebar-search-icon"></i>
<input type="text" id="sidebar-search-input" class="sidebar-search-input" placeholder="ابحث في القائمة..." autocomplete="off">
<button type="button" id="sidebar-search-clear" class="sidebar-search-clear" aria-label="مسح البحث">
<i data-lucide="x"></i>
</button>
</div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<div id="sidebar-search-empty" class="sidebar-search-empty">لا توجد نتائج مطابقة</div>
<ul class="sidebar-menu"> <ul class="sidebar-menu">
<?php foreach ($menuItems as $item): ?> <?php foreach ($menuItems as $item): ?>
<?php <?php
...@@ -191,7 +209,7 @@ if (empty($menuItems)) { ...@@ -191,7 +209,7 @@ if (empty($menuItems)) {
$isOpen = $itemActive || $childActive; $isOpen = $itemActive || $childActive;
$iconName = $getIcon($item['icon'] ?? ''); $iconName = $getIcon($item['icon'] ?? '');
?> ?>
<li class="sidebar-item<?= $isOpen && $hasChildren ? ' open' : '' ?>"> <li class="sidebar-item<?= $isOpen && $hasChildren ? ' open' : '' ?>" data-search="<?= e($searchText($item)) ?>">
<?php if ($hasChildren): ?> <?php if ($hasChildren): ?>
<a href="javascript:void(0)" class="sidebar-link<?= $itemActive ? ' active' : '' ?>" onclick="toggleSubmenu(this)"> <a href="javascript:void(0)" class="sidebar-link<?= $itemActive ? ' active' : '' ?>" onclick="toggleSubmenu(this)">
<span class="sidebar-icon"><i data-lucide="<?= e($iconName) ?>"></i></span> <span class="sidebar-icon"><i data-lucide="<?= e($iconName) ?>"></i></span>
...@@ -201,7 +219,7 @@ if (empty($menuItems)) { ...@@ -201,7 +219,7 @@ if (empty($menuItems)) {
<ul class="sidebar-submenu"<?= $isOpen ? '' : ' style="display:none;"' ?>> <ul class="sidebar-submenu"<?= $isOpen ? '' : ' style="display:none;"' ?>>
<?php foreach ($visibleChildren as $child): ?> <?php foreach ($visibleChildren as $child): ?>
<?php $childRoute = $child['route'] ?? '#'; ?> <?php $childRoute = $child['route'] ?? '#'; ?>
<li> <li data-search="<?= e($searchText($child)) ?>">
<a href="<?= e($childRoute) ?>" class="sidebar-sublink<?= $isActive($childRoute) ? ' active' : '' ?>"> <a href="<?= e($childRoute) ?>" class="sidebar-sublink<?= $isActive($childRoute) ? ' active' : '' ?>">
<?= e($child['label_ar']) ?> <?= e($child['label_ar']) ?>
</a> </a>
......
...@@ -104,6 +104,7 @@ $currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); ...@@ -104,6 +104,7 @@ $currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
<script src="<?= url('assets/js/app.js') ?>?v=<?= @filemtime(dirname(__DIR__, 3) . '/public/assets/js/app.js') ?: time() ?>"></script> <script src="<?= url('assets/js/app.js') ?>?v=<?= @filemtime(dirname(__DIR__, 3) . '/public/assets/js/app.js') ?: time() ?>"></script>
<script src="<?= url('assets/js/searchable-select.js') ?>?v=<?= @filemtime(dirname(__DIR__, 3) . '/public/assets/js/searchable-select.js') ?: time() ?>"></script> <script src="<?= url('assets/js/searchable-select.js') ?>?v=<?= @filemtime(dirname(__DIR__, 3) . '/public/assets/js/searchable-select.js') ?: time() ?>"></script>
<script src="<?= url('assets/js/sidebar-search.js') ?>?v=<?= @filemtime(dirname(__DIR__, 3) . '/public/assets/js/sidebar-search.js') ?: time() ?>"></script>
<script src="<?= url('assets/js/forms-engine.js') ?>?v=<?= @filemtime(dirname(__DIR__, 3) . '/public/assets/js/forms-engine.js') ?: time() ?>"></script> <script src="<?= url('assets/js/forms-engine.js') ?>?v=<?= @filemtime(dirname(__DIR__, 3) . '/public/assets/js/forms-engine.js') ?: time() ?>"></script>
<script> <script>
// Initialize Lucide icons // Initialize Lucide icons
......
...@@ -227,6 +227,89 @@ code { ...@@ -227,6 +227,89 @@ code {
color: #fff; color: #fff;
} }
/* Sidebar Search */
.sidebar-search {
position: relative;
display: flex;
align-items: center;
margin: 14px 16px 0;
flex-shrink: 0;
}
.sidebar-search-icon {
position: absolute;
right: 12px;
width: 15px;
height: 15px;
color: rgba(184, 197, 212, 0.5);
pointer-events: none;
}
.sidebar-search-input {
width: 100%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--sidebar-border);
color: #e2e8f0;
font-family: inherit;
font-size: 13px;
font-weight: 500;
border-radius: var(--radius-md);
padding: 8px 34px 8px 30px;
transition: all var(--duration-fast) ease;
}
.sidebar-search-input::placeholder {
color: rgba(184, 197, 212, 0.5);
}
.sidebar-search-input:focus {
outline: none;
background: rgba(255, 255, 255, 0.08);
border-color: var(--brand-primary-light);
}
.sidebar-search-clear {
position: absolute;
left: 8px;
display: none;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
background: none;
border: none;
color: rgba(184, 197, 212, 0.6);
cursor: pointer;
border-radius: var(--radius-full);
}
.sidebar-search-clear.visible {
display: flex;
}
.sidebar-search-clear:hover {
color: #fff;
background: rgba(255, 255, 255, 0.08);
}
.sidebar-search-clear svg {
width: 13px;
height: 13px;
}
.sidebar-search-empty {
display: none;
padding: 16px 20px;
font-size: 12.5px;
color: rgba(184, 197, 212, 0.6);
text-align: center;
}
.sidebar-search-empty.visible {
display: block;
}
/* Sidebar Navigation */ /* Sidebar Navigation */
.sidebar-nav { .sidebar-nav {
flex: 1; flex: 1;
......
/**
* Sidebar menu search — filters sidebar-menu items as the user types.
* Normalizes Arabic orthographic variants (hamza forms, ta marbuta, alef maksura,
* Arabic-Indic digits) and does approximate (typo-tolerant) matching so users
* don't need to type an exact match.
*/
(function () {
'use strict';
var ARABIC_FOLD_MAP = {
'أ': 'ا', 'إ': 'ا', 'آ': 'ا', 'ٱ': 'ا',
'ى': 'ي', 'ة': 'ه', 'ؤ': 'و', 'ئ': 'ي'
};
var DIGIT_MAP = {
'٠': '0', '١': '1', '٢': '2', '٣': '3', '٤': '4', '٥': '5', '٦': '6', '٧': '7', '٨': '8', '٩': '9',
'۰': '0', '۱': '1', '۲': '2', '۳': '3', '۴': '4', '۵': '5', '۶': '6', '۷': '7', '۸': '8', '۹': '9'
};
function stripDiacritics(str) {
return str.replace(/[ً-ٰٟۖ-ۭـ]/g, '');
}
function normalize(str) {
if (!str) return '';
str = str.toString().trim().toLowerCase();
str = stripDiacritics(str);
str = str.replace(/[أإآٱىةؤئ]/g, function (c) { return ARABIC_FOLD_MAP[c] || c; });
str = str.replace(/[٠-٩۰-۹]/g, function (c) { return DIGIT_MAP[c] || c; });
return str.replace(/\s+/g, ' ').trim();
}
function levenshtein(a, b) {
if (a === b) return 0;
if (!a.length) return b.length;
if (!b.length) return a.length;
var prev = [];
for (var j = 0; j <= b.length; j++) prev[j] = j;
for (var i = 1; i <= a.length; i++) {
var cur = [i];
for (var j2 = 1; j2 <= b.length; j2++) {
var cost = a[i - 1] === b[j2 - 1] ? 0 : 1;
cur[j2] = Math.min(prev[j2] + 1, cur[j2 - 1] + 1, prev[j2 - 1] + cost);
}
prev = cur;
}
return prev[b.length];
}
// Substring match first (fast path), then per-word approximate match
// so short typos ("ميمبر" vs "ممبر") still find the right entry.
function fuzzyMatch(query, haystack) {
if (!query) return true;
if (!haystack) return false;
if (haystack.indexOf(query) !== -1) return true;
var words = haystack.split(' ');
var tolerance = query.length <= 3 ? 0 : (query.length <= 6 ? 1 : 2);
if (tolerance === 0) return false;
for (var i = 0; i < words.length; i++) {
var w = words[i];
if (!w || Math.abs(w.length - query.length) > tolerance + 2) continue;
if (w.indexOf(query) === 0) return true;
var slice = w.length > query.length ? w.slice(0, query.length + tolerance) : w;
if (levenshtein(slice, query) <= tolerance) return true;
}
return false;
}
function initSidebarSearch() {
var input = document.getElementById('sidebar-search-input');
var clearBtn = document.getElementById('sidebar-search-clear');
var emptyMsg = document.getElementById('sidebar-search-empty');
var menu = document.querySelector('.sidebar-menu');
if (!input || !menu) return;
var items = Array.prototype.slice.call(menu.querySelectorAll(':scope > .sidebar-item'));
var sections = Array.prototype.slice.call(menu.querySelectorAll(':scope > .sidebar-section-label'));
items.forEach(function (item) {
item.__searchText = normalize(item.getAttribute('data-search') || '');
item.__submenu = item.querySelector('.sidebar-submenu');
item.__children = item.__submenu ? Array.prototype.slice.call(item.__submenu.children) : [];
item.__children.forEach(function (child) {
child.__searchText = normalize(child.getAttribute('data-search') || '');
});
item.__wasOpen = item.classList.contains('open');
});
function restoreDefaultState() {
items.forEach(function (item) {
item.style.display = '';
item.classList.toggle('open', item.__wasOpen);
item.__children.forEach(function (child) { child.style.display = ''; });
if (item.__submenu) {
item.__submenu.style.display = item.__wasOpen ? '' : 'none';
}
});
sections.forEach(function (s) { s.style.display = ''; });
}
function applyFilter(rawQuery) {
var query = normalize(rawQuery);
clearBtn.classList.toggle('visible', !!rawQuery);
if (!query) {
restoreDefaultState();
emptyMsg.classList.remove('visible');
return;
}
var anyVisible = false;
items.forEach(function (item) {
var selfMatch = fuzzyMatch(query, item.__searchText);
var childMatches = item.__children.filter(function (child) {
return fuzzyMatch(query, child.__searchText);
});
var visible = selfMatch || childMatches.length > 0;
item.style.display = visible ? '' : 'none';
if (visible) anyVisible = true;
if (item.__submenu) {
if (visible) {
item.__children.forEach(function (child) {
child.style.display = (selfMatch || childMatches.indexOf(child) !== -1) ? '' : 'none';
});
item.__submenu.style.display = '';
item.classList.add('open');
}
}
});
sections.forEach(function (section) {
var next = section.nextElementSibling;
var hasVisible = false;
while (next && !next.classList.contains('sidebar-section-label')) {
if (next.style.display !== 'none') { hasVisible = true; break; }
next = next.nextElementSibling;
}
section.style.display = hasVisible ? '' : 'none';
});
emptyMsg.classList.toggle('visible', !anyVisible);
}
var debounceTimer = null;
input.addEventListener('input', function () {
clearTimeout(debounceTimer);
var value = input.value;
debounceTimer = setTimeout(function () { applyFilter(value); }, 80);
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
input.value = '';
applyFilter('');
input.blur();
}
});
clearBtn.addEventListener('click', function () {
input.value = '';
applyFilter('');
input.focus();
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSidebarSearch);
} else {
initSidebarSearch();
}
})();
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment