Add UI widgets and demo module
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
Close inactive / close-inactive (push) Has been cancelled

ⓘ Add composable widgets, shared styling and interactions, sortable-table options, and a read-only UI gallery.
This commit is contained in:
Ilia Ross
2026-09-05 03:52:39 +02:00
parent e3d6fa3c0a
commit 20a487ca5a
28 changed files with 4860 additions and 19 deletions

File diff suppressed because one or more lines are too long

View File

@@ -133,6 +133,17 @@ if ($mode eq "modules" && foreign_available("webmin")) {
'icon' => '/images/reload.png' });
}
# Show the UI demo module when it has been dropped into the Webmin root,
# so it is reachable even before it has been added to the user's module list
if (-d &module_root_directory('ui-demo')) {
my %ui_demo = &get_module_info('ui-demo');
push(@leftitems, { 'type' => 'item',
'id' => 'ui-demo',
'desc' => $ui_demo{'desc'} || 'UI Demo',
'icon' => '/ui-demo/images/menu.svg',
'link' => '/ui-demo/' });
}
# Show logout link
get_miniserv_config(\%miniserv);
if ($miniserv{'logout'} && !$ENV{'SSL_USER'} && !$ENV{'LOCAL_USER'} &&

View File

@@ -503,7 +503,7 @@ return $rv;
# Returns HTML for a multi-column table, with the given headings
sub theme_ui_columns_start
{
my ($heads, $width, $noborder, $tdtags, $title) = @_;
my ($heads, $width, $noborder, $tdtags, $title, $sortable) = @_;
my ($href) = grep { $_ =~ /<a\s+href/i } @$heads;
my $rv;
$theme_ui_columns_row_toggle = 0;
@@ -516,12 +516,15 @@ if (!$noborder && !$main::COLUMNS_WRAPPER_OPEN) {
if (!$noborder) {
$main::COLUMNS_WRAPPER_OPEN++;
}
# Tables are sorted by sorttable.js unless their headings are links, or
# always when the caller asked for a sortable table
my @classes;
push(@classes, "ui_table") if (!$noborder);
push(@classes, "sortable") if (!$href);
push(@classes, "sortable") if (!$href || $sortable);
push(@classes, "ui_columns");
$rv .= "<table".(@classes ? " class='".join(" ", @classes)."'" : "").
(defined($width) ? " width=$width%" : "").">\n";
(defined($width) ? " width=$width%" : "").
($sortable ? " data-sortable='1'" : "").">\n";
if ($title) {
$rv .= "<thead> <tr $tb class='ui_columns_heading'>".
"<td colspan=".scalar(@$heads)."><b>$title</b></td>".
@@ -1014,9 +1017,11 @@ EOF
# no-sort - Set to 1 to disable sorting by theme
# title - Text to appear above the table
# empty-msg - Message to display if no data
# sortable - Set to 1 to mark the table for client-side sorting
sub theme_ui_columns_table
{
my ($heads, $width, $data, $types, $nosort, $title, $emptymsg) = @_;
my ($heads, $width, $data, $types, $nosort, $title, $emptymsg,
$sortable) = @_;
my $rv;
# Just show empty message if no data
@@ -1040,7 +1045,7 @@ foreach my $r (@$data) {
}
$maxwidth = $cc if ($cc > $maxwidth);
}
$rv .= &ui_columns_start($heads, $width, 0, \@tds, $title);
$rv .= &ui_columns_start($heads, $width, 0, \@tds, $title, $sortable);
# Add the data rows
foreach my $r (@$data) {

View File

@@ -4,6 +4,8 @@
--text-color-success: #3c763d;
--text-color-warning: #b58900;
--text-color-info: #108eda;
--font-family: sans-serif;
--font-family-mono: monospace;
}
body {margin: 8px; color: #212121; line-height:1.5em; text-align:left;}
p { margin-top:4px; }
@@ -38,7 +40,7 @@ table.ui_table thead td {
table.sortable tbody td {
padding: 2px;
}
table.ui_table td textarea {line-height:normal; font-family:monospace;}
table.ui_table td textarea {line-height:normal; font-family:var(--font-family-mono);}
table.ui_table td div.barchart * {
margin: 0;
}
@@ -88,7 +90,7 @@ a:hover, a:visited:hover { color: #0b46ab;
}
a.ui-hidden-table-title { color: #212121; }
title { color: #212121;
font-family: sans-serif;
font-family: var(--font-family);
}
h1,h2,h3,h4,h5 { color: #212121;
}

View File

@@ -336,6 +336,11 @@ ui_warning=Warning
ui_error=Error
ui_error_fatal=Fatal Error
ui_page_help=Help
ui_empty_state=Nothing to display
ui_search=Search
ui_dismiss=Dismiss
header_statusmsg=$1 logged into $2 $3 on $4 ($5)
uptracker_title=Uploading File

312
t/ui-lib-widgets.t Normal file
View File

@@ -0,0 +1,312 @@
#!/usr/bin/perl
# Tests for the widget functions added to ui-lib.pl and their escaping
# contract.
#
# These cover the default (non-theme) code path. The widgets' contract is
# that text-valued options are escaped by the library itself, so an
# attribute-breakout or element-breakout payload passed as plain text
# must never survive into markup.
use strict;
use warnings;
use Test::More;
use File::Basename qw(dirname);
use File::Spec;
my $root = File::Spec->rel2abs(File::Spec->catfile(dirname(__FILE__), '..'));
require File::Spec->catfile($root, 'web-lib-funcs.pl');
require File::Spec->catfile($root, 'ui-lib.pl');
# Resolve the asset versions from this checkout, without init_config
our $root_directory = $root;
# Suppress the asset tags, whose legitimate <script src> would trip the
# injection scanner below
$main::ui_page_assets_done = 1;
# Strip all quoted attribute values so that anything that broke out of an
# attribute shows up in the remaining scaffolding.
sub strip_attr_values {
my ($html) = @_;
$html =~ s/"[^"]*"//g;
$html =~ s/'[^']*'//g;
return $html;
}
sub assert_no_handler_injection {
my ($html, $label) = @_;
my $bare = strip_attr_values($html);
unlike($bare, qr/\bon[a-z]+\s*=/i,
"$label: no event-handler attribute leaks out");
unlike($bare, qr/<script/i, "$label: no script element leaks out");
}
my $xss = q{x"><script>alert(1)</script><b onmouseover="alert(1)};
# ---- escaping contract -----------------------------------------------------
assert_no_handler_injection(
main::ui_page_start({ 'title' => $xss, 'desc' => $xss,
'help' => $xss, 'help_title' => $xss }),
'ui_page_start');
assert_no_handler_injection(
main::ui_card({ 'title' => $xss, 'desc' => $xss }),
'ui_card title+desc');
assert_no_handler_injection(main::ui_badge($xss, 'success'),
'ui_badge text');
assert_no_handler_injection(main::ui_chip($xss), 'ui_chip text');
assert_no_handler_injection(main::ui_code($xss), 'ui_code');
assert_no_handler_injection(main::ui_tip('<b>x</b>', $xss), 'ui_tip');
my $tip = main::ui_tip('<b>x</b>', 'A <i>tip</i>');
like($tip, qr/^<span (?=[^>]*\bclass="ui--span ui_tip")(?=[^>]*\baria-label="A tip")(?=[^>]*\bdata-tooltip[\s>])[^>]*><b>x<\/b><\/span>$/,
'ui_tip uses the theme tooltip attributes of ui_help');
unlike($tip, qr/data-ui-tip|tabindex/, 'ui_tip draws no tooltip of its own');
assert_no_handler_injection(
main::ui_dl([ { 'label' => $xss, 'value' => $xss, 'help' => $xss } ]),
'ui_dl hash row');
assert_no_handler_injection(
main::ui_list([ { 'title' => $xss, 'desc' => $xss,
'meta' => $xss, 'tags' => [ $xss ],
'href' => $xss } ]),
'ui_list item');
assert_no_handler_injection(
main::ui_feed([ { 'when' => $xss, 'text' => $xss } ]),
'ui_feed event');
assert_no_handler_injection(
main::ui_stat({ 'value' => $xss, 'label' => $xss, 'href' => $xss }),
'ui_stat');
assert_no_handler_injection(
main::ui_empty_state({ 'title' => $xss, 'desc' => $xss }),
'ui_empty_state');
assert_no_handler_injection(
main::ui_toggle({ 'name' => $xss, 'label' => $xss, 'value' => $xss }),
'ui_toggle');
assert_no_handler_injection(
main::ui_search({ 'name' => $xss, 'value' => $xss,
'placeholder' => $xss, 'filter' => $xss }),
'ui_search');
assert_no_handler_injection(
main::ui_progress(50, { 'label' => $xss, 'value' => $xss }),
'ui_progress');
assert_no_handler_injection(
main::ui_grid([ '<b>a</b>' ], { 'template' => $xss, 'min' => $xss }),
'ui_grid style options');
# ---- structural behavior ---------------------------------------------------
# States are validated and aliases mapped
like(main::ui_badge('up', 'ok'), qr/ui_badge_success/,
'state alias ok maps to success');
like(main::ui_badge('down', 'err'), qr/ui_badge_danger/,
'state alias err maps to danger');
like(main::ui_badge('what', 'bogus<'), qr/ui_badge_neutral/,
'unknown state falls back to neutral');
# The scheme option stamps the wrapper for the dark or auto palette
like(main::ui_page_start({ 'scheme' => 'auto' }),
qr/data-ui-scheme="auto"/, 'scheme auto stamps the wrapper');
like(main::ui_page_start({ 'scheme' => 'dark' }),
qr/data-ui-scheme="dark"/, 'scheme dark stamps the wrapper');
unlike(main::ui_page_start({ 'scheme' => 'bogus"' }),
qr/data-ui-scheme/, 'invalid scheme is dropped');
# Class names follow the ui_ convention of the rest of the library
{
my $html = main::ui_page_start().main::ui_card({ 'title' => 'T' }).
main::ui_badge('B').main::ui_chip('C');
like($html, qr/class="[^"]*\bui_page\b/, 'page wrapper uses a ui_ class');
like($html, qr/class="ui--div /, 'markup is built with the ui_tag API');
unlike($html, qr/nova/, 'no nova-prefixed names in generated markup');
}
# Description lists accept both array and hash rows
{
my $html = main::ui_dl([ [ 'Label', '<b>html</b>' ],
{ 'label' => 'Esc', 'value' => '<b>text</b>' } ]);
like($html, qr/<dd[^>]*><b>html<\/b><\/dd>/, 'array row value is HTML');
like($html, qr/&lt;b&gt;text&lt;\/b&gt;/, 'hash row value is escaped');
like(main::ui_dl([ [ 'L', 'v', 'tip' ] ]), qr/ui_help/,
'help text uses the existing ui_help bubble');
}
# Cards compose header, body and footer
{
my $html = main::ui_card({ 'title' => 'T', 'actions' => '<i>a</i>',
'body' => '<p>b</p>', 'footer' => 'f',
'flush' => 1, 'state' => 'warn' });
like($html, qr/ui_card_warning/, 'card state alias applied');
like($html, qr/ui_card_actions"><i>a<\/i>/, 'card actions are raw HTML');
like($html, qr/ui_card_flush/, 'flush option applied');
like($html, qr/<footer class="[^"]*ui_card_foot">f<\/footer>/,
'footer emitted');
}
# Grids skip empty cells and honor the template option
{
my $html = main::ui_grid([ 'a', undef, '', 'b' ], { 'template' => '1fr 2fr' });
like($html, qr/--ui-grid-template:1fr 2fr/, 'template option applied');
is(scalar(() = $html =~ /^(a|b)$/mg), 2, 'empty cells are dropped');
}
# Toggles submit like checkboxes and default their value to 1. Attribute
# order is not fixed by ui_tag, so each one is checked on its own.
{
my $html = main::ui_toggle({ 'name' => 'boot', 'checked' => 1,
'attrs' => { 'data-x' => 'y' } });
my ($input) = $html =~ /(<input[^>]*>)/;
like($input, qr/\btype="checkbox"/, 'toggle is a checkbox');
like($input, qr/\bvalue="1"/, 'toggle value defaults to 1');
like($input, qr/\bchecked\b/, 'toggle checked state emitted');
like($input, qr/\bdata-x="y"/, 'toggle passes extra attrs to the input');
unlike(main::ui_toggle({ 'name' => 'boot' }), qr/\bchecked\b/,
'toggle unchecked by default');
like(main::ui_toggle({ 'name' => 'boot', 'value' => '' }),
qr/\bvalue=""/, 'toggle preserves an explicitly empty submitted value');
}
# Search boxes carry the client-side filter target
like(main::ui_search({ 'name' => 'q', 'filter' => '#rows' }),
qr/data-ui-filter="#rows"/, 'search filter selector emitted');
# Progress percentages are clamped, scaled by max, and colored by thresholds
like(main::ui_progress(250), qr/width:100%/, 'progress clamps above 100');
like(main::ui_progress('junk'), qr/width:0%/, 'progress treats junk as 0');
like(main::ui_progress('1.2.3'), qr/width:0%/,
'progress rejects malformed decimal values');
like(main::ui_progress(25, { 'max' => '1.2.3' }), qr/width:25%/,
'progress falls back to 100 for a malformed maximum');
like(main::ui_progress(3.4, { 'max' => 10 }), qr/width:34%/,
'progress scales the value by max');
like(main::ui_progress(45, { 'thresholds' => 1 }), qr/ui_bg_success/,
'below the thresholds the bar is green');
like(main::ui_progress(78, { 'thresholds' => 1 }), qr/ui_bg_warning/,
'past the first threshold the bar is orange');
like(main::ui_progress(96, { 'thresholds' => [ 60, 85 ] }), qr/ui_bg_danger/,
'past a custom second threshold the bar is red');
like(main::ui_progress(96, { 'thresholds' => 1, 'state' => 'info' }),
qr/ui_bg_info/, 'an explicit state wins over thresholds');
# Progress layouts and variants
{
my $seg = main::ui_progress(0, { 'segments' => [
{ 'pct' => 40, 'state' => 'info', 'label' => $xss },
{ 'pct' => 30, 'state' => 'warning', 'label' => 'Cache' } ] });
is(scalar(() = $seg =~ /ui_progress_bar/g), 2, 'segments draw one bar each');
like($seg, qr/aria-valuenow="70"/, 'segment percentages add up');
like($seg, qr/ui_progress_legend/, 'labelled segments get a legend');
assert_no_handler_injection($seg, 'ui_progress segment label');
my $busy = main::ui_progress(0, { 'indeterminate' => 1 });
like($busy, qr/aria-busy="true"/, 'indeterminate bar is marked busy');
unlike($busy, qr/aria-valuenow/, 'indeterminate bar has no value');
unlike($busy, qr/width:/, 'indeterminate bar has no fixed width');
like(main::ui_progress(50, { 'inline' => 1, 'label' => 'CPU' }),
qr/ui_progress_inline.*ui_progress_label[^<]*<\/span>.*ui_progress_track.*ui_progress_value/s,
'inline layout puts label, track and value in a row');
like(main::ui_progress(62, { 'inside' => 1 }),
qr/ui_progress_bar[^>]*><span[^>]*ui_progress_inside_value[^>]*>62%</,
'inside option puts the value marker on the bar');
like(main::ui_progress(97, { 'inside' => 1 }), qr/ui_progress_inside_end/,
'inside marker is kept within the track near 100%');
my $ring = main::ui_progress(34, { 'ring' => 1, 'size' => 72 });
like($ring, qr/<svg[^>]*width="72"/, 'ring is an SVG of the given size');
like($ring, qr/stroke-dasharray="34 100"/, 'ring dash length is the percentage');
like($ring, qr/stroke-width="1\.50"/, 'ring stroke scales to 3px at 72px');
like(main::ui_progress(34, { 'ring' => 1 }), qr/stroke-width="1\.93"/,
'ring stroke scales to 3px at the default size');
foreach my $size ('auto', 0.5, -1) {
my $html = eval { main::ui_progress(34, { 'ring' => 1, 'size' => $size }) };
is($@, '', "ring size $size does not crash rendering");
like($html, qr/<svg[^>]*width="56"/,
"ring size $size falls back to the default");
}
}
# Choice lists : escaping, selection, fields and disabled options
{
my $html = main::ui_choice('dest', 'ftp', [
{ 'value' => 'local', 'label' => $xss, 'desc' => $xss },
{ 'value' => 'ftp', 'label' => 'FTP', 'content' => '<i>host</i>',
'fields' => [ [ $xss, '<i>user</i>' ],
{ 'label' => 'Port', 'html' => '<i>port</i>' } ] },
{ 'value' => 'none', 'label' => 'None', 'disabled' => 1 } ]);
assert_no_handler_injection($html, 'ui_choice');
is(scalar(() = $html =~ /type=.radio./g), 3, 'one radio per option');
like($html, qr/<input[^>]*value=.ftp.[^>]*\bchecked\b/,
'the given value is checked');
is(scalar(() = $html =~ /ui_choice_field"/g), 2, 'both field forms render');
like($html, qr/ui_choice_content"><i>host<\/i>/, 'content is raw HTML');
like($html, qr/<input[^>]*value=.none.[^>]*\bdisabled\b/,
'disabled option disables its button');
like($html, qr/role="radiogroup"/, 'radio list is a radiogroup');
is(scalar(() = $html =~ /\bchecked\b/g), 1, 'exactly one option is checked');
like($html, qr/class=.ui_radio./, 'buttons come from ui_oneradio');
}
# Radio lists and select switches
{
my $list = main::ui_radio_list('mode', 'shared', [
{ 'value' => 'none', 'label' => $xss },
{ 'value' => 'shared', 'label' => 'Shared', 'content' => '<i>sel</i>' } ]);
assert_no_handler_injection($list, 'ui_radio_list');
is(scalar(() = $list =~ /type=.radio./g), 2, 'one radio per list option');
like($list, qr/<input[^>]*value=.shared.[^>]*\bchecked\b/,
'radio list checks the given value');
like($list, qr/ui_radio_list_content"><i>sel<\/i>/, 'radio list content is raw HTML');
my $sw = main::ui_select_switch('dest', 'ftp', [
{ 'value' => 'local', 'label' => $xss, 'content' => '<i>file</i>' },
{ 'value' => 'ftp', 'label' => 'FTP', 'desc' => $xss,
'fields' => [ [ $xss, '<i>host</i>' ] ] },
{ 'value' => 'none', 'label' => 'None' } ]);
assert_no_handler_injection($sw, 'ui_select_switch');
like($sw, qr/<select[^>]*data-ui-switch/, 'switch select carries the hook');
my @panels = $sw =~ /(<div[^>]*ui_select_switch_panel[^>]*>)/g;
is(scalar(@panels), 2, 'options with nothing to show get no block');
my ($ftp) = grep { /data-ui-switch-value="ftp"/ } @panels;
unlike($ftp, qr/\bhidden\b/, 'the chosen block is visible');
my ($local) = grep { /data-ui-switch-value="local"/ } @panels;
like($local, qr/\bhidden\b/, 'other blocks are hidden');
like($sw, qr/ui_select_switch_field_input"><i>host<\/i>/, 'switch fields are raw HTML');
like($sw, qr/ui_select_switch_field_label">FTP<\/span>|ui_select_switch_field_label">x&quot;/,
'inline content of a block is labelled with the option name');
# A removed saved option must agree with the browser's first-option
# fallback, including when the first option has an empty value.
foreach my $first ('local', undef) {
my $fallback = main::ui_select_switch('dest', 'removed', [
{ 'value' => $first, 'label' => 'Default', 'content' => 'File' },
{ 'value' => 'ftp', 'label' => 'FTP', 'content' => 'Host' } ]);
my @panels = $fallback =~ /(<div[^>]*ui_select_switch_panel[^>]*>)/g;
unlike($panels[0], qr/\bhidden\b/, 'fallback option panel is visible');
like($panels[1], qr/\bhidden\b/, 'other option panel stays hidden');
like($fallback, qr/<option value="(?:local)?" selected>/,
'fallback option is explicitly selected');
}
is(main::ui_select_switch('dest', undef, undef), '',
'no switch options produces no markup');
}
# Sortable column tables carry the data-sortable marker for themes
like(main::ui_columns_start([ 'A' ], 100, 0, undef, undef, 1),
qr/data-sortable='1'/, 'ui_columns_start marks sortable tables');
unlike(main::ui_columns_start([ 'A' ], 100),
qr/data-sortable/, 'ui_columns_start is plain by default');
like(main::ui_columns_table([ 'A' ], 100, [ [ 'x' ] ], undef, 0, undef, undef, 1),
qr/data-sortable='1'/, 'ui_columns_table passes the sortable flag on');
like(main::ui_form_columns_table('x.cgi', [ [ 'go', 'Go' ] ], 0, undef, undef,
[ 'A' ], 100, [ [ 'x' ] ], undef, 0, undef, undef, 0, 1),
qr/data-sortable='1'/, 'ui_form_columns_table passes the sortable flag on');
# The assets are only emitted once per request
{
local $main::ui_page_assets_done = 0;
my $first = main::ui_page_assets();
my $second = main::ui_page_assets();
like($first, qr/ui-lib\.css/, 'first assets call links the stylesheet');
like($first, qr/ui-lib\.js/, 'first assets call loads the script');
is($second, '', 'second assets call emits nothing');
}
done_testing();

119
ui-demo/README.md Normal file
View File

@@ -0,0 +1,119 @@
# UI Demo
A read-only Webmin module that shows the widgets added at the end of
`ui-lib.pl` next to the existing tabs, forms, buttons and tables they are
meant to be combined with. It is a reference for writing new modules and
is not part of the Webmin distribution. Dropping this directory into the
Webmin root is enough to make it appear under the *Others* category.
## What it shows
Every tab of `index.cgi` is built by one function in `ui-demo-pages.pl` :
| Tab | Function | Shows |
|------------|-----------------------|------------------------------------------------------------------|
| Cards | `demo_cards_tab` | `ui_card` in its variations : buttons inside a card, header actions and footer, state accents with icon titles, a two-column `ui_dl`, a flush list filtered by a `ui_search` in the header, a standard table inside a card, a metric card with `ui_stat` and inline `ui_progress`, a card printed with `ui_card_start`/`ui_card_end`; `ui_stats`; `ui_grid` with the `template` option |
| Elements | `demo_elements_tab` | a `ui_dl` with help bubbles and HTML values, `ui_stats` with icons and links, `ui_feed` with an HTML event, `ui_empty_state`, then badges with their icon, dot and title options, chips, `ui_code`, `ui_note`, `ui_help`, `ui_tip`, every `ui_progress` variation and the ring gauges, and `ui_svg_icon` |
| Forms | `demo_forms_tab` | the usual `ui_table_start` / `ui_table_row` form with `ui_toggle`, `ui_search`, the date chooser and password fields; a second form of choosers : `file_chooser_button` for files and directories, `ui_user_textbox`, `ui_group_textbox`, `ui_users_textbox`, `ui_groups_textbox`, and an `hlink` help link |
| Choices | `demo_choices_tab` | three replacements for `ui_radio_table` and hand-made tables of radios with inputs : `ui_choice` (boxed, every option's inputs visible), `ui_select_switch` (a select showing only the chosen option's block) and `ui_radio_list` (compact radios); the backup destination selector twice and Virtualmin's new IP address selectors |
| Buttons | `demo_buttons_tab` | `ui_submit`, `ui_reset` and `ui_link_button` in one row, with a disabled and a confirmed one; a form ended by `ui_form_end`, one by `ui_form_grouped_buttons`, one by `ui_form_end_side_by_side` with a separate form at the right; a `ui_confirmation_form` page |
| Accordions | `demo_accordions_tab` | a settings form of `ui_table_start` followed by `ui_hidden_table_start` sections |
| Tables | `demo_tables_tab` | the empty state shown instead of a table with no rows, `ui_columns_table` with a `ui_details` disclosure in its first cell (classes `inline inlined`, as grub2's boot entries), and `ui_form_columns_table`, both with the sortable flag; the tab description itself hides more text behind a `ui_details` tick (class `inline`), as Virtualmin's SSL page does |
| Lists | `demo_lists_tab` | `ui_list` rows with badges, tags, meta and actions; a backup history with state icons, a filesystem list with inline progress bars, and a user list with a confirmed delete link |
| Icon links | `demo_iconlinks_tab` | `icons_table` with SVG icons from `images/`, then a rule and the bottom-of-page `ui_buttons_row` block from `demo_page_actions` |
| Config editor | `demo_editor_tab` | links to `edit_manual.cgi`, a manual config file editor page as in nftables : file selector form, then a large `ui_textarea` with Save, submitting to a `save_manual.cgi` that writes nothing |
| Alerts | `demo_alerts_tab` | every `ui_alert` type, one with a custom icon and title on one line, and an opened `ui_details` box with the `error` class under the error alert, as MariaDB shows its connection error |
The page itself is wrapped in `ui_page_start` / `ui_page_end`, which load
the stylesheet and script from `unauthenticated/css/ui-lib.css` and
`unauthenticated/js/ui-lib.js` once per page.
## Page chrome
Around the gallery, `index.cgi` and `demo_page_actions` show the parts
of a module page that are easy to get wrong :
| Piece | API | Seen in |
|-------|-----|---------|
| Subtitle under the page title | first argument of `ui_print_header`; several lines are separated by `<br>` | grub2 "GRUB version 2.12", Virtualmin Podman "In domain … / Image pool …" |
| Password shown as dots until hovered | `ui_text_mask(text, tag)` inside that tag, e.g. `ui_tag('tt', ui_text_mask($pass, 'tt'))` | Virtualmin Podman "Administrator credentials" |
| Help question mark at the right of the title | `help` argument of `ui_print_header`, naming a page in `help/` (here `help/intro.html`) | every module with help |
| Module config cog | `config` argument of `ui_print_header` set to 1, with a `config.info` listing the options and a `config` file of defaults; hidden for users whose ACL has `noconfig` | BIND, Apache, most modules |
| Search docs button | `help_search_link(term, sections...)` appended to `rightside`; returns nothing when the man module is not available. Explained in `index.cgi` but not used, since the demo has no system documentation | BIND DNS Server |
| Apply / restart button at the right of the title | `rightside` argument of `ui_print_header` with a `ui_link` to `apply.cgi`, `restart.cgi`, `generate.cgi`, `start.cgi` or `stop.cgi`; wrap the text in `<b>` while a change is pending and Authentic shows the button large with its text | grub2 "Regenerate GRUB menu" |
| Links at the left and right of a table | `otherlinks` argument of `ui_form_columns_table`, each `[ url, text, 'right' ]` | Users and Groups "Create a new user", "Run batch file" |
| Icon links to sub-pages | `icons_table` with SVG files from `images/`, on the Icon links tab | grub2 "Global Options", Webmin Configuration |
| Buttons with descriptions at the bottom of a page | `ui_buttons_start`, `ui_buttons_row`, `ui_buttons_end` after `ui_hr`, below the icon links; the after-submit and before-submit slots hold extra inputs | SSH Server, grub2, Virtualmin Podman index, Webmin Configuration "Start at boot time" |
| Return buttons under a page | pairs of URL and text given to `ui_print_footer`, most specific first; the theme draws them as footer buttons | every module page, e.g. `edit_manual.cgi` here with two of them |
| A button followed by a sentence of selects | `ui_buttons_row` with single-cell set, the fields in the after-submit slot, and a flex span opened in before-submit and closed after the fields | Virtualmin Podman "Add New … named … for …", WP Workbench "Create Scheduled Backup for … every …" |
## Rules the examples follow
- Reuse the existing API for tabs, forms, buttons and tables. The widget
functions only add what was missing : cards, grids, stat tiles,
description lists, badges, chips, list rows, feeds, empty states,
progress bars, the toggle switch and the search box.
- Widget options are passed in a hash reference after any positional
content arguments, and each widget returns a string. Text options are
escaped by the library; `body`, `actions`, `footer` and `*_html` options
are raw HTML built from other ui functions.
- Custom widget markup uses `ui_tag`; the examples also compose existing
UI helpers and small HTML fragments.
- Interface labels come from `lang/en`; sample system data is embedded
in the page builders.
- The page builders print nothing and do not need `init_config`, so they
can be rendered outside Webmin for previews and tests.
## Theme caveats
Two more things on the Forms and Config editor tabs are Authentic features,
not core API :
- **Password meter and buttons.** Authentic adds the strength meter, the
show/hide eye and the generate key next to password inputs on module
pages it knows, or on any page where a password input carries a
`data-password` attribute. Mark the repeat field `data-password-again`
and it only gets the eye. Pass the attribute in the tags argument of
`ui_password`.
- **Code editor.** Authentic replaces the text area of a manual config
editor page with a code editor, and adds its own "Save and close" and
file-manager buttons, only on pages it recognizes by name, such as
`edit_manual.cgi`. That is why the demo's editor is a page of its own
rather than a tab : the same form inside a tab of `index.cgi` stays a
plain text area.
Authentic renders every `ui_link` as a small button, and inside widget
pages it draws `ui_link_button` the same way, so the two can be mixed in
card footers, header actions and list rows. Only in a `ui_cluster` row
that also holds submit buttons do a `ui_link_button` and a `ui_reset`
take the normal button size, as on the Buttons tab. A link inside text,
such as in a `ui_dl` value or a `ui_feed` event, must be built with
`ui_tag('a', text, { 'href' => url })` to stay a plain link, as the
Elements tab does. The stylesheet underlines such links inside widget
text, so they are not lost between chips and badges.
Authentic gives buttons their color and icon from the **lang key name**,
not from anything in the code : it looks the button label up in the lang
table, finds the key holding that exact text, and matches the key name
against patterns such as `start`, `restart`, `index_stop`, `index_reload`,
`index_boot`, `delete` and `status` (see `get_button_style` in the theme).
The Service card on the Cards tab shows four such buttons. Until that
behavior goes away :
- name button keys simply and stably : `index_start`, `index_stop`,
`index_restart`, `index_reload`, `index_boot_off`; a key holding the
word `delete`, as `index_b_delete` on the confirmation page, gets the
red style and its icon;
- keep each button text unique in the lang file, or the lookup may land on
another key with the same text and the button loses its style.
## Not to copy
`ui-demo-lib.pl` sets `$main::no_acl_check` before `init_config`, so the
module can be opened by any user even though it is not in anyone's module
ACL. That is only acceptable because this module reads nothing and changes
nothing. A real module must leave the ACL check in place and be installed
through the module installer, which adds it to the ACL.
The full option reference is the POD in the *Widgets* section at the
end of `ui-lib.pl`.

19
ui-demo/apply.cgi Normal file
View File

@@ -0,0 +1,19 @@
#!/usr/local/bin/perl
# Target of the "apply" link shown at the right of the page title. In a
# real module this is where the configuration would be applied or a
# service restarted, usually followed by a redirect back to the page the
# link was on, given in the redir parameter. This module is read-only, so
# it only redirects.
use strict;
use warnings;
require './ui-demo-lib.pl'; ## no critic
our (%in);
ReadParse();
# Only ever go back to this module's own index page
my $redir = $in{'redir'} || '';
$redir = 'index.cgi' if ($redir !~ /^index\.cgi(\?[\w=&.-]*)?$/);
redirect($redir);

1
ui-demo/config Normal file
View File

@@ -0,0 +1 @@
default_tab=cards

1
ui-demo/config.info Normal file
View File

@@ -0,0 +1 @@
default_tab=Tab shown when the demo opens,1,cards-Cards,elements-Elements,alerts-Alerts,forms-Forms,buttons-Buttons,accordions-Accordions,tables-Tables,lists-Lists,iconlinks-Icon links,editor-Config editor

54
ui-demo/edit_manual.cgi Normal file
View File

@@ -0,0 +1,54 @@
#!/usr/local/bin/perl
# Manual editor for one of the demo's sample configuration files, laid
# out as edit_manual.cgi of the Linux Firewall (nftables) and Scheduled
# Cron Jobs modules : a small form with a select of the editable files and
# an Open button, then a second form holding the file contents in a large
# ui_textarea named "data" inside a borderless ui_table, ended by a Save
# button that submits to save_manual.cgi.
#
# This is a separate page rather than a tab of index.cgi because Authentic
# turns the text area into a code editor with line numbers and syntax
# colors, adds a "Save and close" button beside Save, and turns the Open
# button into a magnifier with a File Manager button next to it, only on
# pages it recognizes by name, and edit_manual.cgi is one of them. The
# same form inside a tab of index.cgi stays a plain text area.
#
# Nothing here reads a real file : the contents are generated by
# demo_config_contents, and save_manual.cgi writes nothing.
use strict;
use warnings;
require './ui-demo-lib.pl'; ## no critic
our (%in, %text);
ReadParse();
my @files = demo_config_files();
my $file = $in{'file'} && (grep { $_ eq $in{'file'} } @files) ?
$in{'file'} : $files[0];
ui_print_header(undef, $text{'edit_manual_title'}, "");
# File selector, reloading this page with the chosen file
print ui_form_start('edit_manual.cgi');
print ui_tag('b', html_escape($text{'index_e_file'}))." ";
print ui_select('file', $file, \@files)." ";
print ui_submit($text{'index_e_open'});
print ui_form_end();
# The file contents. A real module would lock the file and read it here
print ui_form_start('save_manual.cgi', 'form-data');
print ui_hidden('file', $file);
print ui_table_start(undef, undef, 2);
print ui_table_row(undef, ui_textarea('data', demo_config_contents($file),
20, 100), 2);
print ui_table_end();
print ui_form_end([ [ 'save', $text{'save'} ] ]);
# The links at the very bottom of a page come from ui_print_footer, as
# pairs of URL and text, the most specific place to return to first.
# Themes draw them as the buttons under the page (Authentic puts them in
# its page-footer-actions bar), so a page deep in a module gives both a
# way back to where it came from and to the module index.
ui_print_footer('index.cgi?mode=editor', $text{'index_return_editor'},
'index.cgi', $text{'index_return'});

11
ui-demo/help/file.html Normal file
View File

@@ -0,0 +1,11 @@
<header>Configuration file</header>
The file the settings are written to. The button next to the field opens
the file chooser, which browses the server's filesystem and fills the
field in.
<p>
This page is part of the UI demo module. Help pages like this one are
opened by the <tt>hlink</tt> links on form labels, and live in the
module's <tt>help</tt> directory, one file per topic.
</footer>

13
ui-demo/help/intro.html Normal file
View File

@@ -0,0 +1,13 @@
<header>UI Demo</header>
A read-only gallery of the widgets in <tt>ui-lib.pl</tt>, and of the
existing tabs, forms, buttons and tables they are meant to be combined
with. It is a reference for writing new modules, and is not part of the
Webmin distribution.
<p>
This page is opened by the question mark at the top right of the module
title, which <tt>ui_print_header</tt> shows when its help argument names
a page in the module's <tt>help</tt> directory. The cog next to it opens
the module configuration, listed in <tt>config.info</tt>.
</footer>

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<rect x="7" y="8" width="34" height="9" rx="2" fill="#f5f3ff" stroke="#6d28d9" stroke-width="2"/>
<rect x="7" y="20" width="34" height="9" rx="2" fill="#f5f3ff" stroke="#6d28d9" stroke-width="2"/>
<rect x="7" y="32" width="34" height="9" rx="2" fill="#f5f3ff" stroke="#6d28d9" stroke-width="2"/>
<path d="M33 11l2.5 3 2.5-3M33 27.5l2.5-3 2.5 3M33 35l2.5 3 2.5-3" fill="none" stroke="#6d28d9" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 12.5h12M12 24.5h12M12 36.5h12" fill="none" stroke="#8b5cf6" stroke-width="2.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 676 B

6
ui-demo/images/cards.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<rect x="6" y="10" width="36" height="26" rx="3" fill="#eff6ff" stroke="#1d4ed8" stroke-width="2"/>
<path d="M6 18h36" fill="none" stroke="#1d4ed8" stroke-width="2"/>
<path d="M12 26h14M12 31h20" fill="none" stroke="#3b82f6" stroke-width="2.5" stroke-linecap="round"/>
<rect x="30" y="12.5" width="9" height="3" rx="1.5" fill="#1d4ed8"/>
</svg>

After

Width:  |  Height:  |  Size: 437 B

7
ui-demo/images/forms.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<rect x="8" y="8" width="32" height="32" rx="3" fill="#fffbeb" stroke="#b45309" stroke-width="2"/>
<rect x="13" y="14" width="22" height="5" rx="1" fill="#ffffff" stroke="#d97706" stroke-width="2"/>
<rect x="13" y="23" width="22" height="5" rx="1" fill="#ffffff" stroke="#d97706" stroke-width="2"/>
<rect x="13" y="32" width="5" height="4" rx="1" fill="#d97706"/>
<path d="M21 34h14" fill="none" stroke="#d97706" stroke-width="2.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 558 B

7
ui-demo/images/lists.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<rect x="6" y="8" width="36" height="32" rx="3" fill="#fef2f2" stroke="#b91c1c" stroke-width="2"/>
<circle cx="14" cy="17" r="2.5" fill="#dc2626"/>
<circle cx="14" cy="24" r="2.5" fill="#dc2626"/>
<circle cx="14" cy="31" r="2.5" fill="#dc2626"/>
<path d="M20 17h16M20 24h12M20 31h15" fill="none" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 458 B

6
ui-demo/images/menu.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<rect x="1.5" y="1.5" width="5.5" height="5.5" fill="#1d4ed8"/>
<rect x="9" y="1.5" width="5.5" height="5.5" fill="#3b82f6"/>
<rect x="1.5" y="9" width="5.5" height="5.5" fill="#3b82f6"/>
<rect x="9" y="9" width="5.5" height="5.5" fill="#1d4ed8"/>
</svg>

After

Width:  |  Height:  |  Size: 347 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<rect x="7" y="9" width="34" height="30" rx="3" fill="#ecfdf5" stroke="#047857" stroke-width="2"/>
<path d="M7 18h34M7 27h34M19 18v21M30 18v21" fill="none" stroke="#059669" stroke-width="2"/>
<rect x="7" y="9" width="34" height="9" rx="3" fill="#a7f3d0" stroke="#047857" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 387 B

75
ui-demo/index.cgi Executable file
View File

@@ -0,0 +1,75 @@
#!/usr/local/bin/perl
# Show a gallery of the widgets of ui-lib.pl, together with the existing
# tabs, forms, buttons and tables they are meant to be combined with.
#
# The page chrome around the gallery follows modules like grub2 and the
# Virtualmin Podman module :
#
# - ui_print_header(subtext, title, image, help, config, nomodule,
# nowebmin, rightside) : the first argument is a subtitle shown under
# the title, typically a version ("GRUB version 2.12", "Webmin 2.660").
# It may hold several lines separated by <br>, the way the Virtualmin
# Podman module shows the domain and the image pool under its title.
#
# - The buttons at the top right of the title come from the other
# arguments, and each appears only when its source exists :
# help - the name of a page in the module's help directory, here
# help/intro.html, shown as a question mark that opens the
# page in a popup; nothing is shown without it.
# config - set to 1 to show the cog linking to the module's
# configuration page, which needs a config.info file in the
# module listing the options, and a config file with their
# defaults. The theme hides it when the user's ACL has
# noconfig set. Here one option chooses the tab opened
# first.
# nomodule - set to 1 on the index page, where the "module index"
# back arrow would only lead to itself; every other page of
# a module leaves it 0 to get that arrow.
# rightside - HTML placed at the right of the title, and where a
# module puts its apply, restart or regenerate link. Lines
# are separated by <br>. Modules about a system service,
# such as BIND, also append help_search_link(term,
# sections...) here, which returns a documentation search
# link only when the man module is available to the user;
# this demo has no such documentation and leaves it out.
#
# - Authentic turns links in that rightside area into icon buttons when
# their URL contains config.cgi, restart.cgi, restart_progressive.cgi,
# generate.cgi, apply.cgi, apply_progressive.cgi, start.cgi, stop.cgi
# or their _progressive variants (plus index.cgi for a back link). A
# plain ui_link to apply.cgi is shown as a small refresh icon with the
# link text as its tooltip. When the link text is wrapped in <b>, as
# grub2 does while the menu needs regenerating, the same button is
# shown large with its text, to call attention to the pending change.
# The demo shows the pending state when opened with ?changed=1.
#
# - The block of action buttons that modules put at the bottom of a
# page after a ui_hr is shown at the end of the Buttons tab, built by
# demo_page_actions in ui-demo-pages.pl.
use strict;
use warnings;
require './ui-demo-lib.pl'; ## no critic
our (%gconfig, %in, %text);
ReadParse();
# Apply link for the right of the title, emphasized while a change is
# pending, and returning to this page afterwards
my $apply = $text{'index_apply'};
$apply = ui_tag('b', $apply) if ($in{'changed'});
my $rightside = ui_link("apply.cgi?redir=".urlize("index.cgi"), $apply);
# Two-line subtitle : the version, then the theme in use
my $subtext = text('index_subtitle', get_webmin_version())."<br>".
text('index_subtitle_theme',
ui_tag('tt', html_escape($gconfig{'theme'} || 'gray-theme')));
ui_print_header($subtext, $text{'index_title'}, "", "intro", 1, 1, undef,
$rightside);
print demo_gallery_page();
# The footer link becomes the "Return to ..." button under the page; an
# index page points back to the Webmin index (see edit_manual.cgi for a
# page with two return links)
ui_print_footer("/", $text{'index'});

316
ui-demo/lang/en Normal file
View File

@@ -0,0 +1,316 @@
index_title=UI library demo
index_desc=A live gallery of the widgets in ui-lib.pl, and of the existing forms, buttons and tables to combine them with
index_docs=Developer docs
index_tab_cards=Cards
index_tab_elements=Elements
index_tab_forms=Forms
index_tab_buttons=Buttons
index_tab_accordions=Accordions
index_tab_tables=Tables
index_tab_lists=Lists
index_tab_iconlinks=Icon links
index_cards_desc=Cards are the container for dashboard-style pages. They hold description lists, stat tiles, lists, feeds or plain text, with optional header actions and a footer.
index_elements_desc=Small elements for showing state and progress inside cards and next to text.
index_forms_desc=Forms keep using the existing ui_table_start and ui_table_row layout with the existing controls. The toggle switch and the search box are the only new controls.
index_buttons_desc=Single buttons come from ui_submit, ui_reset and ui_link_button, or from ui_button when only JavaScript should run. The buttons at the end of a form come from ui_form_end, from ui_form_grouped_buttons when actions should be grouped and kept apart, or from ui_form_end_side_by_side to keep Delete away from Save. A delete link should lead to a ui_confirmation_form page. The return buttons under a page come from the URL and text pairs given to ui_print_footer, most specific first, as the config editor page shows.
index_accordions_desc=A settings page in the style of the grub2 module : a table of common settings first, then collapsible sections built with ui_hidden_table_start for the rest, all in one form.
index_tables_desc=Tables are the existing ui_columns_table and ui_form_columns_table, unchanged. The sortable parameter asks the theme for client-side sorting and searching.
index_lists_desc=Lists show entries with a title, a description and trailing details, and an empty state replaces an empty table.
index_iconlinks_desc=A table of icon links to sub-pages, built with the existing icons_table function and small SVG icons from the module, followed by a rule and the action buttons that end an index page. Modules such as SSH Server, grub2 and Webmin Configuration are laid out this way.
index_service=Service
index_boot=Start at boot
index_boot_on=Enabled
index_boot_off=Do not start at boot
index_stop_confirm=Stop the service?
index_card_desc=With a title, a description, header actions and a footer
index_card_actions=Card with actions
index_card_actions_body=Anything can go in the top-right action area — a status badge, links or buttons.
index_card_footer=Footer with secondary information
index_card_state=Card with a state
index_card_state_body=A state option adds a colored accent along the card edge to call attention to it.
index_running=Running
index_failed=Failed
index_pending=Pending
index_stopped=Stopped
index_syncing=Syncing
index_off=Off
index_manage=Manage
index_stats=Stat tiles
index_stats_desc=Large numbers with labels, optionally linked and colored
index_stat_units=Loaded units
index_stat_units_desc=of 141 installed
index_stat_failed=Failed units
index_stat_failed_desc=needs attention
index_stat_uptime=Uptime
index_stat_uptime_desc=since last reboot
index_stat_disk=Disk used
index_stat_disk_desc=on /dev/sda1
index_badges=Badges and inline elements
index_note=A note from ui_note
index_help_demo=Inline help
index_help_tip=Help bubbles show extra detail on hover using the current theme's tooltip.
index_progress=Progress
index_disk=Disk usage
index_memory=Memory
index_cpu=CPU
index_feed=Activity feed
index_allevents=All events
index_feed_now=Just now
index_feed_minutes=minutes ago
index_feed_hours=hours ago
index_empty=Empty state
index_empty_title=No matching services
index_empty_desc=Try changing the search, or create a new service to get started.
index_empty_action=Create service
index_start=Start
index_restart=Restart
index_stop=Stop
index_icons=Icons
index_icons_desc=The built-in SVG icon set, drawn with the current text color
index_f_title=Edit server
index_f_name=Server name
index_f_name_help=The hostname clients will connect to.
index_f_pass=Admin password
index_f_pass2=Repeat password
index_f_port=Port
index_f_port_help=Between 1 and 65535.
index_f_proto=Protocol
index_f_both=Both protocols
index_f_log=Logging
index_f_log_all=Everything
index_f_log_err=Errors only
index_f_log_none=Nothing
index_f_feat=Features
index_f_ssl=SSL encryption
index_f_compress=Compress responses
index_f_boot=Start at boot
index_f_boot_label=Start when the system boots
index_f_notes=Notes
index_f_notes_value=Monospaced textarea for configuration snippets
index_f_cancel=Cancel
index_f_delete_confirm=Really delete this server and all of its settings?
index_f_active=Active
index_f_limit=Connection limit
index_f_unlimited=Unlimited
index_f_limit_to=Limit to
index_f_quota=Disk quota
index_f_dates=Actions on dates
index_f_dates_all=All dates
index_f_today=Today
index_f_yesterday=Yesterday
index_f_week=This week
index_f_between=Between&nbsp;&nbsp;$1&nbsp;and&nbsp;&nbsp;$2
index_f_grouped=Grouped buttons
index_f_grouped_desc=Built with ui_form_grouped_buttons, as in the Virtualmin Podman module: related actions are joined, destructive ones are set apart, and the right-hand button submits a separate hidden form through its form attribute.
index_f_logs=View logs
index_f_stacked=New controls
index_f_stacked_desc=A search box that filters lists and tables client-side, and the toggle switch, off and on
index_a_general=General settings
index_a_hostname=Hostname
index_a_enabled=Enabled
index_a_desc=Description
index_a_network=Network
index_a_address=IP address
index_a_dhcp=From DHCP
index_a_static=Static
index_a_gateway=Gateway
index_a_dns=DNS servers
index_a_limits=Resource limits
index_a_memory=Memory limit
index_a_cpus=CPU limit
index_a_pids=Process limit
index_a_nolimit=No limit
index_a_limit_to=Limit to
index_a_security=Security
index_a_readonly=Root filesystem
index_a_readonly_label=Read-only
index_a_privileged=Privileged mode
index_a_caps=Added capabilities
index_t_unit=Unit
index_t_desc=Description
index_t_state=State
index_t_cpu=CPU
index_t_create=Create
index_t_sortable=Sortable table
index_t_sortable_desc=A standard ui_columns_table with its sortable parameter set. Themes that support it add client-side sorting and searching, as Authentic does with DataTables; the gray theme sorts with sorttable; other themes show a plain table. A module can also set sortable=1 in module.info for all of its tables.
index_t_checked_desc=A standard ui_form_columns_table with its sortable parameter set: a checkbox column, select all and invert links, and buttons that act on the selection. Failed units are checked by default.
index_t_empty=Empty table
index_t_empty_title=No scheduled jobs yet
index_t_empty_desc=When a table has no rows, it can show an empty state with a next step instead of a bare header.
index_l_title=List rows
index_l_desc=Titles, descriptions, badges, tags, trailing details and actions
index_l_bans=bans
index_edit=Edit
index_subtitle=Widgets of Webmin $1
index_apply=Apply demo changes
index_b_refresh=Refresh status
index_b_refresh_desc=Re-read the state of every service and update the cards above.
index_b_boot=Start at boot time
index_b_boot_desc=Change this option to control whether the service is started at boot time or not.
index_t_new=Create a new service
index_t_export=Export to batch file
index_t_batch=Run batch file
index_b_addnew=Add New
index_b_type=Type of resource
index_b_type_recipe=Application recipe
index_b_type_container=Container
index_b_named=named
index_b_recipe=Recipe
index_b_for=for
index_b_domain=Virtual server
index_subtitle_theme=Theme $1
index_creds=Administrator credentials
index_creds_userpass=$1 with password $2
index_p_disk_value=3.4 GB of 10 GB
index_p_inside=Value marker on the bar
index_p_more=More progress bars
index_p_segments=Disk breakdown
index_p_used=Used
index_p_cache=Cache
index_p_reserved=Reserved
index_p_busy=Rebuilding the search index
index_p_rings=Ring gauges
index_p_rings_desc=The same options as the bars, drawn as a circle with the value in the middle
index_p_quota=Quota
index_p_working=Working
index_tab_editor=Config editor
index_editor_desc=A page for editing a configuration file by hand, laid out as the Linux Firewall, Scheduled Cron Jobs and SSH Server modules do it : a file selector, then the file in a large text area with a Save button.
index_e_file=Editing config file
index_e_open=Open
index_e_sample=Sample configuration shown by the demo
index_tab_alerts=Alerts
index_alerts_desc=The existing ui_alert in every type, with the icon and title the theme gives each one, and with a custom icon and title on one line.
index_al_success=The configuration was saved and the service reloaded.
index_al_info=Changes take effect the next time the service starts.
index_al_warning=The configuration file has been edited manually since it was last saved here.
index_al_danger=The service failed to start. Check the log for details.
index_al_inline=The next run is scheduled for tonight at 02:00.
index_al_inline_title=Scheduled
index_e_button=Open the config editor
index_e_files=Editable files
index_e_file_desc=Opens in the editor page
index_return=Return to the gallery
edit_manual_title=Edit config files
index_f_pickers=Choosers
index_f_file=Configuration file
index_f_dir=Log directory
index_f_owner=Owner
index_f_group=Group
index_f_members=Members
index_f_groups=Secondary groups
index_al_backup=12 virtual servers were backed up to /backup in 4 minutes.
index_al_backup_title=Backup finished
index_al_cert=The SSL certificate for example.com expires in 9 days.
index_al_cert_title=Certificate
index_al_db=Connection to MariaDB on localhost:3306 was refused.
index_al_db_title=Database unreachable
index_al_update=A new version 2.700 is available for download.
index_al_reboot=A kernel update was installed. Reboot to start using it.
index_al_reboot_title=Reboot required
index_l_backups=Recent backups
index_l_backup_desc=Full backup of $1 servers to $2
index_l_backup_failed=Destination not writable, nothing was saved
index_l_failed=Failed
index_l_restore=Restore
index_l_log=View log
index_l_fs=Filesystems
index_l_users=Users
index_l_admin=Administrator
index_l_lastlogin=Last login $1
index_l_days=days ago
index_l_never=never
index_l_delete=Delete
index_l_delete_confirm=Delete this user and its home directory?
index_tables_more=Both tables are built from the same rows. The sortable flag is the last argument of each call; a module can instead set sortable=1 in module.info to mark all of its tables. The unit column of the sortable table keeps details behind a tick, built with ui_details.
index_t_d_pid=Main PID
index_t_d_since=Active since
index_t_d_mem=Memory
index_al_errdetails=Database error message
index_al_errmsg=The full error message was : $1
index_tab_choices=Choices
index_choices_desc=Three ways to pick one option that needs its own inputs, in place of ui_radio_table and of hand-made tables of radios and fields : ui_choice keeps every option and its inputs visible, ui_select_switch shows only the block of the option chosen in a select, and ui_radio_list is the compact list of radios.
index_c_backup=Backup settings
index_c_dest=Backup destination
index_c_local=Local file
index_c_ftp=FTP server
index_c_ssh=SSH server
index_c_path=File on server
index_c_login=Login as user
index_c_pass=Password
index_c_port=Server port
index_c_download=Download in browser
index_c_download_desc=The archive is sent to your browser instead of being stored on the server.
index_c_ip=New virtual IP address
index_c_ip4=New IPv4 address
index_c_ip6=New IPv6 address
index_c_none=None
index_c_shared=Shared address
index_c_shared_of=$1 (Shared address for all servers)
index_c_dedicated=Use dedicated address
index_c_active=Already active
index_c_dest_select=Backup destination as a select
index_return_editor=Return to the config editor tab
index_k_system=System
index_k_hostname=Hostname
index_k_os=Operating system
index_k_kernel=Kernel
index_k_uptime=Uptime
index_k_load=Load average
index_k_memory=Memory
index_k_services=Services
index_k_ok=All services running
index_k_ok_body=Every monitored service answered within the last minute.
index_k_updates=Updates available
index_k_updates_body=14 packages can be updated, including a new kernel.
index_k_install=Install updates
index_k_backup=Backup failed
index_k_backup_body=Last night's backup to /backup stopped after 2 of 12 servers.
index_k_logins=Recent logins
index_k_user=User
index_k_from=From
index_k_when=When
index_k_yesterday=Yesterday
index_k_storage=Storage
index_k_details=Details
index_k_output=Command output
index_k_output_desc=Printed piece by piece with ui_card_start and ui_card_end
index_k_done=Finished
index_l_days_plain=days
index_badge_clock=Scheduled
index_badge_tip=With a tooltip
index_badge_tip_text=Badges take a title option, shown by the browser on hover
index_tip_text=Hover this text
index_tip=Any HTML can carry the theme tooltip that the help bubble uses
index_e_dl=Description list
index_e_dl_desc=Label and value pairs, with help bubbles and HTML values
index_e_dl_status=Status
index_e_dl_status_help=Whether the service is running right now
index_e_dl_version=Version
index_e_dl_config=Configuration file
index_e_dl_config_help=The file read when the service starts
index_e_dl_started=Started
index_e_dl_started_help=When the running instance was started, and by whom
index_e_istats=Stat tiles with icons
index_e_istats_desc=An icon before the value, links on tiles, and a state color
index_e_domains=Domains
index_e_users=Users
index_e_mail=Mailboxes
index_e_alerts=Alerts
index_feed_backup=Backup of $1 finished
index_b_single=Single buttons
index_b_single_desc=The button functions of ui-lib.pl in one row
index_b_disabled=Disabled
index_b_reset=Reset fields
index_b_cards=Open the Cards tab
index_b_end=Buttons of ui_form_end
index_b_saveapply=Save and apply
index_b_sides=Buttons at both sides
index_b_confirm=Confirmation page
index_b_confirm_desc=What a delete link should lead to before anything is removed
index_b_confirm_msg=Are you sure you want to delete the server $1?
index_b_confirm_warn=All of its settings will be lost
index_b_confirm_purge=Also remove its log files
index_b_delete=Delete server
index_f_search_ph=Search services
index_f_alerts=Email alerts
index_f_autoupdate=Automatic updates

6
ui-demo/module.info Normal file
View File

@@ -0,0 +1,6 @@
desc=UI Demo
longdesc=Read-only gallery of the widgets in ui-lib.pl and of the existing forms, buttons and tables they are meant to be combined with. A reference for writing new modules; not part of the distribution.
category=others
os_support=*
noacl=1
readonly=1

19
ui-demo/save_manual.cgi Normal file
View File

@@ -0,0 +1,19 @@
#!/usr/local/bin/perl
# Target of the Save button of edit_manual.cgi. A real module would lock
# the file, write the submitted data, unlock it, record the action with
# webmin_log and redirect back to the editor. This demo deliberately
# writes nothing, and only redirects back to the editor for the same
# file.
use strict;
use warnings;
require './ui-demo-lib.pl'; ## no critic
our (%in);
ReadParse();
# Only ever go back to one of the demo's own sample files
my $file = $in{'file'} || '';
$file = '' if (!(grep { $_ eq $file } demo_config_files()));
redirect("edit_manual.cgi".($file ne '' ? "?file=".urlize($file) : ""));

28
ui-demo/ui-demo-lib.pl Normal file
View File

@@ -0,0 +1,28 @@
=head1 ui-demo-lib.pl
Common functions for the UI demo module. The page builders live in
ui-demo-pages.pl, which uses WebminCore helpers and the page's language,
configuration and input hashes. The builders can also be rendered with
standalone test data without calling init_config.
=cut
use strict;
use warnings;
use lib "..";
use WebminCore;
our (%access, %config, %gconfig, %in, %text);
# This is a read-only reference module with nothing to protect, so it is
# usable without being listed in any user's module ACL. That way it
# works as soon as it is dropped into the Webmin root, without going
# through the module installer.
$main::no_acl_check = 1;
init_config();
do './ui-demo-pages.pl';
1;

1237
ui-demo/ui-demo-pages.pl Normal file

File diff suppressed because it is too large Load Diff

1502
ui-lib.pl

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,992 @@
/*
* ui-lib.css
* Stylesheet for the widgets added to ui-lib.pl: page header, grids,
* cards, stat tiles, description lists, badges, chips, lists, activity
* feeds, empty states, progress bars, toggles, search boxes and the
* boxed choice, radio list and select switch widgets.
*
* Widgets use their own classes, with shared spacing and margin resets
* inside .ui_page. Existing tabs, forms, tables and buttons retain their
* theme styling. Widgets use the theme's --font-family and
* --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.
* 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 {
/* 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
* four --text-color-* state colors itself. */
/* Core palette */
--ui-canvas: var(--bg-color, #f2f3f5);
--ui-surface: var(--main-content, #ffffff);
--ui-surface-2: var(--bg-table-header, #f5f5f5);
--ui-border: var(--border-color, #d7dbe0);
--ui-border-soft: var(--border-color-light, #e8eaee);
--ui-border-strong: var(--border-color-darker, #b3bac2);
--ui-fg: var(--text-color, #212121);
--ui-fg-muted: var(--text-color-muted, #4b5563);
/* Accent */
--ui-accent: var(--link-color, #0b46ab);
--ui-accent-soft: var(--element-highlight-bg-color, #e8f1fb);
--ui-ring: var(--border-color-focused, rgba(11, 70, 171, 0.25));
/* States. The base color is used for icons, dots and bars; the
* -text variant is a darker shade for text sitting on the -soft
* background, as in badges. */
--ui-success: var(--text-color-success, #3c763d);
--ui-success-text: var(--text-color-success-stressed, #1e4f18);
--ui-success-soft: var(--bg-color-success, #edf7e7);
--ui-success-line: var(--border-color-success, #b9e0a4);
--ui-warning: var(--text-color-warning, #b58900);
--ui-warning-text: var(--text-color-warning-stressed, #795600);
--ui-warning-soft: var(--bg-color-warning, #fdf4d8);
--ui-warning-line: var(--border-color-warning, #eed483);
--ui-danger: var(--text-color-danger, #bc0303);
--ui-danger-text: var(--text-color-danger-stressed, #7d1007);
--ui-danger-soft: var(--bg-color-danger, #fbeae8);
--ui-danger-line: var(--border-color-danger, #f2bcb6);
--ui-info: var(--text-color-info, #108eda);
--ui-info-text: var(--text-color-info-stressed, #00477e);
--ui-info-soft: var(--bg-color-info, #e8f1fb);
--ui-info-line: var(--border-color-info, #b8d6f2);
--ui-neutral: var(--text-color-muted, #4b5563);
--ui-neutral-text: var(--text-color-neutral-stressed, #40474f);
--ui-neutral-soft: var(--bg-color-neutral, #eff1f3);
--ui-neutral-line: var(--border-color-neutral, #d7dbe0);
/* Controls */
--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);
/* Shape and rhythm. Boxed widgets have square corners; dots,
* activity markers, ring gauges and toggles use circles or rounded tracks. */
--ui-gap: 7px;
/* Shared inset for boxed choice widgets: padding, option spacing,
* vertical field gaps and gaps between labels and inputs */
--ui-inset: 5px;
/* Where the label text starts after a theme radio, so descriptions
* and fields line up under it */
--ui-radio-indent: 1.6em;
/* Fonts. A theme that defines --font-family and --font-family-mono
* (Authentic does) sets the widgets' text and code fonts with them;
* otherwise text inherits the page font and code uses the system
* monospace stack. */
--ui-font: var(--font-family, inherit);
--ui-font-mono: var(--font-family-mono, ui-monospace, SFMono-Regular,
"SF Mono", Menlo, Consolas, "Liberation Mono", monospace);
color-scheme: light;
color: var(--ui-fg);
font-family: var(--ui-font);
text-align: left;
}
/* 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
* 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-canvas: #15171b;
--ui-surface: #1e2126;
--ui-surface-2: #23262c;
--ui-border: #363b43;
--ui-border-soft: #2a2f36;
--ui-border-strong: #4d545e;
--ui-fg: #e6e8ea;
--ui-fg-muted: #99a3ae;
--ui-accent: #5aa2ec;
--ui-accent-soft: rgba(90, 162, 236, 0.14);
--ui-ring: rgba(90, 162, 236, 0.35);
--ui-success: #7ec563;
--ui-success-text: #8fd376;
--ui-success-soft: rgba(126, 197, 99, 0.13);
--ui-success-line: rgba(126, 197, 99, 0.4);
--ui-warning: #e0a63c;
--ui-warning-text: #eab54f;
--ui-warning-soft: rgba(224, 166, 60, 0.13);
--ui-warning-line: rgba(224, 166, 60, 0.4);
--ui-danger: #ef7b6f;
--ui-danger-text: #f28c82;
--ui-danger-soft: rgba(239, 123, 111, 0.13);
--ui-danger-line: rgba(239, 123, 111, 0.4);
--ui-info: #5aa2ec;
--ui-info-text: #78b3ef;
--ui-info-soft: rgba(90, 162, 236, 0.13);
--ui-info-line: rgba(90, 162, 236, 0.4);
--ui-neutral: #aeb6bf;
--ui-neutral-text: #b6bec7;
--ui-neutral-soft: #272b31;
--ui-neutral-line: #3c424b;
color-scheme: dark;
}
@media (prefers-color-scheme: dark) {
[data-ui-scheme="auto"] .ui_page,
.ui_page[data-ui-scheme="auto"] {
--ui-canvas: #15171b;
--ui-surface: #1e2126;
--ui-surface-2: #23262c;
--ui-border: #363b43;
--ui-border-soft: #2a2f36;
--ui-border-strong: #4d545e;
--ui-fg: #e6e8ea;
--ui-fg-muted: #99a3ae;
--ui-accent: #5aa2ec;
--ui-accent-soft: rgba(90, 162, 236, 0.14);
--ui-ring: rgba(90, 162, 236, 0.35);
--ui-success: #7ec563;
--ui-success-text: #8fd376;
--ui-success-soft: rgba(126, 197, 99, 0.13);
--ui-success-line: rgba(126, 197, 99, 0.4);
--ui-warning: #e0a63c;
--ui-warning-text: #eab54f;
--ui-warning-soft: rgba(224, 166, 60, 0.13);
--ui-warning-line: rgba(224, 166, 60, 0.4);
--ui-danger: #ef7b6f;
--ui-danger-text: #f28c82;
--ui-danger-soft: rgba(239, 123, 111, 0.13);
--ui-danger-line: rgba(239, 123, 111, 0.4);
--ui-info: #5aa2ec;
--ui-info-text: #78b3ef;
--ui-info-soft: rgba(90, 162, 236, 0.13);
--ui-info-line: rgba(90, 162, 236, 0.4);
--ui-neutral: #aeb6bf;
--ui-neutral-text: #b6bec7;
--ui-neutral-soft: #272b31;
--ui-neutral-line: #3c424b;
color-scheme: dark;
}
body[data-ui-scheme="auto"]:has(.ui_page),
[data-ui-scheme="auto"] body:has(.ui_page),
body:has(.ui_page[data-ui-scheme="auto"]) {
background: var(--ui-canvas, #15171b);
}
}
/* Paint the page canvas behind widget pages. Themes can override the
* color by defining --ui-canvas on :root or body. */
body:has(.ui_page) {
background: var(--ui-canvas, var(--bg-color, #f2f3f5));
}
body[data-ui-scheme="dark"]:has(.ui_page),
[data-ui-scheme="dark"] body:has(.ui_page),
body:has(.ui_page[data-ui-scheme="dark"]) {
background: var(--ui-canvas, #15171b);
}
/* ---- Spacing between blocks ----
* Top-level blocks are spaced by the page area. Inside any other wrapper
* (a tab panel, a table cell) a widget that follows a sibling, and
* whatever follows a widget, get the same gap, so rows of cards and the
* forms between them line up whatever the theme wraps them in.
* Children of grids, stacks, clusters and stat rows use their own gaps. */
.ui_page > * + * { margin-top: var(--ui-gap); }
.ui_page :not(.ui_grid, .ui_stack, .ui_cluster, .ui_stats) >
* + :is(.ui_grid, .ui_stack, .ui_card, .ui_empty),
.ui_page :not(.ui_grid, .ui_stack, .ui_cluster, .ui_stats) >
:is(.ui_grid, .ui_stack, .ui_card, .ui_empty) + * {
margin-top: var(--ui-gap);
}
.ui_page [hidden] { display: none !important; }
/* Widgets size their boxes including padding and borders */
.ui_page_head, .ui_grid, .ui_stack, .ui_cluster,
.ui_card, .ui_card *, .ui_stats, .ui_stat, .ui_stat *,
.ui_dl, .ui_dl *, .ui_badge, .ui_chip, .ui_list, .ui_list *,
.ui_feed, .ui_feed *, .ui_empty, .ui_empty *, .ui_progress,
.ui_progress *, .ui_toggle, .ui_toggle *,
.ui_search, .ui_search * {
box-sizing: border-box;
}
.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; }
/* Plain links inside widget text are underlined so they still read as
* links next to chips and badges. Theme buttons made of ui_link are not. */
.ui_dl_row dd a:not(.ui_link), .ui_feed_text a:not(.ui_link),
.ui_list_desc a:not(.ui_link), .ui_empty_desc a:not(.ui_link),
.ui_card_desc a:not(.ui_link) {
text-decoration: underline;
text-underline-offset: 0.15em;
}
/* ---- Utilities ---- */
.ui_push { margin-left: auto; }
.ui_code {
font-family: var(--ui-font-mono);
font-size: 0.92em;
background: var(--ui-neutral-soft);
border: 1px solid var(--ui-border-soft);
padding: 1px 5px;
}
.ui_code_block {
font-family: var(--ui-font-mono);
font-size: 0.92em;
line-height: 1.5;
background: var(--ui-surface-2);
border: 1px solid var(--ui-border-soft);
padding: 10px 12px;
margin: 0;
overflow: auto;
white-space: pre;
color: var(--ui-fg);
}
.ui_fg_success { color: var(--ui-success); }
.ui_fg_warning { color: var(--ui-warning); }
.ui_fg_danger { color: var(--ui-danger); }
.ui_fg_info { color: var(--ui-info); }
.ui_fg_neutral { color: var(--ui-neutral); }
.ui_bg_success { background: var(--ui-success); }
.ui_bg_warning { background: var(--ui-warning); }
.ui_bg_danger { background: var(--ui-danger); }
.ui_bg_info { background: var(--ui-info); }
.ui_bg_neutral { background: var(--ui-neutral); }
/* ---- Page header ---- */
.ui_page_head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px 24px;
flex-wrap: wrap;
margin: 4px 0 2px;
}
.ui_page_title {
font-size: 1.85em;
font-weight: 650;
letter-spacing: -0.015em;
line-height: 1.2;
}
.ui_page_titles {
flex: 1 1 auto;
min-width: 0;
}
.ui_page_desc {
color: var(--ui-fg-muted);
margin-top: 4px;
}
.ui_page_actions {
display: flex;
align-items: center;
gap: 12px;
padding-top: 7px;
}
.ui_page_help {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 500;
}
/* ---- Layout primitives ---- */
.ui_grid {
display: grid;
gap: var(--ui-gap);
grid-template-columns: repeat(auto-fit,
minmax(min(var(--ui-grid-min, 340px), 100%), 1fr));
align-items: stretch;
}
.ui_grid_fixed {
grid-template-columns: var(--ui-grid-template,
repeat(var(--ui-grid-cols, 2), minmax(0, 1fr)));
}
.ui_stack {
display: flex;
flex-direction: column;
gap: var(--ui-gap);
}
.ui_cluster {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: calc(var(--ui-gap) / 2) var(--ui-gap);
}
.ui_cluster { --ui-gap: 10px; }
/* Themes that regroup adjacent submit buttons into one box, as Authentic
* does with its btn-group, wrap them inside that box with no vertical
* space. Give those rows a gap while keeping the buttons joined. */
.ui_cluster > .btn-group {
display: inline-flex;
flex-wrap: wrap;
row-gap: 7px;
}
.ui_cluster_center { justify-content: center; }
.ui_cluster_end { justify-content: flex-end; }
.ui_cluster_between { justify-content: space-between; }
@media (max-width: 760px) {
.ui_grid_fixed { grid-template-columns: 1fr; }
}
/* ---- Cards ---- */
.ui_card {
background: var(--ui-surface);
border: 1px solid var(--ui-border);
display: flex;
flex-direction: column;
min-width: 0;
}
.ui_card_success { border-left: 3px solid var(--ui-success); }
.ui_card_warning { border-left: 3px solid var(--ui-warning); }
.ui_card_danger { border-left: 3px solid var(--ui-danger); }
.ui_card_info { border-left: 3px solid var(--ui-info); }
.ui_card_head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 4px 12px;
padding: 5px 6px 0 7px;
flex-wrap: wrap;
}
.ui_card_title {
font-size: 1.07697em;
font-weight: 650;
line-height: 1.3;
}
.ui_card_desc {
color: var(--ui-fg-muted);
font-size: 0.93em;
margin-top: 3px;
}
.ui_card_actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.ui_card_body {
padding: 3px 7px 7px;
flex: 1;
min-width: 0;
}
.ui_card_body.ui_card_flush { padding: 1px 0 0; }
.ui_card_head + .ui_card_body.ui_card_flush { padding-top: 3px; }
.ui_card_foot {
border-top: 1px solid var(--ui-border-soft);
padding: 5px;
color: var(--ui-fg-muted);
font-size: 0.93em;
}
/* ---- Stat tiles ---- */
.ui_stats {
display: grid;
gap: 12px 24px;
grid-template-columns: repeat(auto-fit,
minmax(min(var(--ui-grid-min, 150px), 100%), 1fr));
}
.ui_stat {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
color: inherit;
text-decoration: none;
}
.ui_stat_value {
font-size: 1.85em;
font-weight: 500;
line-height: 1.2;
letter-spacing: -0.01em;
display: flex;
align-items: center;
gap: 8px;
}
.ui_stat_label { font-weight: 600; }
.ui_stat_desc { color: var(--ui-fg-muted); font-size: 0.9em; }
a.ui_stat, a.ui_stat:hover {
color: inherit !important;
text-decoration: none !important;
}
a.ui_stat:hover .ui_stat_value { color: var(--ui-accent); }
/* ---- Description lists ---- */
/* The list is one grid and every row contributes its dt and dd to it, so
* all labels share a single column sized to the longest label, up to the
* fraction given by --ui-dl-label. Labels only wrap past that limit. */
.ui_dl {
display: grid;
grid-template-columns: var(--ui-dl-label, fit-content(45%)) 1fr;
gap: 10px 20px;
align-items: baseline;
}
.ui_dl_cols {
grid-template-columns:
repeat(2, var(--ui-dl-label, fit-content(45%)) 1fr);
column-gap: 24px;
}
.ui_dl_row { display: contents; }
.ui_dl_row dt { font-weight: 600; }
.ui_dl_row dd {
min-width: 0;
overflow-wrap: anywhere;
}
@media (max-width: 560px) {
.ui_dl, .ui_dl_cols { grid-template-columns: 1fr; }
.ui_dl { row-gap: 2px; }
.ui_dl_row dd { margin-bottom: 8px; }
}
/* ---- Badges and chips ---- */
.ui_badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 10px;
border: 1px solid transparent;
font-size: 0.9em;
font-weight: 600;
line-height: 1.5;
vertical-align: middle;
white-space: nowrap;
}
.ui_badge_success {
background: var(--ui-success-soft);
border-color: var(--ui-success-line);
color: var(--ui-success-text);
}
.ui_badge_warning {
background: var(--ui-warning-soft);
border-color: var(--ui-warning-line);
color: var(--ui-warning-text);
}
.ui_badge_danger {
background: var(--ui-danger-soft);
border-color: var(--ui-danger-line);
color: var(--ui-danger-text);
}
.ui_badge_info {
background: var(--ui-info-soft);
border-color: var(--ui-info-line);
color: var(--ui-info-text);
}
.ui_badge_neutral {
background: var(--ui-neutral-soft);
border-color: var(--ui-neutral-line);
color: var(--ui-neutral-text);
}
.ui_chip {
display: inline-flex;
align-items: center;
padding: 1px 8px;
background: var(--ui-neutral-soft);
border: 1px solid var(--ui-border);
color: var(--ui-neutral-text);
font-size: 0.86em;
line-height: 1.5;
vertical-align: middle;
white-space: nowrap;
}
/* ---- Lists ---- */
.ui_list { display: flex; flex-direction: column; }
.ui_list_item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 5px 0;
border-bottom: 1px solid var(--ui-border-soft);
}
.ui_list_item:last-child { border-bottom: 0; }
.ui_list_flush .ui_list_item { padding: 5px 6px 5px 7px; }
.ui_list_main { flex: 1; min-width: 0; }
.ui_list_title {
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
overflow-wrap: anywhere;
}
.ui_list_desc {
color: var(--ui-fg-muted);
font-size: 0.93em;
margin-top: 2px;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ui_list_side {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
padding-top: 1px;
}
.ui_list_meta { color: var(--ui-fg-muted); font-size: 0.9em; }
/* Badges in list rows are chip-sized, so they do not outweigh the text */
.ui_list_item .ui_badge {
padding: 1px 8px;
gap: 4px;
font-size: 0.86em;
}
.ui_list_item .ui_badge .ui_svg_icon { width: 1em; height: 1em; }
/* Description text is 0.93em, so badges and chips inside it are scaled
* back by that much to match the ones next to the title */
.ui_list_desc :is(.ui_badge, .ui_chip) { font-size: calc(0.86em / 0.93); }
.ui_list_icon { padding-top: 2px; color: var(--ui-fg-muted); }
a.ui_list_link, a.ui_list_link:hover {
color: inherit !important;
text-decoration: none !important;
}
a.ui_list_link:hover { color: var(--ui-accent) !important; }
/* ---- Activity feed ---- */
.ui_feed { display: flex; flex-direction: column; }
.ui_feed_item {
position: relative;
padding: 0 0 16px 20px;
border-left: 2px solid var(--ui-border-soft);
margin-left: 5px;
}
.ui_feed_item:last-child { padding-bottom: 2px; }
.ui_feed_item::before {
content: "";
position: absolute;
left: -6px;
top: 4px;
width: 10px;
height: 10px;
border-radius: 999px;
background: var(--ui-border-strong);
border: 2px solid var(--ui-surface);
}
.ui_feed_success::before { background: var(--ui-success); }
.ui_feed_warning::before { background: var(--ui-warning); }
.ui_feed_danger::before { background: var(--ui-danger); }
.ui_feed_info::before { background: var(--ui-info); }
.ui_feed_when { font-weight: 600; font-size: 1em; }
.ui_feed_text { color: var(--ui-fg-muted); font-size: 0.93em; margin-top: 1px; }
.ui_feed_flush { padding: 3px 19px 7px; }
/* ---- Empty states ---- */
.ui_empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 1px 7px 7px;
text-align: center;
}
.ui_empty_icon { color: var(--ui-border-strong); margin-bottom: 2px; }
.ui_empty_title { font-weight: 600; font-size: 1.04em; }
.ui_empty_desc {
color: var(--ui-fg-muted);
font-size: 0.93em;
max-width: 46ch;
}
.ui_empty_actions { margin-top: 6px; }
/* ---- Progress ---- */
.ui_progress_head {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 0.93em;
font-weight: 600;
margin-bottom: 6px;
}
.ui_progress_value { color: var(--ui-fg-muted); font-weight: 400; }
.ui_progress_track {
display: flex;
height: 4px;
background: var(--ui-neutral-soft);
overflow: hidden;
}
.ui_progress_bar {
height: 100%;
flex-shrink: 0;
transition: width 0.4s ease;
}
/* Inline layout : label, bar and value on one line */
.ui_progress_inline {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.93em;
}
.ui_progress_inline .ui_progress_label {
font-weight: 600;
white-space: nowrap;
}
.ui_progress_inline .ui_progress_track { flex: 1 1 auto; }
.ui_progress_inline .ui_progress_value {
min-width: 3em;
text-align: right;
white-space: nowrap;
}
/* Value shown as a small marker riding on the end of the bar, which
* keeps the same height as every other bar */
.ui_progress_inside .ui_progress_track { overflow: visible; }
.ui_progress_inside .ui_progress_bar { position: relative; }
.ui_progress_inside_value {
position: absolute;
top: 50%;
left: 100%;
transform: translate(-50%, -50%);
padding: 1px 4px;
/* The bar's own color, lightened by a white veil, with dark text */
background: inherit;
background-image: linear-gradient(rgba(255, 255, 255, 0.6),
rgba(255, 255, 255, 0.6));
color: #1c1c1c;
font-size: 0.75em;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
}
.ui_progress_inside_start .ui_progress_inside_value {
transform: translate(0, -50%);
}
.ui_progress_inside_end .ui_progress_inside_value {
transform: translate(-100%, -50%);
}
/* Legend under a segmented bar */
.ui_progress_legend {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
margin-top: 6px;
font-size: 0.9em;
}
.ui_progress_key {
display: inline-flex;
align-items: center;
gap: 6px;
}
.ui_progress_dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
/* Unknown progress : a bar sliding along the track */
.ui_progress_indeterminate .ui_progress_bar {
width: 35%;
animation: ui_progress_slide 1.4s ease-in-out infinite;
}
@keyframes ui_progress_slide {
from { transform: translateX(-100%); }
to { transform: translateX(300%); }
}
@media (prefers-reduced-motion: reduce) {
.ui_progress_indeterminate .ui_progress_bar {
width: 100%;
animation: none;
opacity: 0.6;
background-image: repeating-linear-gradient(45deg,
transparent 0 6px, rgba(255, 255, 255, 0.45) 6px 12px);
}
}
/* Ring gauge : the bar is a circle of circumference 100 whose dash
* length is the percentage */
.ui_progress_ring {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.ui_progress_ring_box {
position: relative;
display: inline-block;
line-height: 0;
}
.ui_progress_ring_svg { transform: rotate(-90deg); }
/* The stroke width is set on the circles by ui_progress, scaled to the
* ring size so it is always 3 pixels */
.ui_progress_ring_track {
fill: none;
stroke: var(--ui-neutral-soft);
}
.ui_progress_ring_bar {
fill: none;
stroke: currentColor;
stroke-linecap: round;
transition: stroke-dasharray 0.4s ease;
}
.ui_progress_ring.ui_progress_indeterminate .ui_progress_ring_svg {
animation: ui_progress_spin 1s linear infinite;
}
@keyframes ui_progress_spin {
from { transform: rotate(-90deg); }
to { transform: rotate(270deg); }
}
.ui_progress_ring_box .ui_progress_value {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8em;
font-weight: 600;
line-height: 1;
color: var(--ui-fg);
}
.ui_progress_ring .ui_progress_label {
font-size: 0.85em;
color: var(--ui-fg-muted);
}
/* ---- Choice lists ----
* One solid box holding a row per option : the theme's own radio button
* with its label, the inline inputs beside it, and further fields under
* them in as many columns as fit, indented under the label. Everything
* stays visible. */
.ui_choice {
display: grid;
gap: var(--ui-inset);
min-width: 0;
/* Half the inset above and below, the full inset at the sides */
padding: calc(var(--ui-inset) / 2) var(--ui-inset);
border: 1px solid var(--ui-border);
background: var(--ui-surface);
}
.ui_choice_item { min-width: 0; }
.ui_choice_head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px var(--ui-inset);
min-width: 0;
}
/* Only the option's own label is bold, not labels of inputs in its content */
.ui_choice_head label:not(.ui_choice_content label) { font-weight: 600; }
.ui_choice_content {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 4px var(--ui-inset);
min-width: 0;
}
.ui_choice_desc {
margin-left: var(--ui-radio-indent);
color: var(--ui-fg-muted);
font-size: 0.93em;
}
.ui_choice_fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
gap: var(--ui-inset) 20px;
margin: var(--ui-inset) 0 0 var(--ui-radio-indent);
}
.ui_choice_field {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px var(--ui-inset);
min-width: 0;
}
.ui_choice_field_label { color: var(--ui-fg-muted); }
.ui_choice_field_input { min-width: 0; }
.ui_choice_disabled { opacity: 0.6; }
/* ---- Radio lists ----
* The compact form : the theme's radio buttons at the left edge of a
* box, one option per line, an optional input after the label */
.ui_radio_list {
display: grid;
gap: var(--ui-inset);
min-width: 0;
padding: var(--ui-inset);
border: 1px solid var(--ui-border);
background: var(--ui-surface);
}
.ui_radio_list_item {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px var(--ui-inset);
min-width: 0;
}
.ui_radio_list_item label:not(.ui_radio_list_content label) {
font-weight: 600;
}
.ui_radio_list_content {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 4px var(--ui-inset);
min-width: 0;
}
.ui_radio_list_disabled { opacity: 0.6; }
/* ---- Select switches ----
* A select, and under it the block of the chosen option only */
.ui_select_switch {
display: grid;
justify-items: start;
gap: var(--ui-inset);
min-width: 0;
}
.ui_select_switch_panel {
display: grid;
gap: var(--ui-inset);
width: 100%;
min-width: 0;
padding: var(--ui-inset);
border: 1px solid var(--ui-border);
background: var(--ui-surface);
}
.ui_select_switch_desc {
color: var(--ui-fg-muted);
font-size: 0.93em;
}
.ui_select_switch_content {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px var(--ui-inset);
min-width: 0;
}
.ui_select_switch_fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
gap: var(--ui-inset) 20px;
}
.ui_select_switch_field {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px var(--ui-inset);
min-width: 0;
}
.ui_select_switch_field_label { color: var(--ui-fg-muted); }
.ui_select_switch_field_input { min-width: 0; }
/* ---- Toggle switch ----
* Some themes wrap checkbox inputs in their own markup with JavaScript,
* so the real input - or whatever wrapper it ends up inside - is parked
* invisibly over the track, where clicks and keyboard focus still reach
* it, and the checked state is read with :has() which does not depend on
* the input being the track's sibling. */
.ui_toggle {
display: inline-flex;
align-items: center;
gap: 10px;
cursor: pointer;
position: relative;
font-weight: normal;
}
.ui_toggle > :not(.ui_toggle_track):not(.ui_toggle_label),
.ui_toggle input {
position: absolute !important;
left: 0 !important;
top: 0 !important;
width: 28px !important;
height: 14px !important;
margin: 0 !important;
padding: 0 !important;
opacity: 0 !important;
overflow: hidden;
z-index: 1;
cursor: pointer;
}
.ui_toggle_track {
display: inline-flex;
align-items: center;
width: 28px;
height: 14px;
padding: 2px;
border-radius: 999px;
background: var(--ui-toggle-bg);
transition: background 0.15s ease;
flex-shrink: 0;
}
.ui_toggle_thumb {
width: 10px;
height: 10px;
border-radius: 999px;
background: var(--ui-toggle-thumb);
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.25);
transition: transform 0.15s ease;
}
.ui_toggle input:checked ~ .ui_toggle_track,
.ui_toggle:has(input:checked) .ui_toggle_track {
background: var(--ui-toggle-bg-checked);
}
.ui_toggle input:checked ~ .ui_toggle_track .ui_toggle_thumb,
.ui_toggle:has(input:checked) .ui_toggle_thumb {
transform: translateX(14px);
}
.ui_toggle input:focus-visible ~ .ui_toggle_track,
.ui_toggle:has(input:focus-visible) .ui_toggle_track {
box-shadow: 0 0 0 3px var(--ui-ring);
}
.ui_toggle input:disabled ~ .ui_toggle_track,
.ui_toggle:has(input:disabled) .ui_toggle_track { opacity: 0.5; }
/* ---- Search box ---- */
.ui_search {
position: relative;
display: inline-flex;
max-width: 100%;
}
.ui_search .ui_search_icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--ui-fg-muted);
pointer-events: none;
}
.ui_search .ui_search_input {
padding-left: 30px;
width: 100%;
min-width: 0;
font: inherit;
color: inherit;
background: var(--ui-surface);
border: 1px solid var(--ui-border-strong);
padding-top: 5px;
padding-bottom: 5px;
padding-right: 10px;
}
.ui_search .ui_search_input:focus {
outline: none;
border-color: var(--ui-accent);
box-shadow: 0 0 0 3px var(--ui-ring);
}
/* ---- Reduced motion ---- */
@media (prefers-reduced-motion: reduce) {
.ui_progress_ring.ui_progress_indeterminate .ui_progress_ring_svg {
animation: none;
}
.ui_page *, .ui_page *::before, .ui_page *::after {
transition: none !important;
}
}

View File

@@ -0,0 +1,97 @@
/*
* ui-lib.js
* Behavior for the widgets added to ui-lib.pl.
*
* Everything is wired through delegated event listeners keyed off
* data-ui-* attributes, so no inline handlers are generated and pages
* remain compatible with a strict Content-Security-Policy. Tabs, sorting
* and select-all links keep using the scripts the existing ui-lib
* functions and themes already provide.
*/
(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;
// Filter table rows or list entries in the target container by the
// text typed into a ui_search box, leaving header rows in place
function applyFilter(input) {
var sel = input.getAttribute('data-ui-filter');
var target = sel && document.querySelector(sel);
if (!target) return;
var query = input.value.trim().toLowerCase();
var items = target.querySelectorAll(
'tbody tr, .ui_list_item, [data-ui-filter-item]');
items.forEach(function (item) {
if (item.classList.contains('ui_columns_heads') ||
item.classList.contains('ui_columns_heading')) return;
item.hidden = query !== '' &&
item.textContent.toLowerCase().indexOf(query) < 0;
});
}
// 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) {
var confirmer = e.target.closest && e.target.closest('[data-ui-confirm]');
if (confirmer &&
!window.confirm(confirmer.getAttribute('data-ui-confirm'))) {
e.preventDefault();
e.stopImmediatePropagation();
}
}, true);
document.addEventListener('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) {
// 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 ||
!e.target.matches('input:not([type="button"]):not([type="submit"]):not([type="reset"]), select, textarea')) return;
var item = e.target.closest && e.target.closest('.ui_choice_item');
if (!item) return;
var input = item.querySelector('input[type="radio"]');
if (input && e.target !== input && !input.checked && !input.disabled) {
input.checked = true;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
});
// Select switches : show the block of the chosen option, hide the rest
function applySwitch(select) {
var box = select.closest('.ui_select_switch');
if (!box) return;
box.querySelectorAll('.ui_select_switch_panel').forEach(function (panel) {
// Nested switches manage their own panels independently.
if (panel.closest('.ui_select_switch') !== box) return;
panel.hidden =
panel.getAttribute('data-ui-switch-value') !== select.value;
});
}
document.addEventListener('change', function (e) {
var select = e.target;
if (select.matches && select.matches('select[data-ui-switch]')) {
applySwitch(select);
}
});
// 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) {
window.setTimeout(function () {
if (e.defaultPrevented) return;
document.querySelectorAll('select[data-ui-switch]').forEach(function (select) {
if (select.form === e.target) applySwitch(select);
});
}, 0);
});
})();