Add themed toggle colors and style options

This commit is contained in:
Ilia Ross
2026-09-09 03:19:43 +02:00
parent f6720b8097
commit 8a43ad1b07
6 changed files with 203 additions and 11 deletions

View File

@@ -179,6 +179,57 @@ unlike(main::ui_page_start({ 'scheme' => 'bogus"' }),
'toggle unchecked by default');
like(main::ui_toggle({ 'name' => 'boot', 'value' => '' }),
qr/\bvalue=""/, 'toggle preserves an explicitly empty submitted value');
like($html, qr/\bui_toggle_neutral\b/,
'toggle defaults to the secondary color');
unlike($html, qr/\bui_toggle_outline\b/, 'toggle is filled by default');
unlike($html, qr/\bui_toggle_round\b/, 'toggle uses the default compact shape');
}
# Toggle states share the widget aliases without changing checkbox behavior.
{
foreach my $case ([ 'primary', 'primary' ], [ 'success', 'success' ],
[ 'warning', 'warning' ],
[ 'error', 'danger' ], [ 'info', 'info' ],
[ 'secondary', 'neutral' ], [ 'gray', 'neutral' ],
[ 'grey', 'neutral' ], [ 'invalid<', 'neutral' ]) {
my ($state, $suffix) = @$case;
my $html = main::ui_toggle({ 'name' => 'colored', 'state' => $state,
'class' => 'custom' });
like($html, qr/<label\b[^>]*class="[^"]*\bui_toggle_$suffix\b[^"]*\bcustom\b/,
"toggle maps $state and preserves the custom class");
}
my $html = main::ui_toggle({ 'name' => 'colored', 'state' => 'success',
'checked' => 1, 'disabled' => 1, 'value' => 'yes' });
my ($input) = $html =~ /(<input[^>]*>)/;
like($input, qr/\bchecked\b/, 'colored toggle stays checked');
like($input, qr/\bdisabled\b/, 'colored toggle stays disabled');
like($input, qr/\bvalue="yes"/, 'colored toggle preserves its value');
}
# Outline is optional and leaves the checkbox attributes intact.
{
my $html = main::ui_toggle({ 'name' => 'outlined', 'state' => 'error',
'outline' => 1, 'checked' => 1, 'disabled' => 1, 'value' => 'yes' });
like($html, qr/\bui_toggle_danger\b[^"]*\bui_toggle_outline\b/,
'outline combines with the normalized state');
my ($input) = $html =~ /(<input[^>]*>)/;
like($input, qr/\bchecked\b/, 'outline preserves checked state');
like($input, qr/\bdisabled\b/, 'outline preserves disabled state');
like($input, qr/\bvalue="yes"/, 'outline preserves the submitted value');
unlike(main::ui_toggle({ 'name' => 'plain', 'outline' => 0 }),
qr/\bui_toggle_outline\b/, 'outline can be explicitly disabled');
}
# Rounding applies independently of the toggle's color and outline style.
{
foreach my $outline (0, 1) {
my $html = main::ui_toggle({ 'name' => 'rounded', 'state' => 'primary',
'round' => 1, 'outline' => $outline });
like($html, qr/\bui_toggle_primary\b[^"]*\bui_toggle_round\b/,
"rounding applies with outline=$outline");
}
unlike(main::ui_toggle({ 'name' => 'square', 'round' => 0 }),
qr/\bui_toggle_round\b/, 'rounding can be explicitly disabled');
}
# Column tables take extra class names for the theme, such as no-hover

View File

@@ -28,6 +28,18 @@ 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.
The Forms tab includes toggle colors: pass `state => 'primary'`, `'success'`,
`'warning'`, `'error'` or `'secondary'` to `ui_toggle`. Checked tracks default
to secondary (gray); primary uses the blue/accent color. Colors follow the
theme with built-in fallbacks.
Add `outline => 1` for a transparent track with a colored border and thumb.
Secondary uses an outline when off and a soft gray fill when on. The Forms
tab shows both positions for every outline color.
Toggles are compact with softened corners by default. Pass `round => 1` for
a fully rounded track and thumb; it works with every color and with `outline => 1`.
In a `ui_multi_select_list` example on the Choices tab, click an
entry and Shift-click another to select or clear the range between them.
Checkboxes, labels and row backgrounds work alike; filtered, folded and

View File

@@ -105,6 +105,18 @@ index_f_grouped_desc=Built with ui_form_grouped_buttons, as in the Virtualmin Po
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_f_toggle_colors=Toggle colors
index_f_toggle_colors_desc=Checked switches default to secondary. Use primary for the accent color, or choose a semantic state. Colors follow the theme with built-in fallbacks.
index_f_toggle_primary=Primary
index_f_toggle_secondary=Secondary (default)
index_f_toggle_outline=Outline toggles
index_f_toggle_outline_desc=Outline tracks use the state color when on. Secondary keeps a soft fill. Click the switches to compare their off and on states.
index_f_toggle_off=Off
index_f_toggle_on=On
index_f_toggle_round=Rounded toggles
index_f_toggle_round_desc=Default toggles are compact with softened corners. The round option uses a fully rounded track and thumb, with filled or outline styling.
index_f_toggle_filled=Filled
index_f_toggle_outlined=Outline
index_a_general=General settings
index_a_hostname=Hostname
index_a_enabled=Enabled

View File

@@ -614,6 +614,64 @@ $rv .= ui_card({
'label' => $text{'index_f_autoupdate'} }),
], { 'gap' => '20px' }),
});
# Checked colors include primary and the shared semantic states.
$rv .= ui_card({
'title' => $text{'index_f_toggle_colors'},
'desc' => $text{'index_f_toggle_colors_desc'},
'body' => ui_cluster([
ui_toggle({ 'name' => 'toggle_primary', 'checked' => 1,
'state' => 'primary',
'label' => $text{'index_f_toggle_primary'} }),
ui_toggle({ 'name' => 'toggle_success', 'checked' => 1,
'state' => 'success', 'label' => $text{'ui_success'} }),
ui_toggle({ 'name' => 'toggle_warning', 'checked' => 1,
'state' => 'warning', 'label' => $text{'ui_warning'} }),
ui_toggle({ 'name' => 'toggle_error', 'checked' => 1,
'state' => 'error', 'label' => $text{'ui_error'} }),
ui_toggle({ 'name' => 'toggle_secondary', 'checked' => 1,
'state' => 'secondary',
'label' => $text{'index_f_toggle_secondary'} }),
], { 'gap' => '20px' }),
});
# Outline variants shown off and on, including the softly filled secondary.
my @toggle_states = (
[ 'primary', $text{'index_f_toggle_primary'} ],
[ 'success', $text{'ui_success'} ],
[ 'warning', $text{'ui_warning'} ],
[ 'error', $text{'ui_error'} ],
[ 'info', $text{'ui_info'} ],
[ 'secondary', $text{'index_f_toggle_secondary'} ],
);
$rv .= ui_card({
'title' => $text{'index_f_toggle_outline'},
'desc' => $text{'index_f_toggle_outline_desc'},
'body' => ui_dl([ map {
my $checked = $_;
[ $text{$checked ? 'index_f_toggle_on' : 'index_f_toggle_off'},
ui_cluster([ map {
ui_toggle({ 'name' => 'outline_'.$_->[0].'_'.$checked,
'state' => $_->[0], 'label' => $_->[1],
'outline' => 1, 'checked' => $checked })
} @toggle_states ], { 'gap' => '20px' }) ]
} (0, 1) ]),
});
# The round option works with every color and both track styles.
$rv .= ui_card({
'title' => $text{'index_f_toggle_round'},
'desc' => $text{'index_f_toggle_round_desc'},
'body' => ui_dl([ map {
my $outline = $_;
[ $text{$outline ? 'index_f_toggle_outlined' : 'index_f_toggle_filled'},
ui_cluster([ map {
ui_toggle({ 'name' => 'round_'.$_->[0].'_'.$outline,
'state' => $_->[0], 'label' => $_->[1],
'round' => 1, 'outline' => $outline, 'checked' => 1 })
} @toggle_states ], { 'gap' => '20px' }) ]
} (0, 1) ]),
});
return $rv;
}

