little refactore
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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): ?>
|
||||
|
||||
@@ -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'] : [];
|
||||
|
||||
@@ -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>
|
||||
|
||||
66
templates/partials/app-search-dialog.phtml
Normal file
66
templates/partials/app-search-dialog.phtml
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user