From 20a487ca5a1f0edbbf775e175c32c952bf28fe3f Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Sat, 5 Sep 2026 03:52:39 +0200 Subject: [PATCH] Add UI widgets and demo module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⓘ Add composable widgets, shared styling and interactions, sortable-table options, and a read-only UI gallery. --- WebminCore.pm | 2 +- gray-theme/left.cgi | 11 + gray-theme/theme.pl | 15 +- gray-theme/unauthenticated/gray-theme.css | 6 +- lang/en | 5 + t/ui-lib-widgets.t | 312 +++++ ui-demo/README.md | 119 ++ ui-demo/apply.cgi | 19 + ui-demo/config | 1 + ui-demo/config.info | 1 + ui-demo/edit_manual.cgi | 54 + ui-demo/help/file.html | 11 + ui-demo/help/intro.html | 13 + ui-demo/images/accordions.svg | 7 + ui-demo/images/cards.svg | 6 + ui-demo/images/forms.svg | 7 + ui-demo/images/lists.svg | 7 + ui-demo/images/menu.svg | 6 + ui-demo/images/tables.svg | 5 + ui-demo/index.cgi | 75 + ui-demo/lang/en | 316 +++++ ui-demo/module.info | 6 + ui-demo/save_manual.cgi | 19 + ui-demo/ui-demo-lib.pl | 28 + ui-demo/ui-demo-pages.pl | 1237 +++++++++++++++++ ui-lib.pl | 1502 ++++++++++++++++++++- unauthenticated/css/ui-lib.css | 992 ++++++++++++++ unauthenticated/js/ui-lib.js | 97 ++ 28 files changed, 4860 insertions(+), 19 deletions(-) create mode 100644 t/ui-lib-widgets.t create mode 100644 ui-demo/README.md create mode 100644 ui-demo/apply.cgi create mode 100644 ui-demo/config create mode 100644 ui-demo/config.info create mode 100644 ui-demo/edit_manual.cgi create mode 100644 ui-demo/help/file.html create mode 100644 ui-demo/help/intro.html create mode 100644 ui-demo/images/accordions.svg create mode 100644 ui-demo/images/cards.svg create mode 100644 ui-demo/images/forms.svg create mode 100644 ui-demo/images/lists.svg create mode 100644 ui-demo/images/menu.svg create mode 100644 ui-demo/images/tables.svg create mode 100755 ui-demo/index.cgi create mode 100644 ui-demo/lang/en create mode 100644 ui-demo/module.info create mode 100644 ui-demo/save_manual.cgi create mode 100644 ui-demo/ui-demo-lib.pl create mode 100644 ui-demo/ui-demo-pages.pl create mode 100644 unauthenticated/css/ui-lib.css create mode 100644 unauthenticated/js/ui-lib.js diff --git a/WebminCore.pm b/WebminCore.pm index 5991e26e4..1c364b3ea 100644 --- a/WebminCore.pm +++ b/WebminCore.pm @@ -23,7 +23,7 @@ $main::export_to_caller = 1; # Add functions in web-lib-funcs.pl # Generated with : # grep -h "^sub " web-lib-funcs.pl ui-lib.pl | sed -e 's/sub //' | xargs echo -@EXPORT = qw(read_file read_file_cached read_file_cached_with_stat write_file html_escape html_unescape html_strip quote_escape quote_literal_escape quote_javascript default_webmin_temp_dir tempname_dir tempname_dir_sys webmin_temp_dir_name webmin_temp_dir_path tempname transname transname_timestamped trunc indexof indexoflc sysprint check_ipaddress check_ip6address is_non_public_ipaddress ipaddress_matches_network get_download_address_callback check_download_address generate_icon urlize un_urlize include copydata copydata_len ReadParseMime ReadParse read_fully read_parse_mime_callback read_parse_mime_javascript PrintHeader header get_html_title get_html_framed_title get_html_status_line popup_header footer popup_footer load_module_preferences load_theme_library redirect kill_byname kill_byname_logged find_byname error error_stderr popup_error register_error_handler call_error_handlers error_setup wait_for fast_wait_for has_command make_date make_date_relative file_chooser_button popup_window_button popup_window_link read_acl acl_filename acl_check get_miniserv_config_file get_miniserv_config put_miniserv_config restart_miniserv reload_miniserv check_os_support http_download complete_http_download http_post ftp_download ftp_upload no_proxy open_socket download_timeout ftp_command to_ipaddress to_ip6address to_hostname icons_table replace_meta replace_file_line read_file_lines flush_file_lines unflush_file_lines unix_user_input unix_group_input hlink user_chooser_button group_chooser_button foreign_check foreign_exists foreign_available foreign_require foreign_call foreign_config foreign_installed foreign_defined get_system_hostname get_webmin_version get_webmin_version_release get_webmin_full_version get_module_acl get_group_module_acl save_module_acl save_group_module_acl init_config load_language_auto load_language text_subs text encode_base64 decode_base64 encode_base32 decode_base32 get_module_info get_all_module_infos list_themes get_theme_info list_locales list_languages safe_language read_env_file write_env_file lock_file unlock_file test_lock unlock_all_files can_lock_file webmin_log additional_log var_dump webmin_debug_log system_logged backquote_logged backquote_with_timeout backquote_command kill_logged rename_logged rename_file symlink_logged symlink_file link_file make_dir make_dir_recursive set_ownership_permissions unlink_logged unlink_file copy_permissions_source_dest copy_source_dest move_source_dest remote_session_name verify_session_id remote_foreign_require remote_foreign_call remote_foreign_check remote_foreign_config remote_eval remote_write remote_read remote_finished remote_error_setup remote_rpc_call remote_multi_callback remote_multi_callback_error serialise_variable unserialise_variable other_groups date_chooser_button help_file read_help_file seed_random disk_usage_kb recursive_disk_usage help_search_link can_use_http_ssl make_http_connection validate_ssl_connection read_http_connection write_http_connection close_http_connection read_http_headers get_http_auth_reason clean_environment reset_environment clean_language progress_callback switch_to_remote_user switch_to_unix_user eval_as_unix_user create_user_config_dirs create_missing_homedir filter_javascript resolve_links simplify_path same_file flush_webmin_caches list_usermods available_usermods get_available_module_infos get_visible_module_infos get_visible_modules_categories is_under_directory parse_http_url check_clicks_function load_entities_map entities_to_ascii get_product_name get_charset get_display_hostname save_module_config save_user_module_config nice_size get_perl_path get_goto_module select_all_link select_invert_link select_rows_link check_pid_file get_mod_lib module_root_directory list_mime_types guess_mime_type open_tempfile close_tempfile print_tempfile is_selinux_enabled get_clear_file_attributes reset_file_attributes cleanup_tempnames open_lock_tempfile END month_to_number number_to_month supports_ipv6 execute_command execute_command_logged open_readfile open_execute_command translate_filename translate_command register_filename_callback register_command_callback capture_function_output capture_function_output_tempfile modules_chooser_button substitute_template substitute_pattern running_in_zone running_in_vserver running_in_xen running_in_openvz list_categories is_readonly_mode command_as_user list_osdn_mirrors convert_osdn_url get_current_dir supports_users supports_symlinks quote_path get_windows_root read_file_contents write_file_contents read_file_contents_limit unix_crypt split_quoted_string write_to_http_cache check_in_http_cache clear_http_cache supports_javascript get_module_name get_module_variable clear_time_locale reset_time_locale callers_package web_libs_package get_userdb_string connect_userdb disconnect_userdb split_userdb_string uniquelc list_combined_webmin_menu list_modules_webmin_menu module_to_menu_item list_combined_system_info shell_is_bash compare_version_numbers convert_to_json convert_from_json print_json get_referer_relative get_webmin_email_url get_webmin_browser_url trim ui_link ui_help ui_img ui_link_button ui_table_start ui_table_end ui_table_row ui_table_hr ui_table_span ui_columns_start ui_columns_row ui_columns_header ui_checked_columns_row ui_radio_columns_row ui_columns_end ui_columns_table ui_form_columns_table ui_form_elements_wrapper ui_form_start ui_form_end ui_form_end_side_by_side ui_form_grouped_buttons ui_textbox ui_filebox ui_bytesbox ui_upload ui_password ui_hidden ui_select ui_multi_select ui_multi_select_javascript ui_radio ui_yesno_radio ui_radio_row ui_checkbox ui_oneradio ui_textarea ui_user_textbox ui_users_textbox ui_group_textbox ui_groups_textbox ui_opt_textbox ui_submit ui_reset ui_button ui_date_input ui_buttons_start ui_buttons_end ui_buttons_row ui_buttons_hr ui_post_header ui_pre_footer ui_print_header ui_print_unbuffered_header ui_print_footer ui_config_link ui_print_endpage ui_subheading ui_links_row ui_hidden_javascript ui_hidden_start ui_hidden_end ui_hidden_table_row_start ui_hidden_table_row_end ui_hidden_table_start ui_hidden_table_end ui_tabs_start ui_tabs_end ui_tabs_start_tab ui_tabs_start_tabletab ui_tabs_end_tab ui_tabs_end_tabletab ui_max_text_width ui_radio_selector ui_radio_selector_javascript ui_switch_theme_javascript ui_grid_table ui_radio_table ui_up_down_arrows ui_hr ui_nav_link ui_confirmation_form ui_text_color ui_alert_box js_disable_inputs ui_page_flipper ui_form_field_state_javascript js_checkbox_disable js_redirect ui_webmin_link ui_line_break_double ui_page_refresh ui_details ui_div_row ui_space ui_newline ui_text_wrap ui_element_inline ui_paginations ui_hide_outside_of_viewport ui_read_file_contents_limit ui_note ui_brh ui_tag_start ui_tag_content ui_tag_end ui_tag ui_alert ui_button_icon ui_link_icon ui_icon ui_br ui_p ui_div ui_text_mask get_python_cmd get_buffer_size get_buffer_size_binary get_webprefix get_sub_ref_name setvar getvar delvar print_call_stack webmin_user_can_rpc webmin_user_login_mode webmin_user_is_admin webmin_user_is get_current_theme_info_cached miniserv_using_default_cert is_int float is_float parse_accepted_language get_default_system_locale get_http_redirect get_http_cookie create_wrapper get_lock_links_dir generate_miniserv_websocket_token allocate_miniserv_websocket get_miniserv_websocket_url remove_miniserv_websocket cleanup_miniserv_websockets get_miniserv_websockets_modules get_webmin_base_url encrypt_phrase decrypt_phrase is_encrypt_phrase); +@EXPORT = qw(read_file read_file_cached read_file_cached_with_stat write_file html_escape html_unescape html_strip quote_escape quote_literal_escape quote_javascript default_webmin_temp_dir tempname_dir tempname_dir_sys webmin_temp_dir_name webmin_temp_dir_path tempname transname transname_timestamped trunc indexof indexoflc sysprint check_ipaddress check_ip6address is_non_public_ipaddress ipaddress_matches_network get_download_address_callback check_download_address generate_icon urlize un_urlize include copydata copydata_len ReadParseMime ReadParse read_fully read_parse_mime_callback read_parse_mime_javascript PrintHeader header get_html_title get_html_framed_title get_html_status_line popup_header footer popup_footer load_module_preferences load_theme_library redirect kill_byname kill_byname_logged find_byname error error_stderr popup_error register_error_handler call_error_handlers error_setup wait_for fast_wait_for has_command make_date make_date_relative file_chooser_button popup_window_button popup_window_link read_acl acl_filename acl_check get_miniserv_config_file get_miniserv_config put_miniserv_config restart_miniserv reload_miniserv check_os_support http_download complete_http_download http_post ftp_download ftp_upload no_proxy open_socket download_timeout ftp_command to_ipaddress to_ip6address to_hostname icons_table replace_meta replace_file_line read_file_lines flush_file_lines unflush_file_lines unix_user_input unix_group_input hlink user_chooser_button group_chooser_button foreign_check foreign_exists foreign_available foreign_require foreign_call foreign_config foreign_installed foreign_defined get_system_hostname get_webmin_version get_webmin_version_release get_webmin_full_version get_module_acl get_group_module_acl save_module_acl save_group_module_acl init_config load_language_auto load_language text_subs text encode_base64 decode_base64 encode_base32 decode_base32 get_module_info get_all_module_infos list_themes get_theme_info list_locales list_languages safe_language read_env_file write_env_file lock_file unlock_file test_lock unlock_all_files can_lock_file webmin_log additional_log var_dump webmin_debug_log system_logged backquote_logged backquote_with_timeout backquote_command kill_logged rename_logged rename_file symlink_logged symlink_file link_file make_dir make_dir_recursive set_ownership_permissions unlink_logged unlink_file copy_permissions_source_dest copy_source_dest move_source_dest remote_session_name verify_session_id remote_foreign_require remote_foreign_call remote_foreign_check remote_foreign_config remote_eval remote_write remote_read remote_finished remote_error_setup remote_rpc_call remote_multi_callback remote_multi_callback_error serialise_variable unserialise_variable other_groups date_chooser_button help_file read_help_file seed_random disk_usage_kb recursive_disk_usage help_search_link can_use_http_ssl make_http_connection validate_ssl_connection read_http_connection write_http_connection close_http_connection read_http_headers get_http_auth_reason clean_environment reset_environment clean_language progress_callback switch_to_remote_user switch_to_unix_user eval_as_unix_user create_user_config_dirs create_missing_homedir filter_javascript resolve_links simplify_path same_file flush_webmin_caches list_usermods available_usermods get_available_module_infos get_visible_module_infos get_visible_modules_categories is_under_directory parse_http_url check_clicks_function load_entities_map entities_to_ascii get_product_name get_charset get_display_hostname save_module_config save_user_module_config nice_size get_perl_path get_goto_module select_all_link select_invert_link select_rows_link check_pid_file get_mod_lib module_root_directory list_mime_types guess_mime_type open_tempfile close_tempfile print_tempfile is_selinux_enabled get_clear_file_attributes reset_file_attributes cleanup_tempnames open_lock_tempfile END month_to_number number_to_month supports_ipv6 execute_command execute_command_logged open_readfile open_execute_command translate_filename translate_command register_filename_callback register_command_callback capture_function_output capture_function_output_tempfile modules_chooser_button substitute_template substitute_pattern running_in_zone running_in_vserver running_in_xen running_in_openvz list_categories is_readonly_mode command_as_user list_osdn_mirrors convert_osdn_url get_current_dir supports_users supports_symlinks quote_path get_windows_root read_file_contents write_file_contents read_file_contents_limit unix_crypt split_quoted_string write_to_http_cache check_in_http_cache clear_http_cache supports_javascript get_module_name get_module_variable clear_time_locale reset_time_locale callers_package web_libs_package get_userdb_string connect_userdb disconnect_userdb split_userdb_string uniquelc list_combined_webmin_menu list_modules_webmin_menu module_to_menu_item list_combined_system_info shell_is_bash compare_version_numbers convert_to_json convert_from_json print_json get_referer_relative get_webmin_email_url get_webmin_browser_url trim ui_link ui_help ui_img ui_link_button ui_table_start ui_table_end ui_table_row ui_table_hr ui_table_span ui_columns_start ui_columns_row ui_columns_header ui_checked_columns_row ui_radio_columns_row ui_columns_end ui_columns_table ui_form_columns_table ui_form_elements_wrapper ui_form_start ui_form_end ui_form_end_side_by_side ui_form_grouped_buttons ui_textbox ui_filebox ui_bytesbox ui_upload ui_password ui_hidden ui_select ui_multi_select ui_multi_select_javascript ui_radio ui_yesno_radio ui_radio_row ui_checkbox ui_oneradio ui_textarea ui_user_textbox ui_users_textbox ui_group_textbox ui_groups_textbox ui_opt_textbox ui_submit ui_reset ui_button ui_date_input ui_buttons_start ui_buttons_end ui_buttons_row ui_buttons_hr ui_post_header ui_pre_footer ui_print_header ui_print_unbuffered_header ui_print_footer ui_config_link ui_print_endpage ui_subheading ui_links_row ui_hidden_javascript ui_hidden_start ui_hidden_end ui_hidden_table_row_start ui_hidden_table_row_end ui_hidden_table_start ui_hidden_table_end ui_tabs_start ui_tabs_end ui_tabs_start_tab ui_tabs_start_tabletab ui_tabs_end_tab ui_tabs_end_tabletab ui_max_text_width ui_radio_selector ui_radio_selector_javascript ui_switch_theme_javascript ui_grid_table ui_radio_table ui_up_down_arrows ui_hr ui_nav_link ui_confirmation_form ui_text_color ui_alert_box js_disable_inputs ui_page_flipper ui_form_field_state_javascript js_checkbox_disable js_redirect ui_webmin_link ui_line_break_double ui_page_refresh ui_details ui_div_row ui_space ui_newline ui_text_wrap ui_element_inline ui_paginations ui_hide_outside_of_viewport ui_read_file_contents_limit ui_note ui_brh ui_tag_start ui_tag_content ui_tag_end ui_tag ui_alert ui_button_icon ui_link_icon ui_icon ui_br ui_p ui_div ui_text_mask get_python_cmd get_buffer_size get_buffer_size_binary get_webprefix get_sub_ref_name setvar getvar delvar print_call_stack webmin_user_can_rpc webmin_user_login_mode webmin_user_is_admin webmin_user_is get_current_theme_info_cached miniserv_using_default_cert is_int float is_float parse_accepted_language get_default_system_locale get_http_redirect get_http_cookie create_wrapper get_lock_links_dir generate_miniserv_websocket_token allocate_miniserv_websocket get_miniserv_websocket_url remove_miniserv_websocket cleanup_miniserv_websockets get_miniserv_websockets_modules get_webmin_base_url encrypt_phrase decrypt_phrase is_encrypt_phrase ui_svg_icon ui_code ui_tip ui_page_assets ui_page_start ui_page_end ui_grid ui_stack ui_cluster ui_card ui_card_start ui_card_end ui_stat ui_stats ui_dl ui_badge ui_chip ui_list ui_feed ui_empty_state ui_progress ui_toggle ui_search ui_choice ui_radio_list ui_select_switch); # Add global variables in web-lib.pl push(@EXPORT, qw(&can_read_file_under_global_acl &global_acl_file_unix_user diff --git a/gray-theme/left.cgi b/gray-theme/left.cgi index 7e592a16e..8789cdb2a 100755 --- a/gray-theme/left.cgi +++ b/gray-theme/left.cgi @@ -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'} && diff --git a/gray-theme/theme.pl b/gray-theme/theme.pl index 90a0250a4..8b7cb7cc8 100755 --- a/gray-theme/theme.pl +++ b/gray-theme/theme.pl @@ -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 { $_ =~ /\n"; + (defined($width) ? " width=$width%" : ""). + ($sortable ? " data-sortable='1'" : "").">\n"; if ($title) { $rv .= " ". "$title". @@ -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) { diff --git a/gray-theme/unauthenticated/gray-theme.css b/gray-theme/unauthenticated/gray-theme.css index 81964e901..3d09f2313 100644 --- a/gray-theme/unauthenticated/gray-theme.css +++ b/gray-theme/unauthenticated/gray-theme.css @@ -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; } diff --git a/lang/en b/lang/en index 2247b5102..5431b6b8c 100644 --- a/lang/en +++ b/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 diff --git a/t/ui-lib-widgets.t b/t/ui-lib-widgets.t new file mode 100644 index 000000000..5bf19bfd0 --- /dev/null +++ b/t/ui-lib-widgets.t @@ -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 ]*\baria-label="A tip")(?=[^>]*\bdata-tooltip[\s>])[^>]*>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([ 'a' ], { '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', 'html' ], + { 'label' => 'Esc', 'value' => 'text' } ]); + like($html, qr/]*>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' => 'a', + 'body' => '