View File

@@ -4293,8 +4293,8 @@ scheme option of ui_page_start. A theme can still replace any function
by defining C<theme_ui_*>, but should rarely need to.
Widget states used throughout are C<success>, C<warning>, C<danger>,
C<info> and C<neutral>. The aliases C<ok>, C<warn>, C<error>, C<err> and
C<off> are accepted.
C<info> and C<neutral>. The aliases C<ok>, C<warn>, C<error>, C<err>,
C<off>, C<secondary>, C<gray> and C<grey> are accepted.
=cut
@@ -4309,6 +4309,7 @@ my %ui_states = (
'danger' => 'danger', 'error' => 'danger', 'err' => 'danger',
'info' => 'info',
'neutral' => 'neutral', 'off' => 'neutral',
'secondary' => 'neutral', 'gray' => 'neutral', 'grey' => 'neutral',
);
# Built-in icon set, as 16x16 SVG path data drawn with the current color.
@@ -5388,6 +5389,16 @@ are :
=item checked - Set to 1 if on by default.
=item state - Color for the checked track: primary, success, warning, danger,
info or neutral (the default). C<error> aliases danger; secondary, gray and grey
alias neutral. Use primary for the theme's blue/accent toggle color.
=item outline - Set to 1 for a bordered track with a matching thumb. Secondary
uses the unchecked track color as a soft fill when on.
=item round - Set to 1 for a fully rounded track and thumb. The default is
smaller with softened corners. Works with filled and outline styles.
=item disabled - Set to 1 to disable the switch.
=item attrs - Hash reference of additional attributes for the input, such
@@ -5401,6 +5412,8 @@ sub ui_toggle
return &theme_ui_toggle(@_) if (defined(&theme_ui_toggle));
my ($opts) = @_;
$opts ||= {};
my $state = ($opts->{'state'} || '') eq 'primary' ? 'primary' :
&_ui_state($opts->{'state'});
my $value = defined($opts->{'value'}) ? $opts->{'value'} : 1;
my $attrs = &_ui_attrs({
%{ $opts->{'attrs'} || {} },
@@ -5422,7 +5435,10 @@ my $label = &_ui_text($opts, 'label');
$body .= &ui_tag('span', $label, { 'class' => 'ui_toggle_label' })
if (defined($label));
return &ui_tag('label', $body,
{ 'class' => &_ui_class('ui_toggle', $opts->{'class'}) });
{ 'class' => &_ui_class('ui_toggle', 'ui_toggle_'.$state,
$opts->{'outline'} ? 'ui_toggle_outline' : undef,
$opts->{'round'} ? 'ui_toggle_round' : undef,
$opts->{'class'}) });
}
=head2 ui_search(&opts)

