diff --git a/CLAUDE.md b/CLAUDE.md index b3404c0..f9e7fe3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,6 +175,12 @@ docker/ # Dockerfiles, Nginx configs, PHP configs - **Server:** endpoint calls `exportRequireGetRequest()` + `exportCapLimit()` + `exportSendCsv()` from `core/Support/helpers/export.php`, builds columns as `ExportColumn[]` (`core/Service/Export/CsvExportService.php`), and reuses the SAME `filter-schema.php` via `gridParseFiltersFromSchemaFile()` as the data endpoint. Extract a shared service or presenter if row-formatting is non-trivial (see `DomainListService` / `SystemAuditRowPresenter`). - **View:** `require templatePath('partials/app-list-export-dropdown.phtml');` inside `$listTitleActionsHtml`. Pass the URL via `'exportUrl' => endpointUrl('')` in the page config — **always `endpointUrl()`, never plain `lurl()`**. The helper resolves module routes to their target path so query strings survive. - **Client:** `initListExport({ gridConfig, exportUrl })` from `/js/core/app-list-export.js`. No hand-rolled click listener, no inline URL construction. +- **Detail drawer**: Row-level detail views that slide in from the right use the core primitive — no per-module drawer implementation. + - **Server:** Action `/-fragment($id).php` + view `/-fragment(none).phtml`. The `(none)` template renders without layout/topbar/breadcrumbs. Reuse the same partial that the full-page view uses (extract a `modules//templates/-profile.phtml` if needed). Must enforce auth + scope exactly like the full-page view. + - **Route:** Declare `['path' => '/-fragment', 'target' => '/-fragment']` in the module manifest. + - **Client:** `initDetailDrawer({ gridConfig, fetchUrl: (uuid) => new URL('/-fragment/' + uuid, appBase).toString(), fullUrl, hashPrefix, rowProvider? })` from `/js/components/app-detail-drawer.js`. The drawer handles focus-trap, body-scroll-lock, session-expiry reload, and auto-initializes tabs/tooltips/fslightbox in the fragment via `initFragmentContent()`. Consumers do NOT need to call `initTabs`, `refreshFsLightbox`, etc. manually. + - **Architecture test** `DetailDrawerFragmentContractTest` enforces that every `initDetailDrawer({ fetchUrl })` has matching `*-fragment($id).php` + `*-fragment(none).phtml`. + - **Filename rule:** URL parameters go only into the `.php` action filename (`*-fragment($id).php`). The `(none).phtml` view filename must NOT contain `($id)` — MintyPHP's router parses paren groups and breaks silently on nested ones. ### Never Do This diff --git a/modules/addressbook/i18n/default_de.json b/modules/addressbook/i18n/default_de.json index 38279d5..0a49460 100644 --- a/modules/addressbook/i18n/default_de.json +++ b/modules/addressbook/i18n/default_de.json @@ -1,12 +1,20 @@ { "About": "Über", "Address book": "Adressbuch", + "Call": "Anrufen", "Call mobile": "Mobil anrufen", "Call phone": "Telefon anrufen", + "Failed to load": "Laden fehlgeschlagen", "From": "Von", + "Loading": "Lädt", + "More": "Mehr", "No departments": "Keine Abteilungen", + "Open details": "Details öffnen", + "Open full page": "Vollseite öffnen", "Quick actions": "Schnellaktionen", "Send email": "E-Mail senden", + "Tenant / Department": "Mandant / Abteilung", "To": "Bis", - "View in address book": "Im Adressbuch ansehen" + "View in address book": "Im Adressbuch ansehen", + "further assignments": "weitere Zuordnungen" } diff --git a/modules/addressbook/i18n/default_en.json b/modules/addressbook/i18n/default_en.json index 10dc3a5..91efc15 100644 --- a/modules/addressbook/i18n/default_en.json +++ b/modules/addressbook/i18n/default_en.json @@ -1,12 +1,20 @@ { "About": "About", "Address book": "Address book", + "Call": "Call", "Call mobile": "Call mobile", "Call phone": "Call phone", + "Failed to load": "Failed to load", "From": "From", + "Loading": "Loading", + "More": "More", "No departments": "No departments", + "Open details": "Open details", + "Open full page": "Open full page", "Quick actions": "Quick actions", "Send email": "Send email", + "Tenant / Department": "Tenant / Department", "To": "To", - "View in address book": "View in address book" + "View in address book": "View in address book", + "further assignments": "further assignments" } diff --git a/modules/addressbook/lib/Module/AddressBook/Service/AddressBookService.php b/modules/addressbook/lib/Module/AddressBook/Service/AddressBookService.php index 89965e1..14d0c38 100644 --- a/modules/addressbook/lib/Module/AddressBook/Service/AddressBookService.php +++ b/modules/addressbook/lib/Module/AddressBook/Service/AddressBookService.php @@ -135,11 +135,29 @@ class AddressBookService ); } + $activeTenant = trim((string) ($query['tenant'] ?? '')); + + $tenantTabs = array_values(array_filter(array_map( + static function (array $tenant): array { + return [ + 'uuid' => (string) ($tenant['uuid'] ?? ''), + 'description' => (string) ($tenant['description'] ?? ''), + ]; + }, + $tenants + ), static fn (array $tab): bool => $tab['uuid'] !== '')); + usort($tenantTabs, static fn (array $a, array $b): int => strnatcasecmp( + (string) $a['description'], + (string) $b['description'] + )); + return [ 'activeTenants' => $activeTenants, 'activeRoles' => $activeRoles, 'activeDepartments' => $activeDepartments, + 'activeTenant' => $activeTenant, 'tenantItems' => $tenantItems, + 'tenantTabs' => $tenantTabs, 'departments' => $departments, 'roles' => $roles, 'customFieldFilterDefinitions' => $customFieldFilterDefinitions, @@ -191,6 +209,10 @@ class AddressBookService $displayName = (string) ($row['email'] ?? ''); } + $primaryTenantLabel = $tenantList[0] ?? ''; + $primaryDepartmentLabel = $departmentList[0] ?? ''; + $extraAssignmentsCount = max(0, count($tenantList) - 1) + max(0, count($departmentList) - 1); + $rows[] = [ 'uuid' => $uuid, 'display_name' => $displayName, @@ -203,6 +225,9 @@ class AddressBookService 'tenants' => $tenantList, 'departments' => $departmentList, 'roles' => $roleList, + 'primary_tenant_label' => $primaryTenantLabel, + 'primary_department_label' => $primaryDepartmentLabel, + 'extra_assignments_count' => $extraAssignmentsCount, 'has_avatar' => $uuid !== '' && $this->avatarService->hasAvatar($uuid), ]; } diff --git a/modules/addressbook/module.php b/modules/addressbook/module.php index b4974e1..df65cb9 100644 --- a/modules/addressbook/module.php +++ b/modules/addressbook/module.php @@ -17,6 +17,7 @@ return [ // Keep canonical route stable without forcing a /index target to avoid Router redirect loops. ['path' => 'address-book', 'target' => 'address-book'], ['path' => 'address-book/data', 'target' => 'address-book/data'], + ['path' => 'address-book/view-fragment', 'target' => 'address-book/view-fragment'], // Legacy aliases for existing bookmarks/links. ['path' => 'addressbook', 'target' => 'address-book'], ['path' => 'adressbook', 'target' => 'address-book'], @@ -70,6 +71,9 @@ return [ 'modules/addressbook/css/pages/address-book-view.css', 'modules/addressbook/css/pages/address-book-banner.css', ], + 'address-book-index' => [ + 'modules/addressbook/css/pages/address-book-index.css', + ], ], 'scheduler_jobs' => [], diff --git a/modules/addressbook/pages/address-book/filter-schema.php b/modules/addressbook/pages/address-book/filter-schema.php index a38d5df..7aacf56 100644 --- a/modules/addressbook/pages/address-book/filter-schema.php +++ b/modules/addressbook/pages/address-book/filter-schema.php @@ -13,6 +13,12 @@ return gridFilterSchema([ 'roles' => ['type' => 'csv_strings', 'max' => 200], ], 'toolbar' => [ + [ + 'key' => 'tenant', + 'type' => 'hidden', + 'input_id' => 'address-book-tenant-filter-single', + 'default' => '', + ], [ 'key' => 'search', 'type' => 'text', diff --git a/modules/addressbook/pages/address-book/index().php b/modules/addressbook/pages/address-book/index().php index 08ec1bd..7fb5eb0 100644 --- a/modules/addressbook/pages/address-book/index().php +++ b/modules/addressbook/pages/address-book/index().php @@ -19,6 +19,8 @@ $activeTenants = $context['activeTenants'] ?? []; $activeRoles = $context['activeRoles'] ?? []; $activeDepartments = $context['activeDepartments'] ?? []; $tenantItems = $context['tenantItems'] ?? []; +$tenantTabs = $context['tenantTabs'] ?? []; +$activeTenant = (string) ($context['activeTenant'] ?? ''); $departments = $context['departments'] ?? []; $roles = $context['roles'] ?? []; $customFieldFilterDefinitions = $context['customFieldFilterDefinitions'] ?? []; @@ -30,6 +32,7 @@ $toolbarOptionSets = [ 'role_items' => (array) $roles, ]; $toolbarFilterStateOverrides = [ + 'tenant' => $activeTenant, 'search' => (string) gridQueryString($query, 'search', ''), 'tenants' => array_map('strval', (array) $activeTenants), 'departments' => array_map('strval', (array) $activeDepartments), @@ -81,6 +84,7 @@ $breadcrumbs = [ ['label' => t('Address book')], ]; Buffer::set('grid_lang', json_encode(gridLang(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); +Buffer::set('style_groups', json_encode(['address-book-index'])); Buffer::set( 'grid_csrf', json_encode(['key' => $csrfKey, 'token' => $csrfToken], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) diff --git a/modules/addressbook/pages/address-book/index(default).phtml b/modules/addressbook/pages/address-book/index(default).phtml index 916af0f..a5499f7 100644 --- a/modules/addressbook/pages/address-book/index(default).phtml +++ b/modules/addressbook/pages/address-book/index(default).phtml @@ -14,12 +14,39 @@ $toolbarOptionSets = is_array($toolbarOptionSets ?? null) ? $toolbarOptionSets : $clientFilterSchema = is_array($clientFilterSchema ?? null) ? $clientFilterSchema : []; $filterChipMeta = is_array($filterChipMeta ?? null) ? $filterChipMeta : []; $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null; +$tenantTabs = is_array($tenantTabs ?? null) ? $tenantTabs : []; +$activeTenant = (string) ($activeTenant ?? ''); +$showTenantTabs = count($tenantTabs) > 1; ?> + + 'address-book', + 'label' => t('All'), + 'active' => $activeTenant === '', + ], + ]; + foreach ($tenantTabs as $tenantTab) { + $tenantUuid = (string) ($tenantTab['uuid'] ?? ''); + if ($tenantUuid === '') { + continue; + } + $listTabsItems[] = [ + 'href' => 'address-book?tenant=' . rawurlencode($tenantUuid), + 'label' => (string) ($tenantTab['description'] ?? ''), + 'active' => $activeTenant === $tenantUuid, + ]; + } + require templatePath('partials/app-list-tabs.phtml'); + ?> +
$gridLang, 'labels' => [ 'name' => t('Name'), - 'email' => t('Email'), + 'context' => t('Tenant / Department'), 'phone' => t('Phone'), - 'mobile' => t('Mobile'), - 'shortDial' => t('Short dial'), - 'tenants' => t('Tenants'), - 'departments' => t('Departments'), - 'roles' => t('Roles'), + 'actions' => t('Actions'), + 'more' => t('More'), + 'openDetails' => t('Open details'), + 'sendEmail' => t('Send email'), + 'call' => t('Call'), + 'extraAssignments' => t('further assignments'), + 'drawerClose' => t('Close'), + 'drawerPrev' => t('Previous'), + 'drawerNext' => t('Next'), + 'drawerOpenFull' => t('Open full page'), + 'drawerLoading' => t('Loading'), + 'drawerError' => t('Failed to load'), ], ]; ?> diff --git a/modules/addressbook/pages/address-book/view(default).phtml b/modules/addressbook/pages/address-book/view(default).phtml index 3a2c1ba..6c89226 100644 --- a/modules/addressbook/pages/address-book/view(default).phtml +++ b/modules/addressbook/pages/address-book/view(default).phtml @@ -1,239 +1,2 @@ format($format); - } catch (\Exception $e) { - $hireDateLabel = $hireDate; - } -} -$avatarUuid = (string) ($values['uuid'] ?? ''); -$hasAvatar = !empty($values['has_avatar']) && $avatarUuid !== ''; - -$initials = ''; -if ($displayName !== '') { - $parts = array_filter(array_map('trim', preg_split('/\s+/', $displayName) ?: [])); - $chars = ''; - foreach ($parts as $part) { - if (function_exists('mb_substr')) { - $chars .= mb_substr($part, 0, 1); - } else { - $chars .= substr($part, 0, 1); - } - } - $initials = strtoupper($chars !== '' ? $chars : '?'); -} -$tenantGroups = $values['tenant_groups'] ?? []; -$hasContact = ($email !== '' || $phone !== '' || $mobile !== '' || $shortDial !== ''); -$hasAddress = ($address !== '' || $postalCode !== '' || $city !== '' || $region !== '' || $country !== ''); -$hasOrganization = (!empty($tenantGroups)); -$aboutSummary = ''; -if ($profileDescription !== '') { - foreach (preg_split('/\\r\\n|\\r|\\n/', $profileDescription) as $line) { - $line = trim((string) $line); - if ($line !== '') { - $aboutSummary = $line; - break; - } - } -} - -?> - -
-
-
-
-
-
-
- - - - - - - -
-
-
-

