1
0

little refactore

This commit is contained in:
2026-04-01 16:31:59 +02:00
parent e97d1ddd31
commit 83b65812d0
12 changed files with 942 additions and 748 deletions

View File

@@ -376,6 +376,11 @@
"Search...": "Suchen...",
"To start searching, type in the search field.": "Um die Suche zu starten, gib etwas in das Suchfeld ein.",
"Tip: Use {shortcut} to focus search quickly.": "Tipp: Mit {shortcut} fokussierst du die Suche schnell.",
"Searching...": "Suche läuft...",
"Search failed. Please try again.": "Suche fehlgeschlagen. Bitte erneut versuchen.",
"No results found": "Keine Ergebnisse gefunden",
"View all results": "Alle Ergebnisse anzeigen",
"to close": "zum Schließen",
"Created from": "Erstellt von",
"Created to": "Erstellt bis",
"Files": "Dateien",

View File

@@ -376,6 +376,11 @@
"Search...": "Search...",
"To start searching, type in the search field.": "To start searching, type in the search field.",
"Tip: Use {shortcut} to focus search quickly.": "Tip: Use {shortcut} to focus search quickly.",
"Searching...": "Searching...",
"Search failed. Please try again.": "Search failed. Please try again.",
"No results found": "No results found",
"View all results": "View all results",
"to close": "to close",
"Created from": "Created from",
"Created to": "Created to",
"Files": "Files",

View File