View File

@@ -68,11 +68,12 @@
--ui-input-bg: var(--bg-color-input, var(--ui-surface));
--ui-input-border: var(--border-color-input, var(--ui-border-strong));
--ui-toggle-bg: var(--toggle-bg-color, var(--ui-border-strong));
--ui-toggle-bg-checked: var(--toggle-bg-color-checked, var(--ui-accent));
/* Keep the default close to the unchecked track. */
--ui-toggle-bg-checked: color-mix(in srgb,
var(--ui-toggle-bg) 75%, var(--ui-neutral));
--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. */
/* Shape and rhythm. Boxed widgets stay square; toggle corners are softened. */
--ui-gap: 7px;
/* Shared inset for boxed choice widgets: padding, option spacing,
* vertical field gaps and gaps between labels and inputs */
@@ -919,6 +920,14 @@ a.ui_list_link:hover { color: var(--ui-accent) !important; }
position: relative;
font-weight: normal;
}
/* State colors share the theme-backed palette used by other widgets. */
.ui_toggle_primary {
--ui-toggle-bg-checked: var(--toggle-bg-color-checked, var(--ui-accent));
}
.ui_toggle_success { --ui-toggle-bg-checked: var(--ui-success); }
.ui_toggle_warning { --ui-toggle-bg-checked: var(--ui-warning); }
.ui_toggle_danger { --ui-toggle-bg-checked: var(--ui-danger); }
.ui_toggle_info { --ui-toggle-bg-checked: var(--ui-info); }
.ui_toggle > :not(.ui_toggle_track):not(.ui_toggle_label),
.ui_toggle input {
position: absolute !important;
@@ -936,21 +945,32 @@ a.ui_list_link:hover { color: var(--ui-accent) !important; }
.ui_toggle_track {
display: inline-flex;
align-items: center;
width: 28px;
height: 14px;
width: 26px;
height: 12px;
padding: 2px;
border-radius: 999px;
border-radius: 3px;
background: var(--ui-toggle-bg);
transition: background 0.15s ease;
flex-shrink: 0;
}
.ui_toggle_thumb {
width: 8px;
height: 8px;
border-radius: 2px;
background: var(--ui-toggle-thumb);
box-shadow: 0 1px 1px rgba(16, 24, 40, 0.18);
transition: transform 0.15s ease;
}
.ui_toggle_round .ui_toggle_track {
width: 28px;
height: 14px;
border-radius: 999px;
}
.ui_toggle_round .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 {
@@ -960,6 +980,29 @@ a.ui_list_link:hover { color: var(--ui-accent) !important; }
.ui_toggle:has(input:checked) .ui_toggle_thumb {
transform: translateX(14px);
}
/* Trade one pixel of padding for the border to keep the thumb in place. */
.ui_toggle_outline .ui_toggle_track {
padding: 1px;
border: 1px solid currentColor;
color: var(--ui-toggle-bg);
background: transparent;
}
.ui_toggle_outline .ui_toggle_thumb {
background: currentColor;
box-shadow: none;
}
.ui_toggle_outline input:checked ~ .ui_toggle_track,
.ui_toggle_outline:has(input:checked) .ui_toggle_track {
color: var(--ui-toggle-bg-checked);
background: transparent;
}
/* Secondary stays quiet when on, using the normal off-state fill. */
.ui_toggle_outline.ui_toggle_neutral input:checked ~ .ui_toggle_track,
.ui_toggle_outline.ui_toggle_neutral:has(input:checked) .ui_toggle_track {
color: var(--ui-toggle-thumb);
border-color: var(--ui-toggle-bg);
background: var(--ui-toggle-bg);
}
.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);