diff --git a/static/css/components/itemTooltip.css b/static/css/components/itemTooltip.css new file mode 100644 index 00000000..0c4a0efc --- /dev/null +++ b/static/css/components/itemTooltip.css @@ -0,0 +1,115 @@ +/* ── Item tooltip — "technical sheet" ─────────────────────────────────────── */ +/* */ +/* Overlays the search bar area in the top-bar while hovering a file item. */ +/* Spans from the sidebar right-edge to just before .user-controls. */ +/* */ +/* 3-column CSS grid: [icon] [label] [value] */ +/* All values start at the same x position regardless of label width. */ + +.path-tooltip { + position: fixed; + + /* Fill most of the top-bar (topbar height 70px, 8px margin top/bottom) */ + top: 8px; + + /* Desktop: starts at sidebar right edge + top-bar padding */ + left: calc(var(--sidebar-width) + 30px); + + /* + * Right edge stops just before .user-controls: + * 30px (top-bar right padding) + ~90px (notif bell + gap + avatar btn) + 4px gap + * Using left + right instead of width so the browser computes it dynamically. + */ + right: 124px; + + /* High enough to sit above the top-bar content */ + z-index: 200; + + display: grid; + grid-template-columns: 1em max-content 1fr; + column-gap: 0.6em; + row-gap: 6px; + align-items: center; + + /* Fill the top-bar height minus an 8px margin on each side */ + height: 54px; + padding: 0 16px; + border-radius: 10px; + border: 2px solid var(--color-border-medium); + + background-color: var(--color-bg-input); + color: var(--color-text-muted); + font-size: 0.75rem; + + pointer-events: none; + + /* avoid blinking when pointer moves between items */ + transition: display 0.2s allow-discrete; +} + +/* Icon column */ +.path-tooltip__icon { + color: var(--color-text-faint); + font-size: 0.7rem; + text-align: center; + justify-self: center; +} + +/* Label column */ +.path-tooltip__label { + font-weight: 600; + color: var(--color-text-secondary); + white-space: nowrap; +} + +.path-tooltip__label::after { + content: ':'; +} + +/* Value column */ +.path-tooltip__value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +/* Path value uses monospace to read folder separators clearly */ +.path-tooltip__value--path { + font-family: monospace; +} + +/* "?" placeholder when data is absent */ +.path-tooltip__value--unknown { + color: var(--color-text-faint); + font-style: italic; +} + +/* ── Narrow screens (sidebar hidden) ────────────────────────────────────── */ +/* On mobile the sidebar slides off-screen; align after the #sidebar-toggle */ +/* button (≈44px) that takes its place in the top-bar. */ + +@media (max-width: 768px) { + .path-tooltip { + /* 16px topbar padding + ~44px sidebar-toggle + 8px gap */ + left: 68px; + + /* + * Right edge stops before search-toggle-btn + user-controls: + * ~40px (search-toggle) + 12px (gap) + ~90px (user-controls) + 16px (padding) + */ + right: 158px; + } +} + +/* ── Vignette inside tooltip ─────────────────────────────────────────────── */ +/* The vignette name hard-codes 14px; scale it down to match the tooltip. */ + +.path-tooltip .user-vignette { + overflow: hidden; + min-width: 0; +} + +.path-tooltip .user-vignette__name { + font-size: 0.75rem; +} diff --git a/static/css/components/pathTooltip.css b/static/css/components/pathTooltip.css deleted file mode 100644 index 575c0ff8..00000000 --- a/static/css/components/pathTooltip.css +++ /dev/null @@ -1,25 +0,0 @@ -.path-tooltip { - position: fixed; - bottom: 8px; - left: calc(var(--sidebar-width) + 8px); - z-index: 5; - - max-width: 120ch; - padding: 4px 10px; - border-radius: 3px; - - background-color: var(--color-bg-subtle); - border: 1px solid var(--color-border-faint); - color: var(--color-text-muted); - font-size: 0.75rem; - font-family: monospace; - - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - pointer-events: none; - - /* avoid blinking when pointer change items */ - transition: display 0.2s allow-discrete; -} diff --git a/static/css/main.css b/static/css/main.css index 83aa67a1..158ed1ee 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -30,7 +30,7 @@ @import url("./components/search.css"); @import url("./components/icons.css"); @import url("./components/csp-utilities.css"); -@import url("./components/pathTooltip.css"); +@import url("./components/itemTooltip.css"); /* Theme */ @import url("./themes/dark.css"); diff --git a/static/js/app/trashView.js b/static/js/app/trashView.js index 2b4ceb1b..91414e31 100644 --- a/static/js/app/trashView.js +++ b/static/js/app/trashView.js @@ -6,7 +6,7 @@ import { escapeHtml, formatDateTime } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; import { batchToolbar } from '../features/files/batchToolbar.js'; import { fileOps } from '../features/files/fileOperations.js'; -import * as pathTooltip from '../features/pathTooltip.js'; +import * as itemTooltip from '../features/itemTooltip.js'; import { appElements } from './state.js'; import { ui } from './ui.js'; @@ -23,7 +23,7 @@ async function loadTrashItems() { try { if (batchToolbar) batchToolbar.clear(); - pathTooltip.destroy(elements.filesList); + itemTooltip.destroy(elements.filesList); ui.resetFilesList(); // ensure also list visible & error hidden elements.filesList.innerHTML = `
@@ -50,7 +50,7 @@ async function loadTrashItems() { trashItems.forEach((item) => { addTrashItemToView(item); }); - pathTooltip.init(elements.filesList); + itemTooltip.init(elements.filesList); } catch (error) { console.error('Error loading trash items:', error); ui.showNotification('Error', 'Error loading trash items'); diff --git a/static/js/components/resourceList.js b/static/js/components/resourceList.js index 168d07fe..d1393997 100644 --- a/static/js/components/resourceList.js +++ b/static/js/components/resourceList.js @@ -407,6 +407,7 @@ export class ResourceListComponent { el.dataset.folderName = folder.name; el.dataset.parentId = folder.parent_id || ''; if (folder.path) el.dataset.path = folder.path; + if (folder.owner_id) el.dataset.ownerId = folder.owner_id; if (cfg.draggable) el.setAttribute('draggable', 'true'); const isFav = cfg.isFavorite ? cfg.isFavorite(folder.id, 'folder') : false; @@ -463,6 +464,7 @@ export class ResourceListComponent { el.dataset.fileName = file.name; el.dataset.folderId = file.folder_id || ''; if (file.path) el.dataset.path = file.path; + if (file.owner_id) el.dataset.ownerId = file.owner_id; if (cfg.draggable) el.setAttribute('draggable', 'true'); el.innerHTML = ` diff --git a/static/js/core/icons.js b/static/js/core/icons.js index 28ff4464..125d945f 100644 --- a/static/js/core/icons.js +++ b/static/js/core/icons.js @@ -263,6 +263,10 @@ const OxiIcons = { 512, 'M40 48C26.7 48 16 58.7 16 72l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24L40 48zM192 64c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L192 64zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-288 0zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-288 0zM16 232l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24l-48 0c-13.3 0-24 10.7-24 24zM40 368c-13.3 0-24 10.7-24 24l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24l-48 0z' ], + 'location-crosshairs': [ + 576, + 'M288-16c17.7 0 32 14.3 32 32l0 18.3c98.1 14 175.7 91.6 189.7 189.7l18.3 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-18.3 0c-14 98.1-91.6 175.7-189.7 189.7l0 18.3c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-18.3C157.9 463.7 80.3 386.1 66.3 288L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l18.3 0C80.3 125.9 157.9 48.3 256 34.3L256 16c0-17.7 14.3-32 32-32zM128 256a160 160 0 1 0 320 0 160 160 0 1 0 -320 0zm160-96a96 96 0 1 1 0 192 96 96 0 1 1 0-192z' + ], lock: [ 448, 'M144 144l0 48 160 0 0-48c0-44.2-35.8-80-80-80s-80 35.8-80 80zM80 192l0-48C80 64.5 144.5 0 224 0s144 64.5 144 144l0 48 16 0c35.3 0 64 28.7 64 64l0 192c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 256c0-35.3 28.7-64 64-64l16 0z' diff --git a/static/js/features/itemTooltip.js b/static/js/features/itemTooltip.js new file mode 100644 index 00000000..3848b55a --- /dev/null +++ b/static/js/features/itemTooltip.js @@ -0,0 +1,181 @@ +// @ts-check + +/** + * Item tooltip — unified hover tooltip showing a stable "technical sheet" for + * a hovered `.file-item`. + * + * Both rows are always rendered so the layout never shifts between items. + * A "?" placeholder is shown when data is absent for a given row. + * + * data-owner-id → 👤 Owner [userVignette] (avatar + name, async) + * data-path → ⊕ Path Documents/Work (monospace) + * + * The tooltip is shown only when at least one of the two attributes is present. + * Lines are laid out in a 3-column CSS grid (icon | label | value) so values + * are always left-aligned at the same x position. + * + * Replaces the former `pathTooltip` and `ownerTooltip` modules. + * + * Usage: + * import * as itemTooltip from '../features/itemTooltip.js'; + * itemTooltip.init(containerEl) — call after rendering items + * itemTooltip.destroy(containerEl) — call when leaving the section + */ + +import { createUserVignette } from '../components/userVignette.js'; +import { i18n } from '../core/i18n.js'; +import { systemUsers } from '../model/systemUsers.js'; + +// ── Tooltip DOM ─────────────────────────────────────────────────────────────── + +/** @returns {HTMLElement} */ +function _getOrCreateTooltip() { + let el = document.getElementById('path-tooltip'); + if (!el) { + el = document.createElement('div'); + el.id = 'path-tooltip'; + el.className = 'path-tooltip hidden'; + document.body.appendChild(el); + } + return el; +} + +function _hide() { + const el = document.getElementById('path-tooltip'); + if (el) el.classList.add('hidden'); +} + +// ── Row builder ─────────────────────────────────────────────────────────────── + +/** + * Append one grid row (icon | label | value) to the tooltip container. + * The three cells are direct children of the CSS grid — column assignment + * is automatic. + * + * @param {HTMLElement} tooltip + * @param {string} iconClass FontAwesome class string, e.g. `"fas fa-user"` + * @param {string} labelText + * @param {(el: HTMLElement) => void} populate Fills the value cell. + * @returns {HTMLElement} The value cell. + */ +function _addRow(tooltip, iconClass, labelText, populate) { + const icon = document.createElement('i'); + icon.className = `${iconClass} path-tooltip__icon`; + tooltip.appendChild(icon); + + const label = document.createElement('span'); + label.className = 'path-tooltip__label'; + label.textContent = labelText; + tooltip.appendChild(label); + + const value = document.createElement('span'); + value.className = 'path-tooltip__value'; + populate(value); + tooltip.appendChild(value); + + return value; +} + +/** + * Append a "?" placeholder cell (used when data is unavailable). + * @param {HTMLElement} el + */ +function _setUnknown(el) { + el.classList.add('path-tooltip__value--unknown'); + el.textContent = '?'; +} + +// ── Event handler ───────────────────────────────────────────────────────────── + +/** + * @param {MouseEvent} e + */ +function _onEnter(e) { + const item = /** @type {HTMLElement} */ (e.currentTarget); + const ownerId = item.dataset.ownerId; + const path = item.dataset.path; + + // Nothing to show — don't display an all-? tooltip. + if (!ownerId && !path) return; + + const tooltip = _getOrCreateTooltip(); + + // Clear previous content. + while (tooltip.firstChild) tooltip.removeChild(tooltip.firstChild); + + // ── Owner row (always rendered) ─────────────────────────────────────────── + _addRow(tooltip, 'fas fa-user', i18n.t('files.owner', 'Owner'), (el) => { + if (ownerId && systemUsers.isAvailable()) { + el.appendChild(createUserVignette(ownerId, 'xs')); + } else { + _setUnknown(el); + } + }); + + // ── Path row (always rendered) ──────────────────────────────────────────── + _addRow(tooltip, 'fas fa-location-crosshairs', i18n.t('tooltip.path', 'Path'), (el) => { + if (path) { + el.classList.add('path-tooltip__value--path'); + el.textContent = path; + } else { + _setUnknown(el); + } + }); + + tooltip.classList.remove('hidden'); +} + +function _onLeave() { + _hide(); +} + +// ── Listener registry (WeakMap for leak-free cleanup) ──────────────────────── + +/** + * @typedef {{ enter: (e: MouseEvent) => void, leave: () => void }} Handlers + */ + +/** @type {WeakMap} */ +const _registry = new WeakMap(); + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Attach tooltip listeners to every `.file-item` inside `container`. + * Items with neither `data-owner-id` nor `data-path` will not trigger the + * tooltip. Safe to call repeatedly — already-wired elements are skipped. + * @param {HTMLElement} container + */ +function init(container) { + for (const item of container.querySelectorAll('.file-item')) { + const el = /** @type {HTMLElement} */ (item); + if (_registry.has(el)) continue; // already wired + + const enter = (/** @type {MouseEvent} */ ev) => _onEnter(ev); + const leave = () => _onLeave(); + + el.addEventListener('mouseenter', enter); + el.addEventListener('mouseleave', leave); + _registry.set(el, { enter, leave }); + } +} + +/** + * Remove tooltip listeners from all `.file-item` elements inside `container` + * and hide any visible tooltip. + * @param {HTMLElement} container + */ +function destroy(container) { + for (const item of container.querySelectorAll('.file-item')) { + const el = /** @type {HTMLElement} */ (item); + const h = _registry.get(el); + if (h) { + el.removeEventListener('mouseenter', h.enter); + el.removeEventListener('mouseleave', h.leave); + _registry.delete(el); + } + } + _hide(); +} + +export { destroy, init }; diff --git a/static/js/features/library/recent.js b/static/js/features/library/recent.js index 8da2aa07..126dbf7f 100644 --- a/static/js/features/library/recent.js +++ b/static/js/features/library/recent.js @@ -11,7 +11,7 @@ import { ResourceListComponent } from '../../components/resourceList.js'; import { getCsrfHeaders } from '../../core/csrf.js'; import { i18n } from '../../core/i18n.js'; import { batchToolbar } from '../files/batchToolbar.js'; -import * as pathTooltip from '../pathTooltip.js'; +import * as itemTooltip from '../itemTooltip.js'; /** @import {FileItem, FolderItem, ItemTypeEnum, RecentItem} from '../../core/types.js' */ @@ -208,7 +208,7 @@ const recent = { } batchToolbar.setActiveComponent(this._component); this._component.render(items); - pathTooltip.init(filesList); + itemTooltip.init(filesList); } } catch (error) { console.error('Error displaying recent files:', error); diff --git a/static/js/features/ownerTooltip.js b/static/js/features/ownerTooltip.js deleted file mode 100644 index 3349374d..00000000 --- a/static/js/features/ownerTooltip.js +++ /dev/null @@ -1,121 +0,0 @@ -// @ts-check - -/** - * Owner tooltip — shows "Shared by: " when hovering a - * `.file-item[data-owner-id]` element. - * - * Reuses the existing `#path-tooltip` DOM element (same position and style) - * so no extra CSS is needed. The tooltip is hidden immediately on mouseleave - * and the display-name resolution is async-but-usually-instant because - * `systemUsers` is pre-fetched when the Shared-with-me section is entered. - * - * Usage: - * ownerTooltip.init(containerEl) — call after rendering items - * ownerTooltip.destroy(containerEl) — call when leaving the section - */ - -import { i18n } from '../core/i18n.js'; -import { systemUsers } from '../model/systemUsers.js'; - -// ── Tooltip DOM ─────────────────────────────────────────────────────────────── - -/** @returns {HTMLElement} */ -function _getOrCreateTooltip() { - let el = document.getElementById('path-tooltip'); - if (!el) { - el = document.createElement('div'); - el.id = 'path-tooltip'; - el.className = 'path-tooltip hidden'; - document.querySelector('.main-content')?.appendChild(el); - } - return el; -} - -function _hide() { - document.getElementById('path-tooltip')?.classList.add('hidden'); -} - -// ── Event handlers ──────────────────────────────────────────────────────────── - -/** - * @param {MouseEvent} e - */ -async function _onEnter(e) { - const item = /** @type {HTMLElement} */ (e.currentTarget); - const ownerId = item.dataset.ownerId; - if (!ownerId) return; - - if (!systemUsers.isAvailable()) return; - - const tooltip = _getOrCreateTooltip(); - - // Show immediately with a placeholder so the tooltip appears without lag. - const label = i18n.t('sharedwithme_sharedBy', 'Shared by'); - tooltip.textContent = `${label}: …`; - tooltip.classList.remove('hidden'); - - // Resolve the name (usually instant from the pre-fetched cache). - const name = await systemUsers.getDisplayName(ownerId); - - // Guard: don't update if the user already moved away. - if (!tooltip.classList.contains('hidden')) { - tooltip.textContent = `${label}: ${name}`; - } -} - -function _onLeave() { - _hide(); -} - -// ── Listener registry (WeakMap for leak-free cleanup) ──────────────────────── - -/** - * @typedef {{ enter: (e: MouseEvent) => void, leave: () => void }} Handlers - */ - -/** @type {WeakMap} */ -const _registry = new WeakMap(); - -// ── Public API ──────────────────────────────────────────────────────────────── - -/** - * Attach owner-tooltip listeners to every `.file-item[data-owner-id]` - * inside `container`. - * @param {HTMLElement} container - */ -function init(container) { - for (const item of container.querySelectorAll('.file-item[data-owner-id]')) { - const el = /** @type {HTMLElement} */ (item); - if (_registry.has(el)) continue; // already wired - - /** @type {(e: MouseEvent) => void} */ - const enter = (e) => { - _onEnter(e); - }; // intentionally discard the Promise - const leave = () => _onLeave(); - - el.addEventListener('mouseenter', enter); - el.addEventListener('mouseleave', leave); - _registry.set(el, { enter, leave }); - } -} - -/** - * Remove owner-tooltip listeners from all `.file-item` elements inside - * `container` and hide any visible tooltip. - * @param {HTMLElement} container - */ -function destroy(container) { - for (const item of container.querySelectorAll('.file-item')) { - const el = /** @type {HTMLElement} */ (item); - const h = _registry.get(el); - if (h) { - el.removeEventListener('mouseenter', h.enter); - el.removeEventListener('mouseleave', h.leave); - _registry.delete(el); - } - } - _hide(); -} - -export const ownerTooltip = { init, destroy }; diff --git a/static/js/features/pathTooltip.js b/static/js/features/pathTooltip.js deleted file mode 100644 index 743f824b..00000000 --- a/static/js/features/pathTooltip.js +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Path tooltip — shows the full path of a hovered file/folder item - * in an overlay at the bottom-left of the content area. - * - * Usage: call init(container) after rendering items, destroy(container) on teardown. - * Only file-item elements with a data-path attribute trigger the tooltip. - */ - -/** @type {HTMLElement|null} */ -let _tooltip = null; - -function _getOrCreateTooltip() { - if (_tooltip) return _tooltip; - _tooltip = document.getElementById('path-tooltip'); - if (!_tooltip) { - _tooltip = document.createElement('div'); - _tooltip.id = 'path-tooltip'; - _tooltip.className = 'path-tooltip hidden'; - document.querySelector('.main-content')?.appendChild(_tooltip); - } - return _tooltip; -} - -/** - * @param {MouseEvent} e - */ -function _onEnter(e) { - const item = /** @type {HTMLElement} */ (e.currentTarget); - const path = item.dataset.path; - if (!path) return; - - const tooltip = _getOrCreateTooltip(); - tooltip.textContent = path; - tooltip.classList.remove('hidden'); -} - -function _onLeave() { - _tooltip?.classList.add('hidden'); -} - -/** - * @typedef {Object} EnterLeaveF - * @property {(e: MouseEvent) => void} enter - * @property {(e: MouseEvent) => void} leave - * - -/** @type {WeakMap} */ -const _listeners = new WeakMap(); - -/** - * Attach path tooltip listeners to all file-item elements inside container. - * @param {HTMLElement} container - */ -function init(container) { - const items = container.querySelectorAll('.file-item[data-path]'); - items.forEach((item) => { - const el = /** @type {HTMLElement} */ (item); - - /** @type {(e: MouseEvent) => void} */ - const enter = (e) => _onEnter(e); - el.addEventListener('mouseenter', enter); - - /** @type {(e: MouseEvent) => void} */ - const leave = (_e) => _onLeave(); - el.addEventListener('mouseleave', leave); - - _listeners.set(el, { enter, leave }); - }); -} - -/** - * Remove path tooltip listeners from all file-item elements inside container. - * @param {HTMLElement} container - */ -function destroy(container) { - const items = container.querySelectorAll('.file-item'); - items.forEach((item) => { - const el = /** @type {HTMLElement} */ (item); - const fns = _listeners.get(el); - if (fns) { - el.removeEventListener('mouseenter', fns.enter); - el.removeEventListener('mouseleave', fns.leave); - _listeners.delete(el); - } - }); - _onLeave(); -} - -export { destroy, init }; diff --git a/static/js/views/sharedWithMe/sharedWithMeView.js b/static/js/views/sharedWithMe/sharedWithMeView.js index 1ddd6324..779e78df 100644 --- a/static/js/views/sharedWithMe/sharedWithMeView.js +++ b/static/js/views/sharedWithMe/sharedWithMeView.js @@ -15,8 +15,8 @@ import { ResourceListComponent } from '../../components/resourceList.js'; import { normalizeDateBucket, sizeBucket } from '../../core/formatters.js'; import { i18n } from '../../core/i18n.js'; import { batchToolbar } from '../../features/files/batchToolbar.js'; +import * as itemTooltip from '../../features/itemTooltip.js'; import { favorites } from '../../features/library/favorites.js'; -import { ownerTooltip } from '../../features/ownerTooltip.js'; import { grants } from '../../model/grants.js'; import { systemUsers } from '../../model/systemUsers.js'; @@ -262,7 +262,7 @@ const sharedWithMeView = { batchToolbar.setActiveComponent(null); const filesList = document.getElementById('files-list'); - if (filesList) ownerTooltip.destroy(filesList); + if (filesList) itemTooltip.destroy(filesList); }, // ── Internal helpers ────────────────────────────────────────────────────── @@ -319,7 +319,7 @@ const sharedWithMeView = { // Wire owner tooltips after items are in the DOM const filesList = document.getElementById('files-list'); - if (filesList) ownerTooltip.init(filesList); + if (filesList) itemTooltip.init(filesList); // Fill the Owner column cells (idempotent: skips already-resolved rows). await this._component?.resolveOwnerCells();