feat(tooltip): add tooltip on users and groups

This commit is contained in:
Edouard Vanbelle
2026-06-05 15:01:22 +02:00
parent c5e0800336
commit e4bb5b064a
23 changed files with 566 additions and 26 deletions
+146 -2
View File
@@ -17,6 +17,142 @@
*/
import { escapeHtml } from '../core/formatters.js';
import { i18n } from '../core/i18n.js';
import { groups, INTERNAL_GROUP_ID } from '../model/groups.js';
import { systemUsers } from '../model/systemUsers.js';
import { attachRichTooltip, OxiTooltipClass } from '../utils/tooltip.js';
/** Max member names displayed in the on-hover tooltip; any extra count
* surfaces as a "+N" badge on the last line. Kept small enough to fit
* inside the 280 px tooltip without scrolling, large enough to be
* informative for the typical share-with-a-team case. */
const MAX_MEMBERS_IN_TOOLTIP = 8;
/**
* Session-scoped cache of resolved member lists, keyed by group UUID.
* One entry per group ever hovered; the promise is reused across
* subsequent vignettes for the same group so a list of 50 rows that
* all reference the same group hits `/api/groups/{id}/members` once.
*
* @type {Map<string, Promise<{ names: string[], total: number }>>}
*/
const _membersCache = new Map();
/**
* Resolve the first N direct members of a group to display names.
* Idempotent + memoised across vignettes that share a group id.
*
* Failures (403 on a group the caller can't list, 404 on a stale id,
* network errors) resolve to an empty-names + zero-total shape so the
* UI shows a graceful "no members" placeholder instead of throwing.
*
* @param {string} groupId
* @returns {Promise<{ names: string[], total: number }>}
*/
async function _resolveGroupMembers(groupId) {
const cached = _membersCache.get(groupId);
if (cached) return cached;
const promise = (async () => {
/** @type {import('../core/types.js').GroupMemberItem[]} */
let members;
try {
members = await groups.listMembers(groupId);
} catch {
return { names: [], total: 0 };
}
const total = members.length;
// Only resolve the names we'll actually display — the "+N more"
// badge counts the rest from `total - MAX`. Cuts down on the
// number of /api/users/{id} backfills for big groups.
const slice = members.slice(0, MAX_MEMBERS_IN_TOOLTIP);
// Parallelise the per-member name lookups so a 8-member group
// resolves in one round-trip's worth of latency, not eight.
const names = await Promise.all(
slice.map(async (m) => {
if (m.kind === 'user') {
try {
return await systemUsers.getDisplayName(m.id);
} catch {
return `${m.id.slice(0, 8)}…`;
}
}
// Nested group — resolve via groups model.
try {
const resolved = await groups.resolveGroups([m.id]);
const g = resolved?.[m.id];
if (g?.name) return `👥 ${g.name}`;
} catch {
// fall through
}
return `👥 ${m.id.slice(0, 8)}…`;
})
);
return { names, total };
})();
_membersCache.set(groupId, promise);
return promise;
}
/**
* Attach the on-hover member popover to a group vignette. Delegates
* positioning + show/hide + portal placement to the generic
* `attachRichTooltip` helper (see `utils/tooltip.js`); this function
* just owns the per-row content — the placeholder, the member lines,
* and the "+N" overflow badge.
*
* @param {HTMLElement} vignetteEl the wrapper returned by `createGroupVignette`
* @param {string} groupId UUID of the group whose members to show
*/
function _attachMembersTooltip(vignetteEl, groupId) {
attachRichTooltip(vignetteEl, async (pop) => {
// Placeholder shown until the network resolves. Slow connections
// see "Loading members…" instead of an empty box.
const placeholder = document.createElement('div');
placeholder.className = OxiTooltipClass.PLACEHOLDER;
placeholder.textContent = i18n.t('groups.members_loading', 'Loading members…');
pop.appendChild(placeholder);
const { names, total } = await _resolveGroupMembers(groupId);
pop.replaceChildren();
if (total === 0) {
const empty = document.createElement('div');
empty.className = OxiTooltipClass.PLACEHOLDER;
// Special case: the built-in "Internal" virtual group has
// implicit membership — it represents every internal user
// on this server. `listMembers` returns an empty array
// because no explicit rows exist in `auth.subject_group_members`,
// but "No members" would mislead. Surface the real meaning
// instead. Future virtual groups with implicit membership
// (e.g. "Everyone") would extend this branch.
empty.textContent =
groupId === INTERNAL_GROUP_ID
? i18n.t('groups.virtual_internal_explanation', 'Every internal user on this server')
: i18n.t('groups.members_empty', 'No members');
pop.appendChild(empty);
return;
}
for (const name of names) {
const line = document.createElement('div');
line.className = OxiTooltipClass.LINE;
line.textContent = name;
pop.appendChild(line);
}
if (total > MAX_MEMBERS_IN_TOOLTIP) {
const overflow = document.createElement('div');
overflow.className = OxiTooltipClass.LINE;
// Inner badge so the "+N" reads as a count, not another name.
const badge = document.createElement('span');
badge.className = OxiTooltipClass.OVERFLOW;
badge.textContent = `+${total - MAX_MEMBERS_IN_TOOLTIP}`;
overflow.append('… ', badge);
pop.appendChild(overflow);
}
});
}
/**
* Build the inline vignette.
@@ -26,17 +162,25 @@ import { escapeHtml } from '../core/formatters.js';
* @param {'xs'|'sm'|'md'|'list'} [size='sm']
* Matches the size scale of `createUserVignette`. The size class is
* `user-vignette--${size}`; see `static/css/components/userVignette.css`.
* @param {{ icon?: string }} [opts]
* @param {{ icon?: string, groupId?: string }} [opts]
* `icon`: FA class string without the `fa-` prefix (defaults to
* `'fa-user-group'`). Used to signal virtual groups visually — see
* `groupIconClass()` / `groupIconClassByVirtual()` in `./groupDisplay.js`
* return a distinct icon for system-wide virtual groups (Internal,
* future Everyone, …).
*
* `groupId`: UUID of the group. When supplied, hovering the vignette
* reveals a tooltip listing up to {@link MAX_MEMBERS_IN_TOOLTIP}
* member names with a `+N` badge for overflow. The member list is
* fetched lazily on first hover, memoised across all vignettes that
* share the same id, so a row of 50 grants pointing at the same
* team only hits `/api/groups/{id}/members` once.
* @returns {HTMLElement}
*/
export function createGroupVignette(name, size = 'sm', { icon = 'fa-user-group' } = {}) {
export function createGroupVignette(name, size = 'sm', { icon = 'fa-user-group', groupId } = {}) {
const el = document.createElement('div');
el.className = `user-vignette user-vignette-group user-vignette--${size}`;
el.innerHTML = `<span class="user-vignette__avatar"><i class="fas ${escapeHtml(icon)}"></i></span><span class="user-vignette__name">${escapeHtml(name)}</span>`;
if (groupId) _attachMembersTooltip(el, groupId);
return el;
}
+4 -2
View File
@@ -270,7 +270,8 @@ class MySharesList {
}
if (swimKey.startsWith('group:')) {
return createGroupVignette(this._groupName(grant.subject_id), 'list', {
icon: this._groupIcon(grant.subject_id)
icon: this._groupIcon(grant.subject_id),
groupId: grant.subject_id
});
}
const el = document.createElement('div');
@@ -347,7 +348,8 @@ class MySharesList {
} else if (grant.subject_type === 'group') {
el.appendChild(
createGroupVignette(this._groupName(grant.subject_id), 'xs', {
icon: this._groupIcon(grant.subject_id)
icon: this._groupIcon(grant.subject_id),
groupId: grant.subject_id
})
);
} else {
+18 -6
View File
@@ -20,6 +20,7 @@
*/
import { systemUsers } from '../model/systemUsers.js';
import { attachTooltip } from '../utils/tooltip.js';
// ── Helpers ────────────────────────────────────────────────────────────────────
@@ -167,12 +168,23 @@ export function createUserVignette(userId, size = 'sm', { showName = true, showE
]).then(([name, photo, email, isExternal]) => {
if (nameEl) nameEl.textContent = name;
if (emailEl) emailEl.textContent = email ?? '';
// Tooltip: surface the email on hover when it's not already
// rendered as the visible label (showEmail mode) and isn't
// already the displayed name (the fallback case where the user
// has no given/family/username and the label IS the email).
if (email && !showEmail && email !== name) {
wrapper.title = email;
// Tooltip: surface the email on hover for every vignette that
// has one — including external users whose visible label IS
// the email already. The redundant "alice@x.com → alice@x.com"
// hover is a small price for keeping the interaction uniform:
// every user row in a list reacts to hover the same way, so
// the user doesn't learn "internal rows have tooltips, external
// rows are silent". Suppressed only in `showEmail` mode, where
// the email is already a permanent line below the name.
//
// `attachTooltip` portals the popover to `document.body` and
// applies the shared 250 ms hover-intent delay (much faster
// than the native `title` attribute's ~500–1500 ms wait).
// `aria-label` is set in parallel so screen readers still get
// the email — popover content is mouse/keyboard-hover only.
if (email && !showEmail) {
wrapper.setAttribute('aria-label', email);
attachTooltip(wrapper, email);
}
if (photo) {
_applyPhoto(avatar, photo, name);
+232
View File
@@ -0,0 +1,232 @@
// @ts-check
/**
* Generic tooltip helper used app-wide.
*
* Two flavours:
*
* - {@link attachTooltip} — short text label, optionally populated
* from a `data-tooltip` attribute. Use
* for one-liners (the email-on-hover on
* a user vignette, the title-text on a
* chip / button, etc.).
* - {@link attachRichTooltip} — structured DOM populated lazily on
* first hover. Use when the content is
* multi-line, async-fetched, or needs
* inner styling (e.g. the member list
* in a group vignette).
*
* Both portal the popover to `document.body` and position it with
* `position: fixed` so it escapes every ancestor `overflow: hidden`
* clip in the page. This is the only reliable cross-browser way to
* keep tooltips fully visible from triggers buried inside list rows,
* scroll containers, or modal panels.
*
* The class toggle (`oxi-tooltip-popover--visible`) is JS-driven on
* `mouseenter` / `mouseleave` / `focusin` / `focusout`. The hover-intent
* delay (250 ms before fade-in, 0 ms before fade-out) lives entirely
* in the CSS transition rules — never in a JS `setTimeout`. See
* `static/css/components/tooltip.css` for the timing source of truth.
*
* Layout helpers exported for the rich variant:
* - `OxiTooltipClass.LINE` — apply to each row inside the popover
* - `OxiTooltipClass.OVERFLOW` — small "+N" badge for truncated lists
* - `OxiTooltipClass.PLACEHOLDER` — italic dimmed text for loading /
* empty states
*/
const POPOVER_CLASS = 'oxi-tooltip-popover';
const VISIBLE_CLASS = 'oxi-tooltip-popover--visible';
const SIMPLE_CLASS = 'oxi-tooltip-popover--simple';
/** Class names exported so callers can build the popover body with the
* layout helpers without dragging in private CSS module conventions. */
export const OxiTooltipClass = Object.freeze({
LINE: 'oxi-tooltip-popover__line',
OVERFLOW: 'oxi-tooltip-popover__overflow',
PLACEHOLDER: 'oxi-tooltip-popover__placeholder'
});
/** Distance between the tooltip and the trigger edge, in pixels. */
const GAP = 6;
/** Inset from the viewport edges when clamping the tooltip position. */
const MARGIN = 8;
/**
* Position `popover` above (or below, when there isn't room above) the
* given trigger element. Uses `position: fixed` so it escapes any
* ancestor `overflow: hidden`. Clamps horizontally and vertically into
* the viewport so tooltips near the edges still read cleanly.
*
* @param {HTMLElement} popover
* @param {HTMLElement} triggerEl
*/
function _positionPopover(popover, triggerEl) {
const triggerRect = triggerEl.getBoundingClientRect();
// Measure after content has been added so we know the final size.
const popRect = popover.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
// Vertical: prefer above the trigger. Flip below when there's not
// enough room above.
let top = triggerRect.top - popRect.height - GAP;
if (top < MARGIN) {
top = triggerRect.bottom + GAP;
}
// Horizontal: center on the trigger, clamp into the viewport.
let left = triggerRect.left + triggerRect.width / 2 - popRect.width / 2;
if (left < MARGIN) left = MARGIN;
if (left + popRect.width > vw - MARGIN) left = vw - popRect.width - MARGIN;
// Final vertical clamp — covers the (very rare) case where the
// tooltip is taller than the visible viewport.
if (top + popRect.height > vh - MARGIN) top = vh - popRect.height - MARGIN;
if (top < MARGIN) top = MARGIN;
popover.style.top = `${top}px`;
popover.style.left = `${left}px`;
}
/**
* Internal: wire mouseenter/leave + focusin/out listeners on `triggerEl`,
* lazily create the popover element on first hover, and call `populate`
* once to fill it. Returns a cleanup function that removes the
* listeners and the popover element.
*
* @param {HTMLElement} triggerEl
* @param {(popover: HTMLElement) => void | Promise<void>} populate
* Called exactly once when the popover is first shown. Synchronous
* populates take effect immediately; async populates show the
* placeholder span (if you created one) until the promise resolves,
* after which the popover is re-positioned to account for size
* changes.
* @param {{ simple?: boolean }} [opts]
* `simple`: add the `--simple` modifier so the popover uses the
* single-line label style (white-space: nowrap, no min-width).
* @returns {() => void} Cleanup; idempotent.
*/
function _attach(triggerEl, populate, opts = {}) {
/** @type {HTMLElement | null} */
let popover = null;
let populated = false;
let detached = false;
const ensurePopover = () => {
if (popover) return popover;
popover = document.createElement('div');
popover.className = POPOVER_CLASS + (opts.simple ? ` ${SIMPLE_CLASS}` : '');
// ARIA: behave like a tooltip for screen readers — though we
// also rely on `aria-label` / surrounding text since hover
// isn't reachable via keyboard-only assistive tech.
popover.setAttribute('role', 'tooltip');
document.body.appendChild(popover);
return popover;
};
const show = () => {
if (detached) return;
const pop = ensurePopover();
if (!populated) {
populated = true;
// Synchronous populate paths render immediately. Async
// populates (those returning a Promise) re-position after
// resolve so the tooltip catches up to its final size —
// important when the placeholder text is much narrower
// than the eventual content.
const result = populate(pop);
if (result && typeof (/** @type {Promise<void>} */ (result).then) === 'function') {
/** @type {Promise<void>} */ (result).then(() => {
if (popover?.classList.contains(VISIBLE_CLASS)) {
_positionPopover(popover, triggerEl);
}
});
}
}
_positionPopover(pop, triggerEl);
pop.classList.add(VISIBLE_CLASS);
};
const hide = () => {
if (popover) popover.classList.remove(VISIBLE_CLASS);
};
triggerEl.addEventListener('mouseenter', show);
triggerEl.addEventListener('mouseleave', hide);
triggerEl.addEventListener('focusin', show);
triggerEl.addEventListener('focusout', hide);
return () => {
if (detached) return;
detached = true;
triggerEl.removeEventListener('mouseenter', show);
triggerEl.removeEventListener('mouseleave', hide);
triggerEl.removeEventListener('focusin', show);
triggerEl.removeEventListener('focusout', hide);
popover?.remove();
popover = null;
};
}
/**
* Attach a simple single-line tooltip to `triggerEl`.
*
* @param {HTMLElement} triggerEl
* @param {string} text The label to display.
* @returns {() => void} Cleanup function; idempotent.
*
* @example
* attachTooltip(emailBadgeEl, 'alice@example.com');
*/
export function attachTooltip(triggerEl, text) {
return _attach(
triggerEl,
(pop) => {
pop.textContent = text;
},
{ simple: true }
);
}
/**
* Attach a rich tooltip with structured DOM populated lazily on first
* hover. The `populate` callback receives the popover element and can
* append whatever children it wants. Return a Promise to populate
* async — the popover re-positions on resolve.
*
* @param {HTMLElement} triggerEl
* @param {(popover: HTMLElement) => void | Promise<void>} populate
* @returns {() => void} Cleanup function; idempotent.
*
* @example
* attachRichTooltip(groupEl, async (pop) => {
* const placeholder = document.createElement('div');
* placeholder.className = OxiTooltipClass.PLACEHOLDER;
* placeholder.textContent = i18n.t('groups.members_loading');
* pop.appendChild(placeholder);
* const members = await fetchMembers(groupId);
* pop.replaceChildren(); // drop the placeholder
* for (const name of members.slice(0, 8)) {
* const line = document.createElement('div');
* line.className = OxiTooltipClass.LINE;
* line.textContent = name;
* pop.appendChild(line);
* }
* if (members.length > 8) {
* const overflow = document.createElement('div');
* overflow.className = OxiTooltipClass.LINE;
* const badge = document.createElement('span');
* badge.className = OxiTooltipClass.OVERFLOW;
* badge.textContent = `+${members.length - 8}`;
* overflow.append('… ', badge);
* pop.appendChild(overflow);
* }
* });
*/
export function attachRichTooltip(triggerEl, populate) {
return _attach(triggerEl, populate);
}