Add small and rounded badge options
Some checks are pending
Tests / prove (push) Waiting to run
Package and upload artifacts / build (push) Waiting to run

This commit is contained in:
Ilia Ross
2026-09-22 18:14:39 +02:00
parent 3a2b2b8671
commit 6dfaa54616
5 changed files with 80 additions and 7 deletions

View File

@@ -60,6 +60,24 @@ sub decode_attr {
my $xss = q{x"><script>alert(1)</script><b onmouseover="alert(1)};
# A decoded value elsewhere on the page must not double-encode the note icon.
{
my $decoded = 'efi';
utf8::upgrade($decoded);
foreach my $prefix ('efi', $decoded) {
my $html = $prefix.main::ui_note('Note', 0);
# Model the UTF-8 response bytes and the browser's decoding step.
utf8::encode($html) if (utf8::is_utf8($html));
utf8::decode($html);
like($html, qr/(?:\x{24d8}|&#9432;|&#x24d8;)/i,
'ui_note icon survives byte and decoded page strings');
}
my $html = main::ui_note("Note \x{20ac}", 0);
like($html, qr/(?:\x{24d8}|&#9432;|&#x24d8;)/i,
'ui_note icon survives decoded note text');
like($html, qr/Note \x{20ac}/, 'ui_note preserves decoded note text');
}
# ---- escaping contract -----------------------------------------------------
assert_no_handler_injection(
@@ -71,6 +89,9 @@ assert_no_handler_injection(
'ui_card title+desc');
assert_no_handler_injection(main::ui_badge($xss, 'success'),
'ui_badge text');
assert_no_handler_injection(
main::ui_badge('B', 'success', { 'class' => $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('<b>x</b>', $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');

View File

@@ -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 |

View File

@@ -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([

View File

@@ -3756,8 +3756,10 @@ return &theme_ui_note(@_) if (defined(&theme_ui_note));
my ($text, $whitespace) = @_;
$whitespace //= 2;
my $whitespace_str = "&nbsp;" 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 "<font class='ui_note' style='font-size:92%;opacity:0.66'>".
"${whitespace_str}&nbsp;&nbsp;$text".
"${whitespace_str}&#9432;&nbsp;&nbsp;$text".
"</font>";
}
@@ -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'} }));
}

View File

@@ -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;