From 19aae985b77f60e78d7328fa0590faf1a62bdf7f Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Sat, 24 Oct 2026 12:21:38 +0200 Subject: [PATCH] Add support for multi-select tooltips to shared CSS --- gray-theme/unauthenticated/gray-theme.css | 14 +-- t/ui-lib-widgets.t | 30 +++-- t/ui-multi-select-browser.mjs | 36 +++++- t/ui-multi-select-fixture.pl | 1 + ui-lib.pl | 15 ++- unauthenticated/css/ui-lib.css | 138 ++++++++++++++++++++++ 6 files changed, 215 insertions(+), 19 deletions(-) diff --git a/gray-theme/unauthenticated/gray-theme.css b/gray-theme/unauthenticated/gray-theme.css index 97032786c..804ad9f0b 100644 --- a/gray-theme/unauthenticated/gray-theme.css +++ b/gray-theme/unauthenticated/gray-theme.css @@ -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; } diff --git a/t/ui-lib-widgets.t b/t/ui-lib-widgets.t index f5a5e776e..b0d7931b2 100644 --- a/t/ui-lib-widgets.t +++ b/t/ui-lib-widgets.t @@ -98,7 +98,7 @@ assert_no_handler_injection(main::ui_tip('x', $xss), 'ui_tip'); my $tip = main::ui_tip('x', 'A tip'); like($tip, qr/^]*\bclass="ui--span ui_tip")(?=[^>]*\baria-label="A tip")(?=[^>]*\bdata-tooltip[\s>])[^>]*>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 &lt;example&gt; "quoted".}; + my $html = main::ui_multi_select_list('tip', [], + [ { value => 'one', label => 'One', attrs => { title => $title } } ]); + my ($tip) = $html =~ /]*\bui_tip\b)[^>]*aria-label="([^"]*)"/; + is(decode_attr($tip), q{Literal <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