b

', 'footer' => 'f', + 'flush' => 1, 'state' => 'warn' }); + like($html, qr/ui_card_warning/, 'card state alias applied'); + like($html, qr/ui_card_actions">a<\/i>/, 'card actions are raw HTML'); + like($html, qr/ui_card_flush/, 'flush option applied'); + like($html, qr/
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 =~ /(]*>)/; + 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[^>]*>]*ui_progress_inside_value[^>]*>62% 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/]*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/]*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' => 'host', + 'fields' => [ [ $xss, 'user' ], + { 'label' => 'Port', 'html' => 'port' } ] }, + { '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/]*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">host<\/i>/, 'content is raw HTML'); + like($html, qr/]*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' => 'sel' } ]); + assert_no_handler_injection($list, 'ui_radio_list'); + is(scalar(() = $list =~ /type=.radio./g), 2, 'one radio per list option'); + like($list, qr/]*value=.shared.[^>]*\bchecked\b/, + 'radio list checks the given value'); + like($list, qr/ui_radio_list_content">sel<\/i>/, 'radio list content is raw HTML'); + + my $sw = main::ui_select_switch('dest', 'ftp', [ + { 'value' => 'local', 'label' => $xss, 'content' => 'file' }, + { 'value' => 'ftp', 'label' => 'FTP', 'desc' => $xss, + 'fields' => [ [ $xss, 'host' ] ] }, + { 'value' => 'none', 'label' => 'None' } ]); + assert_no_handler_injection($sw, 'ui_select_switch'); + like($sw, qr/]*data-ui-switch/, 'switch select carries the hook'); + my @panels = $sw =~ /(]*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">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 =~ /(]*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/
diff --git a/ui-demo/help/intro.html b/ui-demo/help/intro.html new file mode 100644 index 000000000..1f6783b30 --- /dev/null +++ b/ui-demo/help/intro.html @@ -0,0 +1,13 @@ +
UI Demo
+ +A read-only gallery of the widgets in ui-lib.pl, 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. + +