- -

- -

- -
-
-
-
-
-
-
-
-
- - - - - - - - - - -
- -
-
- -
- -

-
- - -
- -

-
- -
-
- -
- -

-
- - -
- -

-
- -
-
- - -
-
- -

- - - -

- - -

- - -

- -
-
- - -
- - - - - - - - - - - - - - - - - -
- - - () - - - - - - - - -
- -

-

- -
- -
- -
- -

-
- - - - -

- - - -

-

- -
-
-
-
-
- -
+require __DIR__ . '/../../templates/address-book-profile.phtml'; diff --git a/modules/addressbook/pages/address-book/view-fragment($id).php b/modules/addressbook/pages/address-book/view-fragment($id).php new file mode 100644 index 0000000..3eeaf38 --- /dev/null +++ b/modules/addressbook/pages/address-book/view-fragment($id).php @@ -0,0 +1,35 @@ +all(); +Guard::requireLogin(); +Guard::requireAbilityOrForbidden(\MintyPHP\Module\AddressBook\AddressBookAuthorizationPolicy::ABILITY_VIEW); + +$currentUserId = (int) ($session['user']['id'] ?? 0); +$uuid = trim((string) ($id ?? '')); +if ($uuid === '') { + http_response_code(400); + return; +} + +$addressBookService = app(\MintyPHP\Module\AddressBook\Service\AddressBookService::class); +$viewContext = $addressBookService->buildViewContext($currentUserId, $uuid); +$status = (string) ($viewContext['status'] ?? ''); +if ($status === 'not_found') { + http_response_code(404); + $user = null; + return; +} +if ($status === 'forbidden') { + http_response_code(403); + $user = null; + return; +} +$user = $viewContext['user'] ?? null; +if (!is_array($user)) { + http_response_code(404); + $user = null; + return; +} diff --git a/modules/addressbook/pages/address-book/view-fragment(none).phtml b/modules/addressbook/pages/address-book/view-fragment(none).phtml new file mode 100644 index 0000000..d7ba1e5 --- /dev/null +++ b/modules/addressbook/pages/address-book/view-fragment(none).phtml @@ -0,0 +1,5 @@ +format($format); + } catch (\Exception $e) { + $hireDateLabel = $hireDate; + } +} +$avatarUuid = (string) ($values['uuid'] ?? ''); +$hasAvatar = !empty($values['has_avatar']) && $avatarUuid !== ''; + +$initials = ''; +if ($displayName !== '') { + $parts = array_filter(array_map('trim', preg_split('/\s+/', $displayName) ?: [])); + $chars = ''; + foreach ($parts as $part) { + if (function_exists('mb_substr')) { + $chars .= mb_substr($part, 0, 1); + } else { + $chars .= substr($part, 0, 1); + } + } + $initials = strtoupper($chars !== '' ? $chars : '?'); +} +$tenantGroups = $values['tenant_groups'] ?? []; +$hasContact = ($email !== '' || $phone !== '' || $mobile !== '' || $shortDial !== ''); +$hasAddress = ($address !== '' || $postalCode !== '' || $city !== '' || $region !== '' || $country !== ''); +$hasOrganization = (!empty($tenantGroups)); +$aboutSummary = ''; +if ($profileDescription !== '') { + foreach (preg_split('/\\r\\n|\\r|\\n/', $profileDescription) as $line) { + $line = trim((string) $line); + if ($line !== '') { + $aboutSummary = $line; + break; + } + } +} + +?> + +
+
+
+
+
+
+
+ + + + + + + +
+
+
+

