diff --git a/t/ui-lib-widgets.t b/t/ui-lib-widgets.t
index 26a5d91f7..b2b720c96 100644
--- a/t/ui-lib-widgets.t
+++ b/t/ui-lib-widgets.t
@@ -563,6 +563,36 @@ like(main::ui_form_columns_table('x.cgi', [ [ 'go', 'Go' ] ], 0, undef, undef,
'omitting size preserves the selected description and escaped text');
}
+# Trusted HTML is opt-in and filtering uses the displayed label text.
+{
+ my $label = 'Entire website & files';
+ my $options = [ { 'value' => 'root', 'label' => $label,
+ 'suffix' => '', 'tag' => '' } ];
+ my $plain = main::ui_multi_select_list('plain', [ 'root' ], $options, {});
+ like($plain, qr/<i title=/, 'labels are escaped by default');
+ unlike($plain, qr/Missing <path>' ] ],
+ $options, { 'html' => 1 });
+ like($html, qr/\Q$label\E/, 'the HTML flag retains trusted label markup');
+ like($html, qr/aria-label="Entire website & files"/,
+ 'HTML labels retain an accessible checkbox name');
+ like($html, qr/Missing <path><\/b>/,
+ 'missing selections also support trusted HTML labels');
+ like($html, qr/<suffix>/, 'HTML labels do not enable HTML suffixes');
+ like($html, qr/<tag>/, 'HTML labels do not enable HTML tags');
+ my @filter = $html =~ /data-ui-multi-text="([^"]*)"/g;
+ is_deeply([ map { decode_attr($_) } @filter ],
+ [ 'Entire website & files ', 'Missing <path>' ],
+ 'filter text removes markup and decodes entities only once');
+ like($html, qr/name="markup"[^>]*value="root\nmissing"/,
+ 'HTML labels do not change submitted values');
+ assert_no_handler_injection(main::ui_multi_select_list('safe', [ $xss ],
+ [ { 'value' => $xss, 'label' => 'Trusted',
+ 'suffix' => $xss, 'tag' => $xss } ], { 'html' => 1 }),
+ 'HTML mode value and metadata');
+}
+
# Positional calls preserve the old selected pane's labels and value order.
{
my @values = ( [ 'b', 'B (selected)', q{disabled title="Selected"} ],
diff --git a/ui-lib.pl b/ui-lib.pl
index 78d448770..8bf50cf39 100755
--- a/ui-lib.pl
+++ b/ui-lib.pl
@@ -5714,10 +5714,11 @@ return &_ui_block('div', $select.$panels, &_ui_attrs({
=head2 ui_multi_select_list(name, &values, &options, [&opts] | [size], [add-if-missing], [disabled?], [options-title], [values-title], [width])
Returns a scrolling checkbox list with selection links, an expandable filter
-and a selection count beside the mode selector or links. Like ui_multi_select,
-it submits newline-joined values under name; ui-lib.js keeps them in sync.
-Missing selected values are added automatically. Labels are plain text with
-the options-hash API; positional calls also accept pre-escaped labels.
+and a selection count beside the mode selector or links. It submits
+newline-joined values under name; ui-lib.js keeps them in sync.
+Missing selected values are added automatically. Labels are plain text by
+default; positional calls also accept pre-escaped labels. Set html to 1 for
+trusted label markup.
With no entries, it shows only empty_label, preserving the selection, mode
and children form values in hidden inputs. Nonempty lists load their assets
@@ -5745,6 +5746,8 @@ Size, add-if-missing, titles and width are ignored.
=item count - Show the selection count; defaults to on. Set to 0 to omit it.
+=item html - Set to 1 to render labels as trusted HTML. Labels are escaped by default; values, suffixes and tags remain escaped.
+
=item placeholder - Hint text of the filter box.
=item empty_label - Plain text shown when there are no entries, defaulting to "No entries".
@@ -5958,13 +5961,20 @@ my $body = $tools ? &ui_tag('div', $tools, { 'class' => 'ui_multi_tools' }) : ''
my $rows = "";
foreach my $it (@items) {
my $val = $it->{'value'};
- my $label = &html_escape($it->{'label'}, $legacy);
+ my $label = $opts->{'html'} ? $it->{'label'} :
+ &html_escape($it->{'label'}, $legacy);
+ # Filter on label text, excluding markup while retaining literal entities.
+ my $filter_label = $opts->{'html'} ?
+ &html_escape(&html_strip($label), 1) : $label;
$label .= &ui_tag('span', &html_escape($it->{'suffix'}),
{ 'class' => 'ui_multi_suffix' })
if (defined($it->{'suffix'}) && $it->{'suffix'} ne '');
+ # Checkbox renderers may place HTML outside their label element.
+ my $ctags = "data-ui-multi-item='1'";
+ $ctags .= ' aria-label="'.$filter_label.'"' if ($opts->{'html'});
my $row = &ui_checkbox($name.'_item', $val, $label,
$selected{$val} ? 1 : 0,
- "data-ui-multi-item='1'",
+ $ctags,
$dis || $it->{'disabled'} ? 1 : 0);
if ($note && $it->{'kids'}) {
my $nattrs = { 'class' => 'ui_multi_note' };
@@ -5984,9 +5994,10 @@ foreach my $it (@items) {
$it->{'disabled'} ? 'ui_multi_disabled' : undef),
'data-ui-multi-level' => $it->{'level'} ? int($it->{'level'})
: undef,
- 'data-ui-multi-text' => &html_escape(join(" ",
+ 'data-ui-multi-text' => join(" ",
grep { defined($_) && $_ ne '' }
- $it->{'label'}.($it->{'suffix'} // ''), $it->{'tag'}), $legacy) }) } };
+ $filter_label.&html_escape($it->{'suffix'}, $legacy),
+ &html_escape($it->{'tag'}, $legacy)) }) } };
$attrs->{'hidden'} = undef if ($folded && $it->{'level'});
$rows .= &ui_tag('div', $row, $attrs);
}