fix(webui): stop the styled tooltip doubling with the browser's own

Four rules rendered their bubble with `content: attr(title)` while leaving the
title attribute in place, so the browser drew its native tooltip over the styled
one — two bubbles, offset, one of them unthemed. That is the overlap.

setup-wizard.css already had it right with attr(data-tip). Everything else now
matches: the text moved to data-tip, which the browser does not render, and
aria-label carries it for assistive tech. 24 emitters migrated across 9 files,
including two that assign the text at runtime — those set both attributes so
neither goes stale.

The same sweep found the bubble was defined twice globally. core/forms/css had
the plain one; port-manager.css carried a nicer bordered, wrapping version
commented "Enhanced tooltips for main config help icons" — which never applied
to them, because forms.css loads afterwards at equal specificity and won. So
every help icon outside the port manager had been getting the plain bubble, with
`white-space: nowrap` and no max-width: a sentence-length tooltip rendered as one
unbroken line running past its own background. The wrapping version is now the
one in forms.css, the shadowed copy is gone, and the .port-manager-scoped rule
stays.

Verified on Bookstack's config page: 26 help icons, none carrying a title, all
carrying data-tip, and hovering one flips ::after content from `none` to the text
with a single themed bubble on screen.

No port-descriptor tooltip, deliberately. PORT_N fields are replaced at runtime
by the port manager, whose hidePortFieldLabels() sets the field's help icon to
display:none — anything put there is unreachable, confirmed in the DOM. The
format is already documented column by column by the port manager's own 13 help
icons, several in more detail than a single descriptor string could manage.
This commit is contained in:
librelad 2026-08-21 00:01:18 +01:00
parent 785df3dcd8
commit 156c7fcc08
12 changed files with 66 additions and 63 deletions

View File

