From 69c1114f7a8c32de33033e560f6cbeccdad80daf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kuna?= <1282324+codecalm@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:20:02 +0200 Subject: [PATCH] Add item slots, sizes, selectable and nested items to list group (#3272) --- .changeset/list-group-slots.md | 7 + core/scss/_variables.scss | 8 +- core/scss/ui/_lists.scss | 96 +++- docs/content/ui/components/list-group.mdx | 229 +++++++- preview/pages/lists.astro | 542 +++++++++++++++++- shared/components/cards/UsersList.astro | 51 +- .../components/cards/UsersListHeaders.astro | 20 +- shared/ui/ListGroup.astro | 10 +- shared/ui/ListGroupItem.astro | 45 +- shared/ui/StatusDot.astro | 2 +- 10 files changed, 898 insertions(+), 112 deletions(-) create mode 100644 .changeset/list-group-slots.md diff --git a/.changeset/list-group-slots.md b/.changeset/list-group-slots.md new file mode 100644 index 000000000..2ae4453ed --- /dev/null +++ b/.changeset/list-group-slots.md @@ -0,0 +1,7 @@ +--- +"@tabler/core": minor +"@tabler/preview": minor +"@tabler/docs": patch +--- + +Added item slots, `.list-group-sm` and `.list-group-lg` sizes, selectable and nested items to the list group. diff --git a/core/scss/_variables.scss b/core/scss/_variables.scss index ee24cc5fb..ba31bb158 100644 --- a/core/scss/_variables.scss +++ b/core/scss/_variables.scss @@ -1968,7 +1968,7 @@ $list-group-border-color: var(--border-color) !default; $list-group-border-width: var(--border-width) !default; $list-group-border-radius: var(--border-radius) !default; -$list-group-item-padding-y: $card-cap-padding-y !default; +$list-group-item-padding-y: 1rem !default; $list-group-item-padding-x: $card-cap-padding-x !default; $list-group-hover-bg: var(--hover-bg) !default; @@ -1984,6 +1984,12 @@ $list-group-action-hover-color: var(--emphasis-color) !default; $list-group-action-active-color: var(--body-color) !default; $list-group-action-active-bg: var(--active-bg) !default; $list-group-header-padding-y: 0.5rem !default; +$list-group-item-gap: 1rem !default; +$list-group-nested-indent: 2.25rem !default; +$list-group-item-padding-y-sm: 0.5rem !default; +$list-group-item-gap-sm: 0.75rem !default; +$list-group-item-padding-y-lg: 1.5rem !default; +$list-group-item-gap-lg: 1.25rem !default; // Color variants: a tinted row, matched to the `bg-{color}-lt` utilities $list-group-variant-border-alpha: 0.24 !default; diff --git a/core/scss/ui/_lists.scss b/core/scss/ui/_lists.scss index 5e0ab44db..e7ec6f6ba 100644 --- a/core/scss/ui/_lists.scss +++ b/core/scss/ui/_lists.scss @@ -2,6 +2,8 @@ .list-group { --list-group-header-padding-y: #{$list-group-header-padding-y}; + --list-group-item-gap: #{$list-group-item-gap}; + --list-group-nested-indent: #{$list-group-nested-indent}; margin-inline-start: 0; margin-inline-end: 0; @@ -35,10 +37,98 @@ } } -.list-group-item.active { +// A checked `.list-group-selectable-check` marks its item like `.active`, +// the same way `.table-selectable` marks a table row. +.list-group-item.active, +.list-group-selectable .list-group-item:has(.list-group-selectable-check:checked) { background-color: var(--active-bg); - border-inline-start-color: $component-active-bg; - border-inline-start-width: $border-width-wide; + // An inset shadow instead of a wider border, so the content does not shift + box-shadow: inset $border-width-wide 0 0 $component-active-bg; +} + +// Density +.list-group-sm { + --list-group-item-padding-y: #{$list-group-item-padding-y-sm}; + --list-group-item-gap: #{$list-group-item-gap-sm}; +} + +.list-group-lg { + --list-group-item-padding-y: #{$list-group-item-padding-y-lg}; + --list-group-item-gap: #{$list-group-item-gap-lg}; +} + +// Item slots: media, content (title + description), meta and actions. +// The item becomes a flex row only when it holds a content slot, so items +// with plain text or a grid row inside keep their layout. +.list-group-item:has(> .list-group-item-content) { + display: flex; + gap: var(--list-group-item-gap); + align-items: center; +} + +.list-group-item-media, +.list-group-item-meta, +.list-group-item-actions { + flex: none; +} + +.list-group-item-media { + display: flex; + align-items: center; +} + +.list-group-item-content { + flex: 1; + min-width: 0; +} + +.list-group-item-title, +.list-group-item-description { + display: block; + @include text-truncate(); +} + +.list-group-item-title { + color: inherit; +} + +.list-group-item-description, +.list-group-item-meta { + color: var(--secondary); +} + +// Nested items: a block of indented items under a parent item, usually a +// `.collapse` toggled by that parent. It draws the side and bottom borders its +// borderless items leave out. +.list-group-nested { + border: var(--list-group-border-width) solid var(--list-group-border-color); + border-top-width: 0; + + > .list-group-item { + padding-inline-start: calc(var(--list-group-item-padding-x) + var(--list-group-nested-indent)); + border-width: 0; + } + + + .list-group-item { + border-top-width: 0; + } + + .list-group-flush > & { + border-inline-width: 0; + + &:last-child { + border-bottom-width: 0; + } + } +} + +// Chevron of a parent item, turned over while its nested items are open +.list-group-item-toggle { + @include transition(transform $transition-time); + + [aria-expanded='true'] & { + transform: rotate(180deg); + } } .list-bordered { diff --git a/docs/content/ui/components/list-group.mdx b/docs/content/ui/components/list-group.mdx index 22c47d6ca..0459a3c08 100644 --- a/docs/content/ui/components/list-group.mdx +++ b/docs/content/ui/components/list-group.mdx @@ -13,8 +13,29 @@ classnames: desc: One entry - class: list-group-header desc: Section label between entries + - class: list-group-item-media + desc: Slot for an icon, an avatar or an input on the leading edge of an entry + - class: list-group-item-content + desc: Main slot of an entry; its presence turns the entry into a row of slots + - class: list-group-item-title + desc: First line of the content, cut with an ellipsis + - class: list-group-item-description + desc: Second line of the content, in the secondary color + - class: list-group-item-meta + desc: Slot for a time, a count or a badge after the content - class: list-group-item-actions desc: Slot for controls on the trailing edge of an entry + - class: list-group-selectable-check + desc: Checkbox or radio that marks its entry when checked + - class: list-group-nested + desc: Block of indented entries under a parent entry, usually a collapse + - class: list-group-item-toggle + desc: Chevron of a parent entry, turned over while its nested entries are open + size: + - class: list-group-sm + desc: Compact entries + - class: list-group-lg + desc: Roomy entries style: - class: list-group-flush desc: Removes the outer border, to sit flush in a card @@ -29,6 +50,8 @@ classnames: desc: Highlights the entry under the cursor - class: list-group-item-action desc: Marks an entry as a link or a button + - class: list-group-selectable + desc: Gives an entry with a checked input the active look direction: - class: list-group-horizontal desc: Lays the entries out in a row; -{breakpoint} only above that width @@ -61,6 +84,113 @@ The container can be any element. Use `ul` with `li` items for a real list, or ` +## Item layout + +An item can hold more than one line of text. Put a `list-group-item-content` element in the item and it becomes a row of slots: + +- `list-group-item-media` - an icon, an avatar or an input before the content, +- `list-group-item-content` - the main part, with `list-group-item-title` and `list-group-item-description`, +- `list-group-item-meta` - a time, a count or a badge after the content, +- `list-group-item-actions` - buttons and other controls at the end. + +Only the content slot is required. The title and the description are cut with an ellipsis when they do not fit. + + +
+
+
+ +
+
+
Paweł Kuna
+
UI Designer
+
+
2 h
+
+ +
+
+
+
+ +
+
+
Alice Marlin
+
Frontend developer
+
+
3 d
+
+ +
+
+
+
+ +The item itself can be a link or a `label`. Use a `label` with a checkbox, a radio or a switch inside, so a click on the whole row changes the input. + + +
+ + +
+
+ +### Longer text + +The slots are centered and the description stays on one line. For longer text add the `align-items-start` utility to the item and `text-wrap` to the description. + + +
+
+
+ +
+
+
Paweł Kuna
+
The new list layout works well on the dashboard. Can we use the same slots for the search results?
+
+
2 h
+
+
+
+ +## Sizes + +Add `list-group-sm` for compact items or `list-group-lg` for items with more room. The classes change the vertical padding of the items and the gap between the slots. + + +
+
Small item
+
Small item
+
+
+
Default item
+
Default item
+
+
+
Large item
+
Large item
+
+
+ ## Variants ### Flush @@ -101,6 +231,57 @@ Mark the current item with `active`. Tabler shows it with a thicker border on th +### Selectable items + +Add `list-group-selectable` to the container and `list-group-selectable-check` to the checkbox or radio in each item. A checked input gives its item the same look as `active`, with no JavaScript. Use a `label` as the item, so a click on the whole row changes the input. + + +
+ + +
+
+ +### Nested items + +Put the child items in a `list-group-nested` element right after their parent item. The nested items are indented and have no borders between them. Make the element a `collapse` and the parent item its toggle to open and close the group. Add `list-group-item-toggle` to the chevron, and it turns over while the group is open. + + +
+ + + + + + Settings + + +
+
+ +The indent is the `--tblr-list-group-nested-indent` variable. Its default matches an icon in the media slot. + ### Section header Use `list-group-header` to split a long list into groups. The header is a small uppercase label with its own background. @@ -122,20 +303,16 @@ Add `list-group-hoverable` to the container to highlight the row under the point
-
-
Change deprecated html tags
-
- -
+
+
Change deprecated html tags
+
-
-
Fix spacing in the navbar
-
- -
+
+
Fix spacing in the navbar
+
@@ -234,30 +411,22 @@ This pattern joins several variants: a flush list inside a card, hoverable rows,

Notifications

-
-
- -
-
- Build failed -
The last deploy did not finish
-
-
- -
+
+
+
+ Build failed +
The last deploy did not finish
+
+
-
-
-
- New comment -
Alice replied in the design thread
-
-
- -
+
+
+ New comment +
Alice replied in the design thread
+
diff --git a/preview/pages/lists.astro b/preview/pages/lists.astro index 538288148..0a789c781 100644 --- a/preview/pages/lists.astro +++ b/preview/pages/lists.astro @@ -1,50 +1,540 @@ --- import DefaultLayout from '@shared/layouts/DefaultLayout.astro' import Card from '@ui/Card.astro' +import CardBody from '@ui/CardBody.astro' import CardHeader from '@ui/CardHeader.astro' +import CardTitle from '@ui/CardTitle.astro' +import CardSubtitle from '@ui/CardSubtitle.astro' +import CardDropdown from '@ui/CardDropdown.astro' import UsersList from '@shared/components/cards/UsersList.astro' import UsersList2 from '@shared/components/cards/UsersList2.astro' import UsersListHeaders from '@shared/components/cards/UsersListHeaders.astro' import ListGroup from '@ui/ListGroup.astro' import ListGroupItem from '@ui/ListGroupItem.astro' +import Avatar from '@ui/Avatar.astro' +import Badge from '@ui/Badge.astro' +import Flag from '@ui/Flag.astro' +import Icon from '@ui/Icon.astro' +import Progress from '@ui/Progress.astro' +import StatusDot from '@ui/StatusDot.astro' import DocsLink from '@ui/DocsLink.astro' +import Empty from '@ui/Empty.astro' +import { people } from '@shared/lib/people' +import type { FlagCountry } from '@shared/lib/tokens' +import transactionsData from '@data/transactions.json' +import notificationsData from '@data/notifications.json' + +const team = people.slice(0, 4) + +const folders = [ + { icon: 'inbox', title: 'Inbox', description: 'Messages waiting for a reply', count: 12 }, + { icon: 'send', title: 'Sent', description: 'Everything you have sent', count: 148 }, + { icon: 'archive', title: 'Archive', description: 'Closed conversations', count: 2304 }, + { icon: 'trash', title: 'Trash', description: 'Removed after 30 days', count: 7 }, +] + +const plans = [ + { id: 'starter', title: 'Starter', description: 'Up to 3 projects', price: '$9' }, + { id: 'team', title: 'Team', description: 'Unlimited projects, 10 members', price: '$29' }, + { id: 'business', title: 'Business', description: 'Unlimited members and SSO', price: '$79' }, +] + +const channels = [ + { id: 'email', icon: 'mail', title: 'Email', description: 'A summary every morning', checked: true }, + { id: 'push', icon: 'device-mobile', title: 'Push', description: 'Mentions and direct messages', checked: true }, + { id: 'slack', icon: 'brand-slack', title: 'Slack', description: 'Posts to the #updates channel', checked: false }, +] + +const comments = [ + { text: 'The new list layout works well on the dashboard. Can we use the same slots for the search results?', time: '2 h' }, + { text: 'Yes, the results only need a wrapped description and the avatar aligned to the top.', time: '1 h' }, + { text: 'Done. I also added the nested items to the sidebar.', time: '12 min' }, +] + +const tree = [ + { id: 'list-nested-projects', icon: 'folder', title: 'Projects', open: true, items: ['Website redesign', 'Mobile app', 'Design system'] }, + { id: 'list-nested-teams', icon: 'users', title: 'Teams', open: false, items: ['Design', 'Engineering'] }, +] + +const sizes = [ + { size: undefined, title: 'Default', subtitle: 'Without a size class the items use the default padding.' }, + { size: 'sm', title: 'Small', subtitle: 'Add list-group-sm for a compact list.' }, + { size: 'lg', title: 'Large', subtitle: 'Add list-group-lg for more room around each item.' }, +] as const + +const files = [ + { icon: 'file-type-pdf', name: 'Annual report 2025.pdf', size: '2.4 MB' }, + { icon: 'file-spreadsheet', name: 'Budget forecast.xlsx', size: '318 kB' }, + { icon: 'photo', name: 'Office photos.zip', size: '48 MB' }, + { icon: 'file-text', name: 'Meeting notes.md', size: '4 kB' }, +] + +const visits: { country: string; flag: FlagCountry; value: number }[] = [ + { country: 'United States', flag: 'us', value: 64 }, + { country: 'Germany', flag: 'de', value: 48 }, + { country: 'Poland', flag: 'pl', value: 31 }, + { country: 'Japan', flag: 'jp', value: 17 }, +] + +// Rows of transactions.json whose logo exists in static/. +const transactionIds = ['3', '4', '9', '11'] +const transactions = (transactionsData as { id: string; counterparty: string; counterparty_logo: string; description: string; amount: number }[]) + .filter((item) => transactionIds.includes(item.id)) + .map((item) => ({ + ...item, + amountLabel: `${item.amount < 0 ? '−' : '+'}${Math.abs(item.amount).toLocaleString('en-US', { style: 'currency', currency: 'USD' })}`, + })) + +const notifications = (notificationsData as { title: string; description: string; status: string }[]).slice(0, 4).map((item, index) => ({ + ...item, + unread: item.status === 'unread', + time: `${(index + 1) * 2} h`, +})) --- - +
-
-
-
- -
-
- -
-
+
+ + + Text only + A plain list-group-item holds one line of text. + + + {folders.map((item) => {item.title})} + + +
+ +
+ + + Title and description + Put list-group-item-title and list-group-item-description in list-group-item-content. + + + {folders.map((item) => )} + + +
+ +
+ + + With icon + The list-group-item-media slot holds an icon before the content. + + + { + folders.map((item) => ( + + + + )) + } + + +
+ +
+ + + With avatar + The media slot takes an avatar the same way. + + + { + team.map((person) => ( + + + + )) + } + + +
+ +
+ + + With meta + Use list-group-item-meta for a time, a count or a badge at the end. + + + { + folders.map((item) => ( + + {item.count} + + )) + } + + +
+ +
+ + + All slots + Media, content, meta and list-group-item-actions in one item. + + + { + team.map((person, index) => ( + + + {index === 0 ? : `${index * 3} d`} + + + )) + } + + +
+ +
+ + + Links + Add list-group-item-action to a link item. It supports active and disabled. + + + { + folders.map((item, index) => ( + + + + + )) + } + + +
+ +
+ + + Actions on hover + In a list-group-hoverable list the actions show on hover. Add show to keep them visible. + + + { + team.map((person, index) => ( + + + + + )) + } + + +
+ +
+ + + Buttons + The actions slot holds any control, for example a button. + + + { + team.map((person, index) => ( + + + + + )) + } + + +
+ +
+ + + Checkboxes + In a list-group-selectable list a checked list-group-selectable-check marks its item. + + + { + team.map((person, index) => ( + + + + + + + )) + } + + +
+ +
+ + + Radios + Radio inputs with one name make a single choice list. Use a label as the item. + + + { + plans.map((plan, index) => ( + + + {plan.price} + + )) + } + + +
+ +
+ + + Switches + Put a switch in the actions slot to build a settings list. + + + { + channels.map((channel) => ( + + + + + + + )) + } + + +
+ +
+ + + Wrapped description + Add align-items-start to the item and text-wrap to the description for longer text. + + + { + comments.map((comment, index) => ( + + +
{comment.text}
+ {comment.time} +
+ )) + } +
+
+
+ +
+ + + Nested items + Put list-group-nested on a collapse after the parent item. list-group-item-toggle turns the chevron. + + + { + tree.map((group) => ( + <> + + + + +
+ {group.items.map((item) => ( + + {item} + + ))} +
+ + )) + } + + + +
+
+
+ +
+ + + Drag handle + Add data-sortable to the list and put the handle in the media slot. + + + { + team.map((person) => ( + + + + + + + )) + } + + +
+ + { + sizes.map((item) => ( +
- - - The current link item - A second link item - A third link item - A fourth link item - A disabled link item + + {item.title} + + + + + + {team.map((person) => ( + + + + ))}
-
+ )) + } + +
+ + + + { + notifications.map((item) => ( + + + {item.time} + + )) + } + + +
+ +
+ + + + { + transactions.map((item) => ( + + + + {item.amountLabel} + + + )) + } + + +
+ +
+ + + + { + files.map((file) => ( + + + + + )) + } + + +
+ +
+ + + + { + visits.map((item) => ( + + + + {item.value}% + + )) + } + + +
+ +
+ + + Inside a card body + Without list-group-flush the list keeps its own border and rounded corners. + + { + files.slice(0, 3).map((file) => ( + + + {file.size} + + )) + } + + + +
+ +
+ + +