mirror of
https://github.com/webmin/webmin.git
synced 2026-09-15 18:10:39 +01:00
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:
File diff suppressed because one or more lines are too long
5
lang/en
5
lang/en
@@ -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
|
||||
|
||||
@@ -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&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', '', '"', "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/<b>None & none<\/b>/,
|
||||
'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
285
ui-lib.pl
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user