mirror of
https://github.com/webmin/webmin.git
synced 2026-10-24 13:10:40 +01:00
Compare commits
1 Commits
master
...
dev/fix-gr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad2a1dc44a |
@@ -1183,7 +1183,9 @@ my $lref = &read_file_lines("$config_directory/webmin.groups");
|
||||
@$lref = grep { !/^([^:]+):/ || $1 ne $groupname } @$lref;
|
||||
&flush_file_lines();
|
||||
&unlock_file("$config_directory/webmin.groups");
|
||||
&unlink_file(map { "$config_directory/$_/$groupname.gacl" } &list_modules());
|
||||
# Remove global permissions too, so reusing the name cannot revive old ACLs.
|
||||
&unlink_file(map { "$config_directory/$_/$groupname.gacl" }
|
||||
("", &list_modules()));
|
||||
|
||||
if ($miniserv{'userdb'}) {
|
||||
# Also delete from group database
|
||||
|
||||
@@ -90,25 +90,18 @@ my @mcan = $access{'gassign'} eq '*' ?
|
||||
( ( map { $_->{'name'} } @glist ), '_none' ) :
|
||||
split(/\s+/, $access{'gassign'});
|
||||
my %gcan = map { $_, 1 } @mcan;
|
||||
# Find the user's group even when this editor cannot change it.
|
||||
my $memg;
|
||||
foreach my $g (@glist) {
|
||||
if (&indexof($user{'name'}, @{$g->{'members'}}) >= 0 ||
|
||||
$in{'clone'} &&
|
||||
&indexof($in{'clone'}, @{$g->{'members'}}) >= 0) {
|
||||
# A clone starts in the same group as its source user.
|
||||
$memg = $g;
|
||||
}
|
||||
}
|
||||
# Show the group selector when this editor may change membership.
|
||||
if (@glist && %gcan) {
|
||||
my @opts = ( );
|
||||
if ($gcan{'_none'}) {
|
||||
# Allow leaving a group only when the editor has permission.
|
||||
push(@opts, [ undef, "<$text{'edit_none'}>" ]);
|
||||
}
|
||||
# Keep the current group available even if it cannot be newly assigned.
|
||||
foreach my $g (@glist) {
|
||||
if (&indexof($user{'name'}, @{$g->{'members'}}) >= 0 ||
|
||||
$in{'clone'} &&
|
||||
&indexof($in{'clone'}, @{$g->{'members'}}) >= 0) {
|
||||
$memg = $g;
|
||||
}
|
||||
next if (!$gcan{$g->{'name'}} && $memg ne $g);
|
||||
push(@opts, [ $g->{'name'} ]);
|
||||
}
|
||||
@@ -382,8 +375,8 @@ elsif ($miniserv{'twofactor_provider'}) {
|
||||
&ui_submit($text{'edit_twofactoradd'}, "twofactor"));
|
||||
}
|
||||
|
||||
# RPC is a global permission and cannot be edited on a group member.
|
||||
if ($access{'acl'} && !$memg->{'name'} && !$safe) {
|
||||
# Can accept RPC calls?
|
||||
if ($access{'acl'} && !$safe) {
|
||||
print &ui_table_row(&hlink($text{'acl_rpc'}, 'rpc'),
|
||||
&ui_radio("rpc", int($uaccess{'rpc'}),
|
||||
[ [ 1, $text{'acl_rpc1'} ],
|
||||
@@ -469,9 +462,9 @@ print &ui_table_row(undef, &ui_links_row(\@links).
|
||||
&ui_links_row(\@links), 2);
|
||||
print &ui_hidden_table_end("mods");
|
||||
|
||||
# Group members inherit global permissions, even without a local ACL file.
|
||||
# Hide these fields because save_user.cgi ignores individual edits.
|
||||
if ($access{'acl'} && !$memg->{'name'} && !$safe) {
|
||||
# Add global ACL section, but only if not set from the group
|
||||
my $groupglobal = $memg && -r "$config_directory/$memg->{'name'}.acl";
|
||||
if ($access{'acl'} && !$groupglobal && !$safe) {
|
||||
print &ui_hidden_table_start($text{'edit_global'}, "width=100%", 2,
|
||||
"global", 0, [ "width=30%" ]);
|
||||
print &ui_hidden("acl_security_form", 1);
|
||||
|
||||
@@ -23,10 +23,12 @@
|
||||
--radius: 8px;
|
||||
--radius-sm: 6px;
|
||||
--control-height: 32px;
|
||||
/* Shared gap around the menu and between cards, buttons and blocks.
|
||||
* Light surfaces use a slightly tighter gap than dark surfaces.
|
||||
* The page bottom keeps scroll room under the last element. */
|
||||
--card-gap: 4px;
|
||||
/* Room between cards, buttons and blocks, and the same room around a
|
||||
* page at the top and the sides; the bottom keeps scroll room under
|
||||
* the last element */
|
||||
--card-gap: 10px;
|
||||
--page-pad-x: var(--card-gap);
|
||||
--page-pad-top: var(--card-gap);
|
||||
--page-pad-bottom: 28px;
|
||||
/* Titles on the ground sit a little in from the card edge, so they
|
||||
* neither touch the edge nor go as deep as the text inside the
|
||||
@@ -80,8 +82,6 @@
|
||||
--accent-color-hover: #234d93;
|
||||
--accent-color-soft: #a9c0e8;
|
||||
--element-highlight-bg-color: #e3ebf8;
|
||||
/* Navigation loader, with a separate shade for each color scheme. */
|
||||
--menu-loader-color: #dc143c;
|
||||
/* Text on a button filled with a notice color: white on the deep
|
||||
* light-mode colors, the ground color on the bright dark-mode ones.
|
||||
* The dark palette keeps its own amber for the warning button. */
|
||||
@@ -126,7 +126,6 @@
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html:not([data-scheme="light"]) body:not([data-scheme="light"]) {
|
||||
color-scheme: dark;
|
||||
--card-gap: 7px;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-color: rgba(0, 0, 0, 0.4);
|
||||
--border-color-panel: #30353d;
|
||||
@@ -160,7 +159,6 @@
|
||||
--accent-color-hover: #5a8ad8;
|
||||
--accent-color-soft: #3d5f8a;
|
||||
--element-highlight-bg-color: #26395a;
|
||||
--menu-loader-color: #c7302c;
|
||||
--alert-button-text: var(--bg-color);
|
||||
--alert-button-warning: #e0a63c;
|
||||
--alert-button-warning-text: var(--bg-color);
|
||||
@@ -204,7 +202,6 @@
|
||||
}
|
||||
html[data-scheme="dark"] body, body[data-scheme="dark"] {
|
||||
color-scheme: dark;
|
||||
--card-gap: 7px;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-color: rgba(0, 0, 0, 0.4);
|
||||
--border-color-panel: #30353d;
|
||||
@@ -238,7 +235,6 @@ html[data-scheme="dark"] body, body[data-scheme="dark"] {
|
||||
--accent-color-hover: #5a8ad8;
|
||||
--accent-color-soft: #3d5f8a;
|
||||
--element-highlight-bg-color: #26395a;
|
||||
--menu-loader-color: #c7302c;
|
||||
--alert-button-text: var(--bg-color);
|
||||
--alert-button-warning: #e0a63c;
|
||||
--alert-button-warning-text: var(--bg-color);
|
||||
@@ -297,9 +293,6 @@ html {
|
||||
background: transparent;
|
||||
}
|
||||
body {
|
||||
/* Resolve page insets here so they follow the body's color scheme. */
|
||||
--page-pad-x: var(--card-gap);
|
||||
--page-pad-top: var(--card-gap);
|
||||
margin: 0;
|
||||
padding: var(--page-pad-top) var(--page-pad-x) var(--page-pad-bottom);
|
||||
/* The core header sets height:100% inline on html and body, which
|
||||
@@ -1697,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]:not(.ui_tip) {
|
||||
[data-tooltip][aria-label] {
|
||||
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]:not(.ui_tip) {
|
||||
.ui_card_actions .ui_help[data-tooltip][aria-label] {
|
||||
position: relative;
|
||||
}
|
||||
[data-tooltip][aria-label]:not(.ui_tip)::after {
|
||||
[data-tooltip][aria-label]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
@@ -1724,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]:not(.ui_tip)::before {
|
||||
[data-tooltip][aria-label]::before {
|
||||
content: attr(aria-label);
|
||||
position: absolute;
|
||||
bottom: calc(15px + 100%);
|
||||
@@ -1747,8 +1740,8 @@ td:not(:hover) .ui_link_help { visibility: hidden; }
|
||||
visibility: hidden;
|
||||
z-index: 10;
|
||||
}
|
||||
[data-tooltip][aria-label]:not(.ui_tip):hover::before,
|
||||
[data-tooltip][aria-label]:not(.ui_tip):hover::after {
|
||||
[data-tooltip][aria-label]:hover::before,
|
||||
[data-tooltip][aria-label]:hover::after {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transition-duration: 0.5s;
|
||||
@@ -1756,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]:not(.ui_tip):hover {
|
||||
[data-tooltip][aria-label]:hover {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@@ -1819,7 +1812,7 @@ br[data-x-br] {
|
||||
/* ---- Left frame (left.cgi) ---- */
|
||||
|
||||
/* The frame shows the page ground, and the menu floats on it as a card
|
||||
* with the shared gap at the top, the left and the bottom, and rounder
|
||||
* with a 7px gap at the top, the left and the bottom, and rounder
|
||||
* corners than the other cards. At the right the card meets the frame
|
||||
* edge, so the page padding of the right frame is the whole gap between
|
||||
* the card and the content, and a page opened in a window of its own
|
||||
@@ -1829,7 +1822,7 @@ br[data-x-br] {
|
||||
body.left-frame {
|
||||
box-sizing: border-box;
|
||||
height: 100vh !important;
|
||||
padding: var(--card-gap) 0 var(--card-gap) var(--card-gap);
|
||||
padding: 7px 0 7px 7px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
@@ -1874,9 +1867,11 @@ html.loading-right .menu-loader { display: block; }
|
||||
width: 35%;
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--menu-loader-color);
|
||||
background: var(--accent-color);
|
||||
animation: menu-bounce 0.9s ease-in-out infinite alternate;
|
||||
}
|
||||
/* Match Authentic's crimson progress bar for Cloudmin navigation. */
|
||||
.menu-panel-cloudmin .menu-loader::before { background: #dc143c; }
|
||||
@keyframes menu-bounce { to { left: 65%; } }
|
||||
/* The brand block: the logo has as much room above it as below it, and
|
||||
* the logo files are trimmed to their drawing, so every product sits the
|
||||
|
||||
@@ -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 needs no JavaScript attributes or extra tab stop');
|
||||
unlike($tip, qr/data-ui-tip|tabindex/, 'ui_tip draws no tooltip of its own');
|
||||
assert_no_handler_injection(
|
||||
main::ui_dl([ { 'label' => $xss, 'value' => $xss, 'help' => $xss } ]),
|
||||
'ui_dl hash row');
|
||||
@@ -680,19 +680,6 @@ 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};
|
||||
@@ -707,14 +694,11 @@ 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/<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/title="Policy & owner"/,
|
||||
'legacy title entities are preserved without double escaping');
|
||||
like($html, qr/data-policy="managed"/,
|
||||
'unquoted data attributes survive normalization');
|
||||
unlike($html, qr/<input (?=[^>]*value="open")(?=[^>]*\sdisabled=)/,
|
||||
unlike($html, qr/<input (?=[^>]*value="open")(?=[^>]*\bdisabled\b)/,
|
||||
'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');
|
||||
@@ -735,7 +719,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/aria-label="Retained"/, 'missing values retain row attributes');
|
||||
like($missing, qr/title="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")/,
|
||||
@@ -859,8 +843,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/aria-label="Selected"/, 'legacy selected row keeps its tooltip');
|
||||
like($html, qr/aria-label="Retained"/, 'missing legacy selection keeps its tooltip');
|
||||
like($html, qr/title="Selected"/, 'legacy selected row keeps its tooltip');
|
||||
like($html, qr/title="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.trim()"), 'Disabled');
|
||||
assert.equal(await evaluate("box('domains').querySelector('.ui_chip').textContent"), '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,40 +123,6 @@ 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,7 +22,6 @@ 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' } ] }
|
||||
|
||||
@@ -173,20 +173,6 @@ subtest 'Data::Dumper format' => sub {
|
||||
require Data::Dumper;
|
||||
require JSON::PP;
|
||||
|
||||
# Newer peers emit native booleans as !!1 and !!0, including RPC results.
|
||||
for my $value (0, 1) {
|
||||
is(main::unserialise_variable("\$VAR1 = !!$value;"), !!$value,
|
||||
"literal !!$value retains Perl boolean semantics");
|
||||
is_deeply(main::unserialise_variable(
|
||||
"\$VAR1 = {'rv' => !!$value, 'status' => 1};"),
|
||||
{ 'rv' => !!$value, 'status' => 1 },
|
||||
"fast RPC response accepts !!$value");
|
||||
}
|
||||
is_deeply(main::unserialise_variable(q{$VAR1 = [!!1, !!0, \!!0];}),
|
||||
[ !!1, !!0, \!!0 ], 'booleans work in arrays and scalar references');
|
||||
is_deeply(main::unserialise_variable(q{$VAR1 = ['!!1', "!!0"];}),
|
||||
[ '!!1', '!!0' ], 'quoted boolean text remains unchanged');
|
||||
|
||||
# Spot-check the supported scalar, reference, object and container forms.
|
||||
my $str = 'x';
|
||||
my $shared = [ 1 ];
|
||||
@@ -196,7 +182,7 @@ subtest 'Data::Dumper format' => sub {
|
||||
'sref' => \$str, 'rref' => \\'y', 'a' => $shared, 'b' => $shared,
|
||||
'obj' => bless({ 'x' => 1 }, 'RPC::Untrusted'),
|
||||
'sobj' => bless(\$str, 'RPC::Untrusted'),
|
||||
'empty' => [], 'eh' => {}, 'true' => !!1, 'false' => !!0 };
|
||||
'empty' => [], 'eh' => {} };
|
||||
$v->{'self'} = $v;
|
||||
# Data::Dumper cannot preserve a reference to a hash value if it sees that
|
||||
# reference first. Sorting keys makes this test deterministic.
|
||||
@@ -277,15 +263,6 @@ subtest 'Data::Dumper format' => sub {
|
||||
# Reject hostile input without executing it.
|
||||
our $ran = 0;
|
||||
my @hostile = (
|
||||
q{$VAR1 = !!2;},
|
||||
q{$VAR1 = !!10;},
|
||||
q{$VAR1 = !!01;},
|
||||
q{$VAR1 = !!1e0;},
|
||||
q{$VAR1 = !!0.0;},
|
||||
q{$VAR1 = !!1foo;},
|
||||
q{$VAR1 = !!($main::ran = 1);},
|
||||
q{$VAR1 = !!0 || do { $main::ran = 1 };},
|
||||
q{$VAR1 = !!1; $main::ran = 1;},
|
||||
q{$VAR1 = {}; $main::ran = 1;},
|
||||
q{$VAR1 = "@{[ $main::ran = 1 ]}";},
|
||||
q{$VAR1 = "${\ ($main::ran = 1)}";},
|
||||
|
||||
15
ui-lib.pl
15
ui-lib.pl
@@ -4530,10 +4530,9 @@ return &ui_tag('code', &html_escape($str), { 'class' => 'ui_code' });
|
||||
|
||||
=head2 ui_tip(content-html, tip)
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
=cut
|
||||
sub ui_tip
|
||||
@@ -6133,7 +6132,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; 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 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 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.
|
||||
|
||||
@@ -6403,16 +6402,10 @@ 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,144 +124,6 @@
|
||||
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
|
||||
@@ -419,9 +281,7 @@ body:has(.ui_page[data-ui-scheme="dark"]) {
|
||||
.ui_field, .ui_field * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* Reset widget paragraphs without removing spacing from help and other prose. */
|
||||
.ui_page h1, .ui_page h2, .ui_page p.ui--p,
|
||||
.ui_page dl, .ui_page dd { margin: 0; }
|
||||
.ui_page h1, .ui_page h2, .ui_page p, .ui_page dl, .ui_page dd { margin: 0; }
|
||||
.ui_page svg.ui_svg_icon { flex-shrink: 0; vertical-align: -0.18em; }
|
||||
|
||||
/* Underline plain text links, but keep links styled as buttons undecorated. */
|
||||
|
||||
@@ -56,10 +56,6 @@ if ($$s =~ /\G'/gc) {
|
||||
elsif ($$s =~ /\G"/gc) {
|
||||
return _parse_double($s);
|
||||
}
|
||||
elsif ($$s =~ /\G!!([01])(?![A-Za-z0-9_.])/gc) {
|
||||
# Read native boolean literals without evaluating arbitrary expressions.
|
||||
return $1 eq '1';
|
||||
}
|
||||
elsif ($$s =~ /\G(-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?)(?![A-Za-z0-9_.])/gc) {
|
||||
# Return an unquoted number as a numeric scalar.
|
||||
return $1 + 0;
|
||||
|
||||
Reference in New Issue
Block a user