Add searchable multi-selection list widget

https://forum.virtualmin.com/t/new-ui-widgets-for-webmin-modules/137932/12?u=ilia
This commit is contained in:
Ilia Ross
2026-09-08 23:51:45 +02:00
parent 308a46bd2f
commit e51d723d00
6 changed files with 891 additions and 15 deletions

File diff suppressed because one or more lines are too long

View File

@@ -329,6 +329,11 @@ ui_etime=Invalid time
ui_paging=Showing rows $1 to $2 of $3
ui_rowlabel=$2 in row $1 :
ui_filterbox=Type to filter..
ui_multi_noentries=No entries
ui_multi_filter=Filter content
ui_multi_filter_clear=Clear or close filter
ui_multi_nomatch=Nothing matches
ui_multi_selected=$1 selected
ui_of=of
ui_success=Success
ui_info=Information

View File

@@ -20,6 +20,14 @@ require File::Spec->catfile($root, 'ui-lib.pl');
# Resolve the asset versions from this checkout, without init_config
our $root_directory = $root;
# Load widget strings without init_config.
open(my $LANG, "<", File::Spec->catfile($root, 'lang', 'en')) or
die "lang/en: $!";
while(my $line = <$LANG>) {
$main::text{$1} = $2 if ($line =~ /^([A-Za-z0-9_]+)=(.*)/);
}
close($LANG);
# Suppress the asset tags, whose legitimate <script src> would trip the
# injection scanner below
$main::ui_page_assets_done = 1;
@@ -41,6 +49,15 @@ sub assert_no_handler_injection {
unlike($bare, qr/<script/i, "$label: no script element leaks out");
}
# Decode once like a browser; html_unescape also expands nested entities.
sub decode_attr {
my ($value) = @_;
my %entities = ( 'amp' => '&', 'lt' => '<', 'gt' => '>',
'quot' => '"', '#39' => "'", '#61' => '=' );
$value =~ s/&(amp|lt|gt|quot|#39|#61);/$entities{$1}/ge;
return $value;
}
my $xss = q{x"><script>alert(1)</script><b onmouseover="alert(1)};
# ---- escaping contract -----------------------------------------------------
@@ -315,4 +332,228 @@ like(main::ui_form_columns_table('x.cgi', [ [ 'go', 'Go' ] ], 0, undef, undef,
is($second, '', 'second assets call emits nothing');
}
# Multi-select values, modes, controls and hierarchy.
# Match attributes independently because their order varies.
{
my $html = main::ui_multi_select_list('doms',
[ 'b', [ 'zz', 'Gone' ] ],
[ [ 'a', 'A' ],
{ 'value' => 'b', 'label' => 'B', 'suffix' => '.x',
'level' => 1, 'tag' => 'Plan' },
[ 'c', 'C' ] ],
{ 'modes' => { 'name' => 'all', 'value' => 1,
'options' => [ [ 1, 'All' ], [ 0, 'Some' ] ],
'hide' => [ 1 ] } });
like($html, qr/type='hidden'[^>]*name="doms"[^>]*value="b\nzz"/,
'hidden input carries the newline-joined selection');
like($html, qr/name="doms_item" value="b"[^>]*checked/,
'selected entry is checked');
unlike($html, qr/name="doms_item" value="a"[^>]*checked/,
'unselected entry is not checked');
like($html, qr/value="zz"[^>]*checked/,
'selected value missing from the options is added');
like($html, qr/>Gone</, 'and keeps the label given with it');
like($html, qr/ui_multi_suffix">\.x</, 'suffix follows the label');
like($html, qr/data-ui-multi-text="B\.x Plan"/,
'filter text keeps the label and suffix joined for full-name searches');
like($html, qr/\bui_multi_level1\b/, 'level indents the row');
like($html, qr/ui_chip">Plan</, 'tag is a chip');
like($html, qr/<a (?=[^>]*\bselect_all\b)(?=[^>]*data-ui-multi-action="all")/,
'select all is the usual link');
like($html, qr/<a (?=[^>]*\bselect_invert\b)(?=[^>]*data-ui-multi-action="invert")/,
'invert selection is the usual link');
like($html, qr/select_all[^>]*>[^<]*<\/a>\s*\|\s*<a/s,
'the links are laid out by ui_links_row');
unlike($html, qr/<br>\s*<span[^>]*ui_search/,
'without the line break that row ends with');
unlike(main::ui_multi_select_list('x', [ ], [ [ 'a', 'A' ] ], { 'disabled' => 1 }),
qr/select_all/, 'a disabled widget has no links');
like($html, qr/<select [^>]*name="all"/, 'modes are a select by default');
my ($hide) = $html =~ /data-ui-multi-hide="([^"]*)"/;
is_deeply(main::convert_from_json(decode_attr($hide)), [ '1' ],
'hiding modes passed to the script as JSON strings');
like($html, qr/ui_multi_modes"[^>]*>(?:(?!<\/div>).)*<span (?=[^>]*\bui_multi_count\b)[^>]*>2 selected</s,
'count of chosen entries next to the mode select');
like(main::ui_multi_select_list('x', [ 'a' ], [ [ 'a', 'A' ] ]),
qr/ui_multi_tools"[^>]*>(?:(?!ui_multi_list).)*<span (?=[^>]*\bui_multi_count\b)[^>]*>1 selected</s,
'count next to the links when there are no modes');
like(main::ui_multi_select_list('x', [ ], [ [ 'a', 'A' ] ]),
qr/<span (?=[^>]*\bui_multi_count\b)(?=[^>]*\bhidden\b)/,
'count hidden while nothing is chosen');
like($html, qr/<span (?=[^>]*\bui_multi_count\b)(?=[^>]*\bhidden\b)/,
'count hidden under a mode that leaves the list out of use');
like($html, qr/<div (?=[^>]*\bui_multi_body\b)(?=[^>]*\bhidden\b)/,
'list hidden under a mode that does not use it');
unlike($html, qr/\bhidden\b[^>]*\bui_multi_item\b|\bui_multi_item\b[^>]*\bhidden\b/,
'every entry is shown, nothing folds');
unlike(main::ui_multi_select_list('x', [ ], [ [ 'a', 'A' ] ]),
qr/ui_search/, 'short list has no filter box');
like(main::ui_multi_select_list('x', [ ], [ map { [ $_, $_ ] } 1..9 ]),
qr/ui_search/, 'long list has one');
like(main::ui_multi_select_list('x', [ ], [ [ 'a', 'A' ] ],
{ 'modes' => { 'name' => 'all', 'value' => 1, 'radios' => 1,
'options' => [ [ 1, 'All' ], [ 0, 'Some' ] ] } }),
qr/type='radio'[^>]*name="all"/, 'modes can be radios');
like(main::ui_multi_select_list('x', [ ], [ [ 'a', 'A' ] ],
{ 'height' => '200px' }),
qr/--ui-multi-height:200px/, 'height option sets the scroll height');
like(main::ui_multi_select_list('g', [ 'a' ],
[ [ 'a', 'A' ], [ 'b', 'B' ] ], 5, 1, 1),
qr/value="a"[^>]*disabled/,
'disabled of ui_multi_select disables the rows');
}
# Preserve UTF-8 bytes and literal entities for browser-side matching.
{
my $label = "\xC3\x89QUIPE";
my $html = main::ui_multi_select_list('g', [ ],
[ [ 'team', $label ] ], { 'search' => 1 });
my ($filter) = $html =~ /data-ui-multi-text="([^"]*)"/;
is($filter, $label, 'filter label retains original UTF-8 bytes and case');
my $literal = 'R&amp;D';
$html = main::ui_multi_select_list('g', [ ], [ [ 'team', $literal ] ]);
($filter) = $html =~ /data-ui-multi-text="([^"]*)"/;
is(decode_attr($filter), $literal,
'filter text preserves literal HTML entity names in plain labels');
}
assert_no_handler_injection(
main::ui_multi_select_list($xss, [ $xss ],
[ { 'value' => $xss, 'label' => $xss, 'suffix' => $xss,
'tag' => $xss } ],
{ 'placeholder' => $xss, 'search' => 1 }),
'ui_multi_select_list');
# Filter accessibility, visibility and disabled state.
{
my $html = main::ui_multi_select_list('filter', [ ], [ [ 'a', 'A' ] ],
{ 'search' => 1 });
like($html, qr/<button (?=[^>]*type="button")(?=[^>]*data-ui-multi-action="filter")(?=[^>]*aria-expanded="false")(?=[^>]*aria-controls="filter_search")(?=[^>]*aria-label="Filter content")/,
'filter starts collapsed with an accessible toggle button');
like($html, qr/<button (?=[^>]*type="button")(?=[^>]*data-ui-multi-action="filter-clear")(?=[^>]*aria-label="Clear or close filter")/,
'filter has a labelled clear button that cannot submit the form');
like($html, qr/<input (?=[^>]*type="search")(?=[^>]*id="filter_search")(?=[^>]*aria-label="Filter content")/,
'search input is labelled and targeted by its buttons');
unlike(main::ui_multi_select_list('filter', [ ],
[ map { [ $_, $_ ] } 1..9 ], { 'search' => 0 }),
qr/data-ui-multi-search/, 'search can still be explicitly hidden');
my $disabled = main::ui_multi_select_list('filter', [ ], [ [ 'a', 'A' ] ],
{ 'search' => 1, 'disabled' => 1 });
is(scalar(() = $disabled =~ /<(?:button|input) (?=[^>]*(?:data-ui-multi-action="filter(?:-clear)?"|data-ui-multi-search="1"))(?=[^>]*\bdisabled\b)/g), 3,
'disabled pickers disable the input and both filter buttons');
}
# Mode radio labels are plain text.
assert_no_handler_injection(
main::ui_multi_select_list('d', [ ], [ [ 'a', 'A' ] ],
{ 'modes' => { 'name' => 'mode', 'value' => 'some', 'radios' => 1,
'options' => [ [ 'some', $xss ], [ 'all', 'All' ] ] } }),
'ui_multi_select_list mode radios');
# Hidden-mode values must survive attribute encoding intact.
{
my @hide = ( 'all servers', '', '&quot;', "line\nbreak" );
my $html = main::ui_multi_select_list('d', [ ], [ [ 'a', 'A' ] ],
{ 'modes' => { 'name' => 'mode', 'value' => 'all servers',
'options' => [ map { [ $_, $_ ] } @hide ],
'hide' => \@hide } });
my ($hide) = $html =~ /data-ui-multi-hide="([^"]*)"/;
is_deeply(main::convert_from_json(decode_attr($hide)), \@hide,
'hidden-mode values survive attribute encoding without splitting');
}
# Empty pickers retain form values without visible controls.
{
my $opts = { 'search' => 1,
'modes' => { 'name' => 'all', 'value' => 2,
'options' => [ [ 1, 'All' ], [ 2, 'Except' ] ] },
'children' => { 'name' => 'sub', 'checked' => 1, 'value' => 'yes' } };
my $html = main::ui_multi_select_list('d', [ ], [ ], $opts);
like($html, qr/^<span class="ui--span">No entries<\/span>/,
'an empty picker is a plain span without empty-state styling');
unlike($html, qr/<(?:select|button|label|a|div|link|script)\b|type=['"](?:checkbox|radio|search)['"]|\bui_multi_(?:tools|list)\b/,
'no controls, wrapper or assets are emitted, even when search is requested');
like($html, qr/type='hidden'[^>]*name="d"[^>]*value=""/,
'an empty picker still submits an empty selection');
like($html, qr/type='hidden'[^>]*name="all"[^>]*value="2"/,
'the saved mode is retained without a visible selector');
like($html, qr/type='hidden'[^>]*name="sub"[^>]*value="yes"/,
'the checked children option retains its submitted value');
unlike($html, qr/\bdata-ui-multi=/,
'the static empty label needs no JavaScript initialization');
my $disabled = main::ui_multi_select_list('d', [ ], [ ],
{ %$opts, 'disabled' => 1 });
unlike($disabled, qr/name="(?:all|sub)"/,
'disabled mode and children controls remain excluded from submission');
my $label = 'No virtual servers have been created yet';
like(main::ui_multi_select_list('d', [ ], [ ], { 'empty_label' => $label }),
qr/^<span class="ui--span">\Q$label\E<\/span>/,
'caller can supply a plain empty label');
like(main::ui_multi_select_list('d', [ ], [ ], { 'empty_label' => '<b>None & none</b>' }),
qr/&lt;b&gt;None &amp; none&lt;\/b&gt;/,
'custom empty label is escaped as plain text');
assert_no_handler_injection(
main::ui_multi_select_list('d', [ ], [ ], { 'empty_label' => $xss }),
'custom empty label');
}
# Add missing saved values only once.
{
my $html = main::ui_multi_select_list('d', [ 'gone', 'gone' ], [ ]);
is(scalar(() = $html =~ /\bclass="[^"]*\bui_multi_item\b/g), 1,
'a missing selected entry is added once');
unlike($html, qr/>No entries</,
'saved values absent from the options still make a populated picker');
}
# Folding hides children and shows their count beside the parent.
{
my @opts = ( [ 'p', 'Parent' ],
{ 'value' => 'c1', 'label' => 'One', 'level' => 1 },
{ 'value' => 'c2', 'label' => 'Two', 'level' => 1 } );
# $1 expands to the child count.
$main::text{'ui_multi_test_note'} = '+$1 kids';
my $html = main::ui_multi_select_list('d', [ 'p' ], \@opts,
{ 'children' => { 'name' => 'sub', 'checked' => 1,
'label' => 'With children',
'note' => 'ui_multi_test_note' } });
like($html, qr/\bui_toggle\b/, 'the switch is a toggle');
like($html, qr/<input (?=[^>]*\bname="sub")(?=[^>]*\bdata-ui-multi-action="children")(?=[^>]*\bchecked\b)/,
'switch rendered on with its action');
my @folded = $html =~ /(<div (?=[^>]*\bui_multi_item\b)(?=[^>]*\bhidden\b)[^>]*>)/g;
is(scalar(@folded), 2, 'indented entries fold away while on');
like($html, qr/<span (?=[^>]*\bui_multi_note\b)[^>]*>\+2 kids</,
'parent row counts its children through the language');
unlike(main::ui_multi_select_list('d', [ 'p' ], \@opts,
{ 'children' => { 'name' => 'sub', 'label' => 'With children' } }),
qr/ui_multi_note/, 'no note without a key for it');
unlike($html, qr/<span (?=[^>]*\bui_multi_note\b)(?=[^>]*\bhidden\b)/,
'count shown while on');
my $open = main::ui_multi_select_list('d', [ 'p' ], \@opts,
{ 'children' => { 'name' => 'sub', 'label' => 'With children',
'note' => 'ui_multi_test_note' } });
my @shown = $open =~ /(<div (?=[^>]*\bui_multi_item\b)(?=[^>]*\bhidden\b)[^>]*>)/g;
is(scalar(@shown), 0, 'entries shown while off');
like($open, qr/<span (?=[^>]*\bui_multi_note\b)(?=[^>]*\bhidden\b)/,
'count hidden while off');
# Initial counts must match JavaScript without losing folded selections.
my $selected = [ 'p', 'c1', 'c2' ];
my $closed = main::ui_multi_select_list('d', $selected, \@opts,
{ 'children' => { 'name' => 'sub', 'checked' => 1 } });
like($closed, qr/<span (?=[^>]*\bui_multi_count\b)[^>]*>1 selected</,
'folded count includes only the selected parent');
like($closed, qr/type='hidden'[^>]*name="d"[^>]*value="p\nc1\nc2"/,
'folded children retain their submitted selections');
my $expanded = main::ui_multi_select_list('d', $selected, \@opts,
{ 'children' => { 'name' => 'sub' } });
like($expanded, qr/<span (?=[^>]*\bui_multi_count\b)[^>]*>3 selected</,
'expanded count includes selected children');
my $only_children = main::ui_multi_select_list('d', [ 'c1' ], \@opts,
{ 'children' => { 'name' => 'sub', 'checked' => 1 } });
like($only_children, qr/<span (?=[^>]*\bui_multi_count\b)(?=[^>]*\bhidden\b)[^>]*>0 selected</,
'count is hidden when only folded children are selected');
like($only_children, qr/type='hidden'[^>]*name="d"[^>]*value="c1"/,
'folding retains child selections even without a selected parent');
}
done_testing();

