diff --git a/t/ui-lib-widgets.t b/t/ui-lib-widgets.t index 0cdf3cf86..08d53a4da 100644 --- a/t/ui-lib-widgets.t +++ b/t/ui-lib-widgets.t @@ -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/]*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 =~ /(]*>)/; + 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 =~ /(]*>)/; + 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 diff --git a/ui-demo/README.md b/ui-demo/README.md index ff6e05eb4..0e5b60df9 100644 --- a/ui-demo/README.md +++ b/ui-demo/README.md @@ -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 diff --git a/ui-demo/lang/en b/ui-demo/lang/en index 1168fd897..28819e118 100644 --- a/ui-demo/lang/en +++ b/ui-demo/lang/en @@ -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 diff --git a/ui-demo/ui-demo-pages.pl b/ui-demo/ui-demo-pages.pl index dfef0111d..bda25b5a1 100644 --- a/ui-demo/ui-demo-pages.pl +++ b/ui-demo/ui-demo-pages.pl @@ -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; } diff --git a/ui-lib.pl b/ui-lib.pl index 588144a0e..7253c35f1 100755 --- a/ui-lib.pl +++ b/ui-lib.pl @@ -4293,8 +4293,8 @@ scheme option of ui_page_start. A theme can still replace any function by defining C, but should rarely need to. Widget states used throughout are C, C, C, -C and C. The aliases C, C, C, C and -C are accepted. +C and C. The aliases C, C, C, C, +C, C, C and C 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 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) diff --git a/unauthenticated/css/ui-lib.css b/unauthenticated/css/ui-lib.css index 5e9c234c0..7a272b030 100644 --- a/unauthenticated/css/ui-lib.css +++ b/unauthenticated/css/ui-lib.css @@ -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);