Add item slots, sizes, selectable and nested items to list group (#3272)

This commit is contained in:
Paweł Kuna authored and GitHub committed 2026-10-08 14:20:02 +02:00
1 parent 5aad08b7c1
commit 69c1114f7a
10 files changed
+898 -112

No files matched your search

+7
View File
@@ -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.
+7 -1
View File
@@ -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;
+93 -3
View File
@@ -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 {
+199 -30
View File
@@ -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 `
</ul>
</Example>
## 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.
<Example bg="surface-secondary" column>
<div class="list-group">
<div class="list-group-item">
<div class="list-group-item-media">
<span class="avatar" style="background-image: url(/static/avatars/000m.jpg)"></span>
</div>
<div class="list-group-item-content">
<div class="list-group-item-title">Paweł Kuna</div>
<div class="list-group-item-description">UI Designer</div>
</div>
<div class="list-group-item-meta">2 h</div>
<div class="list-group-item-actions">
<button type="button" class="btn btn-sm">Follow</button>
</div>
</div>
<div class="list-group-item">
<div class="list-group-item-media">
<span class="avatar" style="background-image: url(/static/avatars/016f.jpg)"></span>
</div>
<div class="list-group-item-content">
<div class="list-group-item-title">Alice Marlin</div>
<div class="list-group-item-description">Frontend developer</div>
</div>
<div class="list-group-item-meta">3 d</div>
<div class="list-group-item-actions">
<button type="button" class="btn btn-sm">Follow</button>
</div>
</div>
</div>
</Example>
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.
<Example bg="surface-secondary" column>
<div class="list-group">
<label class="list-group-item">
<span class="list-group-item-media"><Icon name="mail" color="muted" /></span>
<span class="list-group-item-content">
<span class="list-group-item-title">Email</span>
<span class="list-group-item-description">A summary every morning</span>
</span>
<span class="list-group-item-actions">
<span class="form-check form-switch m-0"><input type="checkbox" role="switch" class="form-check-input" checked /></span>
</span>
</label>
<label class="list-group-item">
<span class="list-group-item-media"><Icon name="device-mobile" color="muted" /></span>
<span class="list-group-item-content">
<span class="list-group-item-title">Push</span>
<span class="list-group-item-description">Mentions and direct messages</span>
</span>
<span class="list-group-item-actions">
<span class="form-check form-switch m-0"><input type="checkbox" role="switch" class="form-check-input" /></span>
</span>
</label>
</div>
</Example>
### 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.
<Example bg="surface-secondary" column>
<div class="list-group">
<div class="list-group-item align-items-start">
<div class="list-group-item-media">
<span class="avatar" style="background-image: url(/static/avatars/000m.jpg)"></span>
</div>
<div class="list-group-item-content">
<div class="list-group-item-title">Paweł Kuna</div>
<div class="list-group-item-description text-wrap">The new list layout works well on the dashboard. Can we use the same slots for the search results?</div>
</div>
<div class="list-group-item-meta">2 h</div>
</div>
</div>
</Example>
## 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.
<Example bg="surface-secondary" column>
<div class="list-group list-group-sm mb-3">
<div class="list-group-item">Small item</div>
<div class="list-group-item">Small item</div>
</div>
<div class="list-group mb-3">
<div class="list-group-item">Default item</div>
<div class="list-group-item">Default item</div>
</div>
<div class="list-group list-group-lg">
<div class="list-group-item">Large item</div>
<div class="list-group-item">Large item</div>
</div>
</Example>
## Variants
### Flush
@@ -101,6 +231,57 @@ Mark the current item with `active`. Tabler shows it with a thicker border on th
</div>
</Example>
### 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.
<Example bg="surface-secondary" column>
<div class="list-group list-group-selectable">
<label class="list-group-item">
<span class="list-group-item-media"><input type="checkbox" class="form-check-input list-group-selectable-check m-0" checked /></span>
<span class="list-group-item-content">
<span class="list-group-item-title">Paweł Kuna</span>
<span class="list-group-item-description">UI Designer</span>
</span>
</label>
<label class="list-group-item">
<span class="list-group-item-media"><input type="checkbox" class="form-check-input list-group-selectable-check m-0" /></span>
<span class="list-group-item-content">
<span class="list-group-item-title">Alice Marlin</span>
<span class="list-group-item-description">Frontend developer</span>
</span>
</label>
</div>
</Example>
### 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.
<Example bg="surface-secondary" column>
<div class="list-group">
<button type="button" class="list-group-item list-group-item-action" data-bs-toggle="collapse" data-bs-target="#list-nested-projects" aria-expanded="true" aria-controls="list-nested-projects">
<span class="list-group-item-media"><Icon name="folder" color="muted" /></span>
<span class="list-group-item-content">
<span class="list-group-item-title">Projects</span>
</span>
<span class="list-group-item-meta"><Icon name="chevron-down" class="list-group-item-toggle" /></span>
</button>
<div class="list-group-nested collapse show" id="list-nested-projects">
<a href="#" class="list-group-item list-group-item-action">Website redesign</a>
<a href="#" class="list-group-item list-group-item-action">Mobile app</a>
</div>
<a href="#" class="list-group-item list-group-item-action">
<span class="list-group-item-media"><Icon name="settings" color="muted" /></span>
<span class="list-group-item-content">
<span class="list-group-item-title">Settings</span>
</span>
</a>
</div>
</Example>
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
<Example bg="surface-secondary" column>
<div class="list-group list-group-hoverable">
<div class="list-group-item">
<div class="row align-items-center">
<div class="col text-truncate">Change deprecated html tags</div>
<div class="col-auto">
<a href="#" class="list-group-item-actions" aria-label="Add to favorites"><Icon name="star" color="muted" /></a>
</div>
<div class="list-group-item-content">
<div class="list-group-item-title">Change deprecated html tags</div>
</div>
<a href="#" class="list-group-item-actions" aria-label="Add to favorites"><Icon name="star" color="muted" /></a>
</div>
<div class="list-group-item">
<div class="row align-items-center">
<div class="col text-truncate">Fix spacing in the navbar</div>
<div class="col-auto">
<a href="#" class="list-group-item-actions show" aria-label="Remove from favorites"><Icon name="star" color="yellow" /></a>
</div>
<div class="list-group-item-content">
<div class="list-group-item-title">Fix spacing in the navbar</div>
</div>
<a href="#" class="list-group-item-actions show" aria-label="Remove from favorites"><Icon name="star" color="yellow" /></a>
</div>
</div>
</Example>
@@ -234,30 +411,22 @@ This pattern joins several variants: a flush list inside a card, hoverable rows,
<div class="card-header"><h3 class="card-title">Notifications</h3></div>
<div class="list-group list-group-flush list-group-hoverable">
<div class="list-group-item">
<div class="row align-items-center">
<div class="col-auto">
<span class="status-dot status-dot-animated bg-red d-block"></span>
</div>
<div class="col text-truncate">
<a href="#" class="text-body d-block">Build failed</a>
<div class="d-block text-secondary text-truncate mt-n1">The last deploy did not finish</div>
</div>
<div class="col-auto">
<a href="#" class="list-group-item-actions" aria-label="Add to favorites"><Icon name="star" color="muted" /></a>
</div>
<div class="list-group-item-media">
<span class="status-dot status-dot-animated bg-red"></span>
</div>
<div class="list-group-item-content">
<a href="#" class="list-group-item-title">Build failed</a>
<div class="list-group-item-description">The last deploy did not finish</div>
</div>
<a href="#" class="list-group-item-actions" aria-label="Add to favorites"><Icon name="star" color="muted" /></a>
</div>
<div class="list-group-item">
<div class="row align-items-center">
<div class="col-auto"><span class="status-dot d-block"></span></div>
<div class="col text-truncate">
<a href="#" class="text-body d-block">New comment</a>
<div class="d-block text-secondary text-truncate mt-n1">Alice replied in the design thread</div>
</div>
<div class="col-auto">
<a href="#" class="list-group-item-actions show" aria-label="Remove from favorites"><Icon name="star" color="yellow" /></a>
</div>
<div class="list-group-item-media"><span class="status-dot"></span></div>
<div class="list-group-item-content">
<a href="#" class="list-group-item-title">New comment</a>
<div class="list-group-item-description">Alice replied in the design thread</div>
</div>
<a href="#" class="list-group-item-actions show" aria-label="Remove from favorites"><Icon name="star" color="yellow" /></a>
</div>
</div>
</div>
+516 -26
View File
@@ -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 <code>list-group-sm</code> for a compact list.' },
{ size: 'lg', title: 'Large', subtitle: 'Add <code>list-group-lg</code> 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`,
}))
---
<DefaultLayout title="Lists" pageMenu="base.lists" description="Lists of users, contacts, and links — built from list-group items or a plain card body.">
<DefaultLayout title="Lists" pageMenu="base.lists" pageLibs={['sortablejs']} description="Vertical lists built from list-group items: media, content, meta and actions in one row.">
<DocsLink slot="page-header-actions" path="/ui/components/list-group" />
<div class="row row-cards">
<div class="col-md-6">
<div class="row row-cards">
<div class="col-12">
<UsersList2 />
</div>
<div class="col-12">
<UsersList offset={8} checkbox checkedIds={[2, 5, 8]} title="Contacts" />
</div>
<div class="col-12">
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Text only</CardTitle>
<CardSubtitle>A plain <code>list-group-item</code> holds one line of text.</CardSubtitle>
</CardBody>
<ListGroup flush>
{folders.map((item) => <ListGroupItem>{item.title}</ListGroupItem>)}
</ListGroup>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Title and description</CardTitle>
<CardSubtitle>Put <code>list-group-item-title</code> and <code>list-group-item-description</code> in <code>list-group-item-content</code>.</CardSubtitle>
</CardBody>
<ListGroup flush>
{folders.map((item) => <ListGroupItem title={item.title} description={item.description} />)}
</ListGroup>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>With icon</CardTitle>
<CardSubtitle>The <code>list-group-item-media</code> slot holds an icon before the content.</CardSubtitle>
</CardBody>
<ListGroup flush>
{
folders.map((item) => (
<ListGroupItem title={item.title} description={item.description}>
<Icon slot="media" name={item.icon} class="text-secondary" />
</ListGroupItem>
))
}
</ListGroup>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>With avatar</CardTitle>
<CardSubtitle>The media slot takes an avatar the same way.</CardSubtitle>
</CardBody>
<ListGroup flush>
{
team.map((person) => (
<ListGroupItem title={person.full_name} description={person.job_title}>
<Avatar slot="media" person={person} />
</ListGroupItem>
))
}
</ListGroup>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>With meta</CardTitle>
<CardSubtitle>Use <code>list-group-item-meta</code> for a time, a count or a badge at the end.</CardSubtitle>
</CardBody>
<ListGroup flush>
{
folders.map((item) => (
<ListGroupItem title={item.title} description={item.description}>
<Fragment slot="meta">{item.count}</Fragment>
</ListGroupItem>
))
}
</ListGroup>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>All slots</CardTitle>
<CardSubtitle>Media, content, meta and <code>list-group-item-actions</code> in one item.</CardSubtitle>
</CardBody>
<ListGroup flush>
{
team.map((person, index) => (
<ListGroupItem title={person.full_name} description={person.job_title}>
<Avatar slot="media" person={person} />
<Fragment slot="meta">{index === 0 ? <Badge color="green" light text="Owner" /> : `${index * 3} d`}</Fragment>
<CardDropdown slot="actions" />
</ListGroupItem>
))
}
</ListGroup>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Links</CardTitle>
<CardSubtitle>Add <code>list-group-item-action</code> to a link item. It supports <code>active</code> and <code>disabled</code>.</CardSubtitle>
</CardBody>
<ListGroup flush>
{
folders.map((item, index) => (
<ListGroupItem as="a" href={index === 3 ? undefined : '#'} action active={index === 0} disabled={index === 3} aria-current={index === 0 ? 'true' : undefined} title={item.title} description={item.description}>
<Icon slot="media" name={item.icon} class="text-secondary" />
<Icon slot="meta" name="chevron-right" />
</ListGroupItem>
))
}
</ListGroup>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Actions on hover</CardTitle>
<CardSubtitle>In a <code>list-group-hoverable</code> list the actions show on hover. Add <code>show</code> to keep them visible.</CardSubtitle>
</CardBody>
<ListGroup flush hoverable>
{
team.map((person, index) => (
<ListGroupItem title={person.full_name} description={person.job_title} showActions={index === 1}>
<Avatar slot="media" person={person} />
<button type="button" slot="actions" class="btn btn-action" aria-label={index === 1 ? 'Remove from favorites' : 'Add to favorites'}>
<Icon name="star" class={index === 1 ? 'text-yellow' : undefined} />
</button>
</ListGroupItem>
))
}
</ListGroup>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Buttons</CardTitle>
<CardSubtitle>The actions slot holds any control, for example a button.</CardSubtitle>
</CardBody>
<ListGroup flush>
{
team.map((person, index) => (
<ListGroupItem title={person.full_name} description={person.job_title}>
<Avatar slot="media" person={person} />
<button type="button" slot="actions" class:list={['btn btn-sm', index === 0 && 'btn-primary']}>
{index === 0 ? 'Follow' : 'Following'}
</button>
</ListGroupItem>
))
}
</ListGroup>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Checkboxes</CardTitle>
<CardSubtitle>In a <code>list-group-selectable</code> list a checked <code>list-group-selectable-check</code> marks its item.</CardSubtitle>
</CardBody>
<ListGroup flush selectable>
{
team.map((person, index) => (
<ListGroupItem as="label" title={person.full_name} description={person.job_title}>
<Fragment slot="media">
<input type="checkbox" class="form-check-input list-group-selectable-check m-0 me-3" checked={index % 2 === 0} />
<Avatar person={person} />
</Fragment>
</ListGroupItem>
))
}
</ListGroup>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Radios</CardTitle>
<CardSubtitle>Radio inputs with one <code>name</code> make a single choice list. Use a <code>label</code> as the item.</CardSubtitle>
</CardBody>
<ListGroup flush selectable>
{
plans.map((plan, index) => (
<ListGroupItem as="label" title={plan.title} description={plan.description}>
<input slot="media" type="radio" name="list-plan" value={plan.id} class="form-check-input list-group-selectable-check m-0" checked={index === 1} />
<Fragment slot="meta">{plan.price}</Fragment>
</ListGroupItem>
))
}
</ListGroup>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Switches</CardTitle>
<CardSubtitle>Put a switch in the actions slot to build a settings list.</CardSubtitle>
</CardBody>
<ListGroup flush>
{
channels.map((channel) => (
<ListGroupItem as="label" title={channel.title} description={channel.description}>
<Icon slot="media" name={channel.icon} class="text-secondary" />
<span slot="actions" class="form-check form-switch m-0">
<input type="checkbox" role="switch" class="form-check-input" checked={channel.checked} />
</span>
</ListGroupItem>
))
}
</ListGroup>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Wrapped description</CardTitle>
<CardSubtitle>Add <code>align-items-start</code> to the item and <code>text-wrap</code> to the description for longer text.</CardSubtitle>
</CardBody>
<ListGroup flush>
{
comments.map((comment, index) => (
<ListGroupItem class="align-items-start" title={team[index]?.full_name}>
<Avatar slot="media" person={team[index]} />
<div class="list-group-item-description text-wrap">{comment.text}</div>
<Fragment slot="meta">{comment.time}</Fragment>
</ListGroupItem>
))
}
</ListGroup>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Nested items</CardTitle>
<CardSubtitle>Put <code>list-group-nested</code> on a collapse after the parent item. <code>list-group-item-toggle</code> turns the chevron.</CardSubtitle>
</CardBody>
<ListGroup flush>
{
tree.map((group) => (
<>
<ListGroupItem as="button" type="button" action title={group.title} data-bs-toggle="collapse" data-bs-target={`#${group.id}`} aria-expanded={group.open ? 'true' : 'false'} aria-controls={group.id}>
<Icon slot="media" name={group.icon} class="text-secondary" />
<Icon slot="meta" name="chevron-down" class="list-group-item-toggle" />
</ListGroupItem>
<div class:list={['list-group-nested collapse', group.open && 'show']} id={group.id}>
{group.items.map((item) => (
<ListGroupItem as="a" href="#" action>
{item}
</ListGroupItem>
))}
</div>
</>
))
}
<ListGroupItem as="a" href="#" action title="Settings">
<Icon slot="media" name="settings" class="text-secondary" />
</ListGroupItem>
</ListGroup>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Drag handle</CardTitle>
<CardSubtitle>Add <code>data-sortable</code> to the list and put the handle in the media slot.</CardSubtitle>
</CardBody>
<ListGroup flush data-sortable='{"animation":150,"handle":".sortable-handle"}'>
{
team.map((person) => (
<ListGroupItem title={person.full_name} description={person.job_title}>
<Fragment slot="media">
<span class="sortable-handle cursor-move text-secondary me-3" aria-hidden="true">
<Icon name="grip-vertical" />
</span>
<Avatar person={person} />
</Fragment>
</ListGroupItem>
))
}
</ListGroup>
</Card>
</div>
{
sizes.map((item) => (
<div class="col-md-6 col-lg-4">
<Card>
<CardHeader title="Links and buttons" />
<ListGroup flush>
<ListGroupItem as="a" href="#" action active aria-current="true"> The current link item </ListGroupItem>
<ListGroupItem as="a" href="#" action>A second link item</ListGroupItem>
<ListGroupItem as="a" href="#" action>A third link item</ListGroupItem>
<ListGroupItem as="a" href="#" action>A fourth link item</ListGroupItem>
<ListGroupItem as="a" action disabled>A disabled link item</ListGroupItem>
<CardBody>
<CardTitle>{item.title}</CardTitle>
<CardSubtitle>
<Fragment set:html={item.subtitle} />
</CardSubtitle>
</CardBody>
<ListGroup flush size={item.size}>
{team.map((person) => (
<ListGroupItem title={person.full_name} description={person.job_title}>
<Avatar slot="media" person={person} size={item.size === 'sm' ? 'sm' : undefined} />
</ListGroupItem>
))}
</ListGroup>
</Card>
</div>
</div>
))
}
<div class="col-md-6 col-lg-4">
<Card>
<CardHeader title="Notifications" />
<ListGroup flush>
{
notifications.map((item) => (
<ListGroupItem as="a" href="#" action title={item.title} description={item.description}>
<StatusDot slot="media" color={item.unread ? 'blue' : undefined} animated={item.unread} ariaLabel={item.unread ? 'Unread' : 'Read'} />
<Fragment slot="meta">{item.time}</Fragment>
</ListGroupItem>
))
}
</ListGroup>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardHeader title="Transactions" />
<ListGroup flush>
{
transactions.map((item) => (
<ListGroupItem title={item.counterparty} description={item.description}>
<Avatar slot="media" src={item.counterparty_logo} />
<span slot="meta" class:list={['fw-medium', item.amount < 0 ? 'text-body' : 'text-green']}>
{item.amountLabel}
</span>
</ListGroupItem>
))
}
</ListGroup>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardHeader title="Files" />
<ListGroup flush>
{
files.map((file) => (
<ListGroupItem title={file.name} description={file.size}>
<Avatar slot="media" icon={file.icon} />
<button type="button" slot="actions" class="btn btn-action" aria-label={`Download ${file.name}`}>
<Icon name="download" />
</button>
</ListGroupItem>
))
}
</ListGroup>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardHeader title="Visits by country" />
<ListGroup flush>
{
visits.map((item) => (
<ListGroupItem title={item.country}>
<Flag slot="media" flag={item.flag} />
<Progress size="sm" class="mt-2" value={item.value} aria-label={`${item.value}% of visits`} />
<Fragment slot="meta">{item.value}%</Fragment>
</ListGroupItem>
))
}
</ListGroup>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Inside a card body</CardTitle>
<CardSubtitle>Without <code>list-group-flush</code> the list keeps its own border and rounded corners.</CardSubtitle>
<ListGroup>
{
files.slice(0, 3).map((file) => (
<ListGroupItem title={file.name}>
<Icon slot="media" name={file.icon} class="text-secondary" />
<Fragment slot="meta">{file.size}</Fragment>
</ListGroupItem>
))
}
</ListGroup>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardHeader title="Loading" />
<ListGroup flush class="placeholder-glow" aria-hidden="true">
{
team.map((_, index) => (
<ListGroupItem>
<span slot="media" class="avatar placeholder" />
<div class:list={['placeholder', index % 2 ? 'col-5' : 'col-7']} />
<div class:list={['placeholder placeholder-xs', index % 2 ? 'col-9' : 'col-8']} />
</ListGroupItem>
))
}
</ListGroup>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardHeader title="Empty" />
<Empty icon="inbox-off" title="No messages" description="New messages from your team show up here." />
</Card>
</div>
<div class="col-md-6">
<UsersList offset={8} checkbox checkedIds={[2, 5, 8]} title="Contacts" />
</div>
<div class="col-md-6">
<div class="row row-cards">
<div class="col-12">
<UsersList hoverable />
</div>
<div class="col-12">
<UsersListHeaders />
</div>
</div>
<UsersList hoverable />
</div>
<div class="col-md-6">
<UsersListHeaders />
</div>
<div class="col-md-6">
<UsersList2 />
</div>
</div>
</DefaultLayout>
+18 -33
View File
@@ -18,7 +18,7 @@ interface Props {
class?: string
}
const { offset = 0, hoverable = false, checkbox, checkedIds, title = 'Last commits', class: className } = Astro.props
const { offset = 0, hoverable = false, checkbox = false, checkedIds, title = 'Last commits', class: className } = Astro.props
const limit = 8
@@ -44,40 +44,25 @@ const rows = people.slice(offset, offset + limit).map((person, idx) => {
<div class="card-header">
<CardTitle>{title}</CardTitle>
</div>
<ListGroup flush hoverable={hoverable}>
<ListGroup flush hoverable={hoverable} selectable={checkbox}>
{
rows.map((row) => (
<ListGroupItem active={row.checked}>
<div class="row align-items-center">
{checkbox ? (
<div class="col-auto">
<input type="checkbox" class="form-check-input" checked={row.checked} />
</div>
) : (
<div class="col-auto">
<span class={`badge${row.color !== 'x' ? ` bg-${row.color}` : ''}`} />
</div>
)}
<div class="col-auto">
<a href="#" tabindex="-1" aria-hidden="true">
<Avatar person={row.person} />
</a>
</div>
<div class="col text-truncate">
<a href="#" class="text-reset d-block">
{row.person.full_name}
</a>
<div class="d-block text-secondary text-truncate mt-n1">{row.description}</div>
</div>
{hoverable && (
<div class="col-auto">
<a href="#" class={`list-group-item-actions${row.checked ? ' show' : ''}`} aria-label={row.checked ? 'Remove from favorites' : 'Add to favorites'}>
<Icon name="star" class={row.starColor} />
</a>
</div>
)}
</div>
<ListGroupItem active={!checkbox && row.checked} showActions={row.checked}>
<Fragment slot="media">
{checkbox ? <input type="checkbox" class="form-check-input list-group-selectable-check m-0" checked={row.checked} aria-label={`Select ${row.person.full_name}`} /> : <span class={`badge${row.color !== 'x' ? ` bg-${row.color}` : ''}`} />}
<a href="#" class="ms-3" tabindex="-1" aria-hidden="true">
<Avatar person={row.person} />
</a>
</Fragment>
<a href="#" class="list-group-item-title">
{row.person.full_name}
</a>
<div class="list-group-item-description">{row.description}</div>
{hoverable && (
<a href="#" slot="actions" aria-label={row.checked ? 'Remove from favorites' : 'Add to favorites'}>
<Icon name="star" class={row.starColor} />
</a>
)}
</ListGroupItem>
))
}
+7 -13
View File
@@ -39,19 +39,13 @@ const rows = sorted.map((person, idx) => {
<>
{row.header && <ListGroupHeader class="sticky-top">{row.header}</ListGroupHeader>}
<ListGroupItem>
<div class="row">
<div class="col-auto">
<a href="#" tabindex="-1" aria-hidden="true">
<Avatar person={row.person} />
</a>
</div>
<div class="col text-truncate">
<a href="#" class="text-body d-block">
{row.person.full_name}
</a>
<div class="text-secondary text-truncate mt-n1">{row.description}</div>
</div>
</div>
<a href="#" slot="media" tabindex="-1" aria-hidden="true">
<Avatar person={row.person} />
</a>
<a href="#" class="list-group-item-title">
{row.person.full_name}
</a>
<div class="list-group-item-description">{row.description}</div>
</ListGroupItem>
</>
))
+8 -2
View File
@@ -5,18 +5,24 @@ import type { HTMLTag } from 'astro/types'
type PolymorphicProps = { as?: HTMLTag }
/** core/scss/ui/_lists.scss `.list-group-{size}` density modifiers */
type ListGroupSize = 'sm' | 'lg'
interface Props extends PolymorphicProps {
size?: ListGroupSize | undefined
flush?: boolean
hoverable?: boolean
/** list-group-selectable: an item with a checked .list-group-selectable-check input gets the active look */
selectable?: boolean
transparent?: boolean
class?: string
/** remaining attributes (data-sortable, style, …) are forwarded to the element */
[key: string]: unknown
}
const { as: Element = 'div', flush, hoverable, transparent, class: className, ...rest }: Props = Astro.props
const { as: Element = 'div', size, flush, hoverable, selectable, transparent, class: className, ...rest }: Props = Astro.props
---
<Element class:list={['list-group', flush && 'list-group-flush', hoverable && 'list-group-hoverable', transparent && 'list-group-transparent', className]} {...rest}>
<Element class:list={['list-group', size && `list-group-${size}`, flush && 'list-group-flush', hoverable && 'list-group-hoverable', selectable && 'list-group-selectable', transparent && 'list-group-transparent', className]} {...rest}>
<slot />
</Element>
+42 -3
View File
@@ -1,7 +1,8 @@
---
import type { HTMLTag } from 'astro/types'
// List group item (.list-group-item).
// List group item (.list-group-item). With a title, a description or a named
// slot it renders the slot layout: media, content, meta and actions in a row.
type PolymorphicProps = { as?: HTMLTag }
@@ -10,14 +11,52 @@ interface Props extends PolymorphicProps {
action?: boolean
active?: boolean
disabled?: boolean
/** first line of the content slot (.list-group-item-title) */
title?: string | undefined
/** second line of the content slot (.list-group-item-description) */
description?: string | undefined
/** keeps the actions slot visible in a hoverable list (.show) */
showActions?: boolean
class?: string
/** remaining attributes (href, data-*, …) are forwarded to the element */
[key: string]: unknown
}
const { as: Element = 'div', action, active, disabled, class: className, ...rest }: Props = Astro.props
const { as: Element = 'div', action, active, disabled, title, description, showActions, class: className, ...rest }: Props = Astro.props
const hasMedia = Astro.slots.has('media')
const hasMeta = Astro.slots.has('meta')
const hasActions = Astro.slots.has('actions')
const hasSlots = Boolean(title || description || hasMedia || hasMeta || hasActions)
---
<Element class:list={['list-group-item', action && 'list-group-item-action', active && 'active', disabled && 'disabled', className]} aria-disabled={disabled ? 'true' : undefined} tabindex={Element === 'a' && disabled ? -1 : undefined} {...rest}>
<slot />
{
hasSlots ? (
<>
{hasMedia && (
<div class="list-group-item-media">
<slot name="media" />
</div>
)}
<div class="list-group-item-content">
{title && <div class="list-group-item-title">{title}</div>}
{description && <div class="list-group-item-description">{description}</div>}
<slot />
</div>
{hasMeta && (
<div class="list-group-item-meta">
<slot name="meta" />
</div>
)}
{hasActions && (
<div class:list={['list-group-item-actions', showActions && 'show']}>
<slot name="actions" />
</div>
)}
</>
) : (
<slot />
)
}
</Element>
+1 -1
View File
@@ -1,6 +1,6 @@
---
interface Props {
color?: string
color?: string | undefined
animated?: boolean
/** accessible name (visually-hidden text inside the dot) */
ariaLabel?: string