285
ui-lib.pl
View File

@@ -1261,7 +1261,8 @@ return $rv;
Returns HTML for selecting many of many from a list. By default, this is
implemented using two <select> lists and Javascript buttons to move elements
between them. The resulting input value is \n separated.
between them. The resulting input value is \n separated. ui_multi_select_list
offers a searchable checkbox list with the same values and submission format.
Parameters are :
@@ -5692,4 +5693,286 @@ return &_ui_block('div', $select.$panels, &_ui_attrs({
'id' => $opts->{'id'} }));
}
####################### multiple selection
=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 no entries, it shows only empty_label, preserving the selection, mode
and children form values in hidden inputs. Nonempty lists load their assets
even outside ui_page_start.
Shift-click applies the clicked state from the last clicked entry to the
current one, skipping filtered, folded and disabled entries. Labels and row
backgrounds work too.
Accepts an options hash or ui_multi_select's trailing positional arguments.
Only disabled is used from the legacy arguments; size, add-if-missing,
titles and width are ignored.
=item name - HTML name for the input.
=item values - Array reference of selected scalars or [ value, label ] pairs.
=item options - Array reference of [ value, label ] pairs or hashes with keys value, label, suffix (muted text after the label), level (indentation depth), tag (chip at the right) and disabled.
=item opts - Optional hash reference with the keys :
=item search - Show or hide the filter button; defaults to on above eight entries. The input opens to its left in reserved space. Selection links affect visible, enabled entries and are omitted when disabled.
=item placeholder - Hint text of the filter box.
=item empty_label - Plain text shown when there are no entries, defaulting to "No entries".
=item height - Height beyond which the list scrolls : a CSS length, 170px by default.
=item modes - Hash with name, value and options as for ui_select. The hide array lists modes that hide the list, such as "all servers". Set radios to 1 to use radio buttons.
=item children - Hash defining a switch that folds indented entries under their parent. Keys: name, value, checked, label or label_html, and note (a language key with $1 for the child count, shown beside the parent while folded). Folded selections are retained but excluded from the count.
=item disabled - Set to 1 to disable every input of the widget.
=item class - Extra CSS class names for the widget.
=item id - HTML id of the widget, ui_multi_ followed by the name by default.
=cut
sub ui_multi_select_list
{
return &theme_ui_multi_select_list(@_)
if (defined(&theme_ui_multi_select_list));
my ($name, $values, $options, $opts) = @_;
if (ref($opts) ne 'HASH') {
# Accept ui_multi_select's positional disabled argument.
$opts = { 'disabled' => $_[5] };
}
my $dis = $opts->{'disabled'} ? 1 : 0;
# Normalize options without changing the caller's data.
my @items;
foreach my $o (@{$options || []}) {
if (ref($o) eq 'HASH') {
push(@items, { %$o });
}
elsif (ref($o) eq 'ARRAY') {
push(@items, { 'value' => $o->[0], 'label' => $o->[1] });
}
}
# Index options to add missing selections without repeated scans.
my %offered = map { defined($_->{'value'}) ? ($_->{'value'}, 1) : () } @items;
my %selected;
foreach my $v (@{$values || []}) {
my ($val, $label) = ref($v) eq 'ARRAY' ? @$v : ($v);
next if (!defined($val));
$selected{$val} = 1;
push(@items, { 'value' => $val, 'label' => $label })
if (!$offered{$val}++);
}
foreach my $it (@items) {
$it->{'value'} = '' if (!defined($it->{'value'}));
$it->{'label'} = $it->{'value'}
if (!defined($it->{'label'}) || $it->{'label'} eq '');
}
my @chosen = grep { $selected{$_->{'value'}} } @items;
# Resolve the mode selector and initial list visibility.
my $modes = $opts->{'modes'};
my $hasmodes = ref($modes) eq 'HASH' && defined($modes->{'name'}) &&
ref($modes->{'options'}) eq 'ARRAY';
my @hide = $hasmodes ? @{$modes->{'hide'} || []} : ();
my $hidden = $hasmodes && defined($modes->{'value'}) &&
(grep { $_ eq $modes->{'value'} } @hide) ? 1 : 0;
# Resolve the optional child-folding switch.
my $children = ref($opts->{'children'}) eq 'HASH' &&
defined($opts->{'children'}->{'name'}) ?
$opts->{'children'} : undef;
my $folded = $children && $children->{'checked'} ? 1 : 0;
# Exclude folded children from the count, retaining their submitted values.
my $nchosen = grep { !$folded || !$_->{'level'} } @chosen;
my $cattrs = { 'class' => 'ui_multi_count' };
$cattrs->{'hidden'} = undef if (!$nchosen || $hidden);
my $count = &ui_tag('span',
&html_escape(&text('ui_multi_selected', $nchosen)), $cattrs);
my $counted = 0;
my $search = defined($opts->{'search'}) ? $opts->{'search'} : @items > 8;
# Empty lists need only a plain label; retain their submitted values.
if (!@items) {
my $empty = &ui_tag('span', &html_escape(
defined($opts->{'empty_label'}) ? $opts->{'empty_label'} :
$text{'ui_multi_noentries'})).
&ui_hidden($name, '');
if (!$dis) {
$empty .= &ui_hidden($modes->{'name'}, $modes->{'value'})
if ($hasmodes && defined($modes->{'value'}));
$empty .= &ui_hidden($children->{'name'},
defined($children->{'value'}) ? $children->{'value'} : 1)
if ($folded);
}
return $empty;
}
my $note = $children ? $children->{'note'} : undef;
# Count children for each parent's folded summary.
my $last;
foreach my $it (@items) {
if ($it->{'level'}) {
$last->{'kids'}++ if ($last);
}
else {
$last = $it;
}
}
# Render the mode selector and count.
my $rv = "";
if ($hasmodes) {
# ui_radio accepts HTML, so escape these plain-text labels first.
my @radios = map {
ref($_) eq 'ARRAY' ?
[ $_->[0], &html_escape($_->[1] || $_->[0]), $_->[2] ] :
[ $_, &html_escape($_) ]
} @{$modes->{'options'}};
$rv .= &ui_tag('div',
($modes->{'radios'} ?
&ui_radio($modes->{'name'}, $modes->{'value'},
\@radios, $dis) :
&ui_select($modes->{'name'}, $modes->{'value'},
$modes->{'options'}, 1, 0, 0, $dis)).
$count,
{ 'class' => 'ui_multi_modes' });
$counted = 1;
}
# Use themed selection links, with actions scoped to this list.
my $tools = "";
if (!$dis) {
my $links = &ui_links_row([
&ui_tag('a', &html_escape($text{'ui_selall'}),
{ 'href' => '#', 'class' => 'select_all',
'data-ui-multi-action' => 'all' }),
&ui_tag('a', &html_escape($text{'ui_selinv'}),
{ 'href' => '#', 'class' => 'select_invert',
'data-ui-multi-action' => 'invert' }) ]);
# Keep the links and filter on the same toolbar.
$links =~ s/<br>\s*$//i;
# Keep link separators inside one flex item.
$tools .= &ui_tag('div', $links, { 'class' => 'ui_multi_links' });
}
$tools .= $count if (!$counted);
if ($search) {
# Render the filter with clear and toggle buttons.
my $hint = defined($opts->{'placeholder'}) ? $opts->{'placeholder'} :
$text{'ui_multi_filter'};
my $attrs = { 'type' => 'search', 'name' => $name.'_search',
'id' => $name.'_search', 'class' => 'ui_input ui_search_input',
'placeholder' => $hint, 'aria-label' => $hint,
'data-ui-multi-search' => 1, 'autocomplete' => 'off' };
$attrs->{'disabled'} = undef if ($dis);
my $filter = &ui_tag('input', undef, $attrs);
foreach my $action ( 'filter-clear', 'filter' ) {
my $clear = $action eq 'filter-clear';
my $battrs = {
'type' => 'button',
'class' => 'ui_multi_filter_button ui_multi_'.
($clear ? 'filter_clear' : 'filter_toggle'),
'data-ui-multi-action' => $action,
'aria-label' => $text{$clear ? 'ui_multi_filter_clear' :
'ui_multi_filter'},
'aria-controls' => $name.'_search' };
$battrs->{'aria-expanded'} = 'false' if (!$clear);
$battrs->{'disabled'} = undef if ($dis);
$filter .= &ui_tag('button',
&ui_svg_icon($clear ? 'x-circle' : 'filter', { 'size' => 14 }),
$battrs);
}
$tools .= &ui_tag('span', $filter,
{ 'class' => 'ui_search ui_multi_filter' });
}
my $body = &ui_tag('div', $tools, { 'class' => 'ui_multi_tools' });
# Render themed checkboxes with optional suffixes, child counts and tags.
my $rows = "";
foreach my $it (@items) {
my $val = $it->{'value'};
my $label = &html_escape($it->{'label'});
$label .= &ui_tag('span', &html_escape($it->{'suffix'}),
{ 'class' => 'ui_multi_suffix' })
if (defined($it->{'suffix'}) && $it->{'suffix'} ne '');
my $row = &ui_checkbox($name.'_item', $val, $label,
$selected{$val} ? 1 : 0,
"data-ui-multi-item='1'",
$dis || $it->{'disabled'} ? 1 : 0);
if ($note && $it->{'kids'}) {
my $nattrs = { 'class' => 'ui_multi_note' };
$nattrs->{'hidden'} = undef if (!$folded);
$row .= &ui_tag('span',
&html_escape(&text($note, $it->{'kids'})), $nattrs);
}
$row .= &ui_tag('span', &ui_chip($it->{'tag'}),
{ 'class' => 'ui_multi_side' })
if (defined($it->{'tag'}) && $it->{'tag'} ne '');
# Lowercase in the browser, after UTF-8 bytes have been decoded.
my $attrs = &_ui_attrs({
'class' => &_ui_class('ui_multi_item',
$it->{'level'} ? 'ui_multi_level'.int($it->{'level'})
: undef,
$it->{'disabled'} ? 'ui_multi_disabled' : undef),
'data-ui-multi-level' => $it->{'level'} ? int($it->{'level'})
: undef,
'data-ui-multi-text' => &html_escape(join(" ",
grep { defined($_) && $_ ne '' }
$it->{'label'}.($it->{'suffix'} // ''), $it->{'tag'})) });
$attrs->{'hidden'} = undef if ($folded && $it->{'level'});
$rows .= &ui_tag('div', $row, $attrs);
}
# Include the initially hidden no-matches message.
$body .= &_ui_block('div',
&_ui_block('div', $rows, { 'class' => 'ui_multi_items' }).
&ui_tag('div', &ui_svg_icon('search', { 'size' => 14 })." ".
&html_escape($text{'ui_multi_nomatch'}),
{ 'class' => 'ui_multi_empty', 'hidden' => undef }),
{ 'class' => 'ui_multi_list' });
# Render the child-folding switch.
if ($children) {
$body .= &ui_tag('div',
&ui_toggle({ 'name' => $children->{'name'},
'value' => defined($children->{'value'}) ?
$children->{'value'} : 1,
'checked' => $folded,
'label' => $children->{'label'},
'label_html' => $children->{'label_html'},
'attrs' => { 'data-ui-multi-action' => 'children' },
'disabled' => $dis }),
{ 'class' => 'ui_multi_foot' });
}
# Preserve ui_multi_select's submission format.
$body .= &ui_hidden($name, join("\n", map { $_->{'value'} } @chosen));
my $battrs = { 'class' => 'ui_multi_body' };
$battrs->{'hidden'} = undef if ($hidden);
$rv .= &_ui_block('div', $body, $battrs);
my $attrs = &_ui_attrs({
'class' => &_ui_class('ui_multi', $opts->{'class'},
$dis ? 'ui_multi_disabled' : undef),
'id' => defined($opts->{'id'}) ? $opts->{'id'} : 'ui_multi_'.$name,
'style' => $opts->{'height'} ?
"--ui-multi-height:".$opts->{'height'} : undef,
'data-ui-multi' => $name,
'data-ui-multi-hide' => &html_escape(
&convert_to_json([ map { "$_" } @hide ])),
'data-ui-multi-text-selected' => $text{'ui_multi_selected'} });
return &ui_page_assets().&_ui_block('div', $rv, $attrs);
}
1;

View File

@@ -11,14 +11,15 @@
* --font-family-mono when defined, otherwise text inherits the page font
* and code uses the monospace fallback below.
*
* The .ui_page wrapper emitted by ui_page_start supplies the widget tokens.
* .ui_page and standalone .ui_multi lists supply the widget tokens.
* The palette and shared spacing come from --ui-* custom properties,
* which a theme may redefine on .ui_page to restyle every
* widget at once. Setting data-ui-scheme="dark" (or "auto") on any
* ancestor switches to the built-in dark palette.
*/
.ui_page {
/* Nested lists inherit the page's tokens. */
.ui_page, .ui_multi:not(:where(.ui_page *)) {
/* Palette tokens read the theme's own custom properties first - the
* names Authentic defines for its palettes and night mode - and
* fall back to the colors of the gray theme, which defines only the
@@ -64,6 +65,8 @@
--ui-neutral-line: var(--border-color-neutral, #d7dbe0);
/* Controls */
--ui-input-bg: var(--bg-color-input, var(--ui-surface));
--ui-input-border: var(--border-color-input, var(--ui-border-strong));
--ui-toggle-bg: var(--toggle-bg-color, var(--ui-border-strong));
--ui-toggle-bg-checked: var(--toggle-bg-color-checked, var(--ui-accent));
--ui-toggle-thumb: var(--toggle-thumb-color, #ffffff);
@@ -98,7 +101,9 @@
* palette follows the browser's prefers-color-scheme setting instead.
* The two blocks below must be kept identical. */
[data-ui-scheme="dark"] .ui_page,
.ui_page[data-ui-scheme="dark"] {
.ui_page[data-ui-scheme="dark"],
[data-ui-scheme="dark"] .ui_multi:not(:where(.ui_page *)),
.ui_multi[data-ui-scheme="dark"]:not(:where(.ui_page *)) {
--ui-canvas: #15171b;
--ui-surface: #1e2126;
--ui-surface-2: #23262c;
@@ -130,11 +135,15 @@
--ui-neutral-text: #b6bec7;
--ui-neutral-soft: #272b31;
--ui-neutral-line: #3c424b;
--ui-input-bg: var(--ui-surface);
--ui-input-border: var(--ui-border-strong);
color-scheme: dark;
}
@media (prefers-color-scheme: dark) {
[data-ui-scheme="auto"] .ui_page,
.ui_page[data-ui-scheme="auto"] {
.ui_page[data-ui-scheme="auto"],
[data-ui-scheme="auto"] .ui_multi:not(:where(.ui_page *)),
.ui_multi[data-ui-scheme="auto"]:not(:where(.ui_page *)) {
--ui-canvas: #15171b;
--ui-surface: #1e2126;
--ui-surface-2: #23262c;
@@ -166,6 +175,8 @@
--ui-neutral-text: #b6bec7;
--ui-neutral-soft: #272b31;
--ui-neutral-line: #3c424b;
--ui-input-bg: var(--ui-surface);
--ui-input-border: var(--ui-border-strong);
color-scheme: dark;
}
body[data-ui-scheme="auto"]:has(.ui_page),
@@ -980,6 +991,142 @@ a.ui_list_link:hover { color: var(--ui-accent) !important; }
box-shadow: 0 0 0 3px var(--ui-ring);
}
/* ---- Multiple selection ----
* Scrollable checkbox lists, also usable outside .ui_page. */
.ui_multi, .ui_multi_body {
display: grid;
gap: var(--ui-inset);
min-width: 0;
}
.ui_multi [hidden] { display: none !important; }
.ui_multi_modes {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 10px;
}
.ui_multi_count {
color: var(--ui-fg-muted);
font-size: 0.93em;
white-space: nowrap;
}
.ui_multi_tools {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 0;
}
/* ui_links_row handles link spacing; add room for the count. */
.ui_multi_links { min-width: 0; }
.ui_multi_tools .ui_multi_count:not(:first-child) {
margin-left: 2px;
margin-right: 10px;
}
/* Reserve filter space to prevent toolbar shifts on opening. */
.ui_multi_tools .ui_search {
margin-left: auto;
flex: 1 1 9em;
min-width: 9em;
max-width: 14em;
}
/* Match the height of compact link buttons. */
.ui_multi_tools .ui_search_input {
height: 22px;
padding: 0 26px;
font-size: 0.93em;
line-height: 20px;
background: var(--ui-input-bg);
border-color: var(--ui-input-border);
}
/* Keep dimensions while hiding closed controls from keyboard navigation. */
.ui_multi_filter:not(.ui_multi_filter_open) .ui_search_input,
.ui_multi_filter:not(.ui_multi_filter_open) .ui_multi_filter_clear {
visibility: hidden;
}
.ui_multi_filter .ui_search_input::-webkit-search-cancel-button {
-webkit-appearance: none;
}
.ui_multi_tools .ui_multi_filter_button {
appearance: none;
position: absolute;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
width: 26px;
margin: 0;
padding: 0;
border: 1px solid transparent;
border-radius: 0;
background: transparent;
color: var(--ui-fg-muted);
font: inherit;
cursor: pointer;
}
.ui_multi_tools .ui_multi_filter_toggle {
right: 0;
/* Share the input's theme colors. */
border-color: var(--ui-input-border);
background: var(--ui-input-bg);
}
.ui_multi_filter_toggle .ui_svg_icon {
fill: currentColor;
opacity: 0.75;
}
.ui_multi_tools .ui_multi_filter_clear { left: 0; }
.ui_multi_filter_open .ui_multi_filter_toggle {
border-color: transparent;
background: transparent;
color: var(--ui-accent);
}
.ui_multi_filter_active .ui_multi_filter_toggle { color: var(--ui-danger); }
.ui_multi_filter_button:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: -2px;
}
.ui_multi_filter_button:disabled { cursor: default; }
.ui_multi_list {
border: 1px solid var(--ui-border);
background: var(--ui-surface);
min-width: 0;
}
.ui_multi_items {
max-height: var(--ui-multi-height, 170px);
overflow-y: scroll;
}
.ui_multi_item {
display: flex;
align-items: center;
min-width: 0;
padding: 1px var(--ui-inset) 2px 7px;
}
/* Space unwrapped checkboxes; theme wrappers handle their own spacing. */
.ui_multi_item > input[type="checkbox"] { margin-right: 6px; }
.ui_multi_item:hover { background: var(--ui-surface-2); }
.ui_multi_level1 { padding-left: 27px; }
.ui_multi_level2 { padding-left: 47px; }
.ui_multi_suffix { color: var(--ui-fg-muted); }
.ui_multi_note {
margin-left: 6px;
color: var(--ui-fg-muted);
font-size: 0.93em;
white-space: nowrap;
}
.ui_multi_side { margin-left: auto; padding-left: 8px; flex-shrink: 0; }
.ui_multi_disabled { opacity: 0.6; }
.ui_multi_empty {
padding: 1px 2px 2px 2px;
color: var(--ui-fg-muted);
text-align: center;
}
.ui_multi_empty .ui_svg_icon {
vertical-align: -0.18em;
transform: scale(0.87);
margin-right: 0.025em;
}
/* ---- Reduced motion ---- */
@media (prefers-reduced-motion: reduce) {

View File

@@ -11,10 +11,21 @@
(function () {
'use strict';
// Ajax navigation can load the assets again in the same document.
// Delegated handlers survive navigation and must only be installed once.
if (window.webminUiWidgetsLoaded) return;
window.webminUiWidgetsLoaded = true;
// Replace old listeners when Ajax navigation reloads this script.
if (window.webminUiWidgets) window.webminUiWidgets.remove();
var listeners = [];
function on(type, fn, capture, target) {
target = target || document;
target.addEventListener(type, fn, capture);
listeners.push([ type, fn, capture, target ]);
}
window.webminUiWidgets = {
remove: function () {
listeners.forEach(function (l) {
l[3].removeEventListener(l[0], l[1], l[2]);
});
}
};
// Filter table rows or list entries in the target container by the
// text typed into a ui_search box, leaving header rows in place
@@ -36,7 +47,7 @@
// Confirmation prompts on buttons and links carrying data-ui-confirm,
// including existing ui_submit buttons given the attribute in tags.
// Capture the click before a theme or inline handler performs the action.
document.addEventListener('click', function (e) {
on('click', function (e) {
var confirmer = e.target.closest && e.target.closest('[data-ui-confirm]');
if (confirmer &&
!window.confirm(confirmer.getAttribute('data-ui-confirm'))) {
@@ -45,13 +56,13 @@
}
}, true);
document.addEventListener('input', function (e) {
on('input', function (e) {
var input = e.target.closest && e.target.closest('[data-ui-filter]');
if (input) applyFilter(input);
});
// Choice lists : focusing an input of an option selects that option,
// as the existing ui_opt_textbox does
document.addEventListener('focusin', function (e) {
on('focusin', function (e) {
// Links, help buttons and other focusable content do not change the
// selected option; only focusing one of its editable controls does.
if (!e.target.matches ||
@@ -76,7 +87,7 @@
panel.getAttribute('data-ui-switch-value') !== select.value;
});
}
document.addEventListener('change', function (e) {
on('change', function (e) {
var select = e.target;
if (select.matches && select.matches('select[data-ui-switch]')) {
applySwitch(select);
@@ -85,7 +96,7 @@
// Native reset restores select values after the reset event, without
// firing change. Update the panels once those values have been restored.
document.addEventListener('reset', function (e) {
on('reset', function (e) {
window.setTimeout(function () {
if (e.defaultPrevented) return;
document.querySelectorAll('select[data-ui-switch]').forEach(function (select) {
@@ -94,4 +105,193 @@
}, 0);
});
// Multiple selection lists submit newline-joined values in a hidden input.
var multiAnchors = new WeakMap();
// Find the checkbox inside any theme wrapper.
function multiInput(item) {
return item.querySelector('input[type="checkbox"]');
}
// Closing clears the query and returns focus to the button.
function openMultiFilter(box, open) {
var filter = box.querySelector('.ui_multi_filter');
var search = box.querySelector('[data-ui-multi-search]');
if (!filter || !search || search.disabled) return;
filter.classList.toggle('ui_multi_filter_open', open);
if (!open) search.value = '';
applyMulti(box);
(open ? search : filter.querySelector('.ui_multi_filter_toggle')).focus();
}
function applyMulti(box) {
// Resets and history restores need not fire change events.
var mode = box.querySelector('.ui_multi_modes select, .ui_multi_modes input[type="radio"]:checked');
var body = box.querySelector('.ui_multi_body');
var hide = JSON.parse(box.getAttribute('data-ui-multi-hide') || '[]');
if (body) body.hidden = !!mode && hide.indexOf(mode.value) >= 0;
var search = box.querySelector('[data-ui-multi-search]');
var query = search ? search.value.trim().toLowerCase() : '';
// Reopen restored searches.
var filter = box.querySelector('.ui_multi_filter');
if (filter) {
if (query) filter.classList.add('ui_multi_filter_open');
filter.classList.toggle('ui_multi_filter_active', query !== '');
filter.querySelector('.ui_multi_filter_toggle').setAttribute('aria-expanded',
String(filter.classList.contains('ui_multi_filter_open')));
}
var fold = box.querySelector('[data-ui-multi-action="children"]');
var folded = !!(fold && fold.checked);
var matched = 0;
var chosen = [];
var selectedCount = 0;
box.querySelectorAll('.ui_multi_item').forEach(function (item) {
var input = multiInput(item);
if (input && input.checked) chosen.push(input.value);
// Show child counts only while folded.
var note = item.querySelector('.ui_multi_note');
if (note) note.hidden = !folded;
// The parent covers folded children.
if (folded && item.getAttribute('data-ui-multi-level')) {
item.hidden = true;
return;
}
// Retain folded selections, but count only unfolded entries.
if (input && input.checked) selectedCount++;
var match = query === '' ||
(item.getAttribute('data-ui-multi-text') || '').toLowerCase().indexOf(query) >= 0;
if (match) matched++;
item.hidden = !match;
});
var empty = box.querySelector('.ui_multi_empty');
if (empty) empty.hidden = matched > 0;
// Hide the count for empty selections or hidden lists.
var count = box.querySelector('.ui_multi_count');
if (count) {
count.textContent = box.getAttribute('data-ui-multi-text-selected')
.replace('$1', selectedCount);
count.hidden = selectedCount === 0 || !!(body && body.hidden);
}
var name = box.getAttribute('data-ui-multi');
box.querySelectorAll('input[type="hidden"]').forEach(function (hidden) {
if (hidden.name === name) hidden.value = chosen.join('\n');
});
}
// Bulk actions affect only visible, enabled rows.
function eachShownRow(box, fn) {
box.querySelectorAll('.ui_multi_item').forEach(function (item) {
var input = multiInput(item);
if (input && !input.disabled && !item.hidden) fn(input);
});
}
// Prevent text selection during Shift-click.
on('mousedown', function (e) {
var item = e.target.closest && e.target.closest('.ui_multi_item');
if (e.shiftKey && e.button === 0 && item &&
item.closest('[data-ui-multi]') &&
!e.target.closest('input, a, button')) e.preventDefault();
});
on('input', function (e) {
var search = e.target.closest && e.target.closest('[data-ui-multi-search]');
var box = search && search.closest('[data-ui-multi]');
if (box) applyMulti(box);
});
// Capture Enter/Escape before theme handlers can submit or leave the page.
on('keydown', function (e) {
var search = e.target.closest && e.target.closest('[data-ui-multi-search]');
var box = search && search.closest('[data-ui-multi]');
if (!box || e.isComposing) return;
if (e.key === 'Escape' || e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
}
if (e.key === 'Escape') openMultiFilter(box, false);
}, true);
on('change', function (e) {
var box = e.target.closest && e.target.closest('[data-ui-multi]');
if (!box) return;
var t = e.target;
if (t.matches('.ui_multi_modes select, .ui_multi_modes input[type="radio"]')) {
// Start a new range after a mode change.
multiAnchors.delete(box);
}
applyMulti(box);
});
on('click', function (e) {
var t = e.target.closest && e.target.closest(
'[data-ui-multi-action], .ui_multi_item');
var box = t && t.closest('[data-ui-multi]');
if (!box) return;
var action = t.getAttribute('data-ui-multi-action');
if (action === 'filter' || action === 'filter-clear') {
// Cross clears text or closes an empty filter; funnel toggles.
e.preventDefault();
if (t.disabled) return;
var search = box.querySelector('[data-ui-multi-search]');
var open = action === 'filter-clear' ? search.value !== '' :
!t.closest('.ui_multi_filter').classList.contains('ui_multi_filter_open');
if (action === 'filter-clear') search.value = '';
openMultiFilter(box, open);
return;
}
if (action === 'all' || action === 'invert') {
// Apply the bulk action without following the link.
e.preventDefault();
eachShownRow(box, function (input) {
input.checked = action === 'all' || !input.checked;
});
multiAnchors.delete(box);
}
else if (action) {
// The switch is handled by change.
return;
}
else {
var input = multiInput(t);
if (!input || input.disabled || t.hidden) return;
// Only row backgrounds need toggling; checkboxes and labels do it natively.
if (e.target !== input) {
if (e.target.closest('input, label, a, button')) return;
input.checked = !input.checked;
}
// Apply the clicked state across visible, enabled rows in either direction.
if (e.shiftKey) {
var shown = [];
eachShownRow(box, function (row) { shown.push(row); });
var start = shown.indexOf(multiAnchors.get(box));
var end = shown.indexOf(input);
if (start >= 0 && end >= 0) {
var checked = input.checked;
shown.slice(Math.min(start, end), Math.max(start, end) + 1)
.forEach(function (row) { row.checked = checked; });
}
}
multiAnchors.set(box, input);
}
applyMulti(box);
});
// Wait for native reset before syncing rows and submitted values.
on('reset', function (e) {
window.setTimeout(function () {
if (e.defaultPrevented) return;
document.querySelectorAll('[data-ui-multi]').forEach(function (box) {
if (e.target.contains(box)) {
multiAnchors.delete(box);
applyMulti(box);
}
});
}, 0);
});
// Sync new or restored widgets and discard old range anchors.
function initMulti() {
document.querySelectorAll('[data-ui-multi]').forEach(function (box) {
multiAnchors.delete(box);
applyMulti(box);
});
}
// Back/forward cache restores do not reload scripts.
on('pageshow', initMulti, false, window);
if (document.readyState === 'loading') {
on('DOMContentLoaded', initMulti);
} else {
initMulti();
}
})();