+This page is opened by the question mark at the top right of the module +title, which ui_print_header shows when its help argument names +a page in the module's help directory. The cog next to it opens +the module configuration, listed in config.info. + diff --git a/ui-demo/images/accordions.svg b/ui-demo/images/accordions.svg new file mode 100644 index 000000000..71431f907 --- /dev/null +++ b/ui-demo/images/accordions.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ui-demo/images/cards.svg b/ui-demo/images/cards.svg new file mode 100644 index 000000000..f521dbe42 --- /dev/null +++ b/ui-demo/images/cards.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui-demo/images/forms.svg b/ui-demo/images/forms.svg new file mode 100644 index 000000000..619de7da2 --- /dev/null +++ b/ui-demo/images/forms.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ui-demo/images/lists.svg b/ui-demo/images/lists.svg new file mode 100644 index 000000000..f577a56c6 --- /dev/null +++ b/ui-demo/images/lists.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ui-demo/images/menu.svg b/ui-demo/images/menu.svg new file mode 100644 index 000000000..4b849d4d7 --- /dev/null +++ b/ui-demo/images/menu.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui-demo/images/tables.svg b/ui-demo/images/tables.svg new file mode 100644 index 000000000..2bc211106 --- /dev/null +++ b/ui-demo/images/tables.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui-demo/index.cgi b/ui-demo/index.cgi new file mode 100755 index 000000000..dc05d58d9 --- /dev/null +++ b/ui-demo/index.cgi @@ -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
, 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
. 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 , 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())."
". + 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'}); diff --git a/ui-demo/lang/en b/ui-demo/lang/en new file mode 100644 index 000000000..8a7d8815a --- /dev/null +++ b/ui-demo/lang/en @@ -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 diff --git a/ui-demo/module.info b/ui-demo/module.info new file mode 100644 index 000000000..eca45203e --- /dev/null +++ b/ui-demo/module.info @@ -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 diff --git a/ui-demo/save_manual.cgi b/ui-demo/save_manual.cgi new file mode 100644 index 000000000..f3feef6db --- /dev/null +++ b/ui-demo/save_manual.cgi @@ -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) : "")); diff --git a/ui-demo/ui-demo-lib.pl b/ui-demo/ui-demo-lib.pl new file mode 100644 index 000000000..d454c49fa --- /dev/null +++ b/ui-demo/ui-demo-lib.pl @@ -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; diff --git a/ui-demo/ui-demo-pages.pl b/ui-demo/ui-demo-pages.pl new file mode 100644 index 000000000..1f5dc1dbc --- /dev/null +++ b/ui-demo/ui-demo-pages.pl @@ -0,0 +1,1237 @@ +# Builders for the UI demo pages. These functions use WebminCore helpers +# and the language, configuration and input hashes, and return HTML so +# the same pages can be rendered by the CGIs or with standalone test data. + +use strict; +use warnings; + +our (%config, %in, %text); + +# demo_gallery_page() +# Returns the widget gallery page, organized with the existing tabs. Each +# tab opens with a short description in the same block markup that +# vui_ui_block of Virtualmin produces, as the Virtualmin Podman module does. +sub demo_gallery_page +{ +my @tabs = ( [ 'cards', $text{'index_tab_cards'} ], + [ 'elements', $text{'index_tab_elements'} ], + [ 'alerts', $text{'index_tab_alerts'} ], + [ 'forms', $text{'index_tab_forms'} ], + [ 'choices', $text{'index_tab_choices'} ], + [ 'buttons', $text{'index_tab_buttons'} ], + [ 'accordions', $text{'index_tab_accordions'} ], + [ 'tables', $text{'index_tab_tables'} ], + [ 'lists', $text{'index_tab_lists'} ], + [ 'iconlinks', $text{'index_tab_iconlinks'} ], + [ 'editor', $text{'index_tab_editor'} ] ); +my %builders = ( 'cards' => \&demo_cards_tab, + 'elements' => \&demo_elements_tab, + 'alerts' => \&demo_alerts_tab, + 'forms' => \&demo_forms_tab, + 'choices' => \&demo_choices_tab, + 'buttons' => \&demo_buttons_tab, + 'accordions' => \&demo_accordions_tab, + 'tables' => \&demo_tables_tab, + 'lists' => \&demo_lists_tab, + 'iconlinks' => \&demo_iconlinks_tab, + 'editor' => \&demo_editor_tab ); +# The tab to open comes from the URL, else from the module config, whose +# default_tab option is set on the module configuration page +my $mode = $in{'mode'} || $config{'default_tab'} || 'cards'; +$mode = 'cards' if (!(grep { $_->[0] eq $mode } @tabs)); +my $rv = ""; +$rv .= ui_page_start({ + 'desc' => $text{'index_desc'}, + 'help' => 'https://webmin.com/docs/development/creating-modules', + 'help_title' => $text{'index_docs'}, + }); +$rv .= ui_tabs_start(\@tabs, 'mode', $mode, 1); +foreach my $tab (@tabs) { + $rv .= ui_tabs_start_tab('mode', $tab->[0]); + # The description, with the rest of it behind a ui_details tick when + # the lang file has an index__more string, as the Virtualmin SSL + # certificate page extends its tab descriptions + my $desc = html_escape($text{'index_'.$tab->[0].'_desc'}); + my $more = $text{'index_'.$tab->[0].'_more'}; + $rv .= ui_tag('div', + $more ? ui_details({ 'html' => 1, 'class' => 'inline', + 'title' => $desc, + 'content' => html_escape($more) }) + : ui_p($desc), + { 'class' => 'vui_ui_block' }); + $rv .= $builders{$tab->[0]}->(); + $rv .= ui_tabs_end_tab('mode', $tab->[0]); + } +$rv .= ui_tabs_end(1); +$rv .= ui_page_end(); +return $rv; +} + +# demo_cards_tab() +# Returns the cards and stat tiles panel of the gallery +sub demo_cards_tab +{ +my $rv = ""; + +# A service control card as a module would build it, a card with header +# actions and a footer, and a card with a state accent. +# +# The four buttons are plain ui_submit calls. Their colors and icons in +# Authentic (green Start with a play icon, red Stop, orange Restart, a +# switch icon for the boot option) are not chosen here. The theme looks +# the button label up in the lang table, finds the key or keys holding +# that exact text, and picks the style from the KEY NAME : keys +# containing start, restart, index_stop, index_reload, index_boot, +# delete, status and so on (see get_button_style in Authentic). Two +# rules follow, for as long as this theme behavior exists : +# - name button keys simply and stably, as index_start, index_stop, +# index_restart, index_reload and index_boot_off are here; +# - keep each button text unique in the lang file. If the same text, +# say "Restart", also sits under some other key, the lookup may find +# that key instead and the button gets no color or icon. +# +# The credentials +# row uses the existing ui_text_mask(text, tag) : the password is shown +# as dots and revealed while the enclosing tag (here a tt) is hovered, as +# the Virtualmin Podman module shows recipe logins. +my $service = ui_card({ + 'title' => $text{'index_service'}, + 'actions' => ui_badge($text{'index_running'}, 'success'), + 'body' => + ui_dl([ + [ $text{'index_boot'}, html_escape($text{'index_boot_on'}) ], + [ $text{'index_creds'}, + text('index_creds_userpass', ui_tag('tt', 'admin'), + ui_tag('tt', ui_text_mask('Y8u-2mP4-qL9x', 'tt'))) ], + ]). + ui_hr(). + ui_form_start('index.cgi', 'post'). + ui_cluster([ + ui_submit($text{'index_start'}, 'start'), + ui_submit($text{'index_stop'}, 'stop', 0, + "data-ui-confirm='". + quote_escape($text{'index_stop_confirm'})."'"), + ui_submit($text{'index_restart'}, 'restart'), + ui_submit($text{'index_boot_off'}, 'bootoff'), + ]). + ui_form_end(), + }); +$rv .= ui_grid([ + $service, + ui_card({ + 'title' => $text{'index_card_actions'}, + 'desc' => $text{'index_card_desc'}, + 'actions' => [ + ui_badge($text{'index_syncing'}, 'info'), + ui_link('index.cgi', html_escape($text{'index_manage'})), + ], + 'body' => html_escape($text{'index_card_actions_body'}), + 'footer' => html_escape($text{'index_card_footer'}), + }), + ui_card({ + 'title' => $text{'index_card_state'}, + 'state' => 'warning', + 'body' => html_escape($text{'index_card_state_body'}), + }), + ]); + +# Stat tiles +$rv .= ui_card({ + 'title' => $text{'index_stats'}, + 'desc' => $text{'index_stats_desc'}, + 'body' => ui_stats([ + { 'value' => 128, 'label' => $text{'index_stat_units'}, + 'desc' => $text{'index_stat_units_desc'}, + 'href' => 'index.cgi' }, + { 'value' => 3, 'label' => $text{'index_stat_failed'}, + 'desc' => $text{'index_stat_failed_desc'}, + 'state' => 'danger' }, + { 'value' => '42 d', 'label' => $text{'index_stat_uptime'}, + 'desc' => $text{'index_stat_uptime_desc'}, + 'state' => 'success' }, + { 'value' => "94%", 'label' => $text{'index_stat_disk'}, + 'desc' => $text{'index_stat_disk_desc'}, + 'state' => 'warning' }, + ]), + }); +# A narrow card next to a wide one, laid out with the template option +# of ui_grid. The left card is a description list flowed into two +# columns; the right one is flush, holds a list, and its header action +# is a search box that filters that list client-side, aimed at the +# card through its id +$rv .= ui_grid([ + ui_card({ + 'title' => $text{'index_k_system'}, + 'body' => ui_dl([ + [ $text{'index_k_hostname'}, ui_tag('tt', 'web01.example.com') ], + [ $text{'index_k_os'}, 'AlmaLinux 10.2' ], + [ $text{'index_k_kernel'}, ui_tag('tt', '6.12.0-211.49.1') ], + [ $text{'index_k_uptime'}, '42 '.$text{'index_l_days_plain'} ], + [ $text{'index_k_load'}, '0.42 0.38 0.35' ], + [ $text{'index_k_memory'}, '5.1 GB of 16 GB' ], + ], { 'cols' => 2 }), + }), + ui_card({ + 'id' => 'demo_services', + 'title' => $text{'index_k_services'}, + 'actions' => ui_search({ 'name' => 'svc', 'width' => '12em', + 'filter' => '#demo_services' }), + 'flush' => 1, + 'body' => ui_list([ + { 'title' => 'httpd', 'desc' => 'The Apache HTTP Server', + 'badge' => [ $text{'index_running'}, 'success' ] }, + { 'title' => 'mariadb', 'desc' => 'MariaDB database server', + 'badge' => [ $text{'index_running'}, 'success' ] }, + { 'title' => 'postfix', 'desc' => 'Postfix Mail Transport Agent', + 'badge' => [ $text{'index_stopped'}, 'danger' ] }, + { 'title' => 'sshd', 'desc' => 'OpenSSH server daemon', + 'badge' => [ $text{'index_running'}, 'success' ] }, + ], { 'flush' => 1 }), + }), + ], { 'template' => 'minmax(280px, 1fr) minmax(0, 2fr)' }); + +# Small state cards : the accent color says what kind of news it is, the +# title is built with title_html to carry an icon, and the footer holds +# the next step +$rv .= ui_grid([ + ui_card({ + 'state' => 'success', + 'title_html' => ui_svg_icon('check-circle')." ". + html_escape($text{'index_k_ok'}), + 'body' => html_escape($text{'index_k_ok_body'}), + }), + ui_card({ + 'state' => 'info', + 'title_html' => ui_svg_icon('download')." ". + html_escape($text{'index_k_updates'}), + 'body' => html_escape($text{'index_k_updates_body'}), + 'footer' => ui_link_button('index.cgi', $text{'index_k_install'}), + }), + ui_card({ + 'state' => 'danger', + 'title_html' => ui_svg_icon('x-circle')." ". + html_escape($text{'index_k_backup'}), + 'body' => html_escape($text{'index_k_backup_body'}), + 'footer' => ui_link('index.cgi', html_escape($text{'index_l_log'})), + }), + ]); + +# A card with a standard table inside, keeping the body padding so the +# table's own border stays clear of the card's; a metric card mixing a +# stat tile with inline progress bars; and a card printed progressively +# with ui_card_start and ui_card_end, as a page streaming command output +# would do, holding a code block and ending with a badge in its footer +$rv .= ui_grid([ + ui_card({ + 'title' => $text{'index_k_logins'}, + 'body' => ui_columns_table( + [ $text{'index_k_user'}, $text{'index_k_from'}, + $text{'index_k_when'} ], 100, + [ [ 'root', '10.211.55.2', '2 '.$text{'index_feed_hours'} ], + [ 'deploy', '192.0.2.10', '5 '.$text{'index_feed_hours'} ], + [ 'root', '10.211.55.2', $text{'index_k_yesterday'} ] ]), + }), + ui_card({ + 'title' => $text{'index_k_storage'}, + 'body' => ui_stack([ + ui_stat({ 'value' => '63%', 'label' => $text{'index_disk'}, + 'desc' => '63 GB of 100 GB', 'state' => 'warning' }), + ui_progress(91, { 'label' => '/home', 'inline' => 1, + 'thresholds' => 1 }), + ui_progress(12, { 'label' => '/var/log', 'inline' => 1, + 'thresholds' => 1 }), + ], { 'gap' => '10px' }), + 'footer' => ui_link('index.cgi', html_escape($text{'index_k_details'})), + }), + ui_card_start({ 'title' => $text{'index_k_output'}, + 'desc' => $text{'index_k_output_desc'} }). + ui_tag('pre', html_escape( + "Sep 05 08:12:01 web01 systemd[1]: Starting httpd...\n". + "Sep 05 08:12:02 web01 httpd[2183]: listening on port 443, port 80\n". + "Sep 05 08:12:02 web01 systemd[1]: Started The Apache HTTP Server."), + { 'class' => 'ui_code_block' }). + ui_card_end(ui_badge($text{'index_k_done'}, 'success')), + ]); + +return $rv; +} + +# demo_elements_tab() +# Returns the description list, stat tiles, feed, empty state, badges, +# tooltips, progress bars and icons panel. Cards are paired so that a +# short one sits next to a short one. +sub demo_elements_tab +{ +my $rv = ""; + +# A description list with help bubbles and HTML values, and stat tiles +# with icons and links +$rv .= ui_grid([ + ui_card({ + 'title' => $text{'index_e_dl'}, + 'desc' => $text{'index_e_dl_desc'}, + 'body' => ui_dl([ + # Array rows : label, HTML value, optional help text + [ $text{'index_e_dl_status'}, + ui_badge($text{'index_running'}, 'success'), + $text{'index_e_dl_status_help'} ], + [ $text{'index_e_dl_version'}, ui_code('2.660') ], + # A link inside a value is built with ui_tag, not + # ui_link : Authentic turns every ui_link into a button + [ $text{'index_e_dl_config'}, + ui_code('/etc/webmin/miniserv.conf')." ". + ui_tag('a', html_escape($text{'index_edit'}), + { 'href' => 'index.cgi' }), + $text{'index_e_dl_config_help'} ], + # A hash row escapes its value for you + { 'label' => $text{'index_e_dl_started'}, + 'value' => '2026-09-05 08:12 ', + 'help' => $text{'index_e_dl_started_help'} }, + ], { 'wide' => '35%' }), + }), + ui_card({ + 'title' => $text{'index_e_istats'}, + 'desc' => $text{'index_e_istats_desc'}, + 'body' => ui_stats([ + { 'value' => 12, 'label' => $text{'index_e_domains'}, + 'icon' => 'globe', 'href' => 'index.cgi' }, + { 'value' => 48, 'label' => $text{'index_e_users'}, + 'icon' => 'user', 'href' => 'index.cgi' }, + { 'value' => 131, 'label' => $text{'index_e_mail'}, + 'icon' => 'server' }, + { 'value' => 2, 'label' => $text{'index_e_alerts'}, + 'icon' => 'warning', 'state' => 'danger', + 'href' => 'index.cgi' }, + ], { 'min' => '120px' }), + }), + ]); + +# Activity feed, with one event built from HTML, and the empty state +$rv .= ui_grid([ + ui_card({ + 'title' => $text{'index_feed'}, + 'actions' => ui_link('index.cgi', html_escape($text{'index_allevents'})), + 'body' => ui_feed([ + { 'when' => $text{'index_feed_now'}, + 'text' => 'Service started (v0.1.4)', + 'state' => 'success' }, + { 'when' => '5 '.$text{'index_feed_minutes'}, + 'text' => 'Service stopped' }, + { 'when' => '2 '.$text{'index_feed_hours'}, + # A text link in the event, so ui_tag rather than + # ui_link, which Authentic would render as a button + 'text_html' => text('index_feed_backup', ui_code('/home')). + " ".ui_tag('a', html_escape($text{'index_l_log'}), + { 'href' => 'index.cgi' }), + 'state' => 'info' }, + { 'when' => '14 '.$text{'index_feed_hours'}, + 'text' => '3 failed login attempts blocked', + 'state' => 'warning' }, + ]), + }), + ui_card({ + 'title' => $text{'index_empty'}, + 'body' => ui_empty_state({ + 'icon' => 'search', + 'title' => $text{'index_empty_title'}, + 'desc' => $text{'index_empty_desc'}, + 'actions' => ui_link_button('index.cgi', + $text{'index_empty_action'}), + }), + }), + ]); + +# Badges, chips, tooltips and inline elements next to the ring gauges +$rv .= ui_grid([ + ui_card({ + 'title' => $text{'index_badges'}, + 'body' => + ui_stack([ + # One badge per state, the default icon of each state, + # and the neutral one with no icon at all + ui_cluster([ + ui_badge($text{'index_running'}, 'success'), + ui_badge($text{'index_pending'}, 'warning'), + ui_badge($text{'index_stopped'}, 'danger'), + ui_badge($text{'index_syncing'}, 'info'), + ui_badge($text{'index_off'}, 'neutral', + { 'icon' => '' }), + ]), + # A dot instead of an icon, an icon of your own, a + # tooltip on hover, and chips for tags + ui_cluster([ + ui_badge($text{'index_running'}, 'success', + { 'dot' => 1 }), + ui_badge($text{'index_stopped'}, 'danger', + { 'dot' => 1 }), + ui_badge($text{'index_badge_clock'}, 'info', + { 'icon' => 'clock' }), + ui_badge($text{'index_badge_tip'}, 'neutral', + { 'icon' => 'question-circle', + 'title' => $text{'index_badge_tip_text'} }), + ui_chip('journal'), + ui_chip('ipv6'), + ]), + # Inline code, a note, the existing help bubble, and + # ui_tip, which gives any HTML the same theme tooltip + ui_cluster([ + ui_code('/etc/webmin/miniserv.conf'), + ui_note($text{'index_note'}, 0), + html_escape($text{'index_help_demo'})." ". + ui_help($text{'index_help_tip'}), + ui_tip(ui_tag('u', html_escape($text{'index_tip_text'})), + $text{'index_tip'}), + ]), + ], { 'gap' => '14px' }), + }), + ui_card({ + 'title' => $text{'index_p_rings'}, + 'desc' => $text{'index_p_rings_desc'}, + 'body' => ui_cluster([ + ui_progress(34, { 'label' => $text{'index_disk'}, + 'ring' => 1, 'thresholds' => 1 }), + ui_progress(78, { 'label' => $text{'index_memory'}, + 'ring' => 1, 'thresholds' => 1 }), + ui_progress(96, { 'label' => $text{'index_cpu'}, + 'ring' => 1, 'thresholds' => 1 }), + ui_progress(3.4, { 'label' => $text{'index_p_quota'}, + 'ring' => 1, 'max' => 10, 'size' => 72, + 'value' => '3.4G' }), + ui_progress(0, { 'label' => $text{'index_p_working'}, + 'ring' => 1, 'indeterminate' => 1 }), + ], { 'gap' => '24px' }), + }), + ]); + +# Progress bars, the plain ones and the variations +$rv .= ui_grid([ + ui_card({ + 'title' => $text{'index_progress'}, + 'body' => ui_stack([ + # A bar with its own max and value text, bars whose + # color follows the value through thresholds, and + # bars with the value marker on the bar itself + ui_progress(3.4, { 'label' => $text{'index_disk'}, + 'max' => 10, + 'value' => $text{'index_p_disk_value'} }), + ui_progress(45, { 'label' => $text{'index_memory'}, + 'thresholds' => 1 }), + ui_progress(96, { 'label' => $text{'index_cpu'}, + 'thresholds' => [ 60, 85 ] }), + ui_progress(62, { 'label' => $text{'index_p_inside'}, + 'inside' => 1, 'state' => 'success' }), + ui_progress(34, { 'label' => $text{'index_disk'}, + 'inside' => 1 }), + ui_progress(96, { 'label' => $text{'index_cpu'}, + 'inside' => 1, 'thresholds' => 1 }), + ui_progress(52, { 'small' => 1 }), + ], { 'gap' => '14px' }), + }), + ui_card({ + 'title' => $text{'index_p_more'}, + 'body' => ui_stack([ + ui_progress(0, { + 'label' => $text{'index_p_segments'}, + 'segments' => [ + { 'pct' => 46, 'state' => 'info', + 'label' => $text{'index_p_used'} }, + { 'pct' => 22, 'state' => 'warning', + 'label' => $text{'index_p_cache'} }, + { 'pct' => 9, 'state' => 'danger', + 'label' => $text{'index_p_reserved'} }, + ] }), + ui_progress(0, { 'label' => $text{'index_p_busy'}, + 'indeterminate' => 1 }), + ui_stack([ + ui_progress(18, { 'label' => $text{'index_cpu'}, + 'inline' => 1, 'thresholds' => 1 }), + ui_progress(71, { 'label' => $text{'index_memory'}, + 'inline' => 1, 'thresholds' => 1 }), + ui_progress(93, { 'label' => $text{'index_disk'}, + 'inline' => 1, 'thresholds' => 1 }), + ], { 'gap' => '6px' }), + ], { 'gap' => '18px' }), + }), + ]); + +# Icon set +my @icons = ( 'check', 'check-circle', 'x', 'x-circle', 'info-circle', + 'question-circle', 'warning', 'search', 'external', + 'chevron-down', 'chevron-up', 'chevron-right', 'chevron-left', + 'arrow-right', 'plus', 'minus', 'refresh', 'play', 'stop', + 'power', 'clock', 'shield', 'server', 'gear', 'user', 'trash', + 'download', 'upload', 'edit', 'terminal', 'filter', 'book', + 'globe', 'dot' ); +$rv .= ui_card({ + 'title' => $text{'index_icons'}, + 'desc' => $text{'index_icons_desc'}, + 'body' => ui_cluster( + [ map { ui_tip(ui_svg_icon($_, { 'size' => 18 }), $_) } + @icons ], + { 'gap' => '14px' }), + }); +return $rv; +} + +# demo_forms_tab() +# Returns the form panel of the gallery. The form is the existing +# ui_table_start / ui_table_row layout with the existing controls, and +# shows the new toggle switch among them. +sub demo_forms_tab +{ +my $rv = ""; +$rv .= ui_form_start('index.cgi', 'post'); +$rv .= ui_hidden('demo', 1); +$rv .= ui_table_start($text{'index_f_title'}, 'width=100%', 2); +$rv .= ui_table_row($text{'index_f_name'}, + ui_textbox('name', 'web01', 30)." ".ui_help($text{'index_f_name_help'})); +# Password fields. The strength meter, the show/hide eye and the generate +# key button seen next to them are an Authentic feature, not part of the +# core library. The theme only turns them on for a list of module pages +# it knows, or when a password input on the page carries a data-password +# attribute : then every password input gets the meter and the buttons, +# except one marked data-password-again, which is treated as the repeat +# field and only gets the eye. Other themes show plain password inputs. +# The theme measures the field at page load to size the meter. A field +# in a hidden tab pane measures 0 then, so themes that do not account +# for hidden inputs can collapse it to zero width. +$rv .= ui_table_row($text{'index_f_pass'}, + ui_password('pass', '', 30, 0, undef, 'data-password')); +$rv .= ui_table_row($text{'index_f_pass2'}, + ui_password('pass2', '', 30, 0, undef, 'data-password-again')); +$rv .= ui_table_row($text{'index_f_port'}, + ui_textbox('port', 10000, 8)." ".ui_help($text{'index_f_port_help'})); +$rv .= ui_table_row($text{'index_f_proto'}, + ui_select('proto', 'https', + [ [ 'http', 'HTTP' ], [ 'https', 'HTTPS' ], + [ 'both', $text{'index_f_both'} ] ])); +$rv .= ui_table_row($text{'index_f_log'}, + ui_radio('log', 'errors', + [ [ 'all', $text{'index_f_log_all'} ], + [ 'errors', $text{'index_f_log_err'} ], + [ 'none', $text{'index_f_log_none'} ] ])); +$rv .= ui_table_row($text{'index_f_feat'}, + ui_checkbox('ssl', 1, $text{'index_f_ssl'}, 1)."
". + ui_checkbox('compress', 1, $text{'index_f_compress'}, 0)); +$rv .= ui_table_row($text{'index_f_boot'}, + ui_toggle({ 'name' => 'boot', 'checked' => 1, + 'label' => $text{'index_f_boot_label'} })); +$rv .= ui_table_row($text{'index_f_active'}, ui_yesno_radio('active', 1)); +$rv .= ui_table_row($text{'index_f_limit'}, + ui_opt_textbox('limit', '', 6, $text{'index_f_unlimited'}, + $text{'index_f_limit_to'})); +$rv .= ui_table_row($text{'index_f_quota'}, ui_bytesbox('quota', 1073741824)); + +# Date selection as in the Webmin Actions Log module : quick choices, or +# a range of two date inputs each with the popup calendar chooser +$rv .= ui_table_row($text{'index_f_dates'}, + ui_radio('tall', 2, + [ [ 1, $text{'index_f_dates_all'}."
" ], + [ 2, $text{'index_f_today'}."
" ], + [ 3, $text{'index_f_yesterday'}."
" ], + [ 4, $text{'index_f_week'}."
" ], + [ 0, "". + text('index_f_between', demo_date_input('from'), + demo_date_input('to'))."" ] ])); +$rv .= ui_table_row($text{'index_f_notes'}, + ui_textarea('notes', "# ".$text{'index_f_notes_value'}, 4, 60)); +$rv .= ui_table_end(); +$rv .= ui_form_end([ [ 'save', $text{'save'} ], + [ 'cancel', $text{'index_f_cancel'} ], + [ 'delete', $text{'delete'}, undef, 0, + "data-ui-confirm='". + quote_escape($text{'index_f_delete_confirm'})."'" ] ]); + +# Choosers : each is the plain textbox followed by a button that opens the +# matching popup chooser and fills the field in. file_chooser_button +# picks a file, or a directory when its type argument is 1; ui_filebox is +# the shorthand for the same pair when the form is the first on the +# page. ui_user_textbox and ui_group_textbox pick one Unix user or group, +# ui_users_textbox and ui_groups_textbox several, separated by spaces. +# The popups find their field by the index of the form in the page, so +# it is passed here : the service card form is 0, the edit form above is +# 1, and this one is 2. The first label carries a help link built with +# hlink, which opens help/file.html from the module's help directory. +my $formno = 2; +$rv .= ui_form_start('index.cgi', 'post'); +$rv .= ui_hidden('demo', 6); +$rv .= ui_table_start($text{'index_f_pickers'}, 'width=100%', 2); +$rv .= ui_table_row(hlink($text{'index_f_file'}, 'file'), + ui_textbox('cfg', '/etc/example/service.conf', 40)." ". + file_chooser_button('cfg', 0, $formno)); +$rv .= ui_table_row($text{'index_f_dir'}, + ui_textbox('logdir', '/var/log', 40)." ". + file_chooser_button('logdir', 1, $formno)); +$rv .= ui_table_row($text{'index_f_owner'}, + ui_user_textbox('owner', 'root', $formno)); +$rv .= ui_table_row($text{'index_f_group'}, + ui_group_textbox('grp', 'wheel', $formno)); +$rv .= ui_table_row($text{'index_f_members'}, + ui_users_textbox('members', 'root nobody', $formno)); +$rv .= ui_table_row($text{'index_f_groups'}, + ui_groups_textbox('grps', 'wheel adm', $formno)); +$rv .= ui_table_end(); +$rv .= ui_form_end([ [ undef, $text{'save'} ] ]); + +# The new controls on their own : a search box with its own placeholder, +# and a toggle in each state, each labelled with the setting it switches +$rv .= ui_card({ + 'title' => $text{'index_f_stacked'}, + 'desc' => $text{'index_f_stacked_desc'}, + 'body' => ui_cluster([ + ui_search({ 'name' => 'q', 'width' => '18em', + 'placeholder' => $text{'index_f_search_ph'} }), + ui_toggle({ 'name' => 'alerts', + 'label' => $text{'index_f_alerts'} }), + ui_toggle({ 'name' => 'autoupdate', 'checked' => 1, + 'label' => $text{'index_f_autoupdate'} }), + ], { 'gap' => '20px' }), + }); +return $rv; +} + +# demo_date_input(name) +# Returns day, month and year inputs followed by the calendar popup button, +# exactly as the Webmin Actions Log module builds its date fields +sub demo_date_input +{ +my ($name) = @_; +return ui_date_input(undef, undef, undef, + $name."_d", $name."_m", $name."_y"). + date_chooser_button($name."_d", $name."_m", $name."_y"); +} + +# demo_choices_tab() +# Returns the choice widgets panel : three ways to let the user pick one +# option that needs its own inputs, all replacing ui_radio_table and the +# hand-built tables of radios and fields of pages like the backup +# destination selector of the Backup Configuration Files module or the +# address selectors of Virtualmin's "Change IP Address" page : +# - ui_choice : a boxed list where every option shows its inputs and +# fields all the time, each row wrapping on narrow screens instead of +# squeezing a five-column table; +# - ui_select_switch : a select and, under it, only the block of the +# chosen option, switched by ui-lib.js, for many options or long field +# lists; +# - ui_radio_list : the compact list of radios with at most one input +# each, buttons at the left edge and close together in one box. +# Each option is a hash : value, label, optional content shown after the +# label (or first in the block for the select), optional desc, and +# optional fields laid out in as many columns as fit. +sub demo_choices_tab +{ +my $rv = ""; +$rv .= ui_form_start('index.cgi', 'post'); +$rv .= ui_hidden('mode', 'choices'); + +# The backup destination options, built for a given field name prefix so +# that the two widgets below can show the same set without clashing. The +# file chooser needs the index of this form on the page : the service +# card form is 0, the edit and choosers forms of the Forms tab are 1 and +# 2, so this one is 3. +my $formno = 3; +my $dest_options = sub { + my ($p) = @_; + return [ + { 'value' => 'local', 'label' => $text{'index_c_local'}, + 'content' => ui_textbox($p.'local_file', '', 45, 0, undef, + "placeholder='/backups/configs-%y-%m-%d.tar.gz'"). + " ".file_chooser_button($p.'local_file', 0, $formno) }, + ( map { { + 'value' => $_->[0], 'label' => $_->[1], + 'content' => ui_textbox($p.$_->[0].'_server', '', 20), + 'fields' => [ + [ $text{'index_c_path'}, + ui_textbox($p.$_->[0].'_path', '', 25) ], + [ $text{'index_c_login'}, + ui_textbox($p.$_->[0].'_user', 'root', 15) ], + [ $text{'index_c_pass'}, + ui_password($p.$_->[0].'_pass', '', 15) ], + [ $text{'index_c_port'}, + ui_opt_textbox($p.$_->[0].'_port', '', 5, $text{'default'}) ], + ] } } ( [ 'ftp', $text{'index_c_ftp'} ], + [ 'ssh', $text{'index_c_ssh'} ] ) ), + { 'value' => 'download', 'label' => $text{'index_c_download'}, + 'desc' => $text{'index_c_download_desc'} }, + ]; + }; +$rv .= ui_table_start($text{'index_c_backup'}, 'width=100%', 2); +$rv .= ui_table_row($text{'index_c_dest'}, + ui_choice('dest', 'ftp', $dest_options->('')), 2); +$rv .= ui_table_row($text{'index_c_dest_select'}, + ui_select_switch('dest2', 'ftp', $dest_options->('s_')), 2); +$rv .= ui_table_end(); + +# The address selectors, one ui_radio_table each in Virtualmin +$rv .= ui_table_start($text{'index_c_ip'}, 'width=100%', 2); +foreach my $v ( [ 'mode4', 'index_c_ip4', '10.211.55.20' ], + [ 'mode6', 'index_c_ip6', 'fdb2:2c26:f4e4:0:21c:42ff:fea2:f273' ] ) { + my ($name, $key, $addr) = @$v; + $rv .= ui_table_row($text{$key}, ui_radio_list($name, 'shared', [ + { 'value' => 'none', 'label' => $text{'index_c_none'} }, + { 'value' => 'shared', 'label' => $text{'index_c_shared'}, + 'content' => ui_select($name.'_shared', $addr, + [ [ $addr, text('index_c_shared_of', $addr) ] ]) }, + { 'value' => 'dedicated', 'label' => $text{'index_c_dedicated'}, + 'content' => ui_textbox($name.'_ip', '', 24)." ". + ui_checkbox($name.'_active', 1, + $text{'index_c_active'}, 0) }, + ])); + } +$rv .= ui_table_end(); +$rv .= ui_form_end([ [ undef, $text{'save'} ] ]); +return $rv; +} + +# demo_buttons_tab() +# Returns the button examples : every single button function, the row of +# ui_form_end, the responsive groups of ui_form_grouped_buttons, the +# left and right split of ui_form_end_side_by_side, and the confirmation +# page a delete link should lead to. +# +# The buttons under a page (the "Return to ..." ones) are not built here +# at all : they come from the pairs of URL and text given to +# ui_print_footer, most specific first, which every theme draws as its +# footer buttons. edit_manual.cgi shows a page with two of them. +sub demo_buttons_tab +{ +my $rv = ""; +my $confirm = "data-ui-confirm='". + quote_escape($text{'index_f_delete_confirm'})."'"; + +# Submit, reset and link buttons in one row. ui_submit sends the form, and gets +# its color and icon in Authentic from the lang key of its label (see +# the Cards tab) : "save" is green here and "delete" red. The third +# argument disables a button. ui_reset restores the form's initial values, and +# ui_link_button is a link drawn as a button. ui_button, a button that +# does only what its onclick says, is left out here. The confirm attribute +# is handled by ui-lib.js. +$rv .= ui_card({ + 'title' => $text{'index_b_single'}, + 'desc' => $text{'index_b_single_desc'}, + 'body' => ui_form_start('index.cgi', 'post'). + ui_hidden('demo', 4). + ui_cluster([ + ui_submit($text{'save'}), + ui_submit($text{'delete'}, 'delete', 0, $confirm), + ui_submit($text{'index_b_disabled'}, 'noop', 1), + ui_reset($text{'index_b_reset'}), + ui_link_button('index.cgi?mode=cards', + $text{'index_b_cards'}), + ]). + ui_form_end(), + }); + +# The usual end of an edit form. ui_form_end takes one array per button : +# name, label, HTML after the button, disabled flag, extra attributes. +# A button without a name is the default action. +$rv .= ui_form_start('index.cgi', 'post'); +$rv .= ui_hidden('demo', 5); +$rv .= ui_table_start($text{'index_b_end'}, 'width=100%', 2); +$rv .= ui_table_row($text{'index_f_name'}, ui_textbox('name3', 'web02', 30)); +$rv .= ui_table_end(); +$rv .= ui_form_end([ + [ undef, $text{'save'} ], + [ 'apply', $text{'index_b_saveapply'} ], + [ 'delete', $text{'delete'}, undef, 0, $confirm ], + ]); + +# Responsive button groups : related actions stay together, destructive +# ones are kept apart, and a button that submits elsewhere is bound to +# its own hidden form through the form attribute +$rv .= ui_form_start('index.cgi', 'post'); +$rv .= ui_hidden('demo', 2); +$rv .= ui_table_start($text{'index_f_grouped'}, 'width=100%', 2); +$rv .= ui_table_row($text{'index_f_name'}, ui_textbox('name2', 'web01', 30)); +$rv .= ui_table_row($text{'index_f_boot'}, + ui_toggle({ 'name' => 'boot4', 'checked' => 1, + 'label' => $text{'index_f_boot_label'} })); +$rv .= ui_table_end(); +my @save_actions = ( [ undef, $text{'save'} ] ); +my @lifecycle_actions = ( [ 'stop', $text{'index_stop'} ], + [ 'restart', $text{'index_restart'} ] ); +my @delete_actions = ( [ 'delete', $text{'delete'}, undef, 0, $confirm ] ); +my @inspect_actions = ( [ undef, $text{'index_f_logs'}, undef, undef, + "form='demo_logs_form'" ] ); +$rv .= ui_form_grouped_buttons([ + [ \@save_actions, \@lifecycle_actions, \@delete_actions ], + \@inspect_actions, + ]); +$rv .= ui_form_end(); +$rv .= ui_form_start('index.cgi', undef, undef, + "id='demo_logs_form' style='display:none;'"). + ui_hidden('demo', 3). + ui_form_end(); +# A note under buttons goes in its own div with a little room above it +$rv .= ui_tag('div', ui_note($text{'index_f_grouped_desc'}), + { 'style' => 'margin-top: 6px' }); + +# Save at the left and Delete at the right, as Virtualmin's edit pages +# end. The form gets an id, ui_form_end_side_by_side closes it, and the +# left buttons still submit it through the form attribute. The right +# side is raw HTML, here a small form of its own, since a form cannot +# be nested in another one. +$rv .= ui_form_start('index.cgi', 'post', undef, "id='demo_side_form'"); +$rv .= ui_hidden('demo', 6); +$rv .= ui_table_start($text{'index_b_sides'}, 'width=100%', 2); +$rv .= ui_table_row($text{'index_f_name'}, ui_textbox('name4', 'web03', 30)); +$rv .= ui_table_end(); +$rv .= ui_form_end_side_by_side('demo_side_form', + [ [ undef, $text{'save'} ], [ 'apply', $text{'index_b_saveapply'} ] ], + [ ui_form_start('index.cgi', 'post'). + ui_hidden('demo', 7). + ui_submit($text{'delete'}, 'delete', 0, $confirm). + ui_form_end() ]); + +# The page a delete link leads to before anything is removed, as +# delete_user.cgi of Users and Groups shows. ui_confirmation_form returns +# the message, the red warning, any extra inputs, the hidden fields and +# the buttons, centered, and submits to the CGI that does the work. The +# lang key of the button holds "delete", so Authentic paints it red +# with its icon, as it does for every key containing that word. +$rv .= ui_card({ + 'title' => $text{'index_b_confirm'}, + 'desc' => $text{'index_b_confirm_desc'}, + 'body' => ui_confirmation_form('index.cgi', + text('index_b_confirm_msg', ui_tag('tt', 'web01')), + [ [ 'demo', 8 ], [ 'name', 'web01' ] ], + [ [ 'confirm', $text{'index_b_delete'} ] ], + ui_checkbox('purge', 1, $text{'index_b_confirm_purge'}, 0), + $text{'index_b_confirm_warn'}), + }); +return $rv; +} + +# demo_accordions_tab() +# Returns a settings form in the style of the grub2 module : a first +# table of common settings, followed by collapsible sections for the +# rest, all in one form +sub demo_accordions_tab +{ +my $rv = ""; +$rv .= ui_form_start('index.cgi', 'post'); +$rv .= ui_hidden('demo', 4); + +# Common settings, always visible +$rv .= ui_table_start($text{'index_a_general'}, 'width=100%', 2); +$rv .= ui_table_row($text{'index_a_hostname'}, ui_textbox('hostname', 'web01.example.com', 40)); +$rv .= ui_table_row($text{'index_a_enabled'}, ui_yesno_radio('enabled', 1)); +$rv .= ui_table_row($text{'index_a_desc'}, ui_textbox('description', '', 60)); +$rv .= ui_table_end(); + +# Less common settings in collapsible sections. The fifth argument opens +# a section : the first two start open, the last one collapsed +$rv .= ui_hidden_table_start($text{'index_a_network'}, 'width=100%', 2, 'network', 1); +$rv .= ui_table_row($text{'index_a_address'}, + ui_opt_textbox('address', '', 20, $text{'index_a_dhcp'}, $text{'index_a_static'})); +$rv .= ui_table_row($text{'index_a_gateway'}, ui_textbox('gateway', '', 20)); +$rv .= ui_table_row($text{'index_a_dns'}, ui_textarea('dns', "", 3, 40)); +$rv .= ui_hidden_table_end('network'); + +$rv .= ui_hidden_table_start($text{'index_a_limits'}, 'width=100%', 2, 'limits', 1); +$rv .= ui_table_row($text{'index_a_memory'}, ui_bytesbox('memory', 2147483648)); +$rv .= ui_table_row($text{'index_a_cpus'}, + ui_opt_textbox('cpus', '', 6, $text{'index_a_nolimit'}, $text{'index_a_limit_to'})); +$rv .= ui_table_row($text{'index_a_pids'}, + ui_opt_textbox('pids', '', 6, $text{'index_a_nolimit'}, $text{'index_a_limit_to'})); +$rv .= ui_hidden_table_end('limits'); + +$rv .= ui_hidden_table_start($text{'index_a_security'}, 'width=100%', 2, 'security', 0); +$rv .= ui_table_row($text{'index_a_readonly'}, + ui_toggle({ 'name' => 'readonly', 'label' => $text{'index_a_readonly_label'} })); +$rv .= ui_table_row($text{'index_a_privileged'}, ui_yesno_radio('privileged', 0)); +$rv .= ui_table_row($text{'index_a_caps'}, ui_textbox('caps', '', 40)); +$rv .= ui_hidden_table_end('security'); + +$rv .= ui_form_end([ [ undef, $text{'save'} ] ]); +return $rv; +} + +# demo_tables_tab() +# Returns the tables panel : a sortable table, and a table with a +# checkbox column, select-all links and buttons acting on the selection +sub demo_tables_tab +{ +my $rv = ""; + +# Empty state in place of a table with no rows +$rv .= ui_card({ + 'title' => $text{'index_t_empty'}, + 'flush' => 1, + 'body' => ui_empty_state({ + 'icon' => 'clock', + 'title' => $text{'index_t_empty_title'}, + 'desc' => $text{'index_t_empty_desc'}, + 'actions' => ui_link_button('index.cgi', $text{'index_t_create'}), + }), + }); +my @rows = ( + [ 'webmin.service', 'Webmin administration server', + $text{'index_running'}, '0.8%' ], + [ 'sshd.service', 'OpenSSH server daemon', + $text{'index_running'}, '0.1%' ], + [ 'httpd.service', 'The Apache HTTP Server', + $text{'index_failed'}, '0.0%' ], + [ 'postfix.service', 'Postfix Mail Transport Agent', + $text{'index_running'}, '0.4%' ], + [ 'firewalld.service', 'Firewall daemon with D-Bus interface', + $text{'index_stopped'}, '0.0%' ], + [ 'mariadb.service', 'MariaDB database server', + $text{'index_running'}, '11.2%' ], + ); +my @heads = ( $text{'index_t_unit'}, $text{'index_t_desc'}, + $text{'index_t_state'}, $text{'index_t_cpu'} ); + +# A standard column table with its sortable flag set, so that themes +# which support it add client-side sorting and searching +# The unit cell is a ui_details disclosure : the unit name is the +# summary, and the tick next to it opens compact details inside the cell, +# as the GRUB 2 module shows boot entry metadata. The classes inline and +# inlined keep the summary on one line with the tick. With html set the +# title and content are used as given, so they are escaped here. +my $n = 0; +my @detail_rows = map { + my ($unit, @rest) = @$_; + $n++; + [ ui_details({ + 'html' => 1, + 'title' => html_escape($unit), + 'content' => join("
", + $text{'index_t_d_pid'}.": ".ui_tag('tt', 1000 + $n * 111), + $text{'index_t_d_since'}.": 2026-09-0$n 08:1$n", + $text{'index_t_d_mem'}.": ".($n * 17).".4 MB"), + 'class' => 'inline inlined' }), + map { html_escape($_) } @rest ]; + } @rows; +$rv .= ui_columns_table(\@heads, 100, \@detail_rows, + undef, 0, $text{'index_t_sortable'}, undef, 1); +$rv .= ui_note($text{'index_t_sortable_desc'}); + +# The same data with a checkbox column, select links and buttons, as +# ui_form_columns_table builds it, also flagged as sortable. Failed units +# start out checked. The select links need the index of this form on the +# page, counted in document order : service card 0, edit form 1, choosers +# 2, choices 3, then on the Buttons tab the single buttons 4, form end 5, +# grouped buttons 6, its hidden logs form 7, side by side 8 and its right +# form 9, confirmation 10, then accordions 11, so this one is 12. +# The otherlinks argument adds links to the row of +# select links, on the left by default or on the right when the third +# element is "right", as the Users and Groups module places its "Create a +# new user" and "Run batch file" links. +$rv .= ui_hr(); +$rv .= ui_form_columns_table('index.cgi', + [ [ 'restart', $text{'index_restart'} ], [ 'stop', $text{'index_stop'} ] ], + 1, + [ [ 'index.cgi?mode=tables', $text{'index_t_new'} ], + [ 'index.cgi?mode=tables', $text{'index_t_batch'}, 'right' ], + [ 'index.cgi?mode=tables', $text{'index_t_export'}, 'right' ] ], + [ [ 'demo', 5 ] ], + [ '', @heads ], 100, + [ map { [ + { 'type' => 'checkbox', 'name' => 'unit', 'value' => $_->[0], + 'checked' => $_->[2] eq $text{'index_failed'} ? 1 : 0 }, + map { html_escape($_) } @$_, + ] } @rows ], + undef, 0, undef, undef, 12, 1); +$rv .= ui_tag('div', ui_note($text{'index_t_checked_desc'}), + { 'style' => 'margin-top: 6px' }); + +return $rv; +} + +# demo_lists_tab() +# Returns the lists panel of the gallery +sub demo_lists_tab +{ +my $rv = ""; + +# List rows with descriptions, chips and trailing actions +$rv .= ui_card({ + 'title' => $text{'index_l_title'}, + 'desc' => $text{'index_l_desc'}, + 'flush' => 1, + 'body' => ui_list([ + { 'title' => 'apache-auth', + 'href' => 'index.cgi', + 'desc' => '0 failures, 0 bans — 5 tries in 10 minutes', + 'meta' => '2 '.$text{'index_feed_hours'}, + 'actions' => ui_link('index.cgi', + html_escape($text{'index_edit'})) }, + { 'title' => 'sshd', + 'href' => 'index.cgi', + 'badge' => [ '3 '.$text{'index_l_bans'}, 'warning' ], + 'desc' => '14 failures — 5 tries in 10 minutes', + 'tags' => [ 'journal' ], + 'meta' => '10 '.$text{'index_feed_minutes'}, + 'actions' => ui_link('index.cgi', + html_escape($text{'index_edit'})) }, + { 'title' => 'webmin', + 'href' => 'index.cgi', + 'icon' => 'shield', + 'state' => 'success', + 'desc' => '0 failures, 0 bans — then blocked for 1 hour', + 'tags' => [ 'journal', 'ipv6' ], + 'meta' => '1 '.$text{'index_feed_hours'} }, + ], { 'flush' => 1 }), + }); + + +# Three more uses of ui_list side by side : a history with a state icon +# and colored badge per entry and link buttons as actions; a resource +# list whose description line is an inline progress bar; and a record +# list with a confirmed delete link, which asks through the data-ui-confirm +# attribute handled by ui-lib.js on any element +$rv .= ui_grid([ + ui_card({ + 'title' => $text{'index_l_backups'}, + 'flush' => 1, + 'body' => ui_list([ + { 'title' => 'Full backup, 2026-09-04 02:00', + 'icon' => 'check-circle', 'state' => 'success', + 'desc' => text('index_l_backup_desc', 12, '/backup'), + 'meta' => '1.2 GB', + 'actions' => ui_link_button('index.cgi', + $text{'index_l_restore'}) }, + { 'title' => 'Full backup, 2026-09-03 02:00', + 'icon' => 'x-circle', 'state' => 'danger', + 'badge' => [ $text{'index_l_failed'}, 'danger' ], + 'desc' => $text{'index_l_backup_failed'}, + 'meta' => '0 B', + 'actions' => ui_link_button('index.cgi', + $text{'index_l_log'}) }, + { 'title' => 'Incremental, 2026-09-02 02:00', + 'icon' => 'check-circle', 'state' => 'success', + 'desc' => text('index_l_backup_desc', 12, '/backup'), + 'meta' => '140 MB', + 'actions' => ui_link_button('index.cgi', + $text{'index_l_restore'}) }, + ], { 'flush' => 1 }), + }), + ui_card({ + 'title' => $text{'index_l_fs'}, + 'flush' => 1, + 'body' => ui_list([ map { { + 'title' => $_->[0], + 'desc_html' => ui_progress($_->[1], { + 'inline' => 1, 'thresholds' => 1, + 'value' => $_->[2] }), + 'tags' => [ $_->[3] ], + 'meta' => $_->[4], + } } ( [ '/', 63, '63 GB of 100 GB', 'nvme0n1p2', 'xfs' ], + [ '/home', 91, '455 GB of 500 GB', 'nvme0n1p3', 'xfs' ], + [ '/var/log', 12, '2.4 GB of 20 GB', 'sda1', 'ext4' ] ) ], + { 'flush' => 1 }), + }), + ui_card({ + 'title' => $text{'index_l_users'}, + 'flush' => 1, + 'body' => ui_list([ + { 'title' => 'root', 'icon' => 'user', + 'badge' => [ $text{'index_l_admin'}, 'info' ], + 'desc' => text('index_l_lastlogin', '2 '.$text{'index_feed_hours'}), + 'actions' => ui_link('index.cgi', $text{'index_edit'}) }, + { 'title' => 'backup', 'icon' => 'user', + 'desc' => text('index_l_lastlogin', '3 '.$text{'index_l_days'}), + 'actions' => ui_link('index.cgi', $text{'index_edit'})." ". + ui_link('index.cgi', $text{'index_l_delete'}, undef, + "data-ui-confirm='". + quote_escape($text{'index_l_delete_confirm'})."'") }, + { 'title' => 'deploy', 'icon' => 'user', + 'desc' => text('index_l_lastlogin', $text{'index_l_never'}), + 'actions' => ui_link('index.cgi', $text{'index_edit'})." ". + ui_link('index.cgi', $text{'index_l_delete'}, undef, + "data-ui-confirm='". + quote_escape($text{'index_l_delete_confirm'})."'") }, + ], { 'flush' => 1 }), + }), + ]); +return $rv; +} + +# demo_alerts_tab() +# Returns the alerts panel : the existing ui_alert(text, type, [icon], +# [attrs]) in every type. The type is success, info, warning, danger or +# danger-fatal, and the theme adds the icon and the title that go with +# it. The icon argument may be an icon class from the theme's icon set, +# or [ icon, title, no-break ] to use a custom icon and title and keep +# the message on the same line as the title. +sub demo_alerts_tab +{ +my $rv = ""; +$rv .= ui_alert($text{'index_al_success'}, 'success'); +$rv .= ui_alert($text{'index_al_info'}, 'info'); +$rv .= ui_alert($text{'index_al_warning'}, 'warning'); +$rv .= ui_alert($text{'index_al_danger'}, 'danger'); +$rv .= ui_alert($text{'index_al_inline'}, 'info', + [ 'fa-clock', $text{'index_al_inline_title'}, 1 ]); + +# More one-line alerts of every type, each with an icon from the theme's +# set that fits the message +$rv .= ui_alert($text{'index_al_backup'}, 'success', + [ 'fa-hdd-o', $text{'index_al_backup_title'}, 1 ]); +$rv .= ui_alert($text{'index_al_cert'}, 'warning', + [ 'fa-lock', $text{'index_al_cert_title'}, 1 ]); +$rv .= ui_alert($text{'index_al_db'}, 'danger', + [ 'fa-plug', $text{'index_al_db_title'}, 1 ]); + +# A custom icon with the default title, and a custom title with the +# default icon, both on two lines as usual +$rv .= ui_alert($text{'index_al_update'}, 'info', 'fa-download'); +$rv .= ui_alert($text{'index_al_reboot'}, 'warning', + [ undef, $text{'index_al_reboot_title'} ]); + +# The full text of an error goes in a ui_details box with the error +# class, opened by the second argument, as the MariaDB module shows its +# connection error under its alert. It is placed last here because it +# sits flush against whatever follows it. +$rv .= ui_details({ + 'html' => 1, + 'title' => html_escape($text{'index_al_errdetails'}), + 'content' => text('index_al_errmsg', + ui_tag('tt', html_escape("DBI connect failed : Can't connect to ". + "local server through socket ". + "'/var/lib/mysql/mysql.sock' (2)"))), + 'class' => 'error' }, 1); +return $rv; +} + +# demo_config_files() +# Returns the sample files the editor page offers +sub demo_config_files +{ +return ( '/etc/example/service.conf', + '/etc/example/rules.conf', + '/etc/example/limits.conf' ); +} + +# demo_config_contents(file) +# Returns generated contents for one of the sample files +sub demo_config_contents +{ +my ($file) = @_; +return join("\n", "# ".$file, "# ".$text{'index_e_sample'}, "", + "listen = 0.0.0.0:10000", "ssl = 1", + "max_connections = 50", "log_level = info", ""); +} + +# demo_editor_tab() +# Returns the panel leading to the manual config editor. The editor is a +# page of its own, edit_manual.cgi, because Authentic only turns a text +# area into a code editor on pages it recognizes by name, and a tab of +# index.cgi is not one of them. The list rows link straight to each file +# in the editor. +sub demo_editor_tab +{ +my $rv = ""; +$rv .= ui_card({ + 'title' => $text{'index_e_files'}, + 'flush' => 1, + 'body' => ui_list([ map { { + 'title' => $_, + 'href' => 'edit_manual.cgi?file='.urlize($_), + 'desc' => $text{'index_e_file_desc'}, + 'icon' => 'edit', + } } demo_config_files() ], { 'flush' => 1 }), + 'footer' => ui_link_button('edit_manual.cgi', $text{'index_e_button'}), + }); +return $rv; +} + +# demo_iconlinks_tab() +# Returns the icon links panel : a grid of linked icons with titles under +# them, as the grub2 and Webmin Configuration modules show their +# sub-pages. This is the existing icons_table(&links, &titles, &icons, +# columns) function. The icons are small SVG files in the module's images +# directory, 48x48 like those of grub2. icons_table prints rather than +# returns, so its output is captured here. +sub demo_iconlinks_tab +{ +my @pages = ( 'cards', 'forms', 'accordions', 'tables', 'lists' ); +my $rv = capture_function_output(\&icons_table, + [ map { "index.cgi?mode=$_" } @pages ], + [ map { $text{'index_tab_'.$_} } @pages ], + [ map { "images/$_.svg" } @pages ], + scalar(@pages)); + +# Then the bottom-of-page action buttons after a rule, as the SSH Server +# and grub2 modules end their index page : icons first, a rule, then the +# buttons +$rv .= demo_page_actions(); +return $rv; +} + +# demo_page_actions() +# Returns the block of action buttons that modules such as grub2 and the +# Virtualmin Podman module put at the bottom of their index page, after a +# rule. This is the existing ui_buttons_start, ui_buttons_row and +# ui_buttons_end API, which builds one form per row with the button on +# the left and its description on the right. ui_buttons_row(script, +# label, description, hiddens, after-submit, before-submit, method, +# single-cell) : the after-submit and before-submit slots take extra +# inputs shown next to the button, the way the Webmin Configuration +# module asks whether to start at boot. +# +# Everything here submits back to index.cgi with GET, since the module is +# read-only. +sub demo_page_actions +{ +my $rv = ""; +$rv .= ui_hr(); +$rv .= ui_buttons_start(); + +# A sentence of controls after the button, the way the Virtualmin Podman +# module offers "Add New [type] named [recipe] for [domain]" and the WP +# Workbench module "Create Scheduled Backup for [domain] every [hour] +# include [files and database]". Three things make it work : +# - the button and the fields are wrapped in one flex span so they stay +# aligned and wrap together : the span is opened in the before-submit +# slot and closed at the end of the after-submit slot; +# - the words between the fields are plain spans, and each select has an +# aria-label since those words are not labels; +# - single-cell is set, so the row has no description column and the +# sentence can use the full width. +my @types = ( [ 'recipe', $text{'index_b_type_recipe'} ], + [ 'container', $text{'index_b_type_container'} ] ); +my @recipes = ( [ 'budget', 'Actual Budget' ], + [ 'wordpress', 'WordPress' ], + [ 'nextcloud', 'Nextcloud' ] ); +my @domains = ( [ 'example.com', 'example.com' ], + [ 'shop.example.com', '  shop.example.com' ], + [ 'example.net', 'example.net' ] ); +my $select_tags = "style='field-sizing:content;'"; +my $sentence = + ui_select('type', 'recipe', \@types, undef, undef, undef, 0, + "aria-label='".quote_escape($text{'index_b_type'})."' ". + $select_tags). + ui_tag('span', html_escape($text{'index_b_named'})). + ui_select('recipe', 'budget', \@recipes, undef, undef, undef, 0, + "aria-label='".quote_escape($text{'index_b_recipe'})."' ". + $select_tags). + ui_tag('span', html_escape($text{'index_b_for'})). + ui_select('dom', 'example.com', \@domains, undef, undef, undef, 0, + "aria-label='".quote_escape($text{'index_b_domain'})."' ". + $select_tags). + ui_tag_end('span'); +$rv .= ui_buttons_row('index.cgi', $text{'index_b_addnew'}, '', + ui_hidden('mode', 'iconlinks'), + $sentence, + ui_tag_start('span', { + 'style' => 'display:flex;align-items:center;flex-wrap:wrap;'. + 'gap:.5em;white-space:normal;' }), + 'get', 1); + +# A plain action, with a hidden field carrying where to come back to +$rv .= ui_buttons_row('index.cgi', $text{'index_b_refresh'}, + $text{'index_b_refresh_desc'}, ui_hidden('mode', 'iconlinks'), + undef, undef, 'get'); + +# A yes/no setting applied by the button +$rv .= ui_buttons_row('index.cgi', $text{'index_b_boot'}, + $text{'index_b_boot_desc'}, ui_hidden('mode', 'iconlinks'), + ui_yesno_radio('boot', 1), undef, 'get'); + +$rv .= ui_buttons_end(); +return $rv; +} + +1; diff --git a/ui-lib.pl b/ui-lib.pl index d8242105f..6081b4e30 100755 --- a/ui-lib.pl +++ b/ui-lib.pl @@ -286,7 +286,7 @@ $rv .= " ". return $rv; } -=head2 ui_columns_start(&headings, [width-percent], [noborder], [&tdtags], [heading]) +=head2 ui_columns_start(&headings, [width-percent], [noborder], [&tdtags], [heading], [sortable]) Returns HTML for the start of a multi-column table, with the given headings. The parameters are : @@ -301,14 +301,17 @@ The parameters are : =item heading - An optional heading to put above the table. +=item sortable - Set to 1 to ask the theme for client-side sorting and searching of this table, such as with DataTables in the Authentic theme. The table is marked with a data-sortable attribute. A module can instead request this for all tables on its pages with sortable=1 in module.info. + =cut sub ui_columns_start { return &theme_ui_columns_start(@_) if (defined(&theme_ui_columns_start)); -my ($heads, $width, $noborder, $tdtags, $title) = @_; +my ($heads, $width, $noborder, $tdtags, $title, $sortable) = @_; my $rv; $rv .= "\n"; + (defined($width) ? " width='$width%'" : "")." class='ui_columns'". + ($sortable ? " data-sortable='1'" : "").">\n"; if ($title) { $rv .= "". "$title\n"; @@ -470,7 +473,7 @@ return &theme_ui_columns_end(@_) if (defined(&theme_ui_columns_end)); return "\n"; } -=head2 ui_columns_table(&headings, width-percent, &data, &types, no-sort, title, empty-msg) +=head2 ui_columns_table(&headings, width-percent, &data, &types, no-sort, title, empty-msg, [sortable]) Returns HTML for a complete table, typically generated internally by ui_columns_start, ui_columns_row and ui_columns_end. The parameters are : @@ -496,11 +499,13 @@ ui_columns_start, ui_columns_row and ui_columns_end. The parameters are : =item empty-msg - Message to display if no data. +=item sortable - Set to 1 to ask the theme for client-side sorting and searching, as for ui_columns_start. + =cut sub ui_columns_table { return &theme_ui_columns_table(@_) if (defined(&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 @@ -524,7 +529,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) { @@ -601,7 +606,7 @@ $rv .= &ui_columns_end(); return $rv; } -=head2 ui_form_columns_table(cgi, &buttons, select-all, &otherlinks, &hiddens, &headings, width-percent, &data, &types, no-sort, title, empty-msg, form-no) +=head2 ui_form_columns_table(cgi, &buttons, select-all, &otherlinks, &hiddens, &headings, width-percent, &data, &types, no-sort, title, empty-msg, form-no, [sortable]) Similar to ui_columns_table, but wrapped in a form. Parameters are : @@ -617,6 +622,8 @@ Similar to ui_columns_table, but wrapped in a form. Parameters are : =item formno - Index of this form on the page. Defaults to 0, but should be set if there is more than one form on the page. +=item sortable - If set to 1, the table is marked for client-side sorting and searching by themes that support it, as for ui_columns_table. + All other parameters are the same as ui_columns_table. =cut @@ -625,7 +632,8 @@ sub ui_form_columns_table return &theme_ui_form_columns_table(@_) if (defined(&theme_ui_form_columns_table)); my ($cgi, $buttons, $selectall, $others, $hiddens, - $heads, $width, $data, $types, $nosort, $title, $emptymsg, $formno) = @_; + $heads, $width, $data, $types, $nosort, $title, $emptymsg, $formno, + $sortable) = @_; my $rv; # Build links @@ -675,7 +683,7 @@ if (@$data) { # Add the table $rv .= &ui_columns_table($heads, $width, $data, $types, $nosort, $title, - $emptymsg); + $emptymsg, $sortable); # Add the bottom links unless excluded if ($selectall != 2) { @@ -2284,13 +2292,14 @@ my $colspan = 1; if (defined($heading) || defined($rightheading)) { $rv .= ""; if (defined($heading)) { - $rv .= " $heading"; + $rv .= " $heading"; } + $rv .= ""; if (defined($rightheading)) { $rv .= "$rightheading"; $colspan++; } - $rv .= " \n"; + $rv .= " \n"; } $rv .= "

