List group
The list of rows that is not a table. Each row renders the element that matches what it does.
<script setup lang="ts">
import SaListGroup from '@shared/components/common/list/SaListGroup.vue'
import SaListGroupItem from '@shared/components/common/list/SaListGroupItem.vue'
</script>
<template>
<SaListGroup class="max-w-md">
<SaListGroupItem title="Profile" description="Name, photo and contact details" />
<SaListGroupItem title="Notifications" description="What we email you about" />
<SaListGroupItem title="Billing" description="Plan, invoices and payment method" />
</SaListGroup>
</template>Usage
SaListGroup is the container, SaListGroupItem is a row. The container renders a <ul>, or an <ol> with ordered when the order carries meaning, and each item renders an <li>.
The point of the item is that it renders the right element rather than a <div> with a click handler:
| You pass | It renders | Why it matters |
|---|---|---|
to | RouterLink | Middle-click and open-in-new-tab work. |
href | <a> | The same, for an external URL. |
clickable | <button> | An action, not navigation, so Enter and Space both fire it. |
| none of them | <div> | Plain content, with no hover or focus affordance. |
Only the interactive forms get the hover tint and the focus ring, so a list of read-only rows does not pretend to be clickable.
Examples
Links, actions and disabled rows
target="_blank" also sets rel="noopener noreferrer". disabled dims the row, blocks pointer events, sets aria-disabled, and on a button form sets disabled as well.
<script setup lang="ts">
import SaListGroup from '@shared/components/common/list/SaListGroup.vue'
import SaListGroupItem from '@shared/components/common/list/SaListGroupItem.vue'
import { ref } from 'vue'
const log = ref('')
</script>
<template>
<div class="max-w-md space-y-3">
<SaListGroup>
<SaListGroupItem title="Open the dashboard" description="Renders a router link" to="/dashboard" />
<SaListGroupItem title="Read the changelog" description="Renders an anchor" href="https://example.com/changelog" target="_blank" />
<SaListGroupItem title="Export everything" description="Renders a button" clickable @click="log = 'Export pressed'" />
<SaListGroupItem title="Delete the workspace" description="Not available on this plan" clickable disabled />
</SaListGroup>
<p class="text-sm text-muted-foreground">
{{ log || 'Nothing pressed yet' }}
</p>
</div>
</template>Leading, trailing and active
leading and trailing hold an icon, a badge, a switch or a chevron. active tints the row and sets aria-current.
<script setup lang="ts">
import SaListGroup from '@shared/components/common/list/SaListGroup.vue'
import SaListGroupItem from '@shared/components/common/list/SaListGroupItem.vue'
import { Badge } from '@shared/components/ui/badge'
import { Switch } from '@shared/components/ui/switch'
import { Bell, CreditCard, ShieldCheck } from 'lucide-vue-next'
import { ref } from 'vue'
const alerts = ref(true)
</script>
<template>
<SaListGroup class="max-w-md">
<SaListGroupItem title="Two-factor authentication" description="Required for owners">
<template #leading>
<ShieldCheck class="size-5 text-muted-foreground" />
</template>
<template #trailing>
<Badge variant="secondary">
On
</Badge>
</template>
</SaListGroupItem>
<SaListGroupItem title="Product alerts" description="Weekly summary by email">
<template #leading>
<Bell class="size-5 text-muted-foreground" />
</template>
<template #trailing>
<Switch v-model="alerts" aria-label="Product alerts" />
</template>
</SaListGroupItem>
<SaListGroupItem title="Payment method" description="Visa ending 4242" active>
<template #leading>
<CreditCard class="size-5 text-muted-foreground" />
</template>
</SaListGroupItem>
</SaListGroup>
</template>Flush, inside a card
variant="flush" drops the border, background and radius and keeps only the dividers, for a list that already sits in a card.
<script setup lang="ts">
import SaListGroup from '@shared/components/common/list/SaListGroup.vue'
import SaListGroupItem from '@shared/components/common/list/SaListGroupItem.vue'
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'
</script>
<template>
<Card class="max-w-md">
<CardHeader>
<CardTitle>Release steps</CardTitle>
</CardHeader>
<CardContent class="p-0">
<SaListGroup variant="flush" ordered>
<SaListGroupItem title="Freeze the branch" description="No new features after Thursday" />
<SaListGroupItem title="Run the checks" description="Lint, types, unit and end to end" />
<SaListGroupItem title="Tag and publish" description="The pipeline does the rest" />
</SaListGroup>
</CardContent>
</Card>
</template>API
Source: src/shared/components/common/list/
SaListGroup props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'default' | 'flush' | 'default' | default is its own bordered card. flush is dividers only. |
ordered | boolean | — | Renders <ol> instead of <ul>. |
SaListGroup slots
| Slot | Description |
|---|---|
default | The items. |
SaListGroupItem props
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | — | The first line, used when the default slot is empty. |
description | string | — | The second line, used when the default slot is empty. |
to | string | — | An internal route. Renders a RouterLink. |
href | string | — | An external URL. Renders an <a>. |
target | string | — | The anchor target. _blank also sets rel="noopener noreferrer". |
clickable | boolean | — | Renders a <button>. |
active | boolean | — | Tints the row and sets aria-current. |
disabled | boolean | — | Dims the row, blocks pointer events, sets aria-disabled. |
SaListGroupItem emits
| Event | Payload | Description |
|---|---|---|
click | event: MouseEvent | Fires on any form of the row. |
SaListGroupItem slots
| Slot | Description |
|---|---|
default | Replaces the title and description block. |
leading | Before the text. Does not shrink. |
trailing | After the text. Does not shrink. |
Accessibility
- The container is a real list, so a screen reader announces how many rows there are.
- An interactive row is a link or a button, so it is reachable by Tab and fires on the key its element fires on, with no
roleortabindexpatched on. activesetsaria-current, anddisabledsetsaria-disabledas well as the nativedisabledon a button.- The focus ring is drawn inside the row, so it is not clipped by the container's rounded corners.
- Both text lines truncate rather than wrap, so a long value cannot push the trailing control out of the row.