feat(tooltip): add tooltip on users and groups
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
/* ── Tooltip primitive ─────────────────────────────────────────────
|
||||
*
|
||||
* Generic on-hover tooltip used app-wide. Two flavours:
|
||||
*
|
||||
* 1. Single-line label, content from `data-tooltip="…"` on the trigger.
|
||||
* Use `attachTooltip(el, text)` from `static/js/utils/tooltip.js`.
|
||||
* 2. Rich popover with a DOM subtree, populated lazily on first hover.
|
||||
* Use `attachRichTooltip(el, populateAsync)` for things like the
|
||||
* group-vignette member list.
|
||||
*
|
||||
* Both are portal'd to `document.body` (the JS helper creates a child
|
||||
* of body and positions it with `position: fixed`) so they escape the
|
||||
* `overflow: hidden` clipping that lives on lane wrappers, list rows,
|
||||
* and any other "contain my children" ancestor. Without the portal,
|
||||
* tooltips near the edges of those containers get cropped — exactly
|
||||
* the bug that prompted this refactor.
|
||||
*
|
||||
* Hover-intent timing is asymmetric on purpose:
|
||||
* - Entry: 250 ms delay before the fade-in starts. Short enough to
|
||||
* feel responsive (much faster than the browser-native `title`
|
||||
* delay, which is ~500–1500 ms), long enough to suppress flicker
|
||||
* on accidental mouseovers.
|
||||
* - Exit: 0 ms delay. The tooltip dismisses immediately when the
|
||||
* user moves away.
|
||||
*
|
||||
* The class toggle is JS-driven (mouseenter/leave + focusin/out
|
||||
* listeners on the trigger); the visible transition lives entirely
|
||||
* in CSS so a setTimeout never gates the visual change.
|
||||
*/
|
||||
|
||||
.oxi-tooltip-popover {
|
||||
position: fixed;
|
||||
min-width: 0;
|
||||
max-width: 280px;
|
||||
padding: 6px 10px;
|
||||
background-color: var(--color-text);
|
||||
color: var(--color-bg-page);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px var(--color-shadow);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
/* Default (no `--visible`): fast hide, no delay. */
|
||||
transition: opacity 100ms ease-out 0ms;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
/* Simple (data-tooltip) flavour — keeps the label on one line so it
|
||||
reads as a short caption, not a paragraph. Used by short labels
|
||||
like a user vignette's email-on-hover. */
|
||||
.oxi-tooltip-popover--simple {
|
||||
white-space: nowrap;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Visible state overrides the transition to add the hover-intent
|
||||
delay on the *entry* path. Removing the class falls back to
|
||||
the default rule above (no delay → immediate fade-out). */
|
||||
.oxi-tooltip-popover--visible {
|
||||
opacity: 1;
|
||||
transition: opacity 100ms ease-out 250ms;
|
||||
}
|
||||
|
||||
/* ── Rich-popover layout helpers ────────────────────────────────── */
|
||||
|
||||
.oxi-tooltip-popover__line {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* "+N" overflow badge — small pill at the end of a list of items when
|
||||
there are more than the tooltip cares to show. Lighter background
|
||||
so the count reads as meta-information, not just another line. */
|
||||
.oxi-tooltip-popover__overflow {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
padding: 1px 7px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--color-bg-page);
|
||||
background-color: var(--color-text-faint);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* Placeholder line shown briefly between the first hover and the
|
||||
populate() callback's resolve (e.g. while `/api/groups/{id}/members`
|
||||
is in flight). Italic + dimmed so the user perceives it as a
|
||||
transitional state, not real content. */
|
||||
.oxi-tooltip-popover__placeholder {
|
||||
color: var(--color-text-faint);
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -21,6 +21,12 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The on-hover email tooltip (used by `createUserVignette` when the
|
||||
email is set) and the rich group-members popover both live in
|
||||
`components/tooltip.css` and are attached at runtime via
|
||||
`static/js/utils/tooltip.js` — kept generic so other surfaces
|
||||
(role chips, link chips, action buttons) can opt in the same way. */
|
||||
|
||||
.user-vignette__avatar {
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
@import url("./components/shareDialog.css");
|
||||
@import url("./components/shareModal.css");
|
||||
@import url("./components/groupsModal.css");
|
||||
@import url("./components/tooltip.css");
|
||||
@import url("./components/userVignette.css");
|
||||
@import url("./components/linkChip.css");
|
||||
@import url("./components/uploadDropdown.css");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -867,7 +867,10 @@
|
||||
"member_count_other": "{count} أعضاء",
|
||||
"delete_confirm_label": "اكتب اسم المجموعة للتأكيد:",
|
||||
"delete_confirm_mismatch": "اكتب اسم المجموعة كما هو للتأكيد.",
|
||||
"virtual_internal_name": "داخلي"
|
||||
"virtual_internal_name": "داخلي",
|
||||
"members_loading": "جارٍ تحميل الأعضاء…",
|
||||
"members_empty": "لا يوجد أعضاء",
|
||||
"virtual_internal_explanation": "كل مستخدم داخلي على هذا الخادم"
|
||||
},
|
||||
"myshares": {
|
||||
"copyLink": "نسخ الرابط",
|
||||
|
||||
@@ -867,7 +867,10 @@
|
||||
"member_count_other": "{count} Mitglieder",
|
||||
"delete_confirm_label": "Tippe den Gruppennamen zur Bestätigung ein:",
|
||||
"delete_confirm_mismatch": "Tippe den Gruppennamen exakt zur Bestätigung ein.",
|
||||
"virtual_internal_name": "Intern"
|
||||
"virtual_internal_name": "Intern",
|
||||
"members_loading": "Mitglieder werden geladen…",
|
||||
"members_empty": "Keine Mitglieder",
|
||||
"virtual_internal_explanation": "Jeder interne Benutzer auf diesem Server"
|
||||
},
|
||||
"myshares": {
|
||||
"copyLink": "Link kopieren",
|
||||
|
||||
@@ -862,6 +862,8 @@
|
||||
"name_placeholder": "engineering",
|
||||
"description_label": "Description (optional)",
|
||||
"members_section": "Members",
|
||||
"members_loading": "Loading members…",
|
||||
"members_empty": "No members",
|
||||
"add_member_placeholder": "Add a user or group…",
|
||||
"no_members": "No members yet.",
|
||||
"remove_member": "Remove",
|
||||
@@ -877,6 +879,7 @@
|
||||
"member_count_other": "{count} members",
|
||||
"delete_confirm_label": "Type the group name to confirm:",
|
||||
"delete_confirm_mismatch": "Type the group name exactly to confirm.",
|
||||
"virtual_internal_name": "Internal"
|
||||
"virtual_internal_name": "Internal",
|
||||
"virtual_internal_explanation": "Every internal user on this server"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -867,7 +867,10 @@
|
||||
"member_count_other": "{count} miembros",
|
||||
"delete_confirm_label": "Escribe el nombre del grupo para confirmar:",
|
||||
"delete_confirm_mismatch": "Escribe el nombre del grupo exactamente para confirmar.",
|
||||
"virtual_internal_name": "Interno"
|
||||
"virtual_internal_name": "Interno",
|
||||
"members_loading": "Cargando miembros…",
|
||||
"members_empty": "Sin miembros",
|
||||
"virtual_internal_explanation": "Todos los usuarios internos de este servidor"
|
||||
},
|
||||
"myshares": {
|
||||
"copyLink": "Copiar enlace",
|
||||
|
||||
@@ -867,7 +867,10 @@
|
||||
"member_count_other": "{count} عضو",
|
||||
"delete_confirm_label": "نام گروه را برای تأیید وارد کنید:",
|
||||
"delete_confirm_mismatch": "نام گروه را دقیقاً برای تأیید وارد کنید.",
|
||||
"virtual_internal_name": "داخلی"
|
||||
"virtual_internal_name": "داخلی",
|
||||
"members_loading": "در حال بارگیری اعضا…",
|
||||
"members_empty": "بدون عضو",
|
||||
"virtual_internal_explanation": "هر کاربر داخلی روی این سرور"
|
||||
},
|
||||
"myshares": {
|
||||
"copyLink": "کپی پیوند",
|
||||
|
||||
@@ -867,7 +867,10 @@
|
||||
"member_count_other": "{count} membres",
|
||||
"delete_confirm_label": "Tapez le nom du groupe pour confirmer :",
|
||||
"delete_confirm_mismatch": "Tapez le nom du groupe exactement pour confirmer.",
|
||||
"virtual_internal_name": "Interne"
|
||||
"virtual_internal_name": "Interne",
|
||||
"members_loading": "Chargement des membres…",
|
||||
"members_empty": "Aucun membre",
|
||||
"virtual_internal_explanation": "Tous les utilisateurs internes de ce serveur"
|
||||
},
|
||||
"myshares": {
|
||||
"copyLink": "Copier le lien",
|
||||
|
||||
@@ -867,7 +867,10 @@
|
||||
"member_count_other": "{count} सदस्य",
|
||||
"delete_confirm_label": "पुष्टि के लिए समूह का नाम लिखें:",
|
||||
"delete_confirm_mismatch": "पुष्टि के लिए समूह का नाम बिल्कुल वैसा ही लिखें।",
|
||||
"virtual_internal_name": "आंतरिक"
|
||||
"virtual_internal_name": "आंतरिक",
|
||||
"members_loading": "सदस्य लोड हो रहे हैं…",
|
||||
"members_empty": "कोई सदस्य नहीं",
|
||||
"virtual_internal_explanation": "इस सर्वर पर हर आंतरिक उपयोगकर्ता"
|
||||
},
|
||||
"myshares": {
|
||||
"copyLink": "लिंक कॉपी करें",
|
||||
|
||||
@@ -867,7 +867,10 @@
|
||||
"member_count_other": "{count} membri",
|
||||
"delete_confirm_label": "Digita il nome del gruppo per confermare:",
|
||||
"delete_confirm_mismatch": "Digita esattamente il nome del gruppo per confermare.",
|
||||
"virtual_internal_name": "Interno"
|
||||
"virtual_internal_name": "Interno",
|
||||
"members_loading": "Caricamento membri…",
|
||||
"members_empty": "Nessun membro",
|
||||
"virtual_internal_explanation": "Ogni utente interno su questo server"
|
||||
},
|
||||
"myshares": {
|
||||
"copyLink": "Copia link",
|
||||
|
||||
@@ -867,7 +867,10 @@
|
||||
"member_count_other": "{count} メンバー",
|
||||
"delete_confirm_label": "確認のためにグループ名を入力してください:",
|
||||
"delete_confirm_mismatch": "確認のためにグループ名を正確に入力してください。",
|
||||
"virtual_internal_name": "内部"
|
||||
"virtual_internal_name": "内部",
|
||||
"members_loading": "メンバーを読み込み中…",
|
||||
"members_empty": "メンバーなし",
|
||||
"virtual_internal_explanation": "このサーバー上のすべての内部ユーザー"
|
||||
},
|
||||
"myshares": {
|
||||
"copyLink": "リンクをコピー",
|
||||
|
||||
@@ -867,7 +867,10 @@
|
||||
"member_count_other": "구성원 {count}명",
|
||||
"delete_confirm_label": "확인을 위해 그룹 이름을 입력하세요:",
|
||||
"delete_confirm_mismatch": "확인을 위해 그룹 이름을 정확히 입력하세요.",
|
||||
"virtual_internal_name": "내부"
|
||||
"virtual_internal_name": "내부",
|
||||
"members_loading": "구성원 로딩 중…",
|
||||
"members_empty": "구성원 없음",
|
||||
"virtual_internal_explanation": "이 서버의 모든 내부 사용자"
|
||||
},
|
||||
"myshares": {
|
||||
"copyLink": "링크 복사",
|
||||
|
||||
@@ -867,7 +867,10 @@
|
||||
"member_count_other": "{count} leden",
|
||||
"delete_confirm_label": "Typ de groepsnaam ter bevestiging:",
|
||||
"delete_confirm_mismatch": "Typ de groepsnaam exact om te bevestigen.",
|
||||
"virtual_internal_name": "Intern"
|
||||
"virtual_internal_name": "Intern",
|
||||
"members_loading": "Leden laden…",
|
||||
"members_empty": "Geen leden",
|
||||
"virtual_internal_explanation": "Iedere interne gebruiker op deze server"
|
||||
},
|
||||
"myshares": {
|
||||
"copyLink": "Link kopiëren",
|
||||
|
||||
@@ -867,7 +867,10 @@
|
||||
"member_count_other": "{count} członków",
|
||||
"delete_confirm_label": "Wpisz nazwę grupy, aby potwierdzić:",
|
||||
"delete_confirm_mismatch": "Wpisz nazwę grupy dokładnie, aby potwierdzić.",
|
||||
"virtual_internal_name": "Wewnętrzni"
|
||||
"virtual_internal_name": "Wewnętrzni",
|
||||
"members_loading": "Ładowanie członków…",
|
||||
"members_empty": "Brak członków",
|
||||
"virtual_internal_explanation": "Każdy użytkownik wewnętrzny na tym serwerze"
|
||||
},
|
||||
"myshares": {
|
||||
"copyLink": "Skopiuj link",
|
||||
|
||||
@@ -867,7 +867,10 @@
|
||||
"member_count_other": "{count} membros",
|
||||
"delete_confirm_label": "Digite o nome do grupo para confirmar:",
|
||||
"delete_confirm_mismatch": "Digite o nome do grupo exatamente para confirmar.",
|
||||
"virtual_internal_name": "Interno"
|
||||
"virtual_internal_name": "Interno",
|
||||
"members_loading": "A carregar membros…",
|
||||
"members_empty": "Sem membros",
|
||||
"virtual_internal_explanation": "Todos os utilizadores internos neste servidor"
|
||||
},
|
||||
"myshares": {
|
||||
"copyLink": "Copiar link",
|
||||
|
||||
@@ -867,7 +867,10 @@
|
||||
"member_count_other": "{count} участников",
|
||||
"delete_confirm_label": "Введите имя группы для подтверждения:",
|
||||
"delete_confirm_mismatch": "Введите имя группы точно для подтверждения.",
|
||||
"virtual_internal_name": "Внутренние"
|
||||
"virtual_internal_name": "Внутренние",
|
||||
"members_loading": "Загрузка участников…",
|
||||
"members_empty": "Нет участников",
|
||||
"virtual_internal_explanation": "Каждый внутренний пользователь на этом сервере"
|
||||
},
|
||||
"myshares": {
|
||||
"copyLink": "Копировать ссылку",
|
||||
|
||||
@@ -867,7 +867,10 @@
|
||||
"member_count_other": "{count} 個成員",
|
||||
"delete_confirm_label": "請輸入群組名稱以確認:",
|
||||
"delete_confirm_mismatch": "請準確輸入群組名稱以確認。",
|
||||
"virtual_internal_name": "內部"
|
||||
"virtual_internal_name": "內部",
|
||||
"members_loading": "正在載入成員…",
|
||||
"members_empty": "無成員",
|
||||
"virtual_internal_explanation": "本伺服器上的所有內部使用者"
|
||||
},
|
||||
"myshares": {
|
||||
"copyLink": "複製連結",
|
||||
|
||||
@@ -867,7 +867,10 @@
|
||||
"member_count_other": "{count} 个成员",
|
||||
"delete_confirm_label": "请输入群组名称以确认:",
|
||||
"delete_confirm_mismatch": "请准确输入群组名称以确认。",
|
||||
"virtual_internal_name": "内部"
|
||||
"virtual_internal_name": "内部",
|
||||
"members_loading": "正在加载成员…",
|
||||
"members_empty": "无成员",
|
||||
"virtual_internal_explanation": "本服务器上的所有内部用户"
|
||||
},
|
||||
"myshares": {
|
||||
"copyLink": "复制链接",
|
||||
|
||||
Reference in New Issue
Block a user