+ +

+ +

+ +
+
+
+
+
+
+
+
+
+ + + + + + + + + + +
+ +
+
+ +
+ +

+
+ + +
+ +

+
+ +
+
+ +
+ +

+
+ + +
+ +

+
+ +
+
+ + +
+
+ +

+ + + +

+ + +

+ + +

+ +
+
+ + +
+ + + + + + + + + + + + + + + + + +
+ + + () + + + + + + - + +
+ +

-

+ +
+ +
+ +
+ +

+
+ + + + +

+ + + +

-

+ +
+
+
+
+
+ +
diff --git a/modules/addressbook/web/css/pages/address-book-index.css b/modules/addressbook/web/css/pages/address-book-index.css new file mode 100644 index 0000000..8657a1f --- /dev/null +++ b/modules/addressbook/web/css/pages/address-book-index.css @@ -0,0 +1,143 @@ +/* Address book list — dense identity layout */ + +#address-book-grid .gridjs-tr { + vertical-align: middle; +} + +.addr-identity-cell { + display: inline-flex; + align-items: center; + gap: 0.75rem; + min-width: 0; +} + +.addr-identity-avatar { + width: 40px; + height: 40px; + flex-shrink: 0; +} + +.addr-identity-text { + display: inline-flex; + flex-direction: column; + min-width: 0; + line-height: 1.25; +} + +.addr-identity-name { + font-weight: 600; + font-size: 0.95rem; + color: var(--app-color-text, inherit); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 28ch; +} + +.addr-identity-name:hover { + text-decoration: underline; +} + +.addr-identity-email { + color: var(--app-color-muted, #6b7280); + font-size: 0.82rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 28ch; +} + +.addr-context-cell { + display: inline-flex; + align-items: center; + gap: 0.35rem; + min-width: 0; + color: var(--app-color-text, inherit); + font-size: 0.88rem; +} + +.addr-context-primary { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 20ch; +} + +.addr-context-secondary { + color: var(--app-color-muted, #6b7280); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 20ch; +} + +.addr-context-separator { + color: var(--app-color-muted, #9ca3af); + flex-shrink: 0; +} + +.addr-context-extra { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.75rem; + height: 1.25rem; + padding: 0 0.375rem; + border-radius: 9999px; + background: var(--app-color-subtle, #f3f4f6); + color: var(--app-color-muted, #6b7280); + font-size: 0.72rem; + font-weight: 600; + margin-left: 0.25rem; + flex-shrink: 0; +} + +.addr-context-empty, +.addr-phone-empty { + color: var(--app-color-muted, #9ca3af); +} + +.addr-phone-cell { + color: var(--app-color-text, inherit); + text-decoration: none; + font-variant-numeric: tabular-nums; + font-size: 0.88rem; +} + +.addr-phone-cell:hover { + text-decoration: underline; +} + +.addr-action-cell { + display: inline-flex; + gap: 0.25rem; + justify-content: flex-end; +} + +.addr-action-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 0.375rem; + color: var(--app-color-muted, #6b7280); + text-decoration: none; + transition: background 0.12s, color 0.12s; +} + +.addr-action-button:hover { + background: var(--app-color-subtle, #f3f4f6); + color: var(--app-color-text, #111827); +} + +@media (max-width: 900px) { + .addr-identity-name, + .addr-identity-email, + .addr-context-primary, + .addr-context-secondary { + max-width: 100%; + } +} diff --git a/modules/addressbook/web/js/pages/address-book-index.js b/modules/addressbook/web/js/pages/address-book-index.js index 7860430..64741fe 100644 --- a/modules/addressbook/web/js/pages/address-book-index.js +++ b/modules/addressbook/web/js/pages/address-book-index.js @@ -1,7 +1,8 @@ import { initMultiSelect } from '/js/components/app-multiselect-init.js'; import { initMultiSelectCascade } from '/js/components/app-multiselect-cascade.js'; +import { initDetailDrawer } from '/js/components/app-detail-drawer.js'; import { createListPageModule } from '/js/core/app-list-page-module.js'; -import { escapeHtml, buildBadgeList, gridFilterMultiCsv, gridFilterText, withCurrentListReturn } from '/js/pages/app-list-utils.js'; +import { escapeHtml, gridFilterMultiCsv, gridFilterText, withCurrentListReturn } from '/js/pages/app-list-utils.js'; createListPageModule({ configId: 'address-book-index', @@ -53,52 +54,111 @@ createListPageModule({ name: labels.name || 'Name', sort: true, formatter: (cell, row) => { - const nameValue = typeof cell === 'object' && cell !== null - ? (cell.name ?? '') - : cell; - const avatarUuid = typeof cell === 'object' && cell !== null - ? (cell.avatar_uuid ?? '') - : ''; + const nameValue = cell?.name ?? ''; + const email = cell?.email ?? ''; + const avatarUuid = cell?.avatar_uuid ?? ''; const name = escapeHtml(nameValue || '-'); const uuid = row?.cells?.[uuidIndex]?.data; const editUrl = uuid ? withCurrentListReturn(new URL(`address-book/view/${uuid}`, appBase).toString()) : ''; - const nameHtml = editUrl - ? `${name}` - : `${name}`; + const nameNode = editUrl + ? `${name}` + : `${name}`; + const emailNode = email + ? `${escapeHtml(email)}` + : ''; + let avatarNode; if (!avatarUuid) { const initials = escapeHtml(initialsForName(nameValue)); - return gridjs.html(`${initials}${nameHtml}`); + avatarNode = `${initials}`; + } else { + const src = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=64`, appBase).toString(); + const full = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=256`, appBase).toString(); + avatarNode = ``; } - const src = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=64`, appBase).toString(); - const full = new URL(`admin/users/avatar-file?uuid=${avatarUuid}&size=256`, appBase).toString(); - return gridjs.html(`${nameHtml}`); + return gridjs.html(`${avatarNode}${nameNode}${emailNode}`); + }, + }, + { + name: labels.context || 'Tenant / Department', + sort: false, + formatter: (cell) => { + const tenant = escapeHtml(String(cell?.tenant || '')); + const department = escapeHtml(String(cell?.department || '')); + const extra = Number(cell?.extra || 0); + if (!tenant && !department && !extra) { + return gridjs.html(''); + } + const parts = []; + if (tenant) { + parts.push(`${tenant}`); + } + if (department) { + parts.push(`${department}`); + } + if (extra > 0) { + const extraLabel = escapeHtml(labels.extraAssignments || 'further assignments'); + parts.push(`+${extra}`); + } + return gridjs.html(`${parts.join('')}`); + }, + }, + { + name: labels.phone || 'Phone', + sort: false, + formatter: (cell) => { + const number = String(cell || '').trim(); + if (!number) { + return gridjs.html(''); + } + const tel = number.replace(/[^+0-9]/g, ''); + const safe = escapeHtml(number); + return gridjs.html(`${safe}`); + }, + }, + { + name: labels.actions || 'Actions', + sort: false, + formatter: (cell, row) => { + const uuid = row?.cells?.[uuidIndex]?.data; + if (!uuid) { + return gridjs.html(''); + } + const viewUrl = escapeHtml(withCurrentListReturn(new URL(`address-book/view/${uuid}`, appBase).toString())); + const email = String(cell?.email || '').trim(); + const openLabel = escapeHtml(labels.openDetails || 'Open details'); + const mailLabel = escapeHtml(labels.sendEmail || 'Send email'); + const actions = [ + ``, + ]; + if (email) { + const safeMail = escapeHtml(email); + actions.push(``); + } + return gridjs.html(`${actions.join('')}`); }, }, - { name: labels.email || 'Email', sort: true }, - { name: labels.phone || 'Phone', sort: false }, - { name: labels.mobile || 'Mobile', sort: false }, - { name: labels.shortDial || 'Short dial', sort: false }, - { name: labels.tenants || 'Tenants', sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) }, - { name: labels.departments || 'Departments', sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) }, - { name: labels.roles || 'Roles', sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) }, ], - sortColumns: [null, 'display_name', 'email', null, null, null, null, null, null], + sortColumns: [null, 'display_name', null, null, null], paginationLimit: 10, language: config.gridLang ?? {}, - mapData: (data) => data.data.map((row) => [ - row.uuid, - { - name: row.display_name, - avatar_uuid: row.has_avatar ? row.uuid : '', - }, - row.email, - row.phone, - row.mobile, - row.short_dial, - row.tenants, - row.departments, - row.roles, - ]), + mapData: (data) => data.data.map((row) => { + const phone = (row.phone && String(row.phone).trim()) || (row.mobile && String(row.mobile).trim()) || ''; + return [ + row.uuid, + { + name: row.display_name, + email: row.email || '', + avatar_uuid: row.has_avatar ? row.uuid : '', + }, + { + tenant: row.primary_tenant_label || '', + department: row.primary_department_label || '', + extra: row.extra_assignments_count || 0, + }, + phone, + { email: row.email || '' }, + ]; + }), search: gridSearch, filterSchema, extraFilters: customFilters, @@ -118,6 +178,13 @@ createListPageModule({ mode: 'drawer', chipMeta: filterChipMeta, watchInputs: ['#address-book-search'], + preserveFilterParamsOnReset: ['tenant'], + drawer: { + countExcludeParams: ['tenant'], + }, + clearMetaOptions: { + preserveFilterParams: ['tenant'], + }, }, }) || {}; @@ -131,6 +198,23 @@ createListPageModule({ }); }); + initDetailDrawer({ + gridConfig, + triggerSelector: '[data-drawer-trigger]', + rowUuidAttr: 'uuid', + fetchUrl: (uuid) => new URL(`address-book/view-fragment/${uuid}`, appBase).toString(), + fullUrl: (uuid) => withCurrentListReturn(new URL(`address-book/view/${uuid}`, appBase).toString()), + hashPrefix: 'user', + labels: { + close: labels.drawerClose || 'Close', + previous: labels.drawerPrev || 'Previous', + next: labels.drawerNext || 'Next', + openFull: labels.drawerOpenFull || 'Open full page', + loading: labels.drawerLoading || 'Loading', + error: labels.drawerError || 'Failed to load', + }, + }); + return gridConfig; }, }); diff --git a/tests/Architecture/DetailDrawerFragmentContractTest.php b/tests/Architecture/DetailDrawerFragmentContractTest.php new file mode 100644 index 0000000..20087f6 --- /dev/null +++ b/tests/Architecture/DetailDrawerFragmentContractTest.php @@ -0,0 +1,148 @@ +-fragment($id).php ← action + * -fragment(none).phtml ← view (no-layout) + * + * This test scans JS files for `initDetailDrawer` calls, extracts the fragment + * path from the fetchUrl template literal, and verifies both files exist. + */ +class DetailDrawerFragmentContractTest extends TestCase +{ + use ProjectFileAssertionSupport; + + public function testEveryDrawerConsumerHasMatchingFragmentEndpoint(): void + { + $root = $this->projectRootPath(); + $scanRoots = ['web/js', 'modules']; + + $consumers = []; + + foreach ($scanRoots as $scanRoot) { + $basePath = $root . '/' . $scanRoot; + if (!is_dir($basePath)) { + continue; + } + + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($basePath, \FilesystemIterator::SKIP_DOTS)); + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + if (!$file->isFile() || $file->getExtension() !== 'js') { + continue; + } + // Exclude the drawer implementation itself. + $relativePath = str_replace($root . '/', '', $file->getPathname()); + if ($relativePath === 'web/js/components/app-detail-drawer.js') { + continue; + } + + $content = file_get_contents($file->getPathname()); + if ($content === false || !str_contains($content, 'initDetailDrawer(')) { + continue; + } + + // Find `fetchUrl: (uuid) => new URL(`/${uuid}`, ...)` — extract . + // The path must contain "-fragment" per the convention. + if (preg_match_all( + '/fetchUrl\s*:\s*\([^)]*\)\s*=>\s*new\s+URL\s*\(\s*`([^`${]+)\$\{[^}]+\}[^`]*`/', + $content, + $matches + )) { + foreach ($matches[1] as $prefix) { + $prefix = rtrim($prefix, '/'); + $consumers[$prefix] = $relativePath; + } + } + } + } + + $this->assertNotEmpty( + $consumers, + 'No initDetailDrawer fetchUrl patterns found. Either the convention changed or the regex is stale.' + ); + + $violations = []; + + foreach ($consumers as $fragmentPath => $consumerFile) { + if (!str_contains($fragmentPath, '-fragment')) { + $violations[] = sprintf( + "Fragment path does not follow *-fragment convention: %s (in %s)", + $fragmentPath, + $consumerFile + ); + continue; + } + + [$actionFound, $viewFound, $searchedDirs] = $this->locateFragmentFiles($fragmentPath); + + if (!$actionFound) { + $violations[] = sprintf( + "Missing action file for fragment path '%s'. Expected somewhere like:\n pages/%s(\$id).php\n modules/*/pages/%s(\$id).php\nSearched in: %s\n(Consumer: %s)", + $fragmentPath, + $fragmentPath, + $fragmentPath, + implode(', ', $searchedDirs), + $consumerFile + ); + } + if (!$viewFound) { + $violations[] = sprintf( + "Missing (none) view file for fragment path '%s'. Expected:\n %s(none).phtml\nSearched in: %s\n(Consumer: %s)", + $fragmentPath, + $fragmentPath, + implode(', ', $searchedDirs), + $consumerFile + ); + } + } + + $this->assertSame( + [], + $violations, + "Detail-drawer fragment-contract violations:\n" . implode("\n", $violations) + ); + } + + /** + * @return array{0: bool, 1: bool, 2: list} [actionFound, viewFound, searchedDirs] + */ + private function locateFragmentFiles(string $fragmentPath): array + { + $root = $this->projectRootPath(); + $searchBases = [$root . '/pages']; + $modulesDir = $root . '/modules'; + if (is_dir($modulesDir)) { + foreach (scandir($modulesDir) ?: [] as $moduleEntry) { + if ($moduleEntry === '.' || $moduleEntry === '..') { + continue; + } + $modulePages = $modulesDir . '/' . $moduleEntry . '/pages'; + if (is_dir($modulePages)) { + $searchBases[] = $modulePages; + } + } + } + + $actionFound = false; + $viewFound = false; + + foreach ($searchBases as $base) { + $actionCandidate = $base . '/' . $fragmentPath . '($id).php'; + $viewCandidate = $base . '/' . $fragmentPath . '(none).phtml'; + if (is_file($actionCandidate)) { + $actionFound = true; + } + if (is_file($viewCandidate)) { + $viewFound = true; + } + } + + return [$actionFound, $viewFound, $searchBases]; + } +} diff --git a/tests/Architecture/FilterDrawerRuntimeContractTest.php b/tests/Architecture/FilterDrawerRuntimeContractTest.php index 868be95..9d40ee5 100644 --- a/tests/Architecture/FilterDrawerRuntimeContractTest.php +++ b/tests/Architecture/FilterDrawerRuntimeContractTest.php @@ -19,8 +19,14 @@ class FilterDrawerRuntimeContractTest extends TestCase $drawerJs = $this->readProjectFile('web/js/components/app-filter-drawer.js'); $this->assertStringContainsString("drawer.setAttribute('role', 'dialog')", $drawerJs); $this->assertStringContainsString("drawer.setAttribute('aria-modal', 'true')", $drawerJs); - $this->assertStringContainsString("if (event.key !== 'Tab') {return;}", $drawerJs); - $this->assertStringContainsString('if (lastTrigger && typeof lastTrigger.focus === \'function\'', $drawerJs); + // Focus-trap + focus-return implementation moved to shared utility. + $this->assertStringContainsString("from '../core/app-focus-trap.js'", $drawerJs); + $this->assertStringContainsString('focusTrap.activate(', $drawerJs); + $this->assertStringContainsString('focusTrap.deactivate()', $drawerJs); + + $trapJs = $this->readProjectFile('web/js/core/app-focus-trap.js'); + $this->assertStringContainsString("if (event.key !== 'Tab')", $trapJs); + $this->assertStringContainsString("lastTrigger && typeof lastTrigger.focus === 'function'", $trapJs); $experienceJs = $this->readProjectFile('web/js/pages/app-list-filter-experience.js'); $this->assertStringContainsString('countDraftChanges(', $experienceJs); diff --git a/tests/Architecture/FrontendRuntimeHostContractTest.php b/tests/Architecture/FrontendRuntimeHostContractTest.php index f963db4..236a04e 100644 --- a/tests/Architecture/FrontendRuntimeHostContractTest.php +++ b/tests/Architecture/FrontendRuntimeHostContractTest.php @@ -41,7 +41,7 @@ class FrontendRuntimeHostContractTest extends TestCase public function testTabsHostsUseDataAppComponentContract(): void { $files = [ - 'modules/addressbook/pages/address-book/view(default).phtml', + 'modules/addressbook/templates/address-book-profile.phtml', 'pages/admin/departments/_form.phtml', 'pages/admin/permissions/_form.phtml', 'pages/admin/roles/_form.phtml', diff --git a/web/css/components/app-detail-drawer.css b/web/css/components/app-detail-drawer.css new file mode 100644 index 0000000..601bf87 --- /dev/null +++ b/web/css/components/app-detail-drawer.css @@ -0,0 +1,132 @@ +@layer components { + /* Detail drawer — slide-in overlay panel for row detail view. */ + + html.app-detail-drawer-open { + overflow: hidden; + overscroll-behavior: none; + } + + .app-detail-drawer { + position: fixed; + inset: 0; + z-index: 2050; + display: flex; + justify-content: flex-end; + pointer-events: none; + } + + .app-detail-drawer[hidden] { + display: none; + } + + .app-detail-drawer-backdrop { + position: absolute; + inset: 0; + background: rgba(15, 23, 42, 0.3); + -webkit-backdrop-filter: blur(2px); + backdrop-filter: blur(2px); + opacity: 0; + transition: opacity 0.18s ease; + pointer-events: auto; + } + + .app-detail-drawer.is-open .app-detail-drawer-backdrop { + opacity: 1; + } + + .app-detail-drawer-panel { + position: relative; + display: flex; + flex-direction: column; + width: min(720px, 100vw); + height: 100%; + background: var(--app-card-background-color, #fff); + border-left: 1px solid var(--app-muted-border-color, rgba(148, 163, 184, 0.35)); + box-shadow: -12px 0 30px rgba(15, 23, 42, 0.18); + transform: translateX(100%); + transition: transform 0.22s ease; + pointer-events: auto; + outline: none; + } + + .app-detail-drawer.is-open .app-detail-drawer-panel { + transform: translateX(0); + } + + .app-detail-drawer-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--app-muted-border-color, rgba(148, 163, 184, 0.25)); + background: var(--app-card-background-color, #fff); + position: sticky; + top: 0; + z-index: 1; + } + + .app-detail-drawer-stepper, + .app-detail-drawer-actions { + display: inline-flex; + gap: 0.25rem; + align-items: center; + } + + .app-detail-drawer-stepper[hidden] { + display: none; + } + + .app-detail-drawer-header button, + .app-detail-drawer-header a { + margin-bottom: 0; + } + + .app-detail-drawer-body { + flex: 1 1 auto; + overflow-y: auto; + padding: 1rem 1.25rem 2rem; + -webkit-overflow-scrolling: touch; + } + + .app-detail-drawer-loading, + .app-detail-drawer-error { + padding: 2rem 1rem; + text-align: center; + color: var(--app-color-muted, #6b7280); + } + + .app-detail-drawer-error { + color: var(--app-color-danger, #b91c1c); + } + + @media (max-width: 900px) { + .app-detail-drawer-panel { + width: 100vw; + border-left: none; + } + } + + /* Inside the drawer, force single-column layout for any .app-details-container + regardless of viewport width (the grid rule in app-details.css triggers on + viewport ≥968px, which is wrong for a 720px drawer). Any page that uses the + standard details container gets stacked main + aside automatically. */ + .app-detail-drawer .app-details-container, + .app-detail-drawer .app-details-container:has(> aside) { + display: block; + grid-template-columns: none; + min-height: 0; + } + + .app-detail-drawer .app-details-container > section { + width: 100%; + } + + .app-detail-drawer .app-details-container > aside { + border-left: none; + border-top: 1px solid var(--app-border); + min-height: 0; + padding: calc(var(--app-spacing) * 1.5) 0 0; + margin-top: calc(var(--app-spacing) * 1.5); + } +} diff --git a/web/css/core.css b/web/css/core.css index ce7fbb4..5e51789 100644 --- a/web/css/core.css +++ b/web/css/core.css @@ -36,6 +36,7 @@ @import url("components/app-tabs.css"); @import url("components/app-list-toolbar.css"); @import url("components/app-filter-drawer.css"); +@import url("components/app-detail-drawer.css"); @import url("components/app-active-filter-chips.css"); @import url("components/app-confirm-dialog.css"); @import url("components/app-breadcrumb.css"); diff --git a/web/js/components/app-detail-drawer.js b/web/js/components/app-detail-drawer.js new file mode 100644 index 0000000..b7a2f54 --- /dev/null +++ b/web/js/components/app-detail-drawer.js @@ -0,0 +1,312 @@ +/** + * Reusable detail drawer — slides in from the right, loads a fragment URL, + * supports deep-link via URL hash, and can step through rows on the current + * grid page. + */ + +import { getHtml, SessionExpiredError } from '../core/app-http.js'; +import { initFragmentContent } from '../core/app-fragment-init.js'; +import { createFocusTrap, createScrollLock } from '../core/app-focus-trap.js'; + +const STATE_KEY = Symbol('detail-drawer-state'); + +function resolveLabels(labels = {}) { + return { + close: labels.close || 'Close', + previous: labels.previous || 'Previous', + next: labels.next || 'Next', + openFull: labels.openFull || 'Open full page', + loading: labels.loading || 'Loading', + error: labels.error || 'Failed to load', + }; +} + +function ensureDrawerElement(labels) { + let root = document.querySelector('[data-detail-drawer]'); + if (root instanceof HTMLElement) { + return root; + } + root = document.createElement('div'); + root.className = 'app-detail-drawer'; + root.setAttribute('data-detail-drawer', ''); + root.setAttribute('role', 'dialog'); + root.setAttribute('aria-modal', 'true'); + root.setAttribute('aria-hidden', 'true'); + root.hidden = true; + root.innerHTML = ` +
+ + `; + document.body.appendChild(root); + return root; +} + +/** Default row-uuid discovery: scrapes the visible Grid.js rows on the page. */ +function makeGridRowProvider(gridConfig, rowUuidAttr) { + if (!gridConfig) { return null; } + return () => { + const wrapper = gridConfig?.wrapper || document; + const rows = wrapper.querySelectorAll(`.gridjs-tr[data-${rowUuidAttr}]`); + const uuids = []; + rows.forEach((row) => { + const uuid = row.getAttribute(`data-${rowUuidAttr}`); + if (uuid) { uuids.push(uuid); } + }); + return uuids; + }; +} + +export function initDetailDrawer(options = {}) { + const { + gridConfig = null, + triggerSelector = '[data-drawer-trigger]', + rowSelector = '.gridjs-tr', + rowUuidAttr = 'uuid', + rowProvider = null, + fetchUrl, + fullUrl, + hashPrefix = 'detail', + onContentLoaded = null, + labels: labelOverrides = {}, + } = options; + + if (typeof fetchUrl !== 'function') { + console.warn('[detail-drawer] fetchUrl required'); + return null; + } + + const effectiveRowProvider = typeof rowProvider === 'function' + ? rowProvider + : makeGridRowProvider(gridConfig, rowUuidAttr); + const stepperEnabled = typeof effectiveRowProvider === 'function'; + + const labels = resolveLabels(labelOverrides); + const root = ensureDrawerElement(labels); + if (root[STATE_KEY]) { return root[STATE_KEY]; } + + const panel = root.querySelector('[data-detail-drawer-panel]'); + const backdrop = root.querySelector('[data-detail-drawer-backdrop]'); + const closeButton = root.querySelector('[data-detail-drawer-close]'); + const prevButton = root.querySelector('[data-detail-drawer-prev]'); + const nextButton = root.querySelector('[data-detail-drawer-next]'); + const stepperContainer = root.querySelector('.app-detail-drawer-stepper'); + const fullLink = root.querySelector('[data-detail-drawer-full]'); + const contentEl = root.querySelector('[data-detail-drawer-content]'); + const loadingEl = root.querySelector('[data-detail-drawer-loading]'); + const errorEl = root.querySelector('[data-detail-drawer-error]'); + + if (stepperContainer instanceof HTMLElement) { + stepperContainer.hidden = !stepperEnabled; + } + + const hashPattern = new RegExp(`^#${hashPrefix}/([^/?#]+)`); + const focusTrap = createFocusTrap(panel); + const scrollLock = createScrollLock(); + let lastTrigger = null; + + const api = { + currentUuid: null, + isOpen: false, + open(uuid, opts = {}) { + const pushHash = opts.pushHash !== false; + if (!uuid) { return; } + const wasOpen = api.isOpen; + api.currentUuid = String(uuid); + root.hidden = false; + root.setAttribute('aria-hidden', 'false'); + root.classList.add('is-open'); + document.documentElement.classList.add('app-detail-drawer-open'); + api.isOpen = true; + if (!wasOpen) { + lastTrigger = document.activeElement instanceof HTMLElement ? document.activeElement : null; + scrollLock.lock(); + focusTrap.activate(lastTrigger); + } + if (typeof fullUrl === 'function') { + fullLink.href = fullUrl(uuid); + } + if (pushHash) { + const targetHash = `#${hashPrefix}/${uuid}`; + if (window.location.hash !== targetHash) { + history.replaceState(null, '', window.location.pathname + window.location.search + targetHash); + } + } + updateStepper(); + loadContent(uuid); + }, + close(opts = {}) { + const clearHash = opts.clearHash !== false; + if (!api.isOpen) { return; } + api.isOpen = false; + api.currentUuid = null; + root.classList.remove('is-open'); + root.setAttribute('aria-hidden', 'true'); + root.hidden = true; + document.documentElement.classList.remove('app-detail-drawer-open'); + focusTrap.deactivate(); + scrollLock.unlock(); + lastTrigger = null; + contentEl.innerHTML = ''; + if (clearHash && hashPattern.test(window.location.hash)) { + history.replaceState(null, '', window.location.pathname + window.location.search); + } + }, + openNext() { step(+1); }, + openPrev() { step(-1); }, + }; + + function getRowUuids() { + if (!stepperEnabled) { return []; } + try { + const result = effectiveRowProvider(); + return Array.isArray(result) ? result.filter(Boolean).map(String) : []; + } catch (err) { + console.warn('[detail-drawer] rowProvider failed', err); + return []; + } + } + + function step(direction) { + if (!api.currentUuid || !stepperEnabled) { return; } + const uuids = getRowUuids(); + const idx = uuids.indexOf(api.currentUuid); + if (idx < 0) { return; } + const nextIdx = idx + direction; + if (nextIdx < 0 || nextIdx >= uuids.length) { return; } + api.open(uuids[nextIdx]); + } + + function updateStepper() { + if (!stepperEnabled) { + prevButton.disabled = true; + nextButton.disabled = true; + return; + } + const uuids = getRowUuids(); + const idx = uuids.indexOf(api.currentUuid); + prevButton.disabled = idx <= 0; + nextButton.disabled = idx < 0 || idx >= uuids.length - 1; + } + + async function loadContent(uuid) { + contentEl.innerHTML = ''; + errorEl.hidden = true; + loadingEl.hidden = false; + const url = fetchUrl(uuid); + try { + const html = await getHtml(url); + if (!api.isOpen || api.currentUuid !== String(uuid)) { return; } + contentEl.innerHTML = html; + contentEl.querySelectorAll('script').forEach((script) => { + const replacement = document.createElement('script'); + [...script.attributes].forEach((attr) => replacement.setAttribute(attr.name, attr.value)); + replacement.textContent = script.textContent; + script.parentNode.replaceChild(replacement, script); + }); + loadingEl.hidden = true; + initFragmentContent(contentEl); + if (typeof onContentLoaded === 'function') { + try { + onContentLoaded(contentEl, uuid); + } catch (hookErr) { + console.warn('[detail-drawer] onContentLoaded failed', hookErr); + } + } + } catch (err) { + if (err instanceof SessionExpiredError) { + api.close({ clearHash: true }); + window.location.reload(); + return; + } + loadingEl.hidden = true; + errorEl.hidden = false; + console.warn('[detail-drawer] load failed', err); + } + } + + document.addEventListener('click', (event) => { + const trigger = event.target.closest(triggerSelector); + if (!trigger) { return; } + if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) { + return; + } + const row = trigger.closest(rowSelector); + const uuid = row?.getAttribute(`data-${rowUuidAttr}`) || trigger.getAttribute(`data-${rowUuidAttr}`); + if (!uuid) { return; } + event.preventDefault(); + api.open(uuid); + }); + + closeButton.addEventListener('click', () => api.close()); + backdrop.addEventListener('click', () => api.close()); + prevButton.addEventListener('click', () => api.openPrev()); + nextButton.addEventListener('click', () => api.openNext()); + + document.addEventListener('keydown', (event) => { + if (!api.isOpen) { return; } + if (event.key === 'Escape') { + event.preventDefault(); + api.close(); + return; + } + if (event.target instanceof HTMLElement) { + const tag = event.target.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target.isContentEditable) { + return; + } + } + if (event.key === 'j' || event.key === 'ArrowDown') { + event.preventDefault(); + api.openNext(); + } else if (event.key === 'k' || event.key === 'ArrowUp') { + event.preventDefault(); + api.openPrev(); + } + }); + + window.addEventListener('hashchange', () => { + const match = hashPattern.exec(window.location.hash); + if (match) { + api.open(match[1], { pushHash: false }); + } else if (api.isOpen) { + api.close({ clearHash: false }); + } + }); + + const initialMatch = hashPattern.exec(window.location.hash); + if (initialMatch) { + requestAnimationFrame(() => api.open(initialMatch[1], { pushHash: false })); + } + + root[STATE_KEY] = api; + return api; +} diff --git a/web/js/components/app-filter-drawer.js b/web/js/components/app-filter-drawer.js index 36fa045..73df6b5 100644 --- a/web/js/components/app-filter-drawer.js +++ b/web/js/components/app-filter-drawer.js @@ -2,6 +2,7 @@ * Slide-in filter drawer with focus trap, scroll lock, and apply/reset/discard lifecycle. */ import { warnOnce, resolveHost } from '../core/app-dom.js'; +import { createFocusTrap, createScrollLock } from '../core/app-focus-trap.js'; export function initFilterDrawer(options = {}) { const { @@ -46,8 +47,9 @@ export function initFilterDrawer(options = {}) { }; let isOpen = false; - let lockedScrollY = 0; let lastTrigger = null; + const focusTrap = createFocusTrap(panel); + const scrollLock = createScrollLock(); const ensureDrawerInBody = () => { if (!document.body || !drawer.isConnected || drawer.parentNode === document.body) { @@ -83,61 +85,9 @@ export function initFilterDrawer(options = {}) { panel.setAttribute('tabindex', '-1'); } - const lockPageScroll = () => { - const body = document.body; - if (body.dataset.filterDrawerLock === '1') { - return; - } - lockedScrollY = window.scrollY || window.pageYOffset || 0; - body.dataset.filterDrawerLock = '1'; - body.style.position = 'fixed'; - body.style.top = `-${lockedScrollY}px`; - body.style.left = '0'; - body.style.right = '0'; - body.style.width = '100%'; - }; - - const unlockPageScroll = () => { - const body = document.body; - if (body.dataset.filterDrawerLock !== '1') { - return; - } - body.style.position = ''; - body.style.top = ''; - body.style.left = ''; - body.style.right = ''; - body.style.width = ''; - delete body.dataset.filterDrawerLock; - window.scrollTo(0, lockedScrollY); - }; - - const getFocusableElements = () => { - const selectors = [ - 'a[href]', - 'area[href]', - 'input:not([type="hidden"]):not([disabled])', - 'select:not([disabled])', - 'textarea:not([disabled])', - 'button:not([disabled])', - '[tabindex]:not([tabindex="-1"])', - ]; - return Array.from(panel.querySelectorAll(selectors.join(','))).filter((element) => { - if (element.getAttribute('aria-hidden') === 'true') { - return false; - } - if (element instanceof HTMLElement && element.hidden) { - return false; - } - return true; - }); - }; - - const focusFirstField = () => { - const focusable = getFocusableElements()[0] || panel; - if (focusable && typeof focusable.focus === 'function') { - focusable.focus(); - } - }; + const lockPageScroll = () => scrollLock.lock(); + const unlockPageScroll = () => scrollLock.unlock(); + cleanupFns.push(() => focusTrap.destroy()); const setOpen = (nextOpen) => { if (nextOpen) { @@ -151,7 +101,9 @@ export function initFilterDrawer(options = {}) { if (nextOpen) { lockPageScroll(); + focusTrap.activate(lastTrigger); } else { + focusTrap.deactivate(); unlockPageScroll(); } @@ -160,9 +112,6 @@ export function initFilterDrawer(options = {}) { }); if (!nextOpen) { - if (lastTrigger && typeof lastTrigger.focus === 'function' && document.contains(lastTrigger)) { - lastTrigger.focus(); - } lastTrigger = null; if (typeof onClose === 'function') { onClose(); @@ -173,7 +122,6 @@ export function initFilterDrawer(options = {}) { if (typeof onOpen === 'function') { onOpen(); } - focusFirstField(); }; const normalizeForcedClose = () => { @@ -181,6 +129,7 @@ export function initFilterDrawer(options = {}) { return; } isOpen = false; + focusTrap.deactivate(); unlockPageScroll(); document.body.classList.remove('filter-drawer-open'); openButtons.forEach((button) => { @@ -267,32 +216,6 @@ export function initFilterDrawer(options = {}) { if (event.key === 'Escape') { event.preventDefault(); close('discard'); - return; - } - if (event.key !== 'Tab') {return;} - - const focusable = getFocusableElements(); - if (!focusable.length) { - event.preventDefault(); - panel.focus(); - return; - } - - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - const active = document.activeElement; - - if (event.shiftKey) { - if (active === first || !panel.contains(active)) { - event.preventDefault(); - last.focus(); - } - return; - } - - if (active === last) { - event.preventDefault(); - first.focus(); } }; bind(document, 'keydown', onKeyDown); diff --git a/web/js/core/app-focus-trap.js b/web/js/core/app-focus-trap.js new file mode 100644 index 0000000..5ea5ef2 --- /dev/null +++ b/web/js/core/app-focus-trap.js @@ -0,0 +1,170 @@ +/** + * Focus-trap + body-scroll-lock utilities for modal-style overlays + * (filter drawer, detail drawer, dialogs). + * + * Usage: + * const trap = createFocusTrap(panelEl); + * trap.activate(triggerEl); // remembers trigger for focus-return + * trap.deactivate(); // restores focus to trigger + * + * const lock = createScrollLock(); + * lock.lock(); + * lock.unlock(); + */ + +const FOCUSABLE_SELECTORS = [ + 'a[href]', + 'area[href]', + 'input:not([type="hidden"]):not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + 'button:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +const getFocusable = (panel) => { + if (!(panel instanceof HTMLElement)) { + return []; + } + return Array.from(panel.querySelectorAll(FOCUSABLE_SELECTORS)).filter((el) => { + if (el.getAttribute('aria-hidden') === 'true') { + return false; + } + if (el instanceof HTMLElement && el.hidden) { + return false; + } + return true; + }); +}; + +export function createFocusTrap(panel) { + if (!(panel instanceof HTMLElement)) { + return { + activate() {}, + deactivate() {}, + focusFirst() {}, + }; + } + + if (!panel.hasAttribute('tabindex')) { + panel.setAttribute('tabindex', '-1'); + } + + let active = false; + let lastTrigger = null; + + const focusFirst = () => { + const target = getFocusable(panel)[0] || panel; + if (target && typeof target.focus === 'function') { + target.focus(); + } + }; + + const onKeyDown = (event) => { + if (!active) { + return; + } + if (event.key !== 'Tab') { + return; + } + const focusable = getFocusable(panel); + if (!focusable.length) { + event.preventDefault(); + panel.focus(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const current = document.activeElement; + if (event.shiftKey) { + if (current === first || !panel.contains(current)) { + event.preventDefault(); + last.focus(); + } + return; + } + if (current === last) { + event.preventDefault(); + first.focus(); + } + }; + + document.addEventListener('keydown', onKeyDown); + + return { + activate(trigger = null) { + if (active) { return; } + active = true; + lastTrigger = trigger && typeof trigger.focus === 'function' + ? trigger + : (document.activeElement instanceof HTMLElement ? document.activeElement : null); + requestAnimationFrame(() => focusFirst()); + }, + deactivate() { + if (!active) { return; } + active = false; + if (lastTrigger && typeof lastTrigger.focus === 'function' && document.contains(lastTrigger)) { + lastTrigger.focus(); + } + lastTrigger = null; + }, + focusFirst, + destroy() { + active = false; + lastTrigger = null; + document.removeEventListener('keydown', onKeyDown); + }, + }; +} + +/** + * Body scroll-lock with position:fixed strategy. Reference-counted so multiple + * overlays cooperate: the lock is only released when the last caller unlocks. + */ +const SCROLL_LOCK_ATTR = 'data-app-scroll-lock-count'; + +export function createScrollLock() { + let locked = false; + + return { + lock() { + if (locked) { return; } + locked = true; + const body = document.body; + if (!body) { return; } + const current = Number(body.getAttribute(SCROLL_LOCK_ATTR) || '0') || 0; + const next = current + 1; + body.setAttribute(SCROLL_LOCK_ATTR, String(next)); + if (current === 0) { + const y = window.scrollY || window.pageYOffset || 0; + body.dataset.appScrollLockY = String(y); + body.style.position = 'fixed'; + body.style.top = `-${y}px`; + body.style.left = '0'; + body.style.right = '0'; + body.style.width = '100%'; + } + }, + unlock() { + if (!locked) { return; } + locked = false; + const body = document.body; + if (!body) { return; } + const current = Number(body.getAttribute(SCROLL_LOCK_ATTR) || '0') || 0; + const next = Math.max(0, current - 1); + if (next === 0) { + body.removeAttribute(SCROLL_LOCK_ATTR); + const y = Number(body.dataset.appScrollLockY || '0') || 0; + body.style.position = ''; + body.style.top = ''; + body.style.left = ''; + body.style.right = ''; + body.style.width = ''; + delete body.dataset.appScrollLockY; + window.scrollTo(0, y); + } else { + body.setAttribute(SCROLL_LOCK_ATTR, String(next)); + } + }, + }; +} diff --git a/web/js/core/app-fragment-init.js b/web/js/core/app-fragment-init.js new file mode 100644 index 0000000..e868019 --- /dev/null +++ b/web/js/core/app-fragment-init.js @@ -0,0 +1,82 @@ +/** + * Initializes standard UI components inside a dynamically-loaded HTML fragment + * (e.g. detail drawer body). Mirrors the subset of app-init.js components that + * make sense inside content fragments — layout/topbar/global components are + * intentionally excluded since those live in the shell, not the fragment. + * + * Consumers: + * import { initFragmentContent } from '/js/core/app-fragment-init.js'; + * initFragmentContent(contentEl); + * + * Each initializer is best-effort: failures are logged but do not abort the + * remaining initializers so one broken component cannot silently break all + * interactive elements in the fragment. + */ + +import { initTabs } from '../components/app-tabs.js'; +import { initConfirmActions } from '../components/app-confirm-actions.js'; +import { initLookupFields } from '../components/app-lookup-field.js'; +import { initAutoSubmit } from '../components/app-auto-submit.js'; +import { initFileUpload } from '../components/app-file-upload.js'; + +const INITIALIZERS = [ + { + name: 'tabs', + selector: '[data-app-component="tabs"]', + run: (root) => initTabs(root), + }, + { + name: 'confirm-actions', + selector: '[data-confirm]', + run: (root) => initConfirmActions(root), + }, + { + name: 'lookup-field', + selector: '[data-lookup-field]', + run: (root) => initLookupFields(root), + }, + { + name: 'auto-submit', + selector: '[data-auto-submit]', + run: (root) => initAutoSubmit(root), + }, + { + name: 'file-upload', + selector: '[data-app-component="file-upload"]', + run: (root) => initFileUpload(root), + }, +]; + +const refreshFsLightboxSafe = () => { + if (typeof window !== 'undefined' && typeof window.refreshFsLightbox === 'function') { + try { + window.refreshFsLightbox(); + } catch (err) { + console.warn('[fragment-init] refreshFsLightbox failed', err); + } + } +}; + +/** + * Initialize all known components within the given fragment root. + * + * @param {HTMLElement} contentEl — the container holding freshly-injected HTML + */ +export function initFragmentContent(contentEl) { + if (!(contentEl instanceof HTMLElement)) { + return; + } + + for (const { name, selector, run } of INITIALIZERS) { + if (!contentEl.querySelector(selector)) { + continue; + } + try { + run(contentEl); + } catch (err) { + console.warn(`[fragment-init] ${name} failed`, err); + } + } + + refreshFsLightboxSafe(); +} diff --git a/web/js/core/app-http.js b/web/js/core/app-http.js index 6994e32..5f1f943 100644 --- a/web/js/core/app-http.js +++ b/web/js/core/app-http.js @@ -124,6 +124,14 @@ export class HttpError extends Error { } } +export class SessionExpiredError extends Error { + constructor(redirectUrl = '') { + super('Session expired'); + this.name = 'SessionExpiredError'; + this.redirectUrl = redirectUrl; + } +} + const emitHttpTelemetry = (error, source) => { if (!(error instanceof HttpError)) { return; @@ -251,6 +259,62 @@ export const postForm = (url, data, options = {}) => { }); }; +export const getHtml = async (url, options = {}) => { + const normalizedUrl = normalizeUrl(url); + const headers = mergeHeaders({ + 'Accept': 'text/html', + 'X-Requested-With': 'XMLHttpRequest', + }, options.headers || null); + + let response; + try { + response = await fetch(normalizedUrl, { + ...options, + method: 'GET', + credentials: options.credentials || 'same-origin', + headers, + }); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { throw error; } + const httpError = new HttpError({ + status: 0, + method: 'GET', + url: normalizedUrl, + message: 'Network error', + cause: error, + }); + emitHttpTelemetry(httpError, 'http.network'); + throw httpError; + } + + if (!response.ok) { + const httpError = new HttpError({ + status: response.status, + method: 'GET', + url: normalizedUrl, + message: response.statusText || 'Request failed', + }); + emitHttpTelemetry(httpError, 'http.response'); + throw httpError; + } + + // Session-expiry detection: fetch follows 302 redirects transparently, so we + // land on the login page with a 200 response. The final URL path diverges + // from the requested one — that's the signal. + try { + const requested = new URL(normalizedUrl).pathname; + const landed = new URL(response.url || normalizedUrl).pathname; + if (landed !== requested && /\/login(\/|$)/.test(landed)) { + throw new SessionExpiredError(response.url || ''); + } + } catch (err) { + if (err instanceof SessionExpiredError) { throw err; } + // URL parsing error — ignore, let caller process the body + } + + return response.text(); +}; + export const postJson = (url, payload, options = {}) => { const headers = mergeHeaders({ ...DEFAULT_HEADERS,