mirror of
https://github.com/webmin/webmin.git
synced 2026-10-27 13:40:40 +00:00
Add support for multi-select tooltips to shared CSS
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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> &lt;example&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> <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/<div (?=[^>]*\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/<span (?=[^>]*\bui_tip\b)(?=[^>]*aria-label="Policy & owner")/,
|
||||
'legacy title entities are preserved in the shared CSS tooltip');
|
||||
like($html, qr/<input (?=[^>]*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/<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&D' ], [ 'gone', 'Missing', q{title="Retained"} ] ],
|
||||
'legacy selected entries are not modified');
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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' } ] }
|
||||
|
||||
15
ui-lib.pl
15
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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user