@@ -211,8 +211,6 @@ $componentPageConfig = [
'tenantSsoToggle' => [],
'standardDetailPage' => [],
'globalSearch' => [
'inputSelector' => '#side-search',
'resultsSelector' => '[data-global-search-results]',
'appBase' => localeBase(),
],
'tabs' => [
@@ -339,6 +337,7 @@ $componentPageConfig['moduleRuntimeComponents'] = $moduleRuntimeComponents;
<script src="<?php e(assetVersion('vendor/fslightbox/fslightbox.js')); ?>"></script>
<?php require __DIR__ . '/partials/app-confirm-dialog.phtml'; ?>
<?php if ($isLoggedIn): ?>
<?php require __DIR__ . '/partials/app-search-dialog.phtml'; ?>
<?php require __DIR__ . '/partials/app-session-warning-dialog.phtml'; ?>
<?php endif; ?>
<?php foreach ($moduleBodyEndTemplateSlots as $slot): ?>

View File

@@ -15,13 +15,6 @@ $hasAdminPanel = layoutHasAdminPanel($layoutAuth);
<i class="bi bi-house"></i>
</button>
</li>
<li>
<button type="button" id="aside-tab-search" data-aside-target="search" role="tab" aria-selected="false"
aria-controls="aside-panel-search" aria-label="<?php e(t('Search')); ?>"
data-tooltip="<?php e(t('Search')); ?>" data-tooltip-pos="right">
<i class="bi bi-search"></i>
</button>
</li>
<?php
// ── Module-contributed aside tabs (aside.tab_panel slot) ──
$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : [];

View File

@@ -279,7 +279,6 @@ $csrfKey = trim((string) ($layoutNav['csrfKey'] ?? \MintyPHP\Session::$csrfSessi
$csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : [];
$modulePanelSlots = is_array($moduleSlots['aside.tab_panel'] ?? null) ? $moduleSlots['aside.tab_panel'] : [];
$moduleSearchSlots = is_array($moduleSlots['search.resource_item'] ?? null) ? $moduleSlots['search.resource_item'] : [];
?>
<aside class="app-sidebar" id="app-sidebar" data-app-component="sidebar-toggle">
<?php if ($currentTenant): ?>
@@ -376,163 +375,5 @@ $moduleSearchSlots = is_array($moduleSlots['search.resource_item'] ?? null) ? $m
</nav>
<?php endforeach; ?>
<div id="aside-panel-search" class="app-sidebar-panel" data-app-component="global-search" data-aside-panel="search"
data-aside-title="<?php e(t('Search')); ?>" role="tabpanel" aria-labelledby="aside-tab-search" hidden>
<form class="app-search" >
<input type="search" name="side-search" id="side-search" placeholder="<?php e(t('Search...')); ?>"
aria-label="<?php e(t('Search')); ?>">
<span class="app-search-shortcut" data-search-shortcut aria-hidden="true"></span>
</form>
<div class="app-search-empty-state" data-global-search-empty>
<i class="bi bi-search" aria-hidden="true"></i>
<p><?php e(t('To start searching, type in the search field.')); ?></p>
<small class="muted" data-search-empty-hint-template="<?php e(t('Tip: Use {shortcut} to focus search quickly.')); ?>"></small>
</div>
<nav id="global-search">
<ul class="app-search-results" data-global-search-results aria-live="polite">
<?php if ($canViewUsers): ?>
<?php $usersSearch = navActive('admin/users', true); ?>
<li data-search-key="users" data-search-base="<?php e(lurl('admin/users')); ?>">
<a href="<?php e(lurl('admin/users')); ?>" class="<?php e($usersSearch['class']); ?>" <?php echo $usersSearch['aria']; ?>>
<span><?php e(t('Users')); ?></span>
<span class="badge" data-search-count>0</span>
</a>
<ul class="app-search-preview" data-search-preview></ul>
</li>
<?php endif; ?>
<?php if ($canViewTenants): ?>
<?php $tenantsSearch = navActive('admin/tenants', true); ?>
<li data-search-key="tenants" data-search-base="<?php e(lurl('admin/tenants')); ?>">
<a href="<?php e(lurl('admin/tenants')); ?>" class="<?php e($tenantsSearch['class']); ?>" <?php echo $tenantsSearch['aria']; ?>>
<span><?php e(t('Tenants')); ?></span>
<span class="badge" data-search-count>0</span>
</a>
<ul class="app-search-preview" data-search-preview></ul>
</li>
<?php endif; ?>
<?php if ($canViewDepartments): ?>
<?php $departmentsSearch = navActive('admin/departments', true); ?>
<li data-search-key="departments" data-search-base="<?php e(lurl('admin/departments')); ?>">
<a href="<?php e(lurl('admin/departments')); ?>"
class="<?php e($departmentsSearch['class']); ?>" <?php echo $departmentsSearch['aria']; ?>>
<span><?php e(t('Departments')); ?></span>
<span class="badge" data-search-count>0</span>
</a>
<ul class="app-search-preview" data-search-preview></ul>
</li>
<?php endif; ?>
<?php if ($canViewRoles): ?>
<?php $rolesSearch = navActive('admin/roles', true); ?>
<li data-search-key="roles" data-search-base="<?php e(lurl('admin/roles')); ?>">
<a href="<?php e(lurl('admin/roles')); ?>" class="<?php e($rolesSearch['class']); ?>" <?php echo $rolesSearch['aria']; ?>>
<span><?php e(t('Roles')); ?></span>
<span class="badge" data-search-count>0</span>
</a>
<ul class="app-search-preview" data-search-preview></ul>
</li>
<?php endif; ?>
<?php if ($canViewPermissions): ?>
<?php $permissionsSearch = navActive('admin/permissions', true); ?>
<li data-search-key="permissions" data-search-base="<?php e(lurl('admin/permissions')); ?>">
<a href="<?php e(lurl('admin/permissions')); ?>"
class="<?php e($permissionsSearch['class']); ?>" <?php echo $permissionsSearch['aria']; ?>>
<span><?php e(t('Permissions')); ?></span>
<span class="badge" data-search-count>0</span>
</a>
<ul class="app-search-preview" data-search-preview></ul>
</li>
<?php endif; ?>
<?php if ($canViewJobs): ?>
<?php $scheduledJobsSearch = navActive('admin/scheduled-jobs', true); ?>
<li data-search-key="scheduled-jobs" data-search-base="<?php e(lurl('admin/scheduled-jobs')); ?>">
<a href="<?php e(lurl('admin/scheduled-jobs')); ?>"
class="<?php e($scheduledJobsSearch['class']); ?>" <?php echo $scheduledJobsSearch['aria']; ?>>
<span><?php e(t('Scheduled jobs')); ?></span>
<span class="badge" data-search-count>0</span>
</a>
<ul class="app-search-preview" data-search-preview></ul>
</li>
<?php endif; ?>
<?php
$pagesSearch = navActivePublicPages();
?>
<li data-search-key="pages" data-search-base="<?php e(lurl('')); ?>">
<a href="<?php e(lurl('')); ?>" class="<?php e($pagesSearch['class']); ?>" <?php echo $pagesSearch['aria']; ?>>
<span><?php e(t('Pages')); ?></span>
<span class="badge" data-search-count>0</span>
</a>
<ul class="app-search-preview" data-search-preview></ul>
</li>
<?php if ($canViewDocs): ?>
<?php $docsSearch = navActive('admin/docs', true); ?>
<li data-search-key="docs" data-search-base="<?php e(lurl($docsDefaultPath)); ?>">
<a href="<?php e(lurl($docsDefaultPath)); ?>" class="<?php e($docsSearch['class']); ?>" <?php echo $docsSearch['aria']; ?>>
<span><?php e(t('Documentation')); ?></span>
<span class="badge" data-search-count>0</span>
</a>
<ul class="app-search-preview" data-search-preview></ul>
</li>
<?php endif; ?>
<?php $hotkeysSearch = navActive('help/hotkeys', true); ?>
<li data-search-key="hotkeys" data-search-base="<?php e(lurl('help/hotkeys')); ?>">
<a href="<?php e(lurl('help/hotkeys')); ?>" class="<?php e($hotkeysSearch['class']); ?>" <?php echo $hotkeysSearch['aria']; ?>>
<span><?php e(t('Keyboard shortcuts')); ?></span>
<span class="badge" data-search-count>0</span>
</a>
<ul class="app-search-preview" data-search-preview></ul>
</li>
<?php if ($canViewMailLog): ?>
<?php $mailLogSearch = navActive('admin/mail-log', true); ?>
<li data-search-key="mail-log" data-search-base="<?php e(lurl('admin/mail-log')); ?>">
<a href="<?php e(lurl('admin/mail-log')); ?>" class="<?php e($mailLogSearch['class']); ?>"
<?php echo $mailLogSearch['aria']; ?>>
<span><?php e(t('Mail logs')); ?></span>
<span class="badge" data-search-count>0</span>
</a>
<ul class="app-search-preview" data-search-preview></ul>
</li>
<?php endif; ?>
<?php
$coreSearchKeys = [
'users' => true,
'tenants' => true,
'departments' => true,
'roles' => true,
'permissions' => true,
'scheduled-jobs' => true,
'pages' => true,
'docs' => true,
'hotkeys' => true,
'mail-log' => true,
];
foreach ($moduleSearchSlots as $searchSlot):
if (!is_array($searchSlot)) { continue; }
$slotKey = trim((string) ($searchSlot['key'] ?? ''));
if ($slotKey === '' || isset($coreSearchKeys[$slotKey])) { continue; }
$slotPermission = trim((string) ($searchSlot['permission'] ?? ''));
if ($slotPermission !== '' && empty($layoutAuth[$slotPermission])) { continue; }
$slotLabel = trim((string) ($searchSlot['label'] ?? ''));
if ($slotLabel === '') { continue; }
$slotBase = trim((string) ($searchSlot['base_url'] ?? ''));
if ($slotBase === '') { continue; }
$slotHref = $slotBase;
$slotActive = ['class' => '', 'aria' => ''];
if (!str_starts_with($slotBase, '/') && !str_starts_with($slotBase, 'http://') && !str_starts_with($slotBase, 'https://')) {
$slotHref = lurl($slotBase);
$slotActive = navActive($slotBase, true);
}
?>
<li data-search-key="<?php e($slotKey); ?>" data-search-base="<?php e($slotHref); ?>">
<a href="<?php e($slotHref); ?>" class="<?php e($slotActive['class'] ?? ''); ?>"
<?php echo $slotActive['aria'] ?? ''; ?>>
<span><?php e(t($slotLabel)); ?></span>
<span class="badge" data-search-count>0</span>
</a>
<ul class="app-search-preview" data-search-preview></ul>
</li>
<?php endforeach; ?>
</ul>
</nav>
</div>
</div>
</aside>

View File

@@ -0,0 +1,66 @@
<?php
/**
* Command-palette search dialog.
*
* Opened via Ctrl+K / Cmd+K or topbar search trigger.
* Results are rendered dynamically by JS from the /admin/search/data API.
*/
?>
<dialog
data-app-search-dialog
data-app-component="global-search"
data-search-endpoint="<?php e(lurl('admin/search/data')); ?>"
data-search-results-page="<?php e(lurl('search')); ?>"
aria-labelledby="app-search-dialog-title"
>
<article class="app-dialog-lg app-search-dialog">
<header class="app-search-dialog-header">
<h2 id="app-search-dialog-title" class="visually-hidden"><?php e(t('Search')); ?></h2>
<form class="app-search-dialog-form" autocomplete="off">
<i class="bi bi-search app-search-dialog-icon" aria-hidden="true"></i>
<input
type="search"
name="search-dialog-input"
id="search-dialog-input"
class="app-search-dialog-input"
placeholder="<?php e(t('Search...')); ?>"
aria-label="<?php e(t('Search')); ?>"
aria-autocomplete="list"
aria-controls="app-search-dialog-results"
autocomplete="off"
>
<kbd class="app-search-dialog-shortcut" data-search-shortcut aria-hidden="true"></kbd>
</form>
<button type="button" class="close" data-app-search-dialog-close aria-label="<?php e(t('Close')); ?>" title="<?php e(t('Close')); ?>"></button>
</header>
<div class="app-search-dialog-body">
<div class="app-search-dialog-empty-state" data-search-dialog-empty data-no-results-text="<?php e(t('No results found')); ?>">
<p class="muted"><?php e(t('To start searching, type in the search field.')); ?></p>
<small class="muted" data-search-empty-hint-template="<?php e(t('Tip: Use {shortcut} to focus search quickly.')); ?>"></small>
</div>
<div class="app-search-dialog-loading" data-search-dialog-loading hidden>
<p class="muted" aria-live="polite"><?php e(t('Searching...')); ?></p>
</div>
<div class="app-search-dialog-error" data-search-dialog-error hidden>
<p class="muted"><?php e(t('Search failed. Please try again.')); ?></p>
</div>
<ul
id="app-search-dialog-results"
class="app-search-dialog-results"
data-search-dialog-results
role="listbox"
aria-label="<?php e(t('Search results')); ?>"
hidden
></ul>
</div>
<footer class="app-search-dialog-footer" data-search-dialog-footer hidden>
<a href="<?php e(lurl('search')); ?>" data-search-dialog-view-all class="app-search-dialog-view-all">
<?php e(t('View all results')); ?>
<i class="bi bi-arrow-right" aria-hidden="true"></i>
</a>
<span class="app-search-dialog-footer-hint muted">
<kbd>Esc</kbd> <?php e(t('to close')); ?>
</span>
</footer>
</article>
</dialog>

View File

@@ -38,7 +38,13 @@ $moduleTopbarSlots = is_array($moduleSlots['topbar.right_item'] ?? null) ? $modu
</li>
<li id="async-messages" role="status" aria-live="polite" aria-hidden="true"></li>
</ul>
<div class="app-topbar-center" aria-hidden="true"></div>
<div class="app-topbar-center">
<button type="button" class="app-topbar-search-trigger" data-app-search-trigger aria-label="<?php e(t('Search')); ?>" title="<?php e(t('Search')); ?>">
<i class="bi bi-search" aria-hidden="true"></i>
<span class="app-topbar-search-trigger-text"><?php e(t('Search...')); ?></span>
<kbd class="app-topbar-search-trigger-shortcut" data-search-trigger-shortcut aria-hidden="true"></kbd>
</button>
</div>
<ul class="app-topbar-right">
<?php if ($canSwitchTenant): ?>
<li class="app-topbar-tenant-slot" data-app-component="tenant-switcher" data-tenant-switcher data-tooltip="<?php e(t('Tenant')); ?>" data-tooltip-pos="bottom">

View File

@@ -32,7 +32,8 @@ class FrontendRuntimeHostContractTest extends TestCase
$this->assertStringContainsString('data-app-component="details-aside-toggle"', $topbarPartial);
$this->assertStringContainsString('data-app-component="aside-panels"', $asideIconBar);
$this->assertStringContainsString('data-app-component="sidebar-toggle"', $asidePartial);
$this->assertStringContainsString('data-app-component="global-search"', $asidePartial);
$searchDialog = $this->readProjectFile('templates/partials/app-search-dialog.phtml');
$this->assertStringContainsString('data-app-component="global-search"', $searchDialog);
$this->assertStringContainsString('data-app-component="bookmark-panel"', $bookmarkPanelTemplate);
$this->assertStringContainsString('data-app-component="bookmark-save"', $bookmarkDialog);
$this->assertStringContainsString('data-app-component="session-warning"', $sessionWarningDialog);

View File

@@ -1,64 +1,151 @@
@layer components {
/* Global sidebar search — input, result list, preview rows, and search result cards. */
.app-search {
position: relative;
padding: 0 var(--app-spacing);
margin-bottom: var(--app-spacing);
}
/*
* Search command-palette dialog.
*
* Builds on the core dialog base (app-shell.css → dialog, dialog > article)
* and the shared tier system (app-dialog.css → app-dialog-lg).
* Only search-specific overrides live here.
*/
.app-search-shortcut {
position: absolute;
right: calc(var(--app-spacing) + 0.5rem);
top: 50%;
transform: translateY(-50%);
border: 1px solid var(--app-border);
border-radius: 6px;
background: var(--app-card-background-color);
color: var(--app-muted-color);
font-size: var(--text-xs);
line-height: var(--leading-none);
padding: 0.2rem 0.4rem;
pointer-events: none;
}
/* ── Dialog positioning: top-aligned instead of centered ─────────────── */
.app-search-empty-state {
margin: var(--app-spacing);
padding: 1rem;
border: 1px dashed var(--app-border);
border-radius: var(--app-border-radius);
text-align: center;
color: var(--app-muted-color);
font-size: var(--text-sm);
cursor: text;
}
dialog[data-app-search-dialog] {
align-items: flex-start;
padding-top: clamp(2rem, 10vh, 8rem);
}
.app-search-empty-state .bi-search {
display: inline-block;
font-size: var(--text-xl);
margin-bottom: 0.5rem;
color: var(--app-color);
}
/* ── Article: flex column so body scrolls, header/footer stay fixed ──── */
.app-search-empty-state p {
margin: 0 0 0.25rem;
}
.app-search-dialog {
display: flex;
flex-direction: column;
overflow: hidden;
}
.app-search-empty-state small {
display: block;
}
/* ── Header: search input row ────────────────────────────────────────── */
ul.app-search-results {
padding: 0;
margin: 0;
}
.app-search-dialog-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
ul.app-search-results > li > a {
justify-content: space-between;
}
.app-search-dialog-header h2 {
margin: 0;
}
ul.app-search-results .badge {
.app-search-dialog-form {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
margin: 0;
}
.app-search-dialog-icon {
color: var(--app-muted-color);
font-size: var(--text-base);
flex-shrink: 0;
}
.app-search-dialog-input {
flex: 1;
border: none;
background: transparent;
font-size: var(--text-base);
color: var(--app-color);
outline: none;
padding: 0.25rem 0;
margin: 0;
height: auto;
box-shadow: none;
}
.app-search-dialog-input:focus {
box-shadow: none;
border-color: transparent;
}
.app-search-dialog-shortcut {
border: 1px solid var(--app-border);
border-radius: 4px;
background: var(--app-card-background-color);
color: var(--app-muted-color);
font-size: var(--text-xs);
line-height: var(--leading-none);
padding: 0.15rem 0.35rem;
pointer-events: none;
flex-shrink: 0;
}
.app-search-dialog-header .close {
flex-shrink: 0;
}
/* ── Body: scrollable area between header and footer ─────────────────── */
.app-search-dialog-body {
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
}
/* ── Empty / loading / error states ──────────────────────────────────── */
.app-search-dialog-empty-state,
.app-search-dialog-loading,
.app-search-dialog-error {
padding: 1.5rem 1rem;
text-align: center;
color: var(--app-muted-color);
font-size: var(--text-sm);
}
.app-search-dialog-empty-state p,
.app-search-dialog-loading p,
.app-search-dialog-error p {
margin: 0 0 0.25rem;
}
.app-search-dialog-empty-state small {
display: block;
}
/* ── Results list ────────────────────────────────────────────────────── */
.app-search-dialog-results {
list-style: none;
padding: 0;
margin: 0;
}
/* ── Category group ──────────────────────────────────────────────────── */
.app-search-dialog-category {
list-style: none;
margin-bottom: 1rem;
}
.app-search-dialog-category-heading {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: var(--text-xs);
font-weight: var(--font-semibold);
color: var(--app-muted-color);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
margin-bottom: 5px;
}
.app-search-dialog-category-heading .bi {
font-size: var(--text-sm);
}
.app-search-dialog-category-count {
margin-left: auto;
font-size: 10px; /* icon-font metric — intentional px */
padding: 1px;
padding: 1px 4px;
min-width: 18px;
text-align: center;
border-radius: 999px;
@@ -67,84 +154,100 @@ ul.app-search-results .badge {
justify-content: center;
align-items: center;
height: 18px;
position: relative;
top: -1px;
}
}
ul.app-search-preview {
position: relative;
font-size: var(--text-xs);
padding: 0 0 0 0;
margin: 0;
opacity: 0.75;
/* ── Individual result items ─────────────────────────────────────────── */
.app-search-dialog-category-items {
list-style: none;
}
padding: 0;
margin: 0;
flex-direction: column;
gap: 5px;
}
/* Hide preview rows (detail results) */
.search-details-hidden ul.app-search-preview {
display: none;
}
.app-search-dialog-item {
border-radius: var(--app-border-radius);
transition: background var(--app-transition);
cursor: pointer;
list-style: none;
}
ul.app-search-preview li {
position: relative;
padding: 0;
}
ul.app-search-preview li::before {
content: "";
position: absolute;
left: 17px;
bottom: 11px;
width: 8px;
height: 10px;
background: transparent;
border-left: 1px solid var(--app-border);
border-bottom: 1px solid var(--app-border);
border-bottom-left-radius: 6px;
}
ul.app-search-preview a {
padding-inline: 30px;
.app-search-dialog-item a {
display: block;
color: var(--app-color);
text-decoration: none;
font-size: var(--text-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
border: none;
}
border-radius: var(--app-border-radius);
}
ul.app-search-preview a:hover {
border: none;
}
.app-search-dialog-item:hover,
.app-search-dialog-item-active {
background: color-mix(in srgb, var(--app-contrast) 8%, transparent);
}
input#side-search {
--app-form-element-spacing-horizontal: 10px;
--app-form-element-spacing-vertical: 6px;
height: auto;
font-size: var(--text-sm);
padding-right: 3rem;
margin-bottom: 0;
border-radius: var(--border-radius);
}
.app-search-dialog-item-active a {
color: var(--app-primary);
}
ul.app-search-preview:has(li) {
margin-bottom: 1rem!important;
}
/* ── Category "view all" link ────────────────────────────────────────── */
.search-result-row {
.app-search-dialog-category-view-all {
font-size: var(--text-xs);
color: var(--app-primary);
text-decoration: none;
}
.app-search-dialog-category-view-all:hover {
text-decoration: underline;
}
/* ── Footer ──────────────────────────────────────────────────────────── */
.app-search-dialog-footer {
justify-content: space-between;
font-size: var(--text-xs);
color: var(--app-muted-color);
}
.app-search-dialog-view-all {
color: var(--app-primary);
text-decoration: none;
font-weight: var(--font-medium);
}
.app-search-dialog-view-all:hover {
text-decoration: underline;
}
/* ── Mobile ──────────────────────────────────────────────────────────── */
@media (max-width: 576px) {
dialog[data-app-search-dialog] {
padding-top: 1rem;
}
}
/* ── Full search results page (Grid.js) ──────────────────────────────── */
.search-result-row {
display: flex;
gap: 12px;
align-items: flex-start;
}
}
.search-result-title-container {
.search-result-title-container {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
}
.search-result-avatar,
.search-result-avatar-placeholder {
.search-result-avatar,
.search-result-avatar-placeholder {
width: 40px;
height: 40px;
border-radius: 10px;
@@ -156,23 +259,21 @@ ul.app-search-preview:has(li) {
color: var(--app-muted-color);
flex-shrink: 0;
object-fit: cover;
}
}
.search-result-title {
.search-result-title {
font-weight: var(--font-semibold);
text-decoration: none;
}
}
.search-result-desc {
.search-result-desc {
margin: 0;
color: var(--app-muted-color);
font-size: var(--text-sm);
}
}
.search-result-type {
.search-result-type {
color: var(--app-muted-color);
font-weight: var(--font-semibold);
}
}
}

View File

@@ -1,274 +1,342 @@
@layer layout {
.app-skip-link {
position: fixed;
top: calc(var(--app-spacing) * 0.5);
left: var(--app-spacing);
padding: calc(var(--app-spacing) * 0.5) var(--app-spacing);
border: 1px solid var(--app-primary);
border-radius: var(--app-border-radius);
background: var(--app-background-color);
color: var(--app-primary);
text-decoration: none;
transform: translateY(-180%);
transition: transform var(--app-transition);
z-index: 40;
}
.app-skip-link {
position: fixed;
top: calc(var(--app-spacing) * 0.5);
left: var(--app-spacing);
padding: calc(var(--app-spacing) * 0.5) var(--app-spacing);
border: 1px solid var(--app-primary);
border-radius: var(--app-border-radius);
background: var(--app-background-color);
color: var(--app-primary);
text-decoration: none;
transform: translateY(-180%);
transition: transform var(--app-transition);
z-index: 40;
}
.app-skip-link:focus,
.app-skip-link:focus-visible {
transform: translateY(0);
}
.app-skip-link:focus,
.app-skip-link:focus-visible {
transform: translateY(0);
}
.app-header {
--app-topbar-item-padding: 8px;
--app-topbar-list-gap: 8px;
--app-topbar-divider: 1px solid var(--app-border);
--app-topbar-margin-bottom: calc(var(--app-spacing) * 2);
--app-topbar-muted: var(--app-form-element-placeholder-color);
--app-topbar-bg: var(--app-background-color);
padding-block-start: 0;
padding-left: var(--app-spacing);
padding-right: var(--app-spacing);
margin-bottom: var(--app-topbar-margin-bottom);
color: var(--app-contrast);
border-bottom: var(--app-topbar-divider);
}
.app-header {
--app-topbar-item-padding: 8px;
--app-topbar-list-gap: 8px;
--app-topbar-divider: 1px solid var(--app-border);
--app-topbar-margin-bottom: calc(var(--app-spacing) * 2);
--app-topbar-muted: var(--app-form-element-placeholder-color);
--app-topbar-bg: var(--app-background-color);
padding-block-start: 0;
padding-left: var(--app-spacing);
padding-right: var(--app-spacing);
margin-bottom: var(--app-topbar-margin-bottom);
color: var(--app-contrast);
border-bottom: var(--app-topbar-divider);
}
main:has(.app-details-container) .app-header {
margin-bottom: 0;
}
main:has(.app-details-container) .app-header {
margin-bottom: 0;
}
.app-header nav.app-topbar {
display: grid;
grid-template-columns: auto 1fr auto;
gap: var(--app-spacing);
align-items: center;
}
.app-header nav.app-topbar {
display: grid;
grid-template-columns: auto 1fr auto;
gap: var(--app-spacing);
align-items: center;
}
.app-header nav li {
padding: 0;
}
.app-header nav li {
padding: 0;
}
.app-topbar-left,
.app-topbar-right {
display: flex;
gap: var(--app-topbar-list-gap);
align-items: center;
color: var(--app-muted-color);
}
.app-topbar-left,
.app-topbar-right {
display: flex;
gap: var(--app-topbar-list-gap);
align-items: center;
color: var(--app-muted-color);
}
.app-topbar-right {
justify-content: flex-end;
}
.app-topbar-right {
justify-content: flex-end;
}
.app-topbar-center {
min-height: 1px;
}
.app-topbar-center {
min-height: 1px;
display: flex;
justify-content: center;
}
.app-topbar-menu-item-detail-sidebar {
display: none;
}
.app-topbar-search-trigger {
display: inline-flex;
align-items: center;
gap: 0.5rem;
max-width: min(24rem, 50vw);
width: 100%;
padding: 0.3rem 0.65rem;
border: var(--app-border-width) solid var(--app-border);
border-radius: var(--app-border-radius);
background: transparent;
color: var(--app-muted-color);
font-size: var(--text-sm);
cursor: pointer;
transition:
border-color var(--app-transition),
color var(--app-transition);
appearance: none;
-webkit-appearance: none;
margin: 0;
}
main:has(#app-details-aside-section) .app-topbar-menu-item-detail-sidebar {
display: list-item;
}
.app-topbar-search-trigger:hover,
.app-topbar-search-trigger:focus-visible {
border-color: var(--app-primary);
}
.app-header details.dropdown > summary:not([role]) {
border: none;
height: auto;
padding: var(--app-topbar-item-padding);
border-radius: 0;
background: transparent;
}
.app-topbar-search-trigger .bi {
font-size: var(--text-sm);
flex-shrink: 0;
}
/* .app-header details.dropdown > summary:not([role]):after {
.app-topbar-search-trigger-text {
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-topbar-search-trigger-shortcut {
border: 1px solid var(--app-border);
border-radius: 4px;
background: var(--app-card-background-color);
color: var(--app-muted-color);
font-size: var(--text-xs);
line-height: var(--leading-none);
padding: 0.1rem 0.3rem;
flex-shrink: 0;
}
@media (max-width: 576px) {
.app-topbar-search-trigger-text,
.app-topbar-search-trigger-shortcut {
display: none;
}
.app-topbar-search-trigger {
width: auto;
padding: var(--app-topbar-item-padding);
border: none;
}
}
.app-topbar-menu-item-detail-sidebar {
display: none;
}
main:has(#app-details-aside-section) .app-topbar-menu-item-detail-sidebar {
display: list-item;
}
.app-header details.dropdown > summary:not([role]) {
border: none;
height: auto;
padding: var(--app-topbar-item-padding);
border-radius: 0;
background: transparent;
}
/* .app-header details.dropdown > summary:not([role]):after {
display: none;
} */
.app-topbar-left > li > a,
.app-topbar-right > li > a {
padding: var(--app-topbar-item-padding);
color: var(--app-topbar-muted);
display: block;
margin: 0;
}
.app-topbar-left button,
.app-topbar-right button {
padding: var(--app-topbar-item-padding);
color: var(--app-topbar-muted);
display: block;
margin: 0;
background: transparent;
border: none;
border-radius: 0;
}
.app-header #global-back.is-disabled,
.app-header #global-forward.is-disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
.app-topbar-tenant-chip {
display: inline-flex;
align-items: center;
gap: calc(var(--app-spacing) * 0.4);
max-width: min(26rem, 45vw);
padding: calc(var(--app-spacing) * 0.35) calc(var(--app-spacing) * 0.75);
border: var(--app-border-width) solid var(--app-border);
border-radius: 999px;
background: transparent;
color: var(--app-topbar-muted);
}
.app-topbar-tenant-menu > summary.app-topbar-tenant-chip {
cursor: pointer;
}
.app-topbar-tenant-name {
display: inline-block;
max-width: min(20rem, 38vw);
font-size: var(--text-sm);
line-height: var(--leading-tight);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-topbar-user-menu > summary {
color: var(--app-topbar-muted);
}
.app-topbar-menu-list {
min-width: min(17rem, calc(100vw - (var(--app-spacing) * 2)));
max-width: min(24rem, calc(100vw - (var(--app-spacing) * 2)));
max-height: min(32rem, calc(100vh - (var(--app-spacing) * 4)));
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
scrollbar-gutter: stable;
}
@supports (height: 100dvh) {
.app-topbar-menu-list {
max-height: min(32rem, calc(100dvh - (var(--app-spacing) * 4)));
.app-topbar-left > li > a,
.app-topbar-right > li > a {
padding: var(--app-topbar-item-padding);
color: var(--app-topbar-muted);
display: block;
margin: 0;
}
}
@media (min-width: 768px) {
.app-topbar-menu-list {
max-height: min(40rem, calc(100vh - (var(--app-spacing) * 4)));
.app-topbar-left button,
.app-topbar-right button {
padding: var(--app-topbar-item-padding);
color: var(--app-topbar-muted);
display: block;
margin: 0;
background: transparent;
border: none;
border-radius: 0;
}
}
@supports (height: 100dvh) {
@media (min-width: 768px) {
.app-topbar-menu-list {
max-height: min(40rem, calc(100dvh - (var(--app-spacing) * 4)));
}
.app-header #global-back.is-disabled,
.app-header #global-forward.is-disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
}
.app-topbar-menu-list--right {
right: 0;
left: auto;
}
/* Override global dropdown default (left:0) without using dir=rtl hacks. */
.app-header details.dropdown > summary + ul.app-topbar-menu-list--right {
right: 0;
left: auto;
inset-inline-end: 0;
inset-inline-start: auto;
}
.app-topbar-menu-list li > a,
.app-topbar-menu-list li > button {
display: block;
width: 100%;
margin: 0;
padding: 0.375rem 0.5rem;
overflow: hidden;
border: 0;
border-radius: 3px;
background: transparent;
color: var(--app-dropdown-color);
font: inherit;
line-height: inherit;
text-align: left;
text-decoration: none;
text-overflow: ellipsis;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
}
.app-topbar-menu-list li {
display: block;
}
.app-topbar-menu-list li > button:hover,
.app-topbar-menu-list li > button:focus,
.app-topbar-menu-list li > button:focus-visible {
background: color-mix(in srgb, var(--app-contrast) 8%, transparent);
}
.app-topbar-menu-list li > button.app-topbar-menu-with-hotkey {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
column-gap: calc(var(--app-spacing) * 0.6);
}
.app-topbar-hotkey {
color: var(--app-muted-color);
font-size: var(--text-xs);
white-space: nowrap;
}
.app-topbar-menu-list li.app-topbar-menu-heading {
padding: calc(var(--app-spacing) * 0.5) 0.5rem calc(var(--app-spacing) * 0.35);
color: var(--app-muted-color);
font-weight: var(--font-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
pointer-events: none;
}
.app-topbar-menu-list li.app-topbar-menu-divider {
margin: calc(var(--app-spacing) * 0.25) 0;
padding: 0;
height: 1px;
background: var(--app-border);
list-style: none;
}
.app-topbar-menu-list li.app-topbar-menu-divider + li.app-topbar-menu-heading {
margin-top: calc(var(--app-spacing) * 0.15);
}
@media (min-width: 768px) {
header.app-header {
position: sticky;
top: 0;
background: var(--app-topbar-bg);
z-index: 10;
}
}
.app-header details.dropdown[open] > summary::before {
display: none;
}
@media (max-width: 767px) {
.app-topbar-tenant-chip {
max-width: min(14rem, 55vw);
display: inline-flex;
align-items: center;
gap: calc(var(--app-spacing) * 0.4);
max-width: min(26rem, 45vw);
padding: calc(var(--app-spacing) * 0.35) calc(var(--app-spacing) * 0.75);
border: var(--app-border-width) solid var(--app-border);
border-radius: 999px;
background: transparent;
color: var(--app-topbar-muted);
}
.app-topbar-tenant-menu > summary.app-topbar-tenant-chip {
cursor: pointer;
}
.app-topbar-tenant-name {
max-width: min(11rem, 45vw);
display: inline-block;
max-width: min(20rem, 38vw);
font-size: var(--text-sm);
line-height: var(--leading-tight);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-topbar-user-menu > summary {
color: var(--app-topbar-muted);
}
.app-topbar-menu-list {
min-width: min(17rem, calc(100vw - (var(--app-spacing) * 2)));
max-width: min(24rem, calc(100vw - (var(--app-spacing) * 2)));
max-height: min(32rem, calc(100vh - (var(--app-spacing) * 4)));
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
scrollbar-gutter: stable;
}
@supports (height: 100dvh) {
.app-topbar-menu-list {
max-height: min(32rem, calc(100dvh - (var(--app-spacing) * 4)));
}
}
@media (min-width: 768px) {
.app-topbar-menu-list {
max-height: min(40rem, calc(100vh - (var(--app-spacing) * 4)));
}
}
@supports (height: 100dvh) {
@media (min-width: 768px) {
.app-topbar-menu-list {
max-height: min(40rem, calc(100dvh - (var(--app-spacing) * 4)));
}
}
}
.app-topbar-menu-list--right {
right: 0;
left: auto;
}
/* Override global dropdown default (left:0) without using dir=rtl hacks. */
.app-header details.dropdown > summary + ul.app-topbar-menu-list--right {
right: 0;
left: auto;
inset-inline-end: 0;
inset-inline-start: auto;
}
.app-topbar-menu-list li > a,
.app-topbar-menu-list li > button {
display: block;
width: 100%;
margin: 0;
padding: 0.375rem 0.5rem;
overflow: hidden;
border: 0;
border-radius: 3px;
background: transparent;
color: var(--app-dropdown-color);
font: inherit;
line-height: inherit;
text-align: left;
text-decoration: none;
text-overflow: ellipsis;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
}
.app-topbar-menu-list li {
display: block;
}
.app-topbar-menu-list li > button:hover,
.app-topbar-menu-list li > button:focus,
.app-topbar-menu-list li > button:focus-visible {
background: color-mix(in srgb, var(--app-contrast) 8%, transparent);
}
.app-topbar-menu-list li > button.app-topbar-menu-with-hotkey {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
column-gap: calc(var(--app-spacing) * 0.6);
}
.app-topbar-hotkey {
color: var(--app-muted-color);
font-size: var(--text-xs);
white-space: nowrap;
}
.app-topbar-menu-list li.app-topbar-menu-heading {
padding: calc(var(--app-spacing) * 0.5) 0.5rem
calc(var(--app-spacing) * 0.35);
color: var(--app-muted-color);
font-weight: var(--font-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
pointer-events: none;
}
.app-topbar-menu-list li.app-topbar-menu-divider {
margin: calc(var(--app-spacing) * 0.25) 0;
padding: 0;
height: 1px;
background: var(--app-border);
list-style: none;
}
.app-topbar-menu-list
li.app-topbar-menu-divider
+ li.app-topbar-menu-heading {
margin-top: calc(var(--app-spacing) * 0.15);
}
@media (min-width: 768px) {
header.app-header {
position: sticky;
top: 0;
background: var(--app-topbar-bg);
z-index: 10;
}
}
.app-header details.dropdown[open] > summary::before {
display: none;
}
@media (max-width: 767px) {
.app-topbar-tenant-chip {
max-width: min(14rem, 55vw);
}
.app-topbar-tenant-name {
max-width: min(11rem, 45vw);
}
}
}
}

View File

@@ -244,7 +244,7 @@ const bootRuntime = async () => {
// Phase C: high-risk (search, side panel interactions).
runtime.register('global-search', initGlobalSearch, {
selector: '[data-app-component="global-search"]',
selector: '[data-app-search-dialog]',
configPath: 'components.globalSearch',
});

View File

@@ -1,218 +1,324 @@
/**
* Global search sidebar — debounced fetch with live result counts and Ctrl+K shortcut.
* Global search command-palette dialog.
*
* Opens via Ctrl+K / Cmd+K or topbar trigger. Fetches live results from the
* preview API and renders them grouped by category with keyboard navigation.
*/
import { warnOnce, resolveHost } from '../core/app-dom.js';
import { isEditableTarget } from '../core/app-form-utils.js';
import { requestAsidePanelOpen } from '../core/app-ui-channels.js';
const ICON_MAP = {
'users': 'bi-person-badge',
'tenants': 'bi-buildings',
'departments': 'bi-diagram-3',
'roles': 'bi-people',
'permissions': 'bi-shield-lock',
'scheduled-jobs': 'bi-calendar-check',
'pages': 'bi-file-text',
'mail-log': 'bi-envelope-paper',
'docs': 'bi-journal-text',
'hotkeys': 'bi-keyboard',
'contacts': 'bi-person-lines-fill',
'bookmarks': 'bi-bookmark',
'api-audit': 'bi-journal-code',
'system-audit': 'bi-journal-check',
};
export function initGlobalSearch(root = document, options = {}) {
const host = resolveHost(root);
const inputSelector = String(options.inputSelector || '#side-search').trim() || '#side-search';
const resultsSelector = String(options.resultsSelector || '[data-global-search-results]').trim() || '[data-global-search-results]';
const dialog = host.matches?.('[data-app-search-dialog]') ? host : host.querySelector('[data-app-search-dialog]');
if (!dialog) {return { destroy: () => {} };}
const input = host.matches?.(inputSelector) ? host : host.querySelector(inputSelector);
const resultsEl = host.matches?.(resultsSelector) ? host : host.querySelector(resultsSelector);
if (!input || !resultsEl) {return { destroy: () => {} };}
if (input.dataset.globalSearchBound === '1' && input._globalSearchApi) {
return input._globalSearchApi;
if (dialog.dataset.globalSearchBound === '1' && dialog._globalSearchApi) {
return dialog._globalSearchApi;
}
input.dataset.globalSearchBound = '1';
dialog.dataset.globalSearchBound = '1';
const input = dialog.querySelector('#search-dialog-input');
const resultsEl = dialog.querySelector('[data-search-dialog-results]');
const emptyStateEl = dialog.querySelector('[data-search-dialog-empty]');
const loadingEl = dialog.querySelector('[data-search-dialog-loading]');
const errorEl = dialog.querySelector('[data-search-dialog-error]');
const footerEl = dialog.querySelector('[data-search-dialog-footer]');
const viewAllLink = dialog.querySelector('[data-search-dialog-view-all]');
const closeButton = dialog.querySelector('[data-app-search-dialog-close]');
const shortcutEl = dialog.querySelector('[data-search-shortcut]');
const emptyHintEl = dialog.querySelector('[data-search-empty-hint-template]');
const triggerButton = document.querySelector('[data-app-search-trigger]');
const triggerShortcutEl = document.querySelector('[data-search-trigger-shortcut]');
const emptyStateEl = host.querySelector('[data-global-search-empty]');
const emptyHintEl = host.querySelector('[data-search-empty-hint-template]');
const shortcutEl = host.querySelector('[data-search-shortcut]');
const panel = host.matches?.('#aside-panel-search') ? host : host.querySelector('#aside-panel-search');
const toggleButton = host.querySelector('[data-search-details-toggle]');
const toggleIcon = host.querySelector('[data-search-details-icon]');
const appBase = String(options.appBase || document.baseURI || window.location.origin).trim() || document.baseURI;
const endpoint = new URL('admin/search/data', appBase);
const resultsPage = new URL('search', appBase);
const endpointAttr = dialog.getAttribute('data-search-endpoint') || 'admin/search/data';
const resultsPageAttr = dialog.getAttribute('data-search-results-page') || 'search';
const endpoint = new URL(endpointAttr, appBase);
const resultsPage = new URL(resultsPageAttr, appBase);
const minLength = 2;
let pending = null;
let activeIndex = -1;
let flatItems = [];
const shortcutLabel = /(Mac|iPhone|iPad|iPod)/i.test(navigator.platform || navigator.userAgent)
? '⌘K'
: 'Ctrl+K';
if (shortcutEl) {shortcutEl.textContent = shortcutLabel;}
if (triggerShortcutEl) {triggerShortcutEl.textContent = shortcutLabel;}
if (emptyHintEl) {
const template = emptyHintEl.getAttribute('data-search-empty-hint-template') || '';
emptyHintEl.textContent = template.replace('{shortcut}', shortcutLabel);
}
if (shortcutEl) {
shortcutEl.textContent = shortcutLabel;
}
const itemsByKey = new Map();
resultsEl.querySelectorAll('[data-search-key]').forEach((item) => {
const key = item.getAttribute('data-search-key');
if (key) {itemsByKey.set(key, item);}
});
if (itemsByKey.size === 0) {
warnOnce('UI_EL_MISSING', 'No [data-search-key] elements found in results container', { module: 'global-search' });
}
// --- State management ---
const setLoadingState = (value) => {
resultsEl.querySelectorAll('[data-search-count]').forEach((el) => {
el.textContent = value;
const setState = (state) => {
if (emptyStateEl) {emptyStateEl.hidden = state !== 'empty';}
if (loadingEl) {loadingEl.hidden = state !== 'loading';}
if (errorEl) {errorEl.hidden = state !== 'error';}
if (resultsEl) {resultsEl.hidden = state !== 'success';}
if (footerEl) {footerEl.hidden = state !== 'success';}
};
const clearHighlight = () => {
activeIndex = -1;
resultsEl.querySelectorAll('[role="option"]').forEach((el) => {
el.removeAttribute('aria-selected');
el.classList.remove('app-search-dialog-item-active');
});
if (input) {input.removeAttribute('aria-activedescendant');}
};
const setIdleState = (idle) => {
if (emptyStateEl) {
emptyStateEl.hidden = !idle;
}
resultsEl.hidden = idle;
const setHighlight = (index) => {
clearHighlight();
if (index < 0 || index >= flatItems.length) {return;}
activeIndex = index;
const item = flatItems[index];
item.setAttribute('aria-selected', 'true');
item.classList.add('app-search-dialog-item-active');
item.scrollIntoView({ block: 'nearest' });
if (input) {input.setAttribute('aria-activedescendant', item.id);}
};
const render = (items, query) => {
const renderedKeys = new Set();
items.forEach((item) => {
const li = itemsByKey.get(item.key);
if (!li) {
warnOnce('UI_EL_MISSING', `No element found for key: ${item.key}`, { module: 'global-search' });
return;
// --- Rendering ---
const iconForKey = (key) => ICON_MAP[key] || 'bi-search';
const render = (categories, query) => {
resultsEl.innerHTML = '';
flatItems = [];
let itemIndex = 0;
let hasResults = false;
categories.forEach((category) => {
if (category.count === 0 && (!category.items || category.items.length === 0)) {return;}
hasResults = true;
const group = document.createElement('li');
group.setAttribute('role', 'presentation');
group.classList.add('app-search-dialog-category');
const heading = document.createElement('div');
heading.classList.add('app-search-dialog-category-heading');
heading.innerHTML = `<i class="bi ${iconForKey(category.key)}" aria-hidden="true"></i> <span>${escapeHtml(category.label || category.key)}</span> <span class="app-search-dialog-category-count">${category.count}</span>`;
group.appendChild(heading);
if (Array.isArray(category.items) && category.items.length > 0) {
const list = document.createElement('ul');
list.classList.add('app-search-dialog-category-items');
list.setAttribute('role', 'group');
category.items.forEach((item) => {
if (!item || !item.url) {return;}
const li = document.createElement('li');
li.setAttribute('role', 'option');
li.setAttribute('id', `search-item-${itemIndex}`);
li.classList.add('app-search-dialog-item');
li.dataset.searchUrl = item.url;
const link = document.createElement('a');
link.href = item.url;
link.textContent = item.label || '';
link.tabIndex = -1;
li.appendChild(link);
list.appendChild(li);
flatItems.push(li);
itemIndex++;
});
group.appendChild(list);
}
renderedKeys.add(item.key);
const countEl = li.querySelector('[data-search-count]');
const link = li.querySelector('a');
const previewEl = li.querySelector('[data-search-preview]');
const base = item.key === 'pages' && item.url ? item.url : (li.getAttribute('data-search-base') || item.url);
const url = new URL(base, appBase);
if (item.key !== 'pages') {
if (category.url && category.count > 0) {
const viewCategoryLink = document.createElement('a');
viewCategoryLink.classList.add('app-search-dialog-category-view-all');
viewCategoryLink.href = category.url;
viewCategoryLink.innerHTML = `${escapeHtml(category.label || category.key)} <i class="bi bi-arrow-right" aria-hidden="true"></i>`;
group.appendChild(viewCategoryLink);
}
resultsEl.appendChild(group);
});
if (hasResults) {
setState('success');
if (viewAllLink) {
const url = new URL(resultsPage.toString());
url.searchParams.set('search', query);
viewAllLink.href = url.toString();
}
if (countEl) {countEl.textContent = item.count.toString();}
if (link) {
link.href = item.count > 0 ? url.toString() : base;
link.setAttribute('aria-disabled', item.count > 0 ? 'false' : 'true');
}
if (previewEl) {
previewEl.innerHTML = '';
if (Array.isArray(item.items) && item.items.length > 0) {
item.items.forEach((previewItem, idx) => {
if (!previewItem || !previewItem.url) {
warnOnce('UI_DATA_MISSING', `Preview item missing url at index ${idx} for key: ${item.key}`, {
module: 'global-search',
item: previewItem
});
return;
}
const previewLi = document.createElement('li');
const previewLink = document.createElement('a');
previewLink.href = previewItem.url;
previewLink.textContent = previewItem.label || '';
previewLi.appendChild(previewLink);
previewEl.appendChild(previewLi);
});
} else {
setState('empty');
if (emptyStateEl) {
const p = emptyStateEl.querySelector('p');
if (p) {
p.textContent = emptyStateEl.dataset.noResultsText || '';
}
}
if (item.count > 0) {
li.hidden = false;
} else {
li.hidden = true;
}
});
itemsByKey.forEach((li, key) => {
if (renderedKeys.has(key)) {
return;
}
const countEl = li.querySelector('[data-search-count]');
const link = li.querySelector('a');
const previewEl = li.querySelector('[data-search-preview]');
const base = li.getAttribute('data-search-base') || '#';
if (countEl) {countEl.textContent = '0';}
if (link) {
link.href = base;
link.setAttribute('aria-disabled', 'true');
}
if (previewEl) {
previewEl.innerHTML = '';
}
li.hidden = true;
});
}
clearHighlight();
};
const clear = () => {
setIdleState(true);
setLoadingState('0');
resultsEl.querySelectorAll('a').forEach((link) => {
link.setAttribute('aria-disabled', 'true');
const base = link.closest('[data-search-base]')?.getAttribute('data-search-base');
link.setAttribute('href', base || '#');
});
resultsEl.querySelectorAll('[data-search-preview]').forEach((list) => {
list.innerHTML = '';
});
resultsEl.querySelectorAll('[data-search-key]').forEach((li) => {
li.hidden = false;
});
};
// --- Fetch ---
const fetchCounts = async (value) => {
setIdleState(false);
const fetchResults = async (value) => {
setState('loading');
const url = new URL(endpoint.toString());
url.searchParams.set('q', value);
setLoadingState('…');
const response = await fetch(url.toString(), { headers: { 'Accept': 'application/json' } });
if (!response.ok) {
warnOnce('FETCH_FAILED', `Global search failed: ${response.status} ${response.statusText}`, {
module: 'global-search'
});
clear();
return;
try {
const response = await fetch(url.toString(), { headers: { 'Accept': 'application/json' } });
if (!response.ok) {
warnOnce('FETCH_FAILED', `Global search failed: ${response.status} ${response.statusText}`, { module: 'global-search' });
setState('error');
return;
}
const data = await response.json();
if (Array.isArray(data)) {
render(data, value);
} else {
warnOnce('FETCH_INVALID', 'Invalid response format for global search', { module: 'global-search', type: typeof data });
setState('error');
}
} catch (err) {
warnOnce('FETCH_ERROR', 'Global search fetch error', { module: 'global-search', err });
setState('error');
}
const data = await response.json();
if (Array.isArray(data)) {
render(data, value);
} else {
warnOnce('FETCH_INVALID', `Invalid response format for global search`, {
module: 'global-search',
type: typeof data
});
clear();
};
// --- Dialog open/close ---
const openDialog = () => {
if (dialog.open) {return;}
dialog.showModal();
setState('empty');
if (emptyStateEl) {
const p = emptyStateEl.querySelector('p');
if (p) {
p.textContent = emptyHintEl
? ''
: (dialog.dataset.emptyText || '');
}
}
resetEmptyState();
window.requestAnimationFrame(() => {
if (input) {
input.focus();
input.select();
}
});
// If input already has a value, trigger search
const currentValue = input ? input.value.trim() : '';
if (currentValue.length >= minLength) {
fetchResults(currentValue);
}
};
const closeDialog = () => {
if (!dialog.open) {return;}
dialog.close();
clearHighlight();
};
const resetEmptyState = () => {
if (!emptyStateEl) {return;}
const p = emptyStateEl.querySelector('p');
if (p) {
p.textContent = p.dataset.originalText || '';
}
};
// Store original text for empty state
if (emptyStateEl) {
const p = emptyStateEl.querySelector('p');
if (p) {p.dataset.originalText = p.textContent;}
}
// --- Navigation ---
const navigateToItem = (index) => {
if (index < 0 || index >= flatItems.length) {return;}
const item = flatItems[index];
const url = item.dataset.searchUrl || item.querySelector('a')?.href;
if (url) {
closeDialog();
window.location.href = url;
}
};
const submitSearch = () => {
const value = input.value.trim();
const value = input ? input.value.trim() : '';
if (value.length < minLength) {return;}
resultsPage.searchParams.set('search', value);
window.location.href = resultsPage.toString();
const url = new URL(resultsPage.toString());
url.searchParams.set('search', value);
closeDialog();
window.location.href = url.toString();
};
const focusSearch = () => {
window.requestAnimationFrame(() => {
input.focus();
input.select();
});
};
const openSearch = () => {
requestAsidePanelOpen('search', { fromUser: true });
focusSearch();
};
// --- Event handlers (named for cleanup) ---
// --- Event handlers ---
const onInput = () => {
const value = input.value.trim();
if (value.length < minLength) {
clear();
setState('empty');
resetEmptyState();
resultsEl.innerHTML = '';
flatItems = [];
clearHighlight();
if (footerEl) {footerEl.hidden = true;}
return;
}
if (pending) {window.clearTimeout(pending);}
pending = window.setTimeout(() => {
fetchCounts(value).catch((err) => {
fetchResults(value).catch((err) => {
warnOnce('FETCH_ERROR', 'Global search fetch error', { module: 'global-search', err });
clear();
setState('error');
});
}, 300);
};
const onInputKeydown = (event) => {
if (event.key !== 'Enter') {return;}
event.preventDefault();
submitSearch();
if (event.key === 'ArrowDown') {
event.preventDefault();
if (flatItems.length === 0) {return;}
setHighlight(activeIndex < flatItems.length - 1 ? activeIndex + 1 : 0);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
if (flatItems.length === 0) {return;}
setHighlight(activeIndex > 0 ? activeIndex - 1 : flatItems.length - 1);
return;
}
if (event.key === 'Enter') {
event.preventDefault();
if (activeIndex >= 0) {
navigateToItem(activeIndex);
} else {
submitSearch();
}
return;
}
};
const onDocumentKeydown = (event) => {
@@ -221,83 +327,86 @@ export function initGlobalSearch(root = document, options = {}) {
if (key !== 'k' && key !== '/') {return;}
if (isEditableTarget(event.target) && event.target !== input) {return;}
event.preventDefault();
openSearch();
openDialog();
};
const onEmptyStateClick = () => {
focusSearch();
const onCloseClick = () => {
closeDialog();
};
const onTriggerClick = () => {
openDialog();
};
const onDialogClick = (event) => {
// Close on backdrop click (click on <dialog> itself, not its children)
if (event.target === dialog) {
closeDialog();
}
};
const onResultClick = (event) => {
const item = event.target.closest('[role="option"]');
if (!item) {return;}
const link = item.querySelector('a');
if (link && event.target !== link) {
closeDialog();
window.location.href = link.href;
event.preventDefault();
} else if (link) {
closeDialog();
}
};
const onFormSubmit = (event) => {
event.preventDefault();
submitSearch();
};
const onToggleClick = () => {
panel.classList.toggle('search-details-hidden');
if (toggleIcon) {
toggleIcon.classList.toggle('bi-dash-square');
toggleIcon.classList.toggle('bi-plus-square');
if (activeIndex >= 0) {
navigateToItem(activeIndex);
} else {
submitSearch();
}
};
// --- Bind listeners ---
input.addEventListener('input', onInput);
input.addEventListener('keydown', onInputKeydown);
if (input) {
input.addEventListener('input', onInput);
input.addEventListener('keydown', onInputKeydown);
}
document.addEventListener('keydown', onDocumentKeydown);
if (closeButton) {closeButton.addEventListener('click', onCloseClick);}
if (triggerButton) {triggerButton.addEventListener('click', onTriggerClick);}
dialog.addEventListener('click', onDialogClick);
if (resultsEl) {resultsEl.addEventListener('click', onResultClick);}
if (emptyStateEl) {
emptyStateEl.addEventListener('click', onEmptyStateClick);
}
const form = input.closest('form');
if (form) {
form.addEventListener('submit', onFormSubmit);
}
if (toggleButton && panel instanceof HTMLElement) {
toggleButton.addEventListener('click', onToggleClick);
}
// Seed from current URL search param if present.
const currentUrl = new URL(window.location.href);
const preset = (currentUrl.searchParams.get('search') || '').trim();
if (preset && !input.value) {
input.value = preset;
}
const initialValue = input.value.trim();
if (initialValue.length >= minLength) {
fetchCounts(initialValue).catch((err) => {
warnOnce('FETCH_ERROR', 'Global search fetch error', { module: 'global-search', err });
clear();
});
} else {
clear();
}
const form = input ? input.closest('form') : null;
if (form) {form.addEventListener('submit', onFormSubmit);}
// --- Cleanup ---
const destroy = () => {
if (pending) {window.clearTimeout(pending);}
input.removeEventListener('input', onInput);
input.removeEventListener('keydown', onInputKeydown);
if (input) {
input.removeEventListener('input', onInput);
input.removeEventListener('keydown', onInputKeydown);
}
document.removeEventListener('keydown', onDocumentKeydown);
if (emptyStateEl) {
emptyStateEl.removeEventListener('click', onEmptyStateClick);
}
if (form) {
form.removeEventListener('submit', onFormSubmit);
}
if (toggleButton && panel instanceof HTMLElement) {
toggleButton.removeEventListener('click', onToggleClick);
}
delete input.dataset.globalSearchBound;
delete input._globalSearchApi;
if (closeButton) {closeButton.removeEventListener('click', onCloseClick);}
if (triggerButton) {triggerButton.removeEventListener('click', onTriggerClick);}
dialog.removeEventListener('click', onDialogClick);
if (resultsEl) {resultsEl.removeEventListener('click', onResultClick);}
if (form) {form.removeEventListener('submit', onFormSubmit);}
delete dialog.dataset.globalSearchBound;
delete dialog._globalSearchApi;
};
const api = { destroy, openSearch, focusSearch };
input._globalSearchApi = api;
const api = { destroy, openDialog, closeDialog };
dialog._globalSearchApi = api;
return api;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.appendChild(document.createTextNode(text));
return div.innerHTML;
}