@ -33,7 +33,7 @@ class ConfigRenderer {
<span class="checkbox-custom"></span>
<span class="checkbox-text">
${masterKey.title || 'Enable Advanced Configuration'}
<span class="tooltip" title="${masterKey.description || 'Enable advanced configuration options'}"></span>
<span class="tooltip" data-tip="${masterKey.description || 'Enable advanced configuration options'}" aria-label="${masterKey.description || 'Enable advanced configuration options'}"></span>
</span>
</label>
</div>

View File

@ -170,7 +170,7 @@ class ToggleManager {
html += `
<label for="${toggleId}">
${configKey.title || 'Install Mode'}
<span class="tooltip" title="${configKey.description || 'Choose installation mode'}"></span>
<span class="tooltip" data-tip="${configKey.description || 'Choose installation mode'}" aria-label="${configKey.description || 'Choose installation mode'}"></span>
</label>
<select id="${toggleId}"
name="${configKey.key}"
@ -200,7 +200,7 @@ class ToggleManager {
<span class="checkbox-custom"></span>
<span class="checkbox-text">
${configKey.title || 'Enable Configuration'}
<span class="tooltip" title="${configKey.description || 'Enable this configuration option'}"></span>
<span class="tooltip" data-tip="${configKey.description || 'Enable this configuration option'}" aria-label="${configKey.description || 'Enable this configuration option'}"></span>
</span>
</label>
`;

View File

@ -412,7 +412,7 @@ Object.assign(AppsManager.prototype, {
async generateField(fieldKey, cfgKey, value, fieldConfig) {
const fieldId = fieldKey; // Use fieldKey to ensure unique IDs
const required = fieldConfig.required ? '<span class="required">*</span>' : '';
const helpIcon = fieldConfig.tooltip ? `<span class="help-icon" title="${this.escAttr(fieldConfig.tooltip)}">?</span>` : '';
const helpIcon = fieldConfig.tooltip ? `<span class="help-icon" data-tip="${this.escAttr(fieldConfig.tooltip)}" aria-label="${this.escAttr(fieldConfig.tooltip)}">?</span>` : '';
let inputHTML = '';

View File

@ -2,7 +2,7 @@
center). Rendered by MigratePage into #ov-migrate-body. -->
<div class="backup-card backup-migrate-card">
<div class="backup-card-header">
<h2>Cross-host restore <span class="tooltip" title="Pulls a backup taken on another host out of a shared backup location and lays it down here. The destination's existing copy of the app is backed up first (rollback safety), then replaced." style="font-size:.75em;opacity:.7;cursor:help"></span></h2>
<h2>Cross-host restore <span class="tooltip" data-tip="Pulls a backup taken on another host out of a shared backup location and lays it down here. The destination's existing copy of the app is backed up first (rollback safety), then replaced." aria-label="Pulls a backup taken on another host out of a shared backup location and lays it down here. The destination's existing copy of the app is backed up first (rollback safety), then replaced." style="font-size:.75em;opacity:.7;cursor:help"></span></h2>
<span class="backup-card-hint">Restore an app or whole host from another LibrePortal that shares one of your backup locations.</span>
</div>
<div class="backup-migrate-empty" id="ov-migrate-empty" hidden>

View File

@ -36,7 +36,7 @@
/* Custom speech bubble tooltip for port manager */
.port-manager .help-icon:hover::after {
content: attr(title);
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
@ -70,26 +70,10 @@
z-index: 100000;
}
/* Enhanced tooltips for main config help icons */
.help-icon:hover::after {
content: attr(title);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--card-bg, #2a2a2a);
color: var(--text-primary, #fff);
padding: 8px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
text-transform: none; /* Ensure normal case */
white-space: nowrap;
max-width: 200px;
white-space: normal;
z-index: 99999;
border: 2px solid var(--primary-color, var(--accent));
}
/* The global `.help-icon:hover::after` that used to live here has moved to
core/forms/css/forms.css, which loads later and was silently overriding it
so this copy never applied to anything outside .port-manager. The scoped rule
above still does. */
/* Speech bubble triangle for main config */
.help-icon:hover::before {
@ -133,7 +117,7 @@
/* Auto-match indicator tooltip */
.auto-match-indicator:hover::after {
content: attr(title);
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;

View File

@ -305,25 +305,25 @@ class PortManager {
<div class="port-card-body">
<div class="port-row">
<div class="port-field">
<label>Service <span class="help-icon" title="The Docker service this port belongs to">?</span><span class="auto-match-indicator" title=" Auto-assigned: Only one service available" style="display:none;"></span></label>
<label>Service <span class="help-icon" data-tip="The Docker service this port belongs to" aria-label="The Docker service this port belongs to">?</span><span class="auto-match-indicator" title=" Auto-assigned: Only one service available" style="display:none;"></span></label>
<select class="port-service" data-index="${index}">
<option value="">Select a service...</option>
</select>
</div>
<div class="port-field">
<label>Name <span class="help-icon" title="Internal name for this port (e.g., webui, ssh, dns)">?</span></label>
<label>Name <span class="help-icon" data-tip="Internal name for this port (e.g., webui, ssh, dns)" aria-label="Internal name for this port (e.g., webui, ssh, dns)">?</span></label>
<input type="text" class="port-name" placeholder="webui, ssh, etc." value="${port.name}" data-index="${index}">
</div>
<div class="port-field">
<label>External Port <span class="help-icon" title="External port mapping (use 'random' for auto-assignment)">?</span></label>
<label>External Port <span class="help-icon" data-tip="External port mapping (use 'random' for auto-assignment)" aria-label="External port mapping (use 'random' for auto-assignment)">?</span></label>
<input type="text" class="port-external" placeholder="random or 8080" value="${port.external}" data-index="${index}">
</div>
<div class="port-field">
<label>Internal Port <span class="help-icon" title="The port number inside the container">?</span></label>
<label>Internal Port <span class="help-icon" data-tip="The port number inside the container" aria-label="The port number inside the container">?</span></label>
<input type="text" class="port-internal" placeholder="1111" value="${port.internal}" data-index="${index}">
</div>
<div class="port-field port-field-advanced">
<label>Protocol <span class="help-icon" title="Network protocol for this port connection">?</span></label>
<label>Protocol <span class="help-icon" data-tip="Network protocol for this port connection" aria-label="Network protocol for this port connection">?</span></label>
<select class="port-protocol" data-index="${index}">
<option value="tcp" ${port.protocol === 'tcp' ? 'selected' : ''}>TCP</option>
<option value="udp" ${port.protocol === 'udp' ? 'selected' : ''}>UDP</option>
@ -332,7 +332,7 @@ class PortManager {
</div>
<div class="port-row">
<div class="port-field">
<label>Access <span class="help-icon" title="Who can access this port from the network">?</span></label>
<label>Access <span class="help-icon" data-tip="Who can access this port from the network" aria-label="Who can access this port from the network">?</span></label>
<select class="port-access" data-index="${index}">
<option value="disabled" ${port.access === 'disabled' ? 'selected' : ''}>Disabled</option>
<option value="private" ${port.access === 'private' ? 'selected' : ''}>Private</option>
@ -340,43 +340,43 @@ class PortManager {
</select>
</div>
<div class="port-field port-field-advanced">
<label>Traefik Managed <span class="help-icon" title="Let Traefik handle reverse proxy for this port">?</span></label>
<label>Traefik Managed <span class="help-icon" data-tip="Let Traefik handle reverse proxy for this port" aria-label="Let Traefik handle reverse proxy for this port">?</span></label>
<select class="port-traefik" data-index="${index}">
<option value="false" ${!port.traefik_managed ? 'selected' : ''}>False</option>
<option value="true" ${port.traefik_managed ? 'selected' : ''}>True</option>
</select>
</div>
<div class="port-field port-field-advanced">
<label>Button Enabled <span class="help-icon" title="Show this port as a clickable button in the main interface">?</span></label>
<label>Button Enabled <span class="help-icon" data-tip="Show this port as a clickable button in the main interface" aria-label="Show this port as a clickable button in the main interface">?</span></label>
<select class="port-button-enabled" data-index="${index}">
<option value="false" ${!port.button_enabled ? 'selected' : ''}>False</option>
<option value="true" ${port.button_enabled ? 'selected' : ''}>True</option>
</select>
</div>
<div class="port-field port-field-advanced">
<label>Login Required <span class="help-icon" title="Put this route behind Traefik basic auth (uses CFG_TRAEFIK_USER / CFG_TRAEFIK_PASS). Ignored when Authelia is enabled — Authelia takes precedence.">?</span></label>
<label>Login Required <span class="help-icon" data-tip="Put this route behind Traefik basic auth (uses CFG_TRAEFIK_USER / CFG_TRAEFIK_PASS). Ignored when Authelia is enabled — Authelia takes precedence." aria-label="Put this route behind Traefik basic auth (uses CFG_TRAEFIK_USER / CFG_TRAEFIK_PASS). Ignored when Authelia is enabled — Authelia takes precedence.">?</span></label>
<select class="port-login-required" data-index="${index}" ${!port.traefik_managed ? 'disabled' : ''}>
<option value="false" ${!port.login_required ? 'selected' : ''}>False</option>
<option value="true" ${port.login_required ? 'selected' : ''}>True</option>
</select>
</div>
<div class="port-field port-field-advanced">
<label>Recommended <span class="help-icon" title="If true, this port shows up by default in the Traefik routing panel's primary list. Independent of Button Enabled — multiple ports per app can be recommended.">?</span></label>
<label>Recommended <span class="help-icon" data-tip="If true, this port shows up by default in the Traefik routing panel's primary list. Independent of Button Enabled — multiple ports per app can be recommended." aria-label="If true, this port shows up by default in the Traefik routing panel's primary list. Independent of Button Enabled — multiple ports per app can be recommended.">?</span></label>
<select class="port-recommended" data-index="${index}">
<option value="false" ${!port.recommended ? 'selected' : ''}>False</option>
<option value="true" ${port.recommended ? 'selected' : ''}>True</option>
</select>
</div>
<div class="port-field">
<label>Button Text <span class="help-icon" title="Text displayed on the interface button for this service. Comma-separate to render multiple buttons sharing this port (line up with URL paths below).">?</span></label>
<label>Button Text <span class="help-icon" data-tip="Text displayed on the interface button for this service. Comma-separate to render multiple buttons sharing this port (line up with URL paths below)." aria-label="Text displayed on the interface button for this service. Comma-separate to render multiple buttons sharing this port (line up with URL paths below).">?</span></label>
<input type="text" class="port-button-text" placeholder="e.g. Speedtest,Results" value="${port.button_text}" data-index="${index}">
</div>
<div class="port-field port-field-advanced">
<label>URL Path <span class="help-icon" title="Path appended to the URL when opening (e.g. /admin/). Leave empty for root. Comma-separate when paired with multiple Button Text labels for multi-button entries.">?</span></label>
<label>URL Path <span class="help-icon" data-tip="Path appended to the URL when opening (e.g. /admin/). Leave empty for root. Comma-separate when paired with multiple Button Text labels for multi-button entries." aria-label="Path appended to the URL when opening (e.g. /admin/). Leave empty for root. Comma-separate when paired with multiple Button Text labels for multi-button entries.">?</span></label>
<input type="text" class="port-url-path" placeholder="e.g. /,/results/stats.php" value="${port.url_path || ''}" data-index="${index}">
</div>
<div class="port-field port-field-advanced">
<label>Subdomain <span class="help-icon" title="The subdomain this port is served on — e.g. 'vault' → vault.example.com, or 'admin.app' for a multi-level host. Use @ for the root of your domain. Leave empty to default to the app's name.">?</span></label>
<label>Subdomain <span class="help-icon" data-tip="The subdomain this port is served on — e.g. 'vault' → vault.example.com, or 'admin.app' for a multi-level host. Use @ for the root of your domain. Leave empty to default to the app's name." aria-label="The subdomain this port is served on — e.g. 'vault' → vault.example.com, or 'admin.app' for a multi-level host. Use @ for the root of your domain. Leave empty to default to the app's name.">?</span></label>
<input type="text" class="port-subdomain" placeholder="vault · @ = root · blank = app name" value="${port.subdomain || ''}" data-index="${index}">
</div>
</div>

View File

@ -50,7 +50,7 @@ Object.assign(BackupPage.prototype, {
block.className = 'backup-retention-preset-block';
block.innerHTML = `
<label class="backup-form-row">
<span class="backup-form-label">Backup style <span class="tooltip" data-retention-tooltip title="${this.escape(meta?.hint || '')}"></span></span>
<span class="backup-form-label">Backup style <span class="tooltip" data-retention-tooltip data-tip="${this.escape(meta?.hint || '')}" aria-label="${this.escape(meta?.hint || '')}"></span></span>
<select class="form-control" data-backup-retention-preset>${presetOptions}</select>
</label>
`;
@ -75,7 +75,11 @@ Object.assign(BackupPage.prototype, {
const tooltipEl = block.querySelector('[data-retention-tooltip]');
select.addEventListener('change', () => {
const chosen = select.value;
if (tooltipEl) tooltipEl.title = BACKUP_RETENTION_PRESET_META[chosen]?.hint || '';
if (tooltipEl) {
const hint = BACKUP_RETENTION_PRESET_META[chosen]?.hint || '';
tooltipEl.dataset.tip = hint;
tooltipEl.setAttribute('aria-label', hint);
}
applyVisibility(chosen);
if (chosen === 'custom') return;
const p = BACKUP_RETENTION_PRESETS[chosen];
@ -106,7 +110,11 @@ Object.assign(BackupPage.prototype, {
const allowInherit = block.dataset.retentionAllowInherit === '1';
const preset = selectEl.value;
const tooltipEl = block.querySelector('[data-retention-tooltip]');
if (tooltipEl) tooltipEl.title = BACKUP_RETENTION_PRESET_META[preset]?.hint || '';
if (tooltipEl) {
const hint = BACKUP_RETENTION_PRESET_META[preset]?.hint || '';
tooltipEl.dataset.tip = hint;
tooltipEl.setAttribute('aria-label', hint);
}
if (preset === 'custom') {
if (advanced) advanced.hidden = false;

View File

@ -27,7 +27,7 @@
<div class="backup-cards-row">
<div class="backup-card">
<div class="backup-card-header">
<h2>Backup status <span class="tooltip" title="Latest backup per app + System config. Back up System first — it's needed to restore the rest." style="font-size:.75em;opacity:.7;cursor:help"></span></h2>
<h2>Backup status <span class="tooltip" data-tip="Latest backup per app + System config. Back up System first — it's needed to restore the rest." aria-label="Latest backup per app + System config. Back up System first — it's needed to restore the rest." style="font-size:.75em;opacity:.7;cursor:help"></span></h2>
<span class="backup-card-hint" id="backup-next-run" title="Next scheduled backup run (from the app backup crontab)"></span>
</div>
<!-- The "System config" tile is rendered FIRST inside this grid

View File

@ -78,7 +78,7 @@ Object.assign(BackupPage.prototype, {
return `
<div class="backup-form-grid backup-retention-block" data-retention-prefix="${this.escape(prefix)}" data-retention-allow-inherit="${includeInherit ? '1' : '0'}">
<label class="backup-form-row">
<span class="backup-form-label">Backup style <span class="tooltip" data-retention-tooltip title="${this.escape(meta?.hint || '')}"></span></span>
<span class="backup-form-label">Backup style <span class="tooltip" data-retention-tooltip data-tip="${this.escape(meta?.hint || '')}" aria-label="${this.escape(meta?.hint || '')}"></span></span>
<select class="form-control" data-retention-preset>${presetOptions}</select>
</label>
${customRetentionHidden}

View File

@ -4,7 +4,7 @@ class ConfigShared {
// Toggle switch system - handles different types of toggles with proper layout
static createToggleSwitch(fieldId, key, value, title, description, options = {}) {
const isChecked = value === 'true';
const tooltipHtml = description ? `<span class="tooltip" title="${description}"></span>` : '';
const tooltipHtml = description ? `<span class="tooltip" data-tip="${description}" aria-label="${description}"></span>` : '';
// Determine toggle type and layout
const toggleType = options.type || 'standard';
@ -544,7 +544,7 @@ class ConfigShared {
}
// Non-boolean fields - use exact old config structure
const tooltipHtml = description ? `<span class="tooltip" title="${description}"></span>` : '';
const tooltipHtml = description ? `<span class="tooltip" data-tip="${description}" aria-label="${description}"></span>` : '';
let fieldHTML = `
<div class="field-group">
@ -777,7 +777,7 @@ class ConfigShared {
<span class="checkbox-custom"></span>
<span class="checkbox-text">
${title}
${description ? `<span class="tooltip" title="${description}"></span>` : ''}
${description ? `<span class="tooltip" data-tip="${description}" aria-label="${description}"></span>` : ''}
</span>
</label>
</div>
@ -1303,7 +1303,7 @@ class ConfigShared {
<span class="checkbox-custom"></span>
<span class="checkbox-text">
${masterTitle}
<span class="tooltip" title="${masterDescription}"></span>
<span class="tooltip" data-tip="${masterDescription}" aria-label="${masterDescription}"></span>
</span>
</label>
</div>
@ -1382,7 +1382,7 @@ class ConfigShared {
<span class="checkbox-custom"></span>
<span class="checkbox-text">
${masterTitle}
<span class="tooltip" title="${masterDescription}"></span>
<span class="tooltip" data-tip="${masterDescription}" aria-label="${masterDescription}"></span>
</span>
</label>
</div>

View File

@ -39,23 +39,34 @@
position: relative;
}
/* Reads data-tip, NOT title. With content: attr(title) the element keeps its
title, so the browser draws its own native tooltip over this styled one two
bubbles, offset, one unthemed. The text lives in data-tip; aria-label carries
it for assistive tech. Neither is rendered by the browser itself.
Wraps rather than nowrap: these carry sentences, and a single unbroken line
ran past the bubble's own background and off the viewport. This is the
wrapping variant that was sitting unused in port-manager.css that copy was
shadowed, because forms.css loads after it at equal specificity. */
.help-icon:hover::after {
content: attr(title);
content: attr(data-tip);
position: absolute;
bottom: 100%;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--tooltip-bg);
color: var(--tooltip-text);
padding: 4px 8px;
border-radius: 4px;
background: var(--card-bg, #2a2a2a);
color: var(--text-primary, #fff);
padding: 8px 12px;
border-radius: 12px;
font-size: 11px;
white-space: nowrap;
z-index: 1000;
margin-bottom: 6px;
min-width: 180px;
text-align: center;
font-weight: normal;
font-weight: 500;
text-transform: none;
white-space: normal;
width: max-content;
max-width: 280px;
text-align: left;
z-index: 99999;
border: 2px solid var(--primary-color, var(--accent));
}
.form-input,

View File

@ -1317,7 +1317,7 @@ html[data-theme="nebula"]::after {
}
.tooltip::before {
content: attr(title);
content: attr(data-tip);
position: absolute;
bottom: 125%;
left: 50%;