mirror of
https://github.com/webmin/webmin.git
synced 2026-09-07 22:20:39 +01:00
Add UI widgets and demo module
ⓘ Add composable widgets, shared styling and interactions, sortable-table options, and a read-only UI gallery.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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'} &&
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
5
lang/en
5
lang/en
@@ -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
312
t/ui-lib-widgets.t
Normal 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/<b>text<\/b>/, '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"/,
|
||||
'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
119
ui-demo/README.md
Normal 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
19
ui-demo/apply.cgi
Normal 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
1
ui-demo/config
Normal file
@@ -0,0 +1 @@
|
||||
default_tab=cards
|
||||
1
ui-demo/config.info
Normal file
1
ui-demo/config.info
Normal 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
54
ui-demo/edit_manual.cgi
Normal 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
11
ui-demo/help/file.html
Normal 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
13
ui-demo/help/intro.html
Normal 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>
|
||||
7
ui-demo/images/accordions.svg
Normal file
7
ui-demo/images/accordions.svg
Normal 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
6
ui-demo/images/cards.svg
Normal 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
7
ui-demo/images/forms.svg
Normal 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
7
ui-demo/images/lists.svg
Normal 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
6
ui-demo/images/menu.svg
Normal 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 |
5
ui-demo/images/tables.svg
Normal file
5
ui-demo/images/tables.svg
Normal 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
75
ui-demo/index.cgi
Executable 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
316
ui-demo/lang/en
Normal 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 $1 and $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
6
ui-demo/module.info
Normal 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
19
ui-demo/save_manual.cgi
Normal 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
28
ui-demo/ui-demo-lib.pl
Normal 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
1237
ui-demo/ui-demo-pages.pl
Normal file
File diff suppressed because it is too large
Load Diff
992
unauthenticated/css/ui-lib.css
Normal file
992
unauthenticated/css/ui-lib.css
Normal 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;
|
||||
}
|
||||
}
|
||||
97
unauthenticated/js/ui-lib.js
Normal file
97
unauthenticated/js/ui-lib.js
Normal 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);
|
||||
});
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user