<example> "quoted".},
+ 'CSS tooltip preserves angle brackets, entities, quotes and punctuation');
+ assert_no_handler_injection(main::ui_multi_select_list('tip_injection', [],
+ [ { value => 'one', label => 'One', attrs => { title => $xss } } ]),
+ 'CSS row tooltip');
+}
+
# Legacy attributes must retain row presentation and checkbox behavior.
{
my $tags = q{DISABLED="false" class="managed" style='font-style:italic' title="Policy & owner" data-policy=managed};
@@ -694,11 +707,14 @@ like(main::ui_form_columns_table('x.cgi', [ [ 'go', 'Go' ] ], 0, undef, undef,
'legacy classes combine with the widget and disabled row classes');
like($html, qr/]*\bui_multi_item\b)(?=[^>]*style="font-style:italic")/,
'legacy styles apply to the whole row');
- like($html, qr/title="Policy & owner"/,
- 'legacy title entities are preserved without double escaping');
+ like($html, qr/]*\bui_tip\b)(?=[^>]*aria-label="Policy & owner")/,
+ 'legacy title entities are preserved in the shared CSS tooltip');
+ like($html, qr/]*value="locked")(?=[^>]*aria-description="Policy & owner")/,
+ 'checkbox descriptions remain available to assistive technology');
+ unlike($html, qr/\btitle=/, 'row descriptions do not create duplicate native tooltips');
like($html, qr/data-policy="managed"/,
'unquoted data attributes survive normalization');
- unlike($html, qr/]*value="open")(?=[^>]*\bdisabled\b)/,
+ unlike($html, qr/]*value="open")(?=[^>]*\sdisabled=)/,
'disabled inside a quoted attribute does not disable the checkbox');
like($html, qr/data-note="a=b > c"/,
'quoted attribute values retain spaces, equals and greater-than signs');
@@ -719,7 +735,7 @@ like(main::ui_form_columns_table('x.cgi', [ [ 'go', 'Go' ] ], 0, undef, undef,
[ [ 'gone', 'Gone', q{disabled title='Retained'} ] ], [ ]);
like($missing, qr/]*value="gone")(?=[^>]*\bchecked\b)(?=[^>]*\bdisabled\b)/,
'missing selected values retain their legacy disabled attribute');
- like($missing, qr/title="Retained"/, 'missing values retain row attributes');
+ like($missing, qr/aria-label="Retained"/, 'missing values retain row attributes');
my $fixed = main::ui_multi_select_list('fixed', [ ],
[ [ 'a', 'A', q{checked name=wrong value=wrong type=radio} ] ]);
like($fixed, qr/]*type='checkbox')(?=[^>]*name="fixed_item")(?=[^>]*value="a")/,
@@ -843,8 +859,8 @@ like(main::ui_form_columns_table('x.cgi', [ [ 'go', 'Go' ] ], 0, undef, undef,
'selected attributes take precedence over available option attributes');
like($html, qr/]*value="c")(?=[^>]*\bdisabled\b)/,
'unselected entries keep their option attributes');
- like($html, qr/title="Selected"/, 'legacy selected row keeps its tooltip');
- like($html, qr/title="Retained"/, 'missing legacy selection keeps its tooltip');
+ like($html, qr/aria-label="Selected"/, 'legacy selected row keeps its tooltip');
+ like($html, qr/aria-label="Retained"/, 'missing legacy selection keeps its tooltip');
is_deeply(\@values, [ [ 'b', 'B (selected)', q{disabled title="Selected"} ],
[ 'a', 'R&D' ], [ 'gone', 'Missing', q{title="Retained"} ] ],
'legacy selected entries are not modified');
diff --git a/t/ui-multi-select-browser.mjs b/t/ui-multi-select-browser.mjs
index 22a12940f..8b91ddf58 100644
--- a/t/ui-multi-select-browser.mjs
+++ b/t/ui-multi-select-browser.mjs
@@ -95,7 +95,7 @@ try {
assert.equal(await evaluate("value('domains')"), 'parent\nchild');
assert.equal(await evaluate("box('domains').querySelector('[data-ui-multi-level]').hidden"), true);
assert.equal(await evaluate("box('domains').querySelector('.ui_multi_count').textContent"), '1 selected');
- assert.equal(await evaluate("box('domains').querySelector('.ui_chip').textContent"), 'Disabled');
+ assert.equal(await evaluate("box('domains').querySelector('.ui_chip').textContent.trim()"), 'Disabled');
await evaluate("box('domains').querySelector('[data-ui-multi-action=children]').click()");
assert.equal(await evaluate("box('domains').querySelector('[data-ui-multi-level]').hidden"), false);
await evaluate(`const mode = box('domains').querySelector('select'); mode.value = '1'; mode.dispatchEvent(new Event('change', {bubbles:true}));`);
@@ -123,6 +123,40 @@ try {
assert.equal(await evaluate("value('grouped')"), 'image-0');
assert.equal(await evaluate("box('domains').querySelector('[data-ui-multi-level]').hidden"), true);
+ // Row descriptions use CSS on keyboard focus, without native or JS tips.
+ await evaluate(`box('grouped').scrollIntoView();
+ window.tip = box('grouped').querySelector('.ui_tip');
+ tip.querySelector('input').focus();`);
+ assert.equal(await evaluate("getComputedStyle(tip, '::before').display"), 'block');
+ assert.equal(await evaluate("tip.getAttribute('aria-label')"), 'Description for image 0. Literal & punctuation.');
+ assert.equal(await evaluate("tip.querySelector('input').getAttribute('aria-description')"),
+ await evaluate("tip.getAttribute('aria-label')"));
+ assert.equal(await evaluate("box('grouped').querySelectorAll('.ui_multi_item[title], .tooltip').length"), 0);
+ // The arrow follows the checkbox center, not a fixed label offset.
+ const arrowOffset = await evaluate(`{ const a = getComputedStyle(tip, '::after');
+ const r = tip.querySelector('input').getBoundingClientRect();
+ parseFloat(a.left) + parseFloat(a.width) / 2 - (r.x + r.width / 2); }`);
+ assert.ok(Math.abs(arrowOffset) < 1, 'tooltip arrow points at the checkbox');
+ const nextTip = await evaluate(`{ const r = box('grouped').querySelectorAll('.ui_tip')[1].getBoundingClientRect();
+ ({x: r.x + 5, y: r.y + r.height / 2}); }`);
+ await page('Input.dispatchMouseEvent', { type: 'mouseMoved', ...nextTip });
+ assert.equal(await evaluate("getComputedStyle(tip, '::before').display"), 'none', 'hover takes priority over old focus');
+ assert.equal(await evaluate("getComputedStyle(box('grouped').querySelectorAll('.ui_tip')[1], '::before').display"), 'block');
+ await page('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 1100, y: 0 });
+ const tipColors = [];
+ for (const scheme of ['light', 'dark']) {
+ await evaluate(`box('grouped').dataset.uiScheme = '${scheme}'`);
+ tipColors.push(await evaluate("getComputedStyle(tip, '::before').backgroundImage"));
+ }
+ assert.notEqual(tipColors[0], tipColors[1], 'tooltip colors follow the widget scheme');
+ await page('Emulation.setDeviceMetricsOverride', { width: 375, height: 900, deviceScaleFactor: 1, mobile: false });
+ const tipBounds = await evaluate(`{ const s = getComputedStyle(tip, '::before');
+ ({left: parseFloat(s.left), right: parseFloat(s.left) + parseFloat(s.width)}); }`);
+ assert.ok(tipBounds.left >= 0 && tipBounds.right <= 375, 'tooltip fits the narrow viewport');
+ await evaluate("tip.querySelector('input').blur()");
+ assert.equal(await evaluate("getComputedStyle(tip, '::before').display"), 'none');
+ await page('Emulation.setDeviceMetricsOverride', { width: 1120, height: 900, deviceScaleFactor: 1, mobile: false });
+
// Inspect real Cloudmin metadata in both schemes and a narrow viewport.
if (process.argv[3]) {
await evaluate("document.querySelector('#checks').hidden = true");
diff --git a/t/ui-multi-select-fixture.pl b/t/ui-multi-select-fixture.pl
index 747baca6c..a827a63fc 100644
--- a/t/ui-multi-select-fixture.pl
+++ b/t/ui-multi-select-fixture.pl
@@ -22,6 +22,7 @@ my @options = map { { value => "image-$_", label => "Image $_",
group => { label => $_ < 4 ? 'Recommended' : $_ < 8 ? 'Supported' : 'Older',
state => $_ < 4 ? 'success' : $_ < 8 ? 'neutral' : 'warning' },
disabled => $_ == 9,
+ attrs => { title => "Description for image $_. Literal & punctuation." },
metadata => [ { label => '10 GiB', title => 'Minimum disk size: 10 GiB', icon => 'hard-drive' },
{ label => $_ < 8 ? 'Support until 2030' : 'Support ended 2025',
icon => 'clock', state => $_ < 8 ? 'neutral' : 'warning' } ] }
diff --git a/ui-lib.pl b/ui-lib.pl
index bc061a271..13b9ce248 100755
--- a/ui-lib.pl
+++ b/ui-lib.pl
@@ -4530,9 +4530,10 @@ return &ui_tag('code', &html_escape($str), { 'class' => 'ui_code' });
=head2 ui_tip(content-html, tip)
-Wraps arbitrary HTML content so the given plain-text tip is shown on
-hover by the theme's tooltip, the same one ui_help uses. For a plain
-question-mark help bubble, use ui_help instead.
+Wraps HTML content in a CSS tooltip, shown on hover or focus within the
+content. Load ui_page_assets when using this outside a widget page.
+The tip is text; HTML tags are stripped. For a question-mark help bubble,
+use ui_help instead.
=cut
sub ui_tip
@@ -6132,7 +6133,7 @@ Size, add-if-missing, titles and width are ignored.
=item values - Array reference of selected scalars or [ value, label, attributes ] entries. Attributes are also retained when a selected entry is missing from options.
-=item options - Array reference of [ value, label, attributes ] entries or hashes with keys value, label, suffix (muted text after the label), level (indentation depth), tag (chip at the right), disabled and attrs. Attributes may be a hash or trusted HTML attribute string, as in ui_select. disabled applies to the checkbox; other attributes, such as title, style and class, apply to the row. Widget identity and selection remain controlled by the library.
+=item options - Array reference of [ value, label, attributes ] entries or hashes with keys value, label, suffix (muted text after the label), level (indentation depth), tag (chip at the right), disabled and attrs. Attributes may be a hash or trusted HTML attribute string, as in ui_select. disabled applies to the checkbox; title becomes a CSS tooltip around the checkbox label; style and class apply to the row. Widget identity and selection remain controlled by the library.
=item badges - An option hash may also contain a badges array. Each entry holds ui_badge arguments: [ label, state, options ]. Badges appear separately at the right, beside any tag. Their labels are escaped and included in filtering.
@@ -6402,10 +6403,16 @@ foreach my $it (@items) {
# Checkbox renderers may place HTML outside their label element.
my $ctags = "data-ui-multi-item='1'";
$ctags .= ' aria-label="'.$filter_label.'"' if ($opts->{'html'});
+ # Reuse the CSS tooltip for row titles, without a second native tooltip.
+ my $tip = delete($it->{'attrs'}->{'title'});
+ $ctags .= ' aria-description="'.&html_escape($tip, 1).'"'
+ if (defined($tip) && $tip ne '');
my $row = &ui_checkbox($inputname.'_item', $val, $label,
$selected{$val} ? 1 : 0,
$ctags,
$dis || $it->{'disabled'} ? 1 : 0);
+ $row = &ui_tip($row, &html_escape($tip, 1))
+ if (defined($tip) && $tip ne '');
if ($note && $it->{'kids'}) {
my $nattrs = { 'class' => 'ui_multi_note' };
$nattrs->{'hidden'} = undef if (!$folded);
diff --git a/unauthenticated/css/ui-lib.css b/unauthenticated/css/ui-lib.css
index a9a82bd53..9cbda9e86 100644
--- a/unauthenticated/css/ui-lib.css
+++ b/unauthenticated/css/ui-lib.css
@@ -124,6 +124,144 @@
text-align: left;
}
+/* Shared CSS tooltips. Theme tokens keep the bubble and its arrow readable
+ * in light and dark palettes. Ordinary help bubbles retain their theme style. */
+.ui_tip[data-tooltip][aria-label] {
+ position: relative;
+ display: inline-block;
+ vertical-align: baseline;
+ --ui-tip-bg: var(--ui-fg, var(--text-color, #212121));
+ --ui-tip-fg: var(--ui-surface, var(--main-content, var(--bg-color, #fff)));
+}
+.ui_tip[data-tooltip][aria-label]::before,
+.ui_tip[data-tooltip][aria-label]::after {
+ position: absolute;
+ pointer-events: none;
+ display: none;
+ z-index: 1060;
+}
+.ui_tip[data-tooltip][aria-label]::before {
+ content: attr(aria-label);
+ bottom: calc(100% + 8px);
+ left: 0;
+ width: max-content;
+ box-sizing: border-box;
+ max-width: min(32rem, calc(100vw - 24px));
+ padding: 7px 10px;
+ border-radius: 5px;
+ /* Theme text colors may have alpha; paint over a solid surface. */
+ background: linear-gradient(var(--ui-tip-bg), var(--ui-tip-bg)),
+ var(--ui-tip-fg);
+ color: var(--ui-tip-fg);
+ font-size: 13px;
+ line-height: 1.45;
+ font-weight: normal;
+ font-style: normal;
+ text-align: left;
+ text-transform: none;
+ text-shadow: none;
+ white-space: normal;
+ overflow-wrap: anywhere;
+}
+.ui_tip[data-tooltip][aria-label]::after {
+ content: '';
+ bottom: calc(100% + 4px);
+ left: 12px;
+ width: 8px;
+ height: 8px;
+ background: linear-gradient(var(--ui-tip-bg), var(--ui-tip-bg)),
+ var(--ui-tip-fg);
+ transform: rotate(45deg);
+}
+.ui_tip[data-tooltip][aria-label]:is(:hover, :focus-within)::before,
+.ui_tip[data-tooltip][aria-label]:is(:hover, :focus-within)::after {
+ display: block;
+}
+/* A hovered row takes priority over the checkbox that still has focus. */
+.ui_multi:has(.ui_tip:hover) .ui_tip:not(:hover)::before,
+.ui_multi:has(.ui_tip:hover) .ui_tip:not(:hover)::after {
+ display: none;
+}
+/* Fixed anchors escape scrolling lists. Scope each pair so repeated entries
+ * cannot attach to another row's tooltip. Older browsers use the absolute
+ * positioning above. */
+@supports (anchor-scope: --ui-tip) {
+ .ui_tip[data-tooltip][aria-label] {
+ anchor-name: --ui-tip;
+ anchor-scope: --ui-tip, --ui-tip-box, --ui-tip-input;
+ }
+ .ui_tip[data-tooltip][aria-label]::before {
+ position: fixed;
+ position-anchor: --ui-tip;
+ anchor-name: --ui-tip-box;
+ bottom: anchor(top);
+ left: anchor(left);
+ margin-bottom: 8px;
+ position-try-fallbacks: --ui-tip-right, --ui-tip-fit,
+ --ui-tip-below, --ui-tip-below-right, --ui-tip-below-fit;
+ position-visibility: anchors-visible;
+ }
+ .ui_tip[data-tooltip][aria-label]::after {
+ position: fixed;
+ position-anchor: --ui-tip;
+ bottom: auto;
+ /* Attach the arrow to the nearest bubble edge after a position change. */
+ top: calc(clamp(anchor(--ui-tip-box top), anchor(bottom),
+ anchor(--ui-tip-box bottom)) - 4px);
+ left: clamp(calc(anchor(--ui-tip-box left) + 8px),
+ calc(anchor(left) + 12px), calc(anchor(--ui-tip-box right) - 16px));
+ position-visibility: anchors-visible;
+ }
+ /* Point list descriptions at the checkbox, whose size varies by theme.
+ * Leave room beside it for the bubble's rounded corner. */
+ .ui_multi .ui_tip input[type="checkbox"] {
+ anchor-name: --ui-tip-input;
+ }
+ .ui_multi .ui_tip[data-tooltip][aria-label]::before {
+ left: calc(anchor(--ui-tip-input center) - 12px);
+ }
+ .ui_multi .ui_tip[data-tooltip][aria-label]::after {
+ left: clamp(calc(anchor(--ui-tip-box left) + 8px),
+ calc(anchor(--ui-tip-input center) - 4px),
+ calc(anchor(--ui-tip-box right) - 16px));
+ }
+ @position-try --ui-tip-right {
+ left: auto;
+ right: anchor(right);
+ }
+ /* A long description can need more room than either side of its label. */
+ @position-try --ui-tip-fit {
+ left: 12px;
+ right: 12px;
+ width: auto;
+ }
+ @position-try --ui-tip-below {
+ bottom: auto;
+ top: anchor(bottom);
+ margin-top: 8px;
+ margin-bottom: 0;
+ }
+ @position-try --ui-tip-below-right {
+ bottom: auto;
+ top: anchor(bottom);
+ left: auto;
+ right: anchor(right);
+ margin-top: 8px;
+ margin-bottom: 0;
+ }
+ @position-try --ui-tip-below-fit {
+ bottom: auto;
+ top: anchor(bottom);
+ left: 12px;
+ right: 12px;
+ width: auto;
+ margin-top: 8px;
+ margin-bottom: 0;
+ }
+}
+.ui_multi .ui_tip { display: inline-flex; align-items: center; min-width: 0; }
+.ui_multi .ui_tip > input[type="checkbox"] { margin-right: 6px; }
+
/* Built-in dark palette, enabled with data-ui-scheme="dark" on any
* ancestor element (or on the .ui_page wrapper itself, via the scheme
* option of ui_page_start). With data-ui-scheme="auto" the dark