\n"; $main::ui_table_cols = $cols || 4; @@ -4188,6 +4197,7 @@ x-ui-text-mask${classcss}::after{ content: attr(data-mask); position: absolute; inset: 0; + white-space: nowrap; color: var(--ui-password-mask-color, #000); pointer-events: none; transition: opacity .25s ease; @@ -4209,4 +4219,1474 @@ $rv .= &ui_tag('x-ui-text-mask', $text, return $rv; } +####################### widget functions + +=head1 Widgets + +The functions below add the UI elements that were missing from this +library — cards, layout grids, description lists, stat tiles, badges, +lists, activity feeds, empty states and progress bars — styled +by the widget stylesheet served from C with a +small behavior script in C. + +They do not replace the existing functions. Inside a page area started by +ui_page_start, existing tabs, forms, tables and buttons retain their theme +styling. Modules keep using those functions and reach for the functions +below for the new kinds of content. + +Example code : + + ui_print_header(undef, $text{'index_title'}, ""); + + print ui_page_start({ 'desc' => $text{'index_desc'} }); + + print ui_grid([ + ui_card({ 'title' => $text{'index_status'}, + 'actions' => ui_badge($text{'index_running'}, 'success'), + 'body' => ui_dl([ + [ $text{'index_version'}, html_escape($version) ], + [ $text{'index_uptime'}, html_escape($uptime) ], + ]) }), + ui_card({ 'title' => $text{'index_activity'}, + 'body' => ui_stats(\@stats) }), + ]); + + print ui_page_end(); + ui_print_footer("/", $text{'index'}); + +Design principles : + +=item * Options are named. Widget options are passed in a hash reference +after any positional content arguments, so new options can be added +without breaking callers. + +=item * Functions return HTML strings, so widgets can be freely composed. + +=item * Escaping is part of the contract. Text-valued options (C, +C<desc>, C<label>, C<text>, C<help>, C<meta>, C<when>, C<value>) are +HTML-escaped by the library. HTML-valued options (C<body>, C<content>, +C<actions>, C<footer> and supported C<*_html> variants of text options) are +emitted as-is and are expected to be built from other ui functions. +Description-list values given in the array form are HTML for +composability. + +=item * Layout is CSS, not markup. Widgets emit flat, semantic elements +built with ui_tag, carrying stable C<ui_*> classes, and the stylesheet +arranges them with grid and flexbox. + +=item * Behavior is unobtrusive. Interactive elements carry C<data-ui-*> +attributes which ui-lib.js wires up with delegated event listeners, so no +inline event handlers are generated. A C<data-ui-confirm> attribute +passed in the tags of an existing button asks for confirmation, and +ui_search can filter table rows or list entries client-side. + +=item * Theming is a contract, not a rewrite. Colors and spacing +come from C<--ui-*> CSS custom properties which a theme can redefine on +C<.ui_page>, and every element keeps a stable class for deeper styling. A built-in +dark palette is available by setting C<data-ui-scheme> to C<dark> (or +C<auto> to follow the browser) on any ancestor element, or via the +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. + +=cut + +# Per-request state, kept in main:: so it is shared no matter which +# package the library was loaded into +$main::ui_page_assets_done ||= 0; + +# Valid widget states, and aliases accepted for convenience +my %ui_states = ( + 'success' => 'success', 'ok' => 'success', + 'warning' => 'warning', 'warn' => 'warning', + 'danger' => 'danger', 'error' => 'danger', 'err' => 'danger', + 'info' => 'info', + 'neutral' => 'neutral', 'off' => 'neutral', + ); + +# Built-in icon set, as 16x16 SVG path data drawn with the current color. +# Each icon is one or more path definitions joined with | +my %ui_svg_icons = ( + 'check' => 'M3.5 8.5l3 3 6-6.5', + 'check-circle' => 'M8 14.25A6.25 6.25 0 1 0 8 1.75a6.25 6.25 0 0 0 0 12.5z|M5.4 8.3l1.8 1.9 3.4-4', + 'x' => 'M4.5 4.5l7 7|M11.5 4.5l-7 7', + 'x-circle' => 'M8 14.25A6.25 6.25 0 1 0 8 1.75a6.25 6.25 0 0 0 0 12.5z|M6 6l4 4|M10 6l-4 4', + 'info-circle' => 'M8 14.25A6.25 6.25 0 1 0 8 1.75a6.25 6.25 0 0 0 0 12.5z|M8 7.4v3.4|M8 5.1h.01', + 'question-circle' => 'M8 14.25A6.25 6.25 0 1 0 8 1.75a6.25 6.25 0 0 0 0 12.5z|M6.2 6.2a1.8 1.8 0 1 1 2.6 1.8c-.55.28-.8.6-.8 1.15v.25|M8 11.3h.01', + 'warning' => 'M8 2.6l6.4 10.4H1.6L8 2.6z|M8 6.8v2.7|M8 11.3h.01', + 'search' => 'M7 11.25a4.25 4.25 0 1 0 0-8.5 4.25 4.25 0 0 0 0 8.5z|M10.2 10.2l3.3 3.3', + 'external' => 'M6.5 4H4a1.5 1.5 0 0 0-1.5 1.5V12A1.5 1.5 0 0 0 4 13.5h6.5A1.5 1.5 0 0 0 12 12V9.5|M9.5 2.5h4v4|M13.3 2.7L7.8 8.2', + 'chevron-down' => 'M4 6l4 4 4-4', + 'chevron-up' => 'M4 10l4-4 4 4', + 'chevron-right' => 'M6 4l4 4-4 4', + 'chevron-left' => 'M10 4L6 8l4 4', + 'arrow-right' => 'M2.8 8h10|M9 4.2L12.8 8 9 11.8', + 'plus' => 'M8 3v10|M3 8h10', + 'minus' => 'M3 8h10', + 'refresh' => 'M13.2 6.2A5.5 5.5 0 0 0 3.4 4.6|M2.8 9.8a5.5 5.5 0 0 0 9.8 1.6|M13.5 2.5v3.7H9.8|M2.5 13.5V9.8h3.7', + 'play' => 'M5.5 3.6v8.8L12.5 8z', + 'stop' => 'M4.7 4.7h6.6v6.6H4.7z', + 'power' => 'M8 2.2v5.6|M11.4 4.4a5.4 5.4 0 1 1-6.8 0', + 'clock' => 'M8 14.25A6.25 6.25 0 1 0 8 1.75a6.25 6.25 0 0 0 0 12.5z|M8 4.8V8l2.2 1.4', + 'shield' => 'M8 1.8l5.2 2v4c0 3.2-2.2 5.3-5.2 6.4C4.8 13.1 2.8 11 2.8 7.8v-4z', + 'server' => 'M2 3.2h12v4.2H2z|M2 8.6h12v4.2H2z|M4.6 5.3h.01|M4.6 10.7h.01', + 'gear' => 'M8 10.4a2.4 2.4 0 1 0 0-4.8 2.4 2.4 0 0 0 0 4.8z|M8 1.8v2|M8 12.2v2|M1.8 8h2|M12.2 8h2|M3.6 3.6L5 5|M11 11l1.4 1.4|M12.4 3.6L11 5|M5 11l-1.4 1.4', + 'user' => 'M8 8a2.6 2.6 0 1 0 0-5.2A2.6 2.6 0 0 0 8 8z|M2.9 13.6a5.4 5.4 0 0 1 10.2 0', + 'trash' => 'M2.8 4.2h10.4|M6.2 4V2.8h3.6V4|M4.2 4.4l.5 8a1.4 1.4 0 0 0 1.4 1.3h3.8a1.4 1.4 0 0 0 1.4-1.3l.5-8|M6.6 7v4|M9.4 7v4', + 'download' => 'M8 2.5v7|M4.8 6.7L8 9.9l3.2-3.2|M3 12.5h10', + 'upload' => 'M8 9.5v-7|M4.8 5.3L8 2.1l3.2 3.2|M3 12.5h10', + 'edit' => 'M9.8 3.1l3.1 3.1L6 13.1l-3.6.5.5-3.6z|M8.6 4.3l3.1 3.1', + 'terminal' => 'M1.8 3h12.4v10H1.8z|M4.5 6.3L7 8.4l-2.5 2.1|M8.5 10.7h3', + 'filter' => 'M2.5 3.5h11L9.8 8.4v4.3l-3.6-1.6V8.4z', + 'book' => 'M8 3.4C6.9 2.6 5.3 2.2 2.8 2.3v10.3c2.5-.1 4.1.3 5.2 1.1 1.1-.8 2.7-1.2 5.2-1.1V2.3c-2.5-.1-4.1.3-5.2 1.1z|M8 3.4v10.3', + 'globe' => 'M8 14.25A6.25 6.25 0 1 0 8 1.75a6.25 6.25 0 0 0 0 12.5z|M2 8h12|M8 1.9c1.7 1.8 2.6 3.9 2.6 6.1S9.7 12.3 8 14.1C6.3 12.3 5.4 10.2 5.4 8S6.3 3.7 8 1.9z', + 'dot' => 'M8 10.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z', + ); + +# Icons drawn filled rather than stroked +my %ui_svg_icons_filled = ( 'play' => 1, 'stop' => 1, 'dot' => 1 ); + +# Default badge icons per state +my %ui_state_icons = ( + 'success' => 'check-circle', + 'warning' => 'warning', + 'danger' => 'x-circle', + 'info' => 'info-circle', + ); + +# _ui_state(state) +# Maps a caller-supplied state name or alias to a canonical state class +# suffix, defaulting to neutral +sub _ui_state +{ +my ($state) = @_; +return $state && $ui_states{$state} ? $ui_states{$state} : 'neutral'; +} + +# _ui_text(&opts, key) +# Returns the escaped text option, preferring a raw key_html variant when +# the caller supplied pre-built HTML. Returns undef if both are absent or empty. +sub _ui_text +{ +my ($opts, $key) = @_; +return $opts->{$key.'_html'} if (defined($opts->{$key.'_html'}) && + $opts->{$key.'_html'} ne ''); +return &html_escape($opts->{$key}) if (defined($opts->{$key}) && + $opts->{$key} ne ''); +return undef; +} + +# _ui_attrs(&attrs) +# Returns a copy of the given attribute hash without the undefined or empty +# values, ready for ui_tag. Boolean attributes like checked are added by the +# callers afterwards with an undefined value, which ui_tag_start emits bare. +sub _ui_attrs +{ +my ($attrs) = @_; +my %rv; +foreach my $key (keys %$attrs) { + my $value = $attrs->{$key}; + next if (!defined($value) || $value eq ''); + $rv{$key} = $value; + } +return \%rv; +} + +# _ui_class(@classes) +# Joins the given class names, skipping empty ones +sub _ui_class +{ +return join(" ", grep { defined($_) && $_ ne '' } @_); +} + +# _ui_join(&items) +# Joins a list of HTML fragments, accepting either an array reference or a +# single pre-built string, and skipping empty entries +sub _ui_join +{ +my ($items) = @_; +return "" if (!defined($items)); +return $items if (!ref($items)); +return join("\n", grep { defined($_) && $_ ne '' } @$items); +} + +# _ui_block(tag, content, [&attrs]) +# Returns a block element whose content starts and ends on its own line, +# so that nested widgets stay readable in the page source +sub _ui_block +{ +my ($tag, $content, $attrs) = @_; +return &ui_tag($tag, "\n".$content."\n", $attrs); +} + +=head2 ui_svg_icon(name, [&opts]) + +Returns inline SVG for a named icon from the built-in set, drawn with the +current text color. Unlike ui_icon, this does not depend on any theme +icon font. Options are : + +=item size - Pixel size, defaulting to 16. + +=item class - Additional CSS classes for the <svg> element. + +=item title - Accessible label. Without it the icon is hidden from +screen readers. + +=cut +sub ui_svg_icon +{ +return &theme_ui_svg_icon(@_) if (defined(&theme_ui_svg_icon)); +my ($name, $opts) = @_; +$opts ||= {}; +my $paths = $ui_svg_icons{$name}; +return "" if (!$paths); +my $size = int($opts->{'size'} || 16); +my %attrs = ( 'class' => &_ui_class('ui_svg_icon', $opts->{'class'}), + 'width' => $size, + 'height' => $size, + 'viewBox' => '0 0 16 16', + 'fill' => $ui_svg_icons_filled{$name} ? 'currentColor' : 'none', + 'stroke' => 'currentColor', + 'stroke-width' => '1.5', + 'stroke-linecap' => 'round', + 'stroke-linejoin' => 'round' ); +if (defined($opts->{'title'}) && $opts->{'title'} ne '') { + $attrs{'role'} = 'img'; + $attrs{'aria-label'} = $opts->{'title'}; + } +else { + $attrs{'aria-hidden'} = 'true'; + } +my $rv = &ui_tag_start('svg', \%attrs); +foreach my $p (split(/\|/, $paths)) { + $rv .= &ui_tag('path', undef, { 'd' => $p }); + } +$rv .= &ui_tag_end('svg'); +return $rv; +} + +=head2 ui_code(text) + +Returns the given text styled as inline code. + +=cut +sub ui_code +{ +return &theme_ui_code(@_) if (defined(&theme_ui_code)); +my ($str) = @_; +return &ui_tag('code', &html_escape($str), { 'class' => 'ui_code' }); +} + +=head2 ui_tip(content-html, tip) + +Wraps arbitrary HTML content so the given plain-text tip is shown on +hover by the theme's tooltip, the same one ui_help uses. For a plain +question-mark help bubble, use ui_help instead. + +=cut +sub ui_tip +{ +return &theme_ui_tip(@_) if (defined(&theme_ui_tip)); +my ($content, $tip) = @_; +return &ui_tag('span', $content, + { 'class' => 'ui_tip', 'aria-label' => &html_strip($tip), + 'data-tooltip' => undef }); +} + +####################### page structure functions + +=head2 ui_page_assets([&opts]) + +Returns the <link> and <script> tags that load the widget stylesheet and +behavior script, exactly once per page. This is normally called +automatically by ui_page_start, but can also be passed in the head-stuff +parameter of ui_print_header to load the assets from the <head> section. +Options are : + +=item nojs - Set to 1 to skip the behavior script. + +=cut +sub ui_page_assets +{ +return &theme_ui_page_assets(@_) if (defined(&theme_ui_page_assets)); +my ($opts) = @_; +$opts ||= {}; +return "" if ($main::ui_page_assets_done++); +my $pfx = &get_webprefix(); + +# Each file is linked with its modification time as the cache key, so a +# browser picks up a changed file at once instead of keeping the copy it +# cached under the Webmin version, which stays the same between edits +my $ver = &get_webmin_version() || 0; +my $key = sub { + my ($file) = @_; + my @st = stat("$root_directory/unauthenticated/$file"); + return @st ? $st[9] : $ver; + }; +my $rv = &ui_tag('link', undef, + { 'rel' => 'stylesheet', + 'href' => "$pfx/unauthenticated/css/ui-lib.css?". + $key->("css/ui-lib.css") })."\n"; +$rv .= &ui_tag('script', undef, + { 'src' => "$pfx/unauthenticated/js/ui-lib.js?". + $key->("js/ui-lib.js"), + 'defer' => undef }) if (!$opts->{'nojs'}); +return $rv; +} + +=head2 ui_page_start([&opts]) + +Returns HTML for the start of a widget page area, including the assets (if +not yet loaded) and an optional page header with a description and +actions. The page title itself is printed by ui_print_header as usual. +All other widgets, and any existing tabs, forms and tables that +should get the widget styling, should be placed between this and +ui_page_end. Options are : + +=item title - Large page title text, for pages that do not already show +one in the theme header. + +=item desc - Short description shown under the title. + +=item help - URL of a help or documentation page, shown as a link with a +question-mark icon on the right. + +=item help_title - Text for the help link, defaulting to "Help". + +=item actions - HTML (or array ref of HTML fragments) for buttons or +links shown at the top right. + +=item scheme - Color scheme for the page area. By default the light +palette is used; set to dark for the built-in dark palette, or to auto +to follow the browser's preferred color scheme. A theme can instead set +a data-ui-scheme attribute on the body or html element to control all +widget pages at once, or redefine the --ui-* CSS custom properties for +full control. + +=item class - Additional CSS classes for the wrapper element. + +=item id - HTML id for the wrapper element. + +=cut +sub ui_page_start +{ +return &theme_ui_page_start(@_) if (defined(&theme_ui_page_start)); +my ($opts) = @_; +$opts ||= {}; +my $rv = &ui_page_assets(); +my $scheme = $opts->{'scheme'} && $opts->{'scheme'} =~ /^(dark|auto)$/ ? + $opts->{'scheme'} : undef; +$rv .= &ui_tag_start('div', &_ui_attrs({ + 'class' => &_ui_class('ui_page', $opts->{'class'}), + 'data-ui-scheme' => $scheme, + 'id' => $opts->{'id'} }))."\n"; + +# Optional header with title, description, help link and actions +my $title = &_ui_text($opts, 'title'); +my $desc = &_ui_text($opts, 'desc'); +my $actions = &_ui_join($opts->{'actions'}); +my $help = ""; +if ($opts->{'help'}) { + my $htitle = defined($opts->{'help_title'}) ? $opts->{'help_title'} : + $text{'ui_page_help'} || 'Help'; + $help = &ui_tag('a', + &ui_svg_icon('question-circle', { 'size' => 15 }). + &ui_tag('span', &html_escape($htitle)), + { 'class' => 'ui_page_help', 'href' => $opts->{'help'} }); + } +if (defined($title) || defined($desc) || $actions || $help) { + my $titles = ""; + $titles .= &ui_tag('h1', $title, { 'class' => 'ui_page_title' }) + if (defined($title)); + $titles .= &ui_tag('p', $desc, { 'class' => 'ui_page_desc' }) + if (defined($desc)); + my $head = &_ui_block('div', $titles, { 'class' => 'ui_page_titles' }); + $head .= &ui_tag('div', $help.$actions, { 'class' => 'ui_page_actions' }) + if ($actions || $help); + $rv .= &_ui_block('header', $head, { 'class' => 'ui_page_head' }); + } +return $rv; +} + +=head2 ui_page_end + +Returns HTML ending a page area started by ui_page_start. + +=cut +sub ui_page_end +{ +return &theme_ui_page_end(@_) if (defined(&theme_ui_page_end)); +return &ui_tag_end('div'); +} + +####################### layout functions + +=head2 ui_grid(&items, [&opts]) + +Returns HTML for a responsive grid of the given HTML fragments, typically +cards. By default columns are sized automatically to fit at least the +minimum width, wrapping on narrow screens. Options are : + +=item cols - Fixed number of equal columns instead of automatic sizing. + +=item min - Minimum column width for automatic mode, such as 320px. + +=item template - Raw grid-template-columns value for unequal column +layouts, such as "minmax(280px,1fr) minmax(0,2fr)". Overrides cols. On +narrow screens the grid always collapses to one column. + +=item gap - Gap between cells, as a CSS length. + +=item class - Additional CSS classes. + +=cut +sub ui_grid +{ +return &theme_ui_grid(@_) if (defined(&theme_ui_grid)); +my ($items, $opts) = @_; +$opts ||= {}; +my @items = grep { defined($_) && $_ ne '' } @$items; +return "" if (!@items); +my $fixed = $opts->{'cols'} || $opts->{'template'}; +my @style; +push(@style, "--ui-grid-cols:".int($opts->{'cols'})) + if ($opts->{'cols'} && !$opts->{'template'}); +push(@style, "--ui-grid-template:".$opts->{'template'}) + if ($opts->{'template'}); +push(@style, "--ui-grid-min:".$opts->{'min'}) if ($opts->{'min'}); +push(@style, "--ui-gap:".$opts->{'gap'}) if ($opts->{'gap'}); +return &_ui_block('div', join("\n", @items), &_ui_attrs({ + 'class' => &_ui_class('ui_grid', $fixed ? 'ui_grid_fixed' : undef, + $opts->{'class'}), + 'style' => @style ? join(";", @style) : undef })); +} + +=head2 ui_stack(&items, [&opts]) + +Returns HTML stacking the given fragments vertically with consistent +spacing. Options are C<gap> and C<class>. + +=cut +sub ui_stack +{ +return &theme_ui_stack(@_) if (defined(&theme_ui_stack)); +my ($items, $opts) = @_; +$opts ||= {}; +my @items = grep { defined($_) && $_ ne '' } @$items; +return "" if (!@items); +return &_ui_block('div', join("\n", @items), &_ui_attrs({ + 'class' => &_ui_class('ui_stack', $opts->{'class'}), + 'style' => $opts->{'gap'} ? "--ui-gap:".$opts->{'gap'} : undef })); +} + +=head2 ui_cluster(&items, [&opts]) + +Returns HTML for a horizontal row of the given fragments — typically +buttons or badges — which wraps on narrow screens. Options are : + +=item align - Horizontal alignment : start (default), center, end or +between. + +=item gap - Gap between items, as a CSS length. + +=item class - Additional CSS classes. + +=cut +sub ui_cluster +{ +return &theme_ui_cluster(@_) if (defined(&theme_ui_cluster)); +my ($items, $opts) = @_; +$opts ||= {}; +my @items = grep { defined($_) && $_ ne '' } @$items; +return "" if (!@items); +my $align = $opts->{'align'} || ''; +return &_ui_block('div', join("\n", @items), &_ui_attrs({ + 'class' => &_ui_class('ui_cluster', + $align =~ /^(center|end|between)$/ ? + "ui_cluster_$align" : undef, + $opts->{'class'}), + 'style' => $opts->{'gap'} ? "--ui-gap:".$opts->{'gap'} : undef })); +} + +####################### card functions + +# _ui_card_head(&opts) +# Builds the optional header section of a card from title, desc and +# actions options +sub _ui_card_head +{ +my ($opts) = @_; +my $title = &_ui_text($opts, 'title'); +my $desc = &_ui_text($opts, 'desc'); +my $actions = &_ui_join($opts->{'actions'}); +return "" if (!defined($title) && !defined($desc) && !$actions); +my $titles = ""; +$titles .= &ui_tag('h2', $title, { 'class' => 'ui_card_title' }) + if (defined($title)); +$titles .= &ui_tag('p', $desc, { 'class' => 'ui_card_desc' }) + if (defined($desc)); +my $head = &_ui_block('div', $titles, { 'class' => 'ui_card_titles' }); +$head .= &ui_tag('div', $actions, { 'class' => 'ui_card_actions' }) + if ($actions); +return &_ui_block('header', $head, { 'class' => 'ui_card_head' }); +} + +=head2 ui_card(&opts) + +Returns HTML for a card — the primary container widget, drawn as a +surface with a border. Options are : + +=item title - Card heading text. + +=item desc - Short description under the heading. + +=item actions - HTML (or array ref of fragments) shown at the top right +of the card, such as buttons, links or a badge. + +=item body - HTML for the card content, which may include forms and +tables built with the existing ui functions. + +=item footer - HTML shown in a separated footer area. + +=item flush - Set to 1 to remove horizontal body padding, for full-bleed content +such as lists. Tables keep their own border and look better with the +padding. + +=item state - Optional state name, which adds a colored accent border. + +=item class - Additional CSS classes. + +=item id - HTML id for the card element. + +=cut +sub ui_card +{ +return &theme_ui_card(@_) if (defined(&theme_ui_card)); +my ($opts) = @_; +$opts ||= {}; +my $rv = &ui_card_start($opts); +$rv .= defined($opts->{'body'}) ? $opts->{'body'} : ""; +$rv .= &ui_card_end($opts->{'footer'}); +return $rv; +} + +=head2 ui_card_start(&opts) + +Returns HTML for the start of a card, for callers that print content +progressively. Takes the same options as ui_card except body and +footer, and must be followed by ui_card_end. + +=cut +sub ui_card_start +{ +return &theme_ui_card_start(@_) if (defined(&theme_ui_card_start)); +my ($opts) = @_; +$opts ||= {}; +my $rv = &ui_tag_start('section', &_ui_attrs({ + 'class' => &_ui_class('ui_card', + $opts->{'state'} ? + "ui_card_".&_ui_state($opts->{'state'}) : undef, + $opts->{'class'}), + 'id' => $opts->{'id'} }))."\n"; +$rv .= &_ui_card_head($opts); +$rv .= &ui_tag_start('div', { + 'class' => &_ui_class('ui_card_body', + $opts->{'flush'} ? 'ui_card_flush' : undef) })."\n"; +return $rv; +} + +=head2 ui_card_end([footer]) + +Returns HTML for the end of a card started by ui_card_start, with an +optional HTML footer area. + +=cut +sub ui_card_end +{ +return &theme_ui_card_end(@_) if (defined(&theme_ui_card_end)); +my ($footer) = @_; +my $rv = &ui_tag_end('div'); +$rv .= &ui_tag('footer', $footer, { 'class' => 'ui_card_foot' }) + if (defined($footer) && $footer ne ''); +$rv .= &ui_tag_end('section'); +return $rv; +} + +####################### data display functions + +=head2 ui_stat(&opts) + +Returns HTML for a single statistic tile with a large value and a label. +Options are : + +=item value - The number or short value to display prominently. + +=item label - Label text under the value. + +=item desc - Smaller muted description under the label. + +=item state - Optional state name to color the value. + +=item href - Optional URL, making the whole tile a link. + +=item icon - Optional icon name shown before the value. + +=cut +sub ui_stat +{ +return &theme_ui_stat(@_) if (defined(&theme_ui_stat)); +my ($opts) = @_; +$opts ||= {}; +my $value = &_ui_text($opts, 'value'); +$value = "0" if (!defined($value)); +my $body = &ui_tag('span', + ($opts->{'icon'} ? + &ui_svg_icon($opts->{'icon'}, { 'size' => 20 })." " : "").$value, + { 'class' => &_ui_class('ui_stat_value', + $opts->{'state'} ? + "ui_fg_".&_ui_state($opts->{'state'}) : undef) }); +my $label = &_ui_text($opts, 'label'); +$body .= &ui_tag('span', $label, { 'class' => 'ui_stat_label' }) + if (defined($label)); +my $desc = &_ui_text($opts, 'desc'); +$body .= &ui_tag('span', $desc, { 'class' => 'ui_stat_desc' }) + if (defined($desc)); +return &_ui_block($opts->{'href'} ? 'a' : 'div', $body, &_ui_attrs({ + 'class' => 'ui_stat', + 'href' => $opts->{'href'} })); +} + +=head2 ui_stats(&stats, [&opts]) + +Returns HTML for a responsive row of statistic tiles. Each element of the +stats array is a hash reference in ui_stat format. Options are C<class> +and C<min> for the minimum tile width. + +=cut +sub ui_stats +{ +return &theme_ui_stats(@_) if (defined(&theme_ui_stats)); +my ($stats, $opts) = @_; +$opts ||= {}; +return "" if (!$stats || !@$stats); +return &_ui_block('div', join("", map { &ui_stat($_) } @$stats), + &_ui_attrs({ + 'class' => &_ui_class('ui_stats', $opts->{'class'}), + 'style' => $opts->{'min'} ? + "--ui-grid-min:".$opts->{'min'} : undef })); +} + +=head2 ui_dl(&rows, [&opts]) + +Returns HTML for a description list of label and value pairs, for +displaying (rather than editing) information. Each row is either a two +or three element array reference of C<[ label, value-html, help-text ]>, +or a hash reference with keys : + +=item label - Plain label text. + +=item value - Plain value text, which will be escaped. + +=item value_html - Pre-built HTML for the value, used instead of value. + +=item help - Plain help text shown as a question-mark tooltip after the +label. + +Options are : + +=item cols - Set to 2 to flow rows into two columns on wide screens. + +=item wide - CSS width for the label column, such as 40%. + +=item class - Additional CSS classes. + +=cut +sub ui_dl +{ +return &theme_ui_dl(@_) if (defined(&theme_ui_dl)); +my ($rows, $opts) = @_; +$opts ||= {}; +return "" if (!$rows || !@$rows); +my $body = ""; +foreach my $row (@$rows) { + next if (!defined($row)); + my ($label, $value, $help); + if (ref($row) eq 'HASH') { + $label = &html_escape($row->{'label'}); + $value = defined($row->{'value_html'}) ? $row->{'value_html'} + : &html_escape($row->{'value'}); + $help = $row->{'help'}; + } + else { + $label = &html_escape($row->[0]); + $value = defined($row->[1]) ? $row->[1] : ""; + $help = $row->[2]; + } + $body .= &ui_tag('div', + &ui_tag('dt', $label.($help ? " ".&ui_help($help) : "")). + &ui_tag('dd', $value), + { 'class' => 'ui_dl_row' }); + } +return &_ui_block('dl', $body, &_ui_attrs({ + 'class' => &_ui_class('ui_dl', + $opts->{'cols'} && $opts->{'cols'} > 1 ? + 'ui_dl_cols' : undef, + $opts->{'class'}), + 'style' => $opts->{'wide'} ? "--ui-dl-label:".$opts->{'wide'} : undef })); +} + +=head2 ui_badge(text, [state], [&opts]) + +Returns HTML for a status badge, such as "Running" in green. The state +is one of success, warning, danger, info or neutral. Options are : + +=item icon - Icon name to show before the text, replacing the state's +default icon. Set to an empty string for no icon at all. + +=item dot - Set to 1 to show a plain colored dot instead of an icon. + +=item title - Tooltip text for the badge. + +=cut +sub ui_badge +{ +return &theme_ui_badge(@_) if (defined(&theme_ui_badge)); +my ($label, $state, $opts) = @_; +$opts ||= {}; +$state = &_ui_state($state); +my $icon = ""; +if ($opts->{'dot'}) { + $icon = &ui_svg_icon('dot', { 'size' => 10 }); + } +elsif (defined($opts->{'icon'})) { + $icon = $opts->{'icon'} eq '' ? "" : + &ui_svg_icon($opts->{'icon'}, { 'size' => 13 }); + } +elsif ($ui_state_icons{$state}) { + $icon = &ui_svg_icon($ui_state_icons{$state}, { 'size' => 13 }); + } +return &ui_tag('span', $icon.&ui_tag('span', &html_escape($label)), + &_ui_attrs({ 'class' => "ui_badge ui_badge_$state", + 'title' => $opts->{'title'} })); +} + +=head2 ui_chip(text, [&opts]) + +Returns HTML for a small muted chip, such as a category or source label +next to a list entry. + +=cut +sub ui_chip +{ +return &theme_ui_chip(@_) if (defined(&theme_ui_chip)); +my ($label, $opts) = @_; +$opts ||= {}; +return &ui_tag('span', &html_escape($label), + { 'class' => &_ui_class('ui_chip', $opts->{'class'}) }); +} + +=head2 ui_list(&items, [&opts]) + +Returns HTML for a stacked list of entries with titles, descriptions and +trailing details. Each item is a hash reference with keys : + +=item title - Entry title text (or title_html for pre-built HTML). + +=item desc - Secondary description line (or desc_html). + +=item href - Optional URL, making the title a link. + +=item badge - Optional array ref of [ text, state ] shown after the +title, or badge_html for arbitrary HTML. + +=item tags - Optional array ref of plain strings shown as small chips +after the description. + +=item meta - Muted text aligned to the right, such as a time or count. + +=item actions - HTML for buttons or links aligned to the right. + +=item icon - Optional icon name shown before the title. + +=item state - Optional state name, which colors the icon. + +Options are : + +=item flush - Set to 1 when the list fills a flush card body, extending +row separators to the card edges. + +=item class - Additional CSS classes. + +=cut +sub ui_list +{ +return &theme_ui_list(@_) if (defined(&theme_ui_list)); +my ($items, $opts) = @_; +$opts ||= {}; +return "" if (!$items || !@$items); +my $body = ""; +foreach my $item (@$items) { + next if (!defined($item)); + + # Title line, with optional link and badge + my $title = &_ui_text($item, 'title'); + $title = &ui_tag('a', $title, + { 'class' => 'ui_list_link', 'href' => $item->{'href'} }) + if (defined($title) && $item->{'href'}); + my $badge = defined($item->{'badge_html'}) ? $item->{'badge_html'} : + $item->{'badge'} ? &ui_badge(@{$item->{'badge'}}) : ""; + my $main = &ui_tag('div', + (defined($title) ? $title : "").($badge ? " ".$badge : ""), + { 'class' => 'ui_list_title' }); + + # Description line with chips + my $desc = &_ui_text($item, 'desc'); + my $tags = $item->{'tags'} && @{$item->{'tags'}} ? + join(" ", map { &ui_chip($_) } @{$item->{'tags'}}) : ""; + $main .= &ui_tag('div', + (defined($desc) ? $desc : "").($tags ? " ".$tags : ""), + { 'class' => 'ui_list_desc' }) if (defined($desc) || $tags); + + # Leading icon, and trailing meta text and actions + my $row = ""; + $row .= &ui_tag('span', &ui_svg_icon($item->{'icon'}), + { 'class' => &_ui_class('ui_list_icon', + $item->{'state'} ? + "ui_fg_".&_ui_state($item->{'state'}) : + undef) }) if ($item->{'icon'}); + $row .= &_ui_block('div', $main, { 'class' => 'ui_list_main' }); + my $meta = &_ui_text($item, 'meta'); + my $actions = &_ui_join($item->{'actions'}); + $row .= &ui_tag('div', + (defined($meta) ? + &ui_tag('span', $meta, { 'class' => 'ui_list_meta' }) : ""). + $actions, + { 'class' => 'ui_list_side' }) if (defined($meta) || $actions); + $body .= &_ui_block('div', $row, { 'class' => 'ui_list_item' }); + } +return &_ui_block('div', $body, { + 'class' => &_ui_class('ui_list', $opts->{'flush'} ? 'ui_list_flush' : undef, + $opts->{'class'}) }); +} + +=head2 ui_feed(&events, [&opts]) + +Returns HTML for a simple activity feed of timestamped events in the +supplied order. Pass the newest event first for a reverse-chronological +feed. Each event is a hash reference with keys : + +=item when - Time label, such as "5 minutes ago". + +=item text - Event text (or text_html for pre-built HTML). + +=item state - Optional state name, coloring the event marker. + +Options are C<flush> and C<class>, as for ui_list. + +=cut +sub ui_feed +{ +return &theme_ui_feed(@_) if (defined(&theme_ui_feed)); +my ($events, $opts) = @_; +$opts ||= {}; +return "" if (!$events || !@$events); +my $body = ""; +foreach my $e (@$events) { + next if (!defined($e)); + my $state = $e->{'state'} ? &_ui_state($e->{'state'}) : undef; + my $when = &_ui_text($e, 'when'); + my $etext = &_ui_text($e, 'text'); + my $item = ""; + $item .= &ui_tag('div', $when, { 'class' => 'ui_feed_when' }) + if (defined($when)); + $item .= &ui_tag('div', $etext, { 'class' => 'ui_feed_text' }) + if (defined($etext)); + $body .= &_ui_block('div', $item, { + 'class' => &_ui_class('ui_feed_item', + $state ? "ui_feed_$state" : undef) }); + } +return &_ui_block('div', $body, { + 'class' => &_ui_class('ui_feed', $opts->{'flush'} ? 'ui_feed_flush' : undef, + $opts->{'class'}) }); +} + +=head2 ui_empty_state(&opts) + +Returns HTML for an empty-state message, used when a table or list has +nothing to show. Options are : + +=item title - Main message text. + +=item desc - Secondary explanation text. + +=item icon - Icon name shown above the message. + +=item actions - HTML for buttons or links offering a next step. + +=cut +sub ui_empty_state +{ +return &theme_ui_empty_state(@_) if (defined(&theme_ui_empty_state)); +my ($opts) = @_; +$opts ||= {}; +my $title = &_ui_text($opts, 'title'); +$title = &html_escape($text{'ui_empty_state'} || 'Nothing to display') + if (!defined($title)); +my $desc = &_ui_text($opts, 'desc'); +my $actions = &_ui_join($opts->{'actions'}); +my $body = ""; +$body .= &ui_tag('span', &ui_svg_icon($opts->{'icon'}, { 'size' => 22 }), + { 'class' => 'ui_empty_icon' }) if ($opts->{'icon'}); +$body .= &ui_tag('div', $title, { 'class' => 'ui_empty_title' }); +$body .= &ui_tag('div', $desc, { 'class' => 'ui_empty_desc' }) + if (defined($desc)); +$body .= &ui_tag('div', $actions, { 'class' => 'ui_empty_actions' }) + if ($actions); +return &_ui_block('div', $body, { 'class' => 'ui_empty' }); +} + +=head2 ui_progress(value, [&opts]) + +Returns HTML for a progress bar showing the given value as a percentage +of 100, or of the max option. The result is clamped to the range 0 to +100. Options are : + +=item label - Text shown above the bar, or before it in the inline +layout, or under the ring. + +=item state - State name coloring the bar, defaulting to info. + +=item thresholds - Colors the bar by its value instead : success below +the first threshold, warning from there, danger from the second. Set to +1 for the defaults of 70 and 90, or to an array ref of two percentages. + +=item max - The value that counts as 100%, defaulting to 100. Use it to +pass raw numbers such as bytes. + +=item value - Text shown with the bar instead of the percentage, such as +"3.4 GB of 10 GB". + +=item small - Set to 1 for a bare bar with no text at all. + +=item inline - Set to 1 to show label, bar and value on one line, as in +a table cell or a list row. + +=item inside - Set to 1 to show the value as a small marker riding on the +end of the bar, instead of at the right of the label row. + +=item segments - Array ref of hash refs with pct (or value, against max), +state and label, drawn as consecutive segments of one bar, with a legend +under it when segments have labels. The main value is then their sum. + +=item indeterminate - Set to 1 for a task of unknown progress, drawn as +a moving bar. The value is ignored. + +=item ring - Set to 1 for a circular gauge with the value in its center, +sized by the size option in pixels, defaulting to 56. + +=cut +sub ui_progress +{ +return &theme_ui_progress(@_) if (defined(&theme_ui_progress)); +my ($value, $opts) = @_; +$opts ||= {}; +my $max = $opts->{'max'}; +my $label = &_ui_text($opts, 'label'); + +# Segments each get a percentage, and together make up the total +my @segments; +if ($opts->{'segments'} && ref($opts->{'segments'}) eq 'ARRAY') { + foreach my $s (@{$opts->{'segments'}}) { + next if (ref($s) ne 'HASH'); + push(@segments, { + 'pct' => defined($s->{'pct'}) ? + &_ui_progress_pct($s->{'pct'}) : + &_ui_progress_pct($s->{'value'}, $max), + 'state' => &_ui_state($s->{'state'} || 'info'), + 'label' => &_ui_text($s, 'label') }); + } + } +my $pct = 0; +if (@segments) { + $pct += $_->{'pct'} foreach (@segments); + $pct = 100 if ($pct > 100); + } +else { + $pct = &_ui_progress_pct($value, $max); + } + +# The color comes from the state, or from the thresholds the value crosses +my $state; +if ($opts->{'state'}) { + $state = &_ui_state($opts->{'state'}); + } +elsif ($opts->{'thresholds'}) { + my ($warn, $danger) = ref($opts->{'thresholds'}) eq 'ARRAY' ? + @{$opts->{'thresholds'}} : (70, 90); + $state = $pct >= $danger ? 'danger' : + $pct >= $warn ? 'warning' : 'success'; + } +else { + $state = 'info'; + } +my $vtext = defined($opts->{'value'}) ? &html_escape($opts->{'value'}) + : $pct."%"; +my $indet = $opts->{'indeterminate'} ? 1 : 0; +my %aria = ( 'role' => 'progressbar', + 'aria-valuemin' => 0, + 'aria-valuemax' => 100 ); +if ($indet) { + $aria{'aria-busy'} = 'true'; + } +else { + $aria{'aria-valuenow'} = $pct; + } + +# A ring is an SVG circle whose dash length is the percentage, drawn on a +# circle with a circumference of 100 units +if ($opts->{'ring'}) { + my $size = int($opts->{'size'} || 56); + # Invalid or subpixel sizes cannot be used to scale the stroke + $size = 56 if ($size < 1); + # The circle is 36 units across, so a stroke of 3 pixels is 3*36/size + # units whatever the size + my $stroke = sprintf("%.2f", 108 / $size); + my $svg = &ui_tag_start('svg', { + 'class' => 'ui_progress_ring_svg', + 'width' => $size, 'height' => $size, + 'viewBox' => '0 0 36 36', 'aria-hidden' => 'true' }). + &ui_tag('circle', undef, { + 'class' => 'ui_progress_ring_track', + 'cx' => 18, 'cy' => 18, 'r' => 15.9155, + 'stroke-width' => $stroke }). + &ui_tag('circle', undef, { + 'class' => "ui_progress_ring_bar ui_fg_$state", + 'cx' => 18, 'cy' => 18, 'r' => 15.9155, + 'stroke-width' => $stroke, + 'stroke-dasharray' => ($indet ? 25 : $pct)." 100" }). + &ui_tag_end('svg'); + my $body = &ui_tag('span', + $svg.&ui_tag('span', $indet ? "" : $vtext, + { 'class' => 'ui_progress_value' }), + { 'class' => 'ui_progress_ring_box' }); + $body .= &ui_tag('span', $label, { 'class' => 'ui_progress_label' }) + if (defined($label)); + return &_ui_block('div', $body, { + 'class' => &_ui_class('ui_progress', 'ui_progress_ring', + $indet ? 'ui_progress_indeterminate' : + undef), + %aria }); + } + +# The bar itself, or the bars of a segmented one +my $bars = ""; +if ($indet) { + $bars = &ui_tag('div', undef, + { 'class' => "ui_progress_bar ui_bg_$state" }); + } +elsif (@segments) { + foreach my $s (@segments) { + $bars .= &ui_tag('div', undef, { + 'class' => "ui_progress_bar ui_bg_$s->{'state'}", + 'style' => "width:$s->{'pct'}%" }); + } + } +else { + $bars = &ui_tag('div', + $opts->{'inside'} ? + &ui_tag('span', $vtext, + { 'class' => 'ui_progress_inside_value' }) : undef, + { 'class' => "ui_progress_bar ui_bg_$state", + 'style' => "width:$pct%" }); + } +my $track = &ui_tag('div', $bars, { 'class' => 'ui_progress_track' }); + +# Legend for labelled segments +my $legend = ""; +if (grep { defined($_->{'label'}) } @segments) { + my $keys = ""; + foreach my $s (@segments) { + $keys .= &ui_tag('span', + &ui_tag('span', undef, + { 'class' => "ui_progress_dot ui_bg_$s->{'state'}" }). + (defined($s->{'label'}) ? $s->{'label'} : "")." ". + &ui_tag('span', $s->{'pct'}."%", + { 'class' => 'ui_progress_value' }), + { 'class' => 'ui_progress_key' }); + } + $legend = &ui_tag('div', $keys, { 'class' => 'ui_progress_legend' }); + } + +# Put the text around the track according to the layout +my $body; +if ($opts->{'small'}) { + $body = $track; + } +elsif ($opts->{'inline'}) { + $body = (defined($label) ? + &ui_tag('span', $label, { 'class' => 'ui_progress_label' }) : + ""). + $track. + ($indet ? "" : + &ui_tag('span', $vtext, { 'class' => 'ui_progress_value' })); + } +else { + my $head = &ui_tag('span', defined($label) ? $label : ""); + $head .= &ui_tag('span', $vtext, { 'class' => 'ui_progress_value' }) + if (!$opts->{'inside'} && !$indet); + $body = &ui_tag('div', $head, { 'class' => 'ui_progress_head' }). + $track.$legend; + } +return &_ui_block('div', $body, { + 'class' => &_ui_class('ui_progress', + $opts->{'small'} ? 'ui_progress_sm' : undef, + $opts->{'inline'} ? 'ui_progress_inline' : undef, + $opts->{'inside'} ? 'ui_progress_inside' : undef, + # Keep the marker within the track at either end + $opts->{'inside'} && $pct < 8 ? 'ui_progress_inside_start' : + $opts->{'inside'} && $pct > 92 ? 'ui_progress_inside_end' : undef, + @segments ? 'ui_progress_segmented' : undef, + $indet ? 'ui_progress_indeterminate' : undef), + %aria }); +} + +# _ui_progress_pct(value, [max]) +# Converts a value into a whole percentage of max (100 by default), +# clamped to 0..100, treating anything that is not a number as 0 +sub _ui_progress_pct +{ +my ($value, $max) = @_; +$value = 0 if (!defined($value) || + $value !~ /\A-?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)\z/); +$max = 100 if (!defined($max) || + $max !~ /\A(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)\z/ || $max <= 0); +my $pct = $value * 100 / $max; +$pct = 0 if ($pct < 0); +$pct = 100 if ($pct > 100); +return int($pct + 0.5); +} + +####################### new form controls + +=head2 ui_toggle(&opts) + +Returns HTML for an on/off switch, submitted like a checkbox. Options +are : + +=item name - Name for this input. + +=item value - Value submitted when on, defaulting to 1. + +=item label - Text shown next to the switch. + +=item checked - Set to 1 if on by default. + +=item disabled - Set to 1 to disable the switch. + +=item attrs - Hash reference of additional attributes for the input, such +as a data-ui-confirm message. + +=item class, id, form - As for other widgets. + +=cut +sub ui_toggle +{ +return &theme_ui_toggle(@_) if (defined(&theme_ui_toggle)); +my ($opts) = @_; +$opts ||= {}; +my $value = defined($opts->{'value'}) ? $opts->{'value'} : 1; +my $attrs = &_ui_attrs({ + %{ $opts->{'attrs'} || {} }, + 'type' => 'checkbox', + 'name' => $opts->{'name'}, + 'value' => $value, + 'id' => defined($opts->{'id'}) ? $opts->{'id'} : + defined($opts->{'name'}) ? $opts->{'name'}."_".$value : undef, + 'form' => $opts->{'form'} }); +# An explicit empty value must be submitted as empty, not the browser's +# default checkbox value of "on" +$attrs->{'value'} = $value; +$attrs->{'checked'} = undef if ($opts->{'checked'}); +$attrs->{'disabled'} = undef if ($opts->{'disabled'}); +my $body = &ui_tag('input', undef, $attrs); +$body .= &ui_tag('span', &ui_tag('span', undef, { 'class' => 'ui_toggle_thumb' }), + { 'class' => 'ui_toggle_track', 'aria-hidden' => 'true' }); +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'}) }); +} + +=head2 ui_search(&opts) + +Returns HTML for a search input with a magnifying-glass icon. Options +are : + +=item name - Name for this input. + +=item value - Initial contents. + +=item placeholder - Hint text, defaulting to "Search". + +=item filter - CSS selector of a container whose table rows or list +entries are filtered client-side as the user types, with no server +round-trip. + +=item width - CSS width for the input. + +=item size - Approximate width in characters, when width is not set. + +=item attrs - Hash reference of additional attributes for the input. + +=item disabled, class, id, form - As for other widgets. + +=cut +sub ui_search +{ +return &theme_ui_search(@_) if (defined(&theme_ui_search)); +my ($opts) = @_; +$opts ||= {}; +my $attrs = &_ui_attrs({ + %{ $opts->{'attrs'} || {} }, + 'type' => 'search', + 'name' => $opts->{'name'}, + 'id' => defined($opts->{'id'}) ? $opts->{'id'} : $opts->{'name'}, + 'class' => &_ui_class('ui_input', 'ui_search_input', $opts->{'class'}), + 'value' => $opts->{'value'}, + 'placeholder' => defined($opts->{'placeholder'}) ? + $opts->{'placeholder'} : $text{'ui_search'} || 'Search', + 'size' => $opts->{'size'} ? int($opts->{'size'}) : undef, + 'form' => $opts->{'form'}, + 'data-ui-filter' => $opts->{'filter'} }); +$attrs->{'disabled'} = undef if ($opts->{'disabled'}); +return &ui_tag('span', + &ui_svg_icon('search', { 'size' => 14, 'class' => 'ui_search_icon' }). + &ui_tag('input', undef, $attrs), + &_ui_attrs({ 'class' => 'ui_search', + 'style' => $opts->{'width'} ? + "width:".$opts->{'width'} : undef })); +} + +####################### choice lists + +=head2 ui_choice(name, value, &options, [&opts]) + +Returns HTML for a boxed list of options with a radio button each, where +an option can carry its own inputs next to its label and further fields +under it. It replaces the hand-built tables of radios and inputs of pages +like the backup destination selector : every option is a row that wraps +on narrow screens instead of a nested table, and all inputs stay visible, +so nothing moves when the choice changes; focusing an input selects its +option (done by ui-lib.js). For a plain list of radios with at most one +input each, use ui_radio_list; when the options are many or their fields +long, ui_select_switch shows only the fields of the chosen option. Each +option is a hash reference with keys : + +=item value - Value submitted when this option is selected. + +=item label - Option text (or label_html for pre-built HTML). + +=item desc - Optional muted explanation under the label. + +=item content - HTML for inputs shown right after the label, such as the +textbox or select the option needs. + +=item fields - Array ref of further inputs shown under the option, each +as C<[ label, html ]> or a hash with label (or label_html) and html, laid +out in as many columns as fit. + +=item disabled - Set to 1 to disable this option. + +Options are : + +=item class - Additional CSS classes. + +=item id - HTML id for the list element. + +The buttons themselves come from ui_oneradio, so a theme styles them as +it styles every other radio button; for that reason a label is plain +text, and anything else goes in content. A ui_radio_table row +C<[ value, label, html ]> becomes +C<{ 'value' => value, 'label' => label, 'content' => html }>. + +=cut +sub ui_choice +{ +return &theme_ui_choice(@_) if (defined(&theme_ui_choice)); +my ($name, $value, $options, $opts) = @_; +$opts ||= {}; +return "" if (!$options || !@$options); +my $body = ""; +foreach my $o (@$options) { + next if (ref($o) ne 'HASH'); + my $oval = defined($o->{'value'}) ? $o->{'value'} : ''; + + # The button with its label, then the inline inputs, on the first line + my $label = &_ui_text($o, 'label'); + my $head = &ui_oneradio($name, $oval, + defined($label) ? $label : &html_escape($oval), + defined($value) && $value eq $oval ? 1 : 0, + undef, $o->{'disabled'} ? 1 : 0); + my $content = &_ui_join($o->{'content'}); + $head .= &ui_tag('span', $content, { 'class' => 'ui_choice_content' }) + if ($content); + my $item = &ui_tag('div', $head, { 'class' => 'ui_choice_head' }); + + # Description and further fields under it + my $desc = &_ui_text($o, 'desc'); + $item .= &ui_tag('div', $desc, { 'class' => 'ui_choice_desc' }) + if (defined($desc)); + $item .= &_ui_fields($o->{'fields'}, 'ui_choice'); + $body .= &_ui_block('div', $item, { + 'class' => &_ui_class('ui_choice_item', + $o->{'disabled'} ? 'ui_choice_disabled' : undef) }); + } +return &_ui_block('div', $body, &_ui_attrs({ + 'class' => &_ui_class('ui_choice', $opts->{'class'}), + 'role' => 'radiogroup', + 'id' => $opts->{'id'} })); +} + +# _ui_fields(&fields, class-prefix) +# Returns the grid of labelled inputs under an option of ui_choice or +# ui_select_switch, or an empty string when there are none. Each field is +# [ label, html ] or a hash with label (or label_html) and html. +sub _ui_fields +{ +my ($fields, $prefix) = @_; +return "" if (ref($fields) ne 'ARRAY' || !@$fields); +my $rv = ""; +foreach my $f (@$fields) { + my ($flabel, $fhtml); + if (ref($f) eq 'HASH') { + $flabel = &_ui_text($f, 'label'); + $fhtml = $f->{'html'}; + } + elsif (ref($f) eq 'ARRAY') { + $flabel = defined($f->[0]) ? &html_escape($f->[0]) : undef; + $fhtml = $f->[1]; + } + else { + next; + } + $rv .= &ui_tag('div', + (defined($flabel) ? + &ui_tag('span', $flabel, + { 'class' => $prefix.'_field_label' }) : ""). + &ui_tag('span', defined($fhtml) ? $fhtml : "", + { 'class' => $prefix.'_field_input' }), + { 'class' => $prefix.'_field' }); + } +return &_ui_block('div', $rv, { 'class' => $prefix.'_fields' }); +} + +=head2 ui_radio_list(name, value, &options, [&opts]) + +Returns HTML for a compact boxed list of radio buttons, one per line, each +with an optional input right after its label : the direct replacement of +ui_radio_table. The buttons sit at the left edge of the box, close to one +another. For options that need several fields or an explanation, use +ui_choice. Each option is a hash reference with keys value, label (plain +text), content (HTML shown after the label) and disabled, as for +ui_choice; the buttons come from ui_oneradio so that the theme styles +them. Options are class and id. + +=cut +sub ui_radio_list +{ +return &theme_ui_radio_list(@_) if (defined(&theme_ui_radio_list)); +my ($name, $value, $options, $opts) = @_; +$opts ||= {}; +return "" if (!$options || !@$options); +my $body = ""; +foreach my $o (@$options) { + next if (ref($o) ne 'HASH'); + my $oval = defined($o->{'value'}) ? $o->{'value'} : ''; + my $label = &_ui_text($o, 'label'); + my $item = &ui_oneradio($name, $oval, + defined($label) ? $label : &html_escape($oval), + defined($value) && $value eq $oval ? 1 : 0, + undef, $o->{'disabled'} ? 1 : 0); + my $content = &_ui_join($o->{'content'}); + $item .= &ui_tag('span', $content, { 'class' => 'ui_radio_list_content' }) + if ($content); + $body .= &ui_tag('div', $item, { + 'class' => &_ui_class('ui_radio_list_item', + $o->{'disabled'} ? 'ui_radio_list_disabled' : undef) }); + } +return &_ui_block('div', $body, &_ui_attrs({ + 'class' => &_ui_class('ui_radio_list', $opts->{'class'}), + 'role' => 'radiogroup', + 'id' => $opts->{'id'} })); +} + +=head2 ui_select_switch(name, value, &options, [&opts]) + +Returns HTML for a select whose choice decides which block of inputs is +shown under it : the alternative to ui_choice when the options are many +or their fields long, since only the block of the current choice takes +any space. Each option is a hash reference with keys value, label (the +select entry), desc, content and fields as for ui_choice, the last three +making up the option's block. The blocks of the other options are hidden, +and ui-lib.js switches them when the select changes; the inputs of hidden +blocks are still submitted with the form. Options are class and id for +the wrapper. + +=cut +sub ui_select_switch +{ +return &theme_ui_select_switch(@_) if (defined(&theme_ui_select_switch)); +my ($name, $value, $options, $opts) = @_; +$opts ||= {}; +my @options = grep { ref($_) eq 'HASH' } @{$options || []}; +return "" if (!@options); +# A select defaults to its first option when the saved value is absent. +# Use that same value for the panels so the visible fields always agree. +if (!defined($value) || + !(grep { (defined($_->{'value'}) ? $_->{'value'} : '') eq $value } + @options)) { + $value = defined($options[0]->{'value'}) ? $options[0]->{'value'} : ''; + } +my @sel; +my $panels = ""; +foreach my $o (@options) { + my $oval = defined($o->{'value'}) ? $o->{'value'} : ''; + push(@sel, [ $oval, + defined($o->{'label_html'}) ? $o->{'label_html'} : + defined($o->{'label'}) ? &html_escape($o->{'label'}) : + &html_escape($oval) ]); + + # The block for this option, hidden unless it is the current one. Its + # inline content is shown as a first field labelled with the option + # name, since the button that would have named it is not there + my $panel = ""; + my $desc = &_ui_text($o, 'desc'); + $panel .= &ui_tag('div', $desc, { 'class' => 'ui_select_switch_desc' }) + if (defined($desc)); + my $content = &_ui_join($o->{'content'}); + $panel .= &ui_tag('div', + &ui_tag('span', $sel[-1]->[1], + { 'class' => 'ui_select_switch_field_label' }). + &ui_tag('span', $content, + { 'class' => 'ui_select_switch_field_input' }), + { 'class' => 'ui_select_switch_field ui_select_switch_content' }) + if ($content); + $panel .= &_ui_fields($o->{'fields'}, 'ui_select_switch'); + next if ($panel eq ''); + my $attrs = { 'class' => 'ui_select_switch_panel', + 'data-ui-switch-value' => $oval }; + $attrs->{'hidden'} = undef if ($oval ne $value); + $panels .= &_ui_block('div', $panel, $attrs); + } +my $select = &ui_select($name, $value, \@sel, undef, undef, undef, 0, + "data-ui-switch='1'"); +return &_ui_block('div', $select.$panels, &_ui_attrs({ + 'class' => &_ui_class('ui_select_switch', $opts->{'class'}), + 'id' => $opts->{'id'} })); +} + 1; diff --git a/unauthenticated/css/ui-lib.css b/unauthenticated/css/ui-lib.css new file mode 100644 index 000000000..4d587cd0d --- /dev/null +++ b/unauthenticated/css/ui-lib.css @@ -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; + } +} diff --git a/unauthenticated/js/ui-lib.js b/unauthenticated/js/ui-lib.js new file mode 100644 index 000000000..0f8d83eae --- /dev/null +++ b/unauthenticated/js/ui-lib.js @@ -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); + }); + +})();