Add support for multi-select tooltips to shared CSS

This commit is contained in:
Ilia Ross
2026-10-24 12:21:38 +02:00
parent eb8816843a
commit 19aae985b7
6 changed files with 215 additions and 19 deletions

View File

@@ -1690,16 +1690,16 @@ td:not(:hover) .ui_link_help { visibility: hidden; }
.ui_text_wrap { float: right; }
/* Tooltips from the aria-label of elements marked data-tooltip: a dark
* box with an arrow above the element, shown on hover */
[data-tooltip][aria-label] {
[data-tooltip][aria-label]:not(.ui_tip) {
position: relative;
vertical-align: top;
}
/* Card actions align help bubbles through ui-lib, but this theme still
* needs the bubble to anchor its generated tooltip. */
.ui_card_actions .ui_help[data-tooltip][aria-label] {
.ui_card_actions .ui_help[data-tooltip][aria-label]:not(.ui_tip) {
position: relative;
}
[data-tooltip][aria-label]::after {
[data-tooltip][aria-label]:not(.ui_tip)::after {
content: "";
position: absolute;
bottom: 100%;
@@ -1717,7 +1717,7 @@ td:not(:hover) .ui_link_help { visibility: hidden; }
/* Keep a tooltip near the top above the sticky page header. */
z-index: 10;
}
[data-tooltip][aria-label]::before {
[data-tooltip][aria-label]:not(.ui_tip)::before {
content: attr(aria-label);
position: absolute;
bottom: calc(15px + 100%);
@@ -1740,8 +1740,8 @@ td:not(:hover) .ui_link_help { visibility: hidden; }
visibility: hidden;
z-index: 10;
}
[data-tooltip][aria-label]:hover::before,
[data-tooltip][aria-label]:hover::after {
[data-tooltip][aria-label]:not(.ui_tip):hover::before,
[data-tooltip][aria-label]:not(.ui_tip):hover::after {
visibility: visible;
opacity: 1;
transition-duration: 0.5s;
@@ -1749,7 +1749,7 @@ td:not(:hover) .ui_link_help { visibility: hidden; }
/* Opacity gives the tooltip trigger its own stacking context. Raise that
* context while the tooltip is visible, because raising its children alone
* cannot place them above the sticky header. */
[data-tooltip][aria-label]:hover {
[data-tooltip][aria-label]:not(.ui_tip):hover {
z-index: 10;
}

View File

@@ -98,7 +98,7 @@ assert_no_handler_injection(main::ui_tip('<b>x</b>', $xss), 'ui_tip');
my $tip = main::ui_tip('<b>x</b>', 'A <i>tip</i>');
like($tip, qr/^<span (?=[^>]*\bclass="ui--span ui_tip")(?=[^>]*\baria-label="A tip")(?=[^>]*\bdata-tooltip[\s>])[^>]*><b>x<\/b><\/span>$/,
'ui_tip uses the theme tooltip attributes of ui_help');
unlike($tip, qr/data-ui-tip|tabindex/, 'ui_tip draws no tooltip of its own');
unlike($tip, qr/data-ui-tip|tabindex/, 'ui_tip needs no JavaScript attributes or extra tab stop');
assert_no_handler_injection(
main::ui_dl([ { 'label' => $xss, 'value' => $xss, 'help' => $xss } ]),
'ui_dl hash row');
@@ -680,6 +680,19 @@ like(main::ui_form_columns_table('x.cgi', [ [ 'go', 'Go' ] ], 0, undef, undef,
'counter-free long lists retain filtering');
}
# Row titles remain literal text when converted to shared tooltip attributes.
{
my $title = q{Literal <tag> &amp;lt;example&amp;gt; "quoted".};
my $html = main::ui_multi_select_list('tip', [],
[ { value => 'one', label => 'One', attrs => { title => $title } } ]);
my ($tip) = $html =~ /<span (?=[^>]*\bui_tip\b)[^>]*aria-label="([^"]*)"/;
is(decode_attr($tip), q{Literal <tag> &lt;example&gt; "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 &amp; 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/<div (?=[^>]*\bui_multi_item\b)(?=[^>]*style="font-style:italic")/,
'legacy styles apply to the whole row');
like($html, qr/title="Policy &amp; owner"/,
'legacy title entities are preserved without double escaping');
like($html, qr/<span (?=[^>]*\bui_tip\b)(?=[^>]*aria-label="Policy &amp; owner")/,
'legacy title entities are preserved in the shared CSS tooltip');
like($html, qr/<input (?=[^>]*value="locked")(?=[^>]*aria-description="Policy &amp; 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/<input (?=[^>]*value="open")(?=[^>]*\bdisabled\b)/,
unlike($html, qr/<input (?=[^>]*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/<input (?=[^>]*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/<input (?=[^>]*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/<input (?=[^>]*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&amp;D' ], [ 'gone', 'Missing', q{title="Retained"} ] ],
'legacy selected entries are not modified');

View File

@@ -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 <text> & 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");

View File

@@ -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 <text> & 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' } ] }

View File

@@ -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);

View File

@@ -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