From 6dfaa546160b5b5403c4e5e3ce640ddbcc9ba1e4 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Tue, 22 Sep 2026 18:14:39 +0200 Subject: [PATCH] Add small and rounded badge options --- t/ui-lib-widgets.t | 34 ++++++++++++++++++++++++++++++++++ ui-demo/README.md | 2 +- ui-demo/ui-demo-pages.pl | 16 ++++++++++++++++ ui-lib.pl | 15 +++++++++++++-- unauthenticated/css/ui-lib.css | 20 ++++++++++++++++---- 5 files changed, 80 insertions(+), 7 deletions(-) diff --git a/t/ui-lib-widgets.t b/t/ui-lib-widgets.t index 046e56ae8..a2b8334bb 100644 --- a/t/ui-lib-widgets.t +++ b/t/ui-lib-widgets.t @@ -60,6 +60,24 @@ sub decode_attr { my $xss = q{x"> $xss, 'title' => $xss }), + 'ui_badge class+title'); assert_no_handler_injection(main::ui_chip($xss), 'ui_chip text'); assert_no_handler_injection(main::ui_code($xss), 'ui_code'); assert_no_handler_injection(main::ui_tip('x', $xss), 'ui_tip'); @@ -119,6 +140,19 @@ like(main::ui_badge('down', 'err'), qr/ui_badge_danger/, like(main::ui_badge('what', 'bogus<'), qr/ui_badge_neutral/, 'unknown state falls back to neutral'); +# Badges take a smaller size, rounded ends and extra class names +{ + my $plain = main::ui_badge('B', 'info'); + unlike($plain, qr/ui_badge_small|ui_badge_rounded/, + 'a badge is full-sized and square by default'); + my $html = main::ui_badge('B', 'info', + { 'small' => 1, 'rounded' => 1, 'class' => 'mine' }); + like($html, qr/\bui_badge_small\b/, 'small option adds its class'); + like($html, qr/\bui_badge_rounded\b/, 'rounded option adds its class'); + like($html, qr/\bui_badge_info\b/, 'and the state class stays'); + like($html, qr/\bmine\b/, 'extra class names are passed through'); +} + # 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'); diff --git a/ui-demo/README.md b/ui-demo/README.md index 471f9b5fc..ce8dad60d 100644 --- a/ui-demo/README.md +++ b/ui-demo/README.md @@ -13,7 +13,7 @@ Every tab of `index.cgi` is built by one function in `ui-demo-pages.pl` : | Tab | Function | Shows | |------------|-----------------------|------------------------------------------------------------------| | Cards | `demo_cards_tab` | `ui_card` in its variations : buttons inside a card, header actions and footer, state accents with icon titles, a two-column `ui_dl`, a flush list filtered by a `ui_search` in the header, a standard table inside a card, a metric card with `ui_stat` and inline `ui_progress`, a card printed with `ui_card_start`/`ui_card_end`; `ui_stats`; `ui_grid` with the `template` option | -| Elements | `demo_elements_tab` | a `ui_dl` with help bubbles and HTML values, `ui_stats` with icons and links, `ui_feed` with an HTML event, `ui_empty_state`, then badges with their icon, dot and title options, chips, `ui_code`, `ui_note`, `ui_help`, `ui_tip`, every `ui_progress` variation and the ring gauges, and `ui_svg_icon` | +| Elements | `demo_elements_tab` | a `ui_dl` with help bubbles and HTML values, `ui_stats` with icons and links, `ui_feed` with an HTML event, `ui_empty_state`, then badges with their icon, dot, title, small and rounded options, chips, `ui_code`, `ui_note`, `ui_help`, `ui_tip`, every `ui_progress` variation and the ring gauges, and `ui_svg_icon` | | Forms | `demo_forms_tab` | the usual `ui_table_start` / `ui_table_row` form with `ui_toggle`, `ui_search`, the date chooser, password fields and a table of inputs inside one row built with `ui_columns_start` / `ui_columns_row` with the `no-hover` class, as the Nginx module's URL rewrites; a second form of choosers : `file_chooser_button` for files and directories, `ui_user_textbox`, `ui_group_textbox`, `ui_users_textbox`, `ui_groups_textbox`, and an `hlink` help link | | Choices | `demo_choices_tab` | `ui_choice`, `ui_select_switch` and `ui_radio_list` for backup destinations and IP addresses; `ui_multi_select_list` for backup servers with modes and child folding, secondary groups, and an empty list with a custom label | | Buttons | `demo_buttons_tab` | `ui_submit`, `ui_reset` and `ui_link_button` in one row, with a disabled and a confirmed one; a form ended by `ui_form_end`, one by `ui_form_grouped_buttons`, one by `ui_form_end_side_by_side` with a separate form at the right; a `ui_confirmation_form` page | diff --git a/ui-demo/ui-demo-pages.pl b/ui-demo/ui-demo-pages.pl index 31569fb60..c44a38ea9 100644 --- a/ui-demo/ui-demo-pages.pl +++ b/ui-demo/ui-demo-pages.pl @@ -372,6 +372,22 @@ $rv .= ui_grid([ ui_chip('journal'), ui_chip('ipv6'), ]), + # The small badge, which does not outweigh the + # text beside it, and the rounded one. Only + # badges take a rounded option; the widgets are + # drawn square by default + ui_cluster([ + ui_badge($text{'index_running'}, 'success', + { 'small' => 1 }), + ui_badge($text{'index_stopped'}, 'danger', + { 'small' => 1, 'dot' => 1 }), + ui_badge($text{'index_off'}, 'neutral', + { 'small' => 1, 'icon' => '' }), + ui_badge($text{'index_running'}, 'success', + { 'rounded' => 1 }), + ui_badge($text{'index_syncing'}, 'info', + { 'small' => 1, 'rounded' => 1 }), + ]), # Inline code, a note, the existing help bubble, and # ui_tip, which gives any HTML the same theme tooltip ui_cluster([ diff --git a/ui-lib.pl b/ui-lib.pl index 0c2e92cb3..1a4a0a903 100755 --- a/ui-lib.pl +++ b/ui-lib.pl @@ -3756,8 +3756,10 @@ return &theme_ui_note(@_) if (defined(&theme_ui_note)); my ($text, $whitespace) = @_; $whitespace //= 2; my $whitespace_str = " " x $whitespace; +# Written as an entity, not a literal character, so the icon survives +# whether the page is handled as bytes or as decoded text return "". - "${whitespace_str}ⓘ  $text". + "${whitespace_str}ⓘ  $text". ""; } @@ -4955,6 +4957,12 @@ default icon. Set to an empty string for no icon at all. =item title - Tooltip text for the badge. +=item small - Set to 1 for a smaller badge, so it does not outweigh the text next to it. List rows already draw their badges small. + +=item rounded - Set to 1 for rounded ends, instead of the square corners badges use by default. + +=item class - Extra CSS class names for the badge. + =cut sub ui_badge { @@ -4974,7 +4982,10 @@ 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", + &_ui_attrs({ 'class' => &_ui_class('ui_badge', 'ui_badge_'.$state, + $opts->{'small'} ? 'ui_badge_small' : undef, + $opts->{'rounded'} ? 'ui_badge_rounded' : undef, + $opts->{'class'}), 'title' => $opts->{'title'} })); } diff --git a/unauthenticated/css/ui-lib.css b/unauthenticated/css/ui-lib.css index 41c1ab400..87ca692b3 100644 --- a/unauthenticated/css/ui-lib.css +++ b/unauthenticated/css/ui-lib.css @@ -513,6 +513,17 @@ a.ui_stat:hover .ui_stat_value { color: var(--ui-accent); } border-color: var(--ui-neutral-line); color: var(--ui-neutral-text); } +/* A smaller badge, so it does not outweigh the text next to it. The + * icon scales with the text rather than staying at full size */ +.ui_badge_small { + padding: 0 8px; + font-size: 0.8em; + line-height: 1.25; +} +.ui_badge_small .ui_svg_icon { width: 1em; height: 1em; } +/* Rounded ends, only when a caller asks for them. Widgets are drawn + * square, apart from the toggle and the badge dot, which are round */ +.ui_badge_rounded { border-radius: 999px; } .ui_chip { display: inline-flex; align-items: center; @@ -564,16 +575,17 @@ a.ui_stat:hover .ui_stat_value { color: var(--ui-accent); } 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 */ +/* Badges in list rows are small, so they do not outweigh the text */ .ui_list_item .ui_badge { - padding: 1px 8px; + padding: 0 8px; gap: 4px; - font-size: 0.86em; + font-size: 0.81em; } .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_desc .ui_badge { font-size: calc(0.81em / 0.93); } +.ui_list_desc .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;