代码演示
如何引入
<script lang="ts">
import { List, ListItem } from '@fibrae/semi-ui-svelte';
</script>
基本用法
列表的基本用法。可以通过 size 设置尺寸,支持large, default, small。通过 header 和 footer snippet 自定义列表头部和尾部,通过 item snippet(参数 { item, index })渲染 dataSource 的每一项。
Default Size
- 从明天起,做一个幸福的人
- 喂马,劈柴,周游世界
- 从明天起,关心粮食和蔬菜
- 我有一所房子,面朝大海,春暖花开
Small Size
- 从明天起,做一个幸福的人
- 喂马,劈柴,周游世界
- 从明天起,关心粮食和蔬菜
- 我有一所房子,面朝大海,春暖花开
Large Size
- 从明天起,做一个幸福的人
- 喂马,劈柴,周游世界
- 从明天起,关心粮食和蔬菜
- 我有一所房子,面朝大海,春暖花开
代码
<script lang="ts">
import { List, ListItem } from '@fibrae/semi-ui-svelte';
const data = ['从明天起,做一个幸福的人', '喂马,劈柴,周游世界', '从明天起,关心粮食和蔬菜', '我有一所房子,面朝大海,春暖花开'];
const sizes = [
{ title: 'Default Size', size: undefined, margin: '0 0 16px' },
{ title: 'Small Size', size: 'small', margin: '16px 0' },
{ title: 'Large Size', size: 'large', margin: '16px 0' },
] as const;
</script>
<div>
{#each sizes as { title, size, margin } (title)}
<div style="margin-right: 16px">
<h3 style="margin: {margin}">{title}</h3>
<List bordered dataSource={data} {size}>
{#snippet header()}
<div>Header</div>
{/snippet}
{#snippet footer()}
<div>Footer</div>
{/snippet}
{#snippet item({ item: text })}
<ListItem>{text}</ListItem>
{/snippet}
</List>
</div>
{/each}
</div>模板用法
列表的 ListItem 内置了简单的结构,通过 header、main 和 extra snippet 提供。其中 header 和 main 的对齐方式可以通过 align 属性设置,支持 flex-start(默认), flex-end, center, baseline, 和 stretch 。
- SE示例标题
- SE示例标题
Semi Design 是由抖音前端团队与 UED 团队共同设计开发并维护的设计系统。设计系统包含设计语言以及一整套可复用的前端组件,帮助设计师与开发者更容易地打造高质量的、用户体验一致的、符合设计规范的 Web 应用。
- SE示例标题
Semi Design 以用户中心、内容优先、设计人性化的设计系统,打造一致、好看、好用、高效的用户体验。
代码
<script lang="ts">
import { Avatar, Button, ButtonGroup, List, ListItem } from '@fibrae/semi-ui-svelte';
const text = 'Semi Design 是由抖音前端团队与 UED 团队共同设计开发并维护的设计系统。设计系统包含设计语言以及一整套可复用的前端组件,帮助设计师与开发者更容易地打造高质量的、用户体验一致的、符合设计规范的 Web 应用。';
const data = [
{
text,
style: 'color: var(--semi-color-text-2); margin: 4px 0; width: 420px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis',
},
{ text, style: 'color: var(--semi-color-text-2); margin: 4px 0; width: 500px' },
{
text: 'Semi Design 以用户中心、内容优先、设计人性化的设计系统,打造一致、好看、好用、高效的用户体验。',
style: 'color: var(--semi-color-text-2); margin: 4px 0; width: 500px',
},
];
</script>
<div style="padding: 12px; border: 1px solid var(--semi-color-border); margin: 12px">
<List dataSource={data}>
{#snippet item({ item: entry })}
<ListItem>
{#snippet header()}
<Avatar color="blue">SE</Avatar>
{/snippet}
{#snippet main()}
<div>
<span style="color: var(--semi-color-text-0); font-weight: 500">示例标题</span>
<p style={entry.style}>{entry.text}</p>
</div>
{/snippet}
{#snippet extra()}
<ButtonGroup theme="borderless">
<Button>编辑</Button>
<Button>更多</Button>
</ButtonGroup>
{/snippet}
</ListItem>
{/snippet}
</List>
</div>布局
通过 layout 属性可以设置列表的布局,支持vertical(默认)和horizontal。
- SESemi Design Title 1
Semi Design 设计系统包含设计语言以及一整套可复用的前端组件,帮助设计师与开发者更容易地打造高质量的、用户体验一致的、符合设计规范的 Web 应用。
- SESemi Design Title 2
Semi Design 设计系统包含设计语言以及一整套可复用的前端组件,帮助设计师与开发者更容易地打造高质量的、用户体验一致的、符合设计规范的 Web 应用。
- SESemi Design Title 3
Semi Design 设计系统包含设计语言以及一整套可复用的前端组件,帮助设计师与开发者更容易地打造高质量的、用户体验一致的、符合设计规范的 Web 应用。
代码
<script lang="ts">
import { Avatar, List, ListItem, type AvatarColor } from '@fibrae/semi-ui-svelte';
interface Entry {
title: string;
color: AvatarColor;
}
const data: Entry[] = [
{ title: 'Semi Design Title 1', color: 'light-blue' },
{ title: 'Semi Design Title 2', color: 'grey' },
{ title: 'Semi Design Title 3', color: 'light-green' },
];
</script>
<div style="padding: 12px; border: 1px solid var(--semi-color-border); margin: 12px">
<List dataSource={data} layout="horizontal">
{#snippet item({ item: entry })}
<ListItem>
{#snippet header()}
<Avatar color={entry.color}>SE</Avatar>
{/snippet}
{#snippet main()}
<div>
<span style="color: var(--semi-color-text-0); font-weight: 500">{entry.title}</span>
<p style="color: var(--semi-color-text-2); margin: 4px 0">Semi Design 设计系统包含设计语言以及一整套可复用的前端组件,帮助设计师与开发者更容易地打造高质量的、用户体验一致的、符合设计规范的 Web 应用。</p>
</div>
{/snippet}
</ListItem>
{/snippet}
</List>
</div>栅格列表
通过 grid 属性可以实现栅格列表,span 可设置每项的占格数,gutter可设置栅格间隔。
Semi UI
| 满意度 | |
|---|---|
| 反馈数 | 124 |
Semi DSM
| 满意度 | |
|---|---|
| 反馈数 | 108 |
Semi D2C
| 满意度 | |
|---|---|
| 反馈数 | 244 |
代码
<script lang="ts">
import { Button, ButtonGroup, Descriptions, List, ListItem, Rating } from '@fibrae/semi-ui-svelte';
const data = [
{ title: 'Semi UI', rating: 4.5, feedbacks: 124 },
{ title: 'Semi DSM', rating: 4, feedbacks: 108 },
{ title: 'Semi D2C', rating: 4.5, feedbacks: 244 },
];
const grid = { gutter: 12, span: 6 };
const itemStyle = 'border: 1px solid var(--semi-color-border); background-color: var(--semi-color-bg-2); border-radius: 3px; padding-left: 20px';
</script>
<div>
<List dataSource={data} {grid}>
{#snippet item({ item: entry })}
<ListItem style={itemStyle}>
<div>
<h3 style="color: var(--semi-color-text-0); font-weight: 500">{entry.title}</h3>
<Descriptions align="center" data={[{ key: '满意度' }, { key: '反馈数', value: entry.feedbacks }]} row size="small">
{#snippet value({ index, item: field })}
{#if index === 0}
<Rating allowHalf size="small" value={entry.rating} />
{:else}
{field.value}
{/if}
{/snippet}
</Descriptions>
<div style="margin: 12px 0; display: flex; justify-content: flex-end">
<ButtonGroup style="margin-top: 8px" theme="borderless">
<Button>编辑</Button>
<Button>更多</Button>
</ButtonGroup>
</div>
</div>
</ListItem>
{/snippet}
</List>
</div>响应式的栅格列表
响应式的栅格列表。响应尺寸与 Grid 保持一致。
审核管理平台
| 满意度 | |
|---|---|
| 反馈数 | 124 |
扁鹊
| 满意度 | |
|---|---|
| 反馈数 | 108 |
直播审核平台
| 满意度 | |
|---|---|
| 反馈数 | 244 |
抖音安全测试
| 满意度 | |
|---|---|
| 反馈数 | 189 |
内容平台
| 满意度 | |
|---|---|
| 反馈数 | 128 |
策略平台
| 满意度 | |
|---|---|
| 反馈数 | 156 |
代码
<script lang="ts">
import { Button, ButtonGroup, Descriptions, List, ListItem, Rating } from '@fibrae/semi-ui-svelte';
const data = [
{ title: '审核管理平台', rating: 4.5, feedbacks: 124 },
{ title: '扁鹊', rating: 4, feedbacks: 108 },
{ title: '直播审核平台', rating: 3.5, feedbacks: 244 },
{ title: '抖音安全测试', feedbacks: 189 },
{ title: '内容平台', rating: 3, feedbacks: 128 },
{ title: '策略平台', rating: 4, feedbacks: 156 },
];
const grid = { gutter: 12, xs: 0, sm: 0, md: 12, lg: 8, xl: 8, xxl: 6 };
const style = 'border: 1px solid var(--semi-color-border); background-color: var(--semi-color-bg-2); border-radius: 3px; padding-left: 20px; margin: 8px 2px';
</script>
<div>
<List dataSource={data} {grid}>
{#snippet item({ item: entry })}
<ListItem {style}>
<div>
<h3 style="color: var(--semi-color-text-0); font-weight: 500">{entry.title}</h3>
<Descriptions align="center" data={[{ key: '满意度' }, { key: '反馈数', value: entry.feedbacks }]} row size="small">
{#snippet value({ index, item: field })}
{#if index === 0}
<Rating allowHalf size="small" value={entry.rating} />
{:else}
{field.value}
{/if}
{/snippet}
</Descriptions>
<div style="margin: 12px 0; display: flex; justify-content: flex-end">
<ButtonGroup style="margin-top: 8px" theme="borderless">
<Button>编辑</Button>
<Button>更多</Button>
</ButtonGroup>
</div>
</div>
</ListItem>
{/snippet}
</List>
</div>加载更多
可通过 loadMore snippet 实现加载更多的功能。
代码
<script lang="ts">
import { onDestroy } from 'svelte';
import { Avatar, Button, List, ListItem, Skeleton, SkeletonAvatar, SkeletonParagraph, SkeletonTitle } from '@fibrae/semi-ui-svelte';
interface Book {
title?: string;
loading: boolean;
}
const count = 3;
const data: Book[] = [];
for (let i = 0; i < 40; i++) {
data.push({ title: `Semi Design Title ${i}`, loading: false });
}
const placeholders: Book[] = [0, 1, 2].map(() => ({ loading: true }));
const fetchData = (page: number) =>
new Promise<Book[]>((resolve) => {
setTimeout(() => resolve(data.slice(page * count, page * count + count)), 1000);
});
let dataSource = $state.raw<Book[]>([]);
let loading = $state(true);
let noMore = $state(false);
let page = 0;
let disposed = false;
onDestroy(() => {
disposed = true;
});
const load = async () => {
loading = true;
const newDataSource = await fetchData(page);
if (disposed) return;
dataSource = [...dataSource, ...newDataSource];
loading = false;
noMore = !newDataSource.length;
};
const onLoadMore = () => {
page += 1;
void load();
};
void load();
</script>
<List dataSource={loading ? [...dataSource, ...placeholders] : dataSource} {loading}>
{#snippet item({ item: book })}
<Skeleton loading={book.loading}>
{#snippet placeholder()}
<div style="display: flex; align-items: flex-start; padding: 12px; border-bottom: 1px solid var(--semi-color-border)">
<SkeletonAvatar style="margin-right: 12px" />
<div>
<SkeletonTitle style="width: 120px; margin-bottom: 12px; margin-top: 12px" />
<SkeletonParagraph rows={2} style="width: 600px" />
</div>
</div>
{/snippet}
<ListItem>
{#snippet header()}
<Avatar color="grey">SE</Avatar>
{/snippet}
{#snippet main()}
<div>
<span style="color: var(--semi-color-text-0); font-weight: 500">{book.title}</span>
<p style="color: var(--semi-color-text-2); margin: 4px 0">Semi Design 设计系统包含设计语言以及一整套可复用的前端组件,帮助设计师与开发者更容易地打造高质量的、用户体验一致的、符合设计规范的 Web 应用。</p>
</div>
{/snippet}
</ListItem>
</Skeleton>
{/snippet}
{#snippet loadMore()}
{#if !loading && !noMore}
<div style="text-align: center; margin-top: 12px; height: 32px; line-height: 32px">
<Button onclick={onLoadMore}>显示更多</Button>
</div>
{/if}
{/snippet}
</List>滚动加载
可以监听滚动容器的 onscroll 事件,在接近底部时加载下一页。交互建议符合 semi 交互设计规范,这里采用三次滚加载后出现 load more 按钮的形式。
代码
<script lang="ts">
import { onDestroy } from 'svelte';
import { Avatar, Button, List, ListItem, Spin } from '@fibrae/semi-ui-svelte';
interface Book {
title: string;
}
const count = 5;
const dataList: Book[] = [];
for (let i = 0; i < 100; i++) {
dataList.push({ title: `Semi Design Title ${i}` });
}
const fetchData = (page: number) =>
new Promise<Book[]>((resolve) => {
setTimeout(() => resolve(dataList.slice(page * count, page * count + count)), 1000);
});
let dataSource = $state.raw<Book[]>([]);
let loading = $state(true);
let hasMore = $state(true);
let page = $state(0);
let disposed = false;
onDestroy(() => {
disposed = true;
});
const load = async () => {
loading = true;
const newDataSource = await fetchData(page);
if (disposed) return;
dataSource = [...dataSource, ...newDataSource];
loading = false;
hasMore = !!newDataSource.length;
};
const onLoadMore = () => {
page += 1;
void load();
};
const showLoadMore = $derived((page + 1) % 4 === 0);
const onScroll = (event: Event) => {
const el = event.currentTarget as HTMLElement;
if (loading || !hasMore || showLoadMore) return;
if (el.scrollHeight - el.scrollTop - el.clientHeight <= 20) onLoadMore();
};
void load();
</script>
<section aria-label="可滚动列表" class="light-scrollbar" style="height: 420px; overflow: auto; border: 1px solid var(--semi-color-border); padding: 10px" tabindex="0" onscroll={onScroll}>
<List {dataSource}>
{#snippet item({ item: book })}
<ListItem>
{#snippet header()}
<Avatar color="grey">SE</Avatar>
{/snippet}
{#snippet main()}
<div>
<span style="color: var(--semi-color-text-0); font-weight: 500">{book.title}</span>
<p style="color: var(--semi-color-text-2); margin: 4px 0">Semi Design 设计系统包含设计语言以及一整套可复用的前端组件,帮助设计师与开发者更容易地打造高质量的、用户体验一致的、符合设计规范的 Web 应用。</p>
</div>
{/snippet}
</ListItem>
{/snippet}
{#snippet loadMore()}
{#if !loading && hasMore && showLoadMore}
<div style="text-align: center; margin-top: 12px; height: 32px; line-height: 32px">
<Button onclick={onLoadMore}>显示更多</Button>
</div>
{/if}
{/snippet}
</List>
{#if loading && hasMore}
<div style="text-align: center">
<Spin />
</div>
{/if}
</section>滚动加载无限长列表
可以通过集成 TanStack Virtual(@tanstack/svelte-virtual)实现滚动加载无限长列表,带有虚拟化(virtualization)功能,只渲染可见的行,能够提高数据量大时候长列表的性能。createVirtualizer 返回的是 store,用 $virtualizer 读取。
代码
<script lang="ts">
import { untrack } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { createVirtualizer } from '@tanstack/svelte-virtual';
import { Avatar, List, ListItem, Spin } from '@fibrae/semi-ui-svelte';
interface Book {
title: string;
}
const rowCount = 50;
const rowHeight = 118;
// Rows fetched ahead of the window so they arrive before they scroll into view
const prefetch = 15;
const dataList: Book[] = Array.from({ length: rowCount }, (_, i) => ({ title: `Semi Design Title ${i}` }));
let scroller = $state<HTMLElement>();
const loaded = new SvelteSet<number>();
// A requested index is never fetched again, even while pending
const requested = new SvelteSet<number>();
const virtualizer = createVirtualizer<HTMLElement, Element>({ count: rowCount, getScrollElement: () => scroller ?? null, estimateSize: () => rowHeight, overscan: 2 });
// The scroller exists only after mount, so the virtualizer is handed it then. setOptions updates the store this
// effect would otherwise be subscribed to, so it runs untracked.
$effect(() => {
if (scroller) untrack(() => $virtualizer.setOptions({ getScrollElement: () => scroller ?? null }));
});
const items = $derived($virtualizer.getVirtualItems());
// Indexes rather than virtual items: the items are new objects on every scroll
const rows = $derived(items.filter((item) => loaded.has(item.index)).map((item) => item.index));
$effect(() => {
if (!items.length) return;
const first = items[0].index;
const last = Math.min(items[items.length - 1].index + prefetch, rowCount - 1);
// requested is written here, so reading it must not make this effect depend on it
const wanted = untrack(() => {
const fresh: number[] = [];
for (let i = first; i <= last; i++) {
if (!requested.has(i)) {
requested.add(i);
fresh.push(i);
}
}
return fresh;
});
if (!wanted.length) return;
setTimeout(() => {
for (const i of wanted) loaded.add(i);
}, 1000);
});
</script>
<div style="border: 1px solid var(--semi-color-border); padding: 10px">
<section aria-label="虚拟滚动列表" bind:this={scroller} style="height: 500px; overflow: auto" tabindex="0">
<List dataSource={rows} style="position: relative; height: {$virtualizer.getTotalSize()}px">
<!-- Replaces the locale's empty text until a row of the window arrives -->
{#snippet emptyContent()}
<Spin />
{/snippet}
{#snippet item({ item: index })}
<ListItem style="position: absolute; inset-inline: 0; top: {index * rowHeight}px; height: {rowHeight}px">
{#snippet header()}
<Avatar color="grey">SE</Avatar>
{/snippet}
{#snippet main()}
<div>
<span style="color: var(--semi-color-text-0); font-weight: 500">{dataList[index].title}</span>
<p style="color: var(--semi-color-text-2); margin: 4px 0">Semi Design 设计系统包含设计语言以及一整套可复用的前端组件,帮助设计师与开发者更容易地打造高质量的、用户体验一致的、符合设计规范的 Web 应用。</p>
</div>
{/snippet}
</ListItem>
{/snippet}
</List>
</section>
</div>拖拽排序
使用 dnd-kit 的 Svelte 版(@dnd-kit/svelte)可轻松实现拖拽排序。被拖动的就是 ListItem 渲染的 li。@dnd-kit/svelte 不使用 OptimisticSortingPlugin(它直接搬动 DOM 节点,与 Svelte 的 keyed each 冲突),拖动中的顺序由 onDragOver 里的状态驱动。排序的列表要写在 List 标签内,用 {#each ... (item.id)} 按 id 作 key;dataSource 加 item snippet 的写法按位置作 key,拖动重排后节点会对应到别的数据。
- SESemi Design Title 1
Semi Design 设计系统包含设计语言以及一整套可复用的前端组件,帮助设计师与开发者更容易地打造高质量的、用户体验一致的、符合设计规范的 Web 应用。
- SESemi Design Title 2
Semi Design 设计系统包含设计语言以及一整套可复用的前端组件,帮助设计师与开发者更容易地打造高质量的、用户体验一致的、符合设计规范的 Web 应用。
- SESemi Design Title 3
Semi Design 设计系统包含设计语言以及一整套可复用的前端组件,帮助设计师与开发者更容易地打造高质量的、用户体验一致的、符合设计规范的 Web 应用。
- SESemi Design Title 4
Semi Design 设计系统包含设计语言以及一整套可复用的前端组件,帮助设计师与开发者更容易地打造高质量的、用户体验一致的、符合设计规范的 Web 应用。
- SESemi Design Title 5
Semi Design 设计系统包含设计语言以及一整套可复用的前端组件,帮助设计师与开发者更容易地打造高质量的、用户体验一致的、符合设计规范的 Web 应用。
代码
<script lang="ts">
import { untrack, type ComponentProps } from 'svelte';
import { RestrictToVerticalAxis } from '@dnd-kit/abstract/modifiers';
import { Accessibility, DragDropManager } from '@dnd-kit/dom';
import { isSortable, OptimisticSortingPlugin, Sortable } from '@dnd-kit/dom/sortable';
import { DragDropProvider } from '@dnd-kit/svelte';
import { Avatar, List, ListItem, type AvatarColor } from '@fibrae/semi-ui-svelte';
interface Entry {
id: number;
title: string;
color: AvatarColor;
}
type ProviderProps = ComponentProps<typeof DragDropProvider>;
const data: Entry[] = [
{ id: 1, title: 'Semi Design Title 1', color: 'red' },
{ id: 2, title: 'Semi Design Title 2', color: 'grey' },
{ id: 3, title: 'Semi Design Title 3', color: 'light-green' },
{ id: 4, title: 'Semi Design Title 4', color: 'light-blue' },
{ id: 5, title: 'Semi Design Title 5', color: 'pink' },
];
let listItems = $state.raw(data);
// The order shown while dragging; cleared on drop, so `listItems` stays the only truth
let preview = $state.raw<Entry[] | null>(null);
let draggingId = $state<number | string>();
const ordered = $derived(preview ?? listItems);
// The provider overwrites the plugins and modifiers of the manager it is given with its own props, so they go there
const manager = new DragDropManager();
// Accessibility would add role="button" and a Tab stop to each li; pointer dragging only
const plugins: ProviderProps['plugins'] = (defaults) => defaults.filter((plugin) => plugin !== Accessibility);
const modifiers = [RestrictToVerticalAxis];
// OptimisticSortingPlugin moves DOM nodes itself, which collides with Svelte's keyed each; `preview` drives the order instead
const sortablePlugins: ConstructorParameters<typeof Sortable>[0]['plugins'] = (defaults) => defaults.filter((plugin) => plugin !== OptimisticSortingPlugin);
// A non-clickable ListItem hands the attachment its li, which is the element dragged
const sortable = (id: number, position: () => number) => (element: HTMLElement) => {
const item = new Sortable({ id, index: untrack(position), element, plugins: sortablePlugins }, manager);
$effect(() => {
item.index = position();
});
return () => item.destroy();
};
const onDragStart: ProviderProps['onDragStart'] = ({ operation: { source } }) => {
draggingId = source?.id;
};
const onDragOver: ProviderProps['onDragOver'] = ({ operation: { source, target } }) => {
if (!isSortable(source) || !isSortable(target) || source.id === target.id) return;
const next = [...ordered];
const from = next.findIndex((entry) => entry.id === source.id);
const to = next.findIndex((entry) => entry.id === target.id);
if (from < 0 || to < 0) return;
next.splice(to, 0, ...next.splice(from, 1));
preview = next;
};
const onDragEnd: ProviderProps['onDragEnd'] = ({ canceled }) => {
if (!canceled) listItems = ordered;
preview = null;
draggingId = undefined;
};
</script>
<div style="padding: 12px; border: 1px solid var(--semi-color-border); margin: 12px">
<DragDropProvider {manager} {modifiers} {onDragEnd} {onDragOver} {onDragStart} {plugins}>
<List>
<!-- Keyed by id: dnd-kit reorders li nodes mid-drag, and the keyed each moves them with their data -->
{#each ordered as entry, position (entry.id)}
{@const dragging = draggingId === entry.id}
<ListItem {@attach sortable(entry.id, () => position)} class={dragging ? 'isDragging' : undefined} style="border: 1px solid var(--semi-color-border); margin-bottom: 12px; cursor: grabbing{dragging ? '; z-index: 999; position: relative; background-color: var(--semi-color-bg-0)' : ''}">
{#snippet header()}
<Avatar color={entry.color}>SE</Avatar>
{/snippet}
{#snippet main()}
<div>
<span style="color: var(--semi-color-text-0); font-weight: 500">{entry.title}</span>
<p style="color: var(--semi-color-text-2); margin: 4px 0">Semi Design 设计系统包含设计语言以及一整套可复用的前端组件,帮助设计师与开发者更容易地打造高质量的、用户体验一致的、符合设计规范的 Web 应用。</p>
</div>
{/snippet}
</ListItem>
{/each}
</List>
</DragDropProvider>
</div>带分页器
你可以组合使用 Pagination, 实现一个分页的 List
- 围城
- 平凡的世界(全三册)
- 三体(全集)
- 雪中悍刀行(全集)
- 1/3
代码
<script lang="ts">
import { List, ListItem, Pagination } from '@fibrae/semi-ui-svelte';
const data = ['围城', '平凡的世界(全三册)', '三体(全集)', '雪中悍刀行(全集)', '撒哈拉的故事', '明朝那些事', '一禅小和尚', '沙丘', '被讨厌的勇气', '罪与罚', '月亮与六便士', '沉默的大多数'];
const pageSize = 4;
let page = $state(1);
const pageData = $derived(data.slice((page - 1) * pageSize, page * pageSize));
</script>
<div>
<div style="margin-right: 16px; width: 280px; display: flex; flex-wrap: wrap">
<List class="component-list-demo-booklist" dataSource={pageData} size="small" split={false} style="border: 1px solid var(--semi-color-border); flex-basis: 100%; flex-shrink: 0">
{#snippet item({ item: book })}
<ListItem class="list-item">{book}</ListItem>
{/snippet}
</List>
<Pagination bind:currentPage={page} {pageSize} size="small" style="width: 100%; flex-basis: 100%; justify-content: center" total={data.length} />
</div>
</div>带筛选器
你可以通过组装 Input 使用,实现对 List 列表的筛选
- 围城
- 平凡的世界(全三册)
- 三体(全集)
- 雪中悍刀行(全集)
- 撒哈拉的故事
- 明朝那些事
- 一禅小和尚
- 沙丘
- 被讨厌的勇气
- 罪与罚
代码
<script lang="ts">
import { IconSearch } from '@fibrae/semi-icons-svelte';
import { Input, List, ListItem } from '@fibrae/semi-ui-svelte';
const data = ['围城', '平凡的世界(全三册)', '三体(全集)', '雪中悍刀行(全集)', '撒哈拉的故事', '明朝那些事', '一禅小和尚', '沙丘', '被讨厌的勇气', '罪与罚'];
let list = $state.raw(data);
const onSearch = (keyword?: string) => {
list = keyword ? data.filter((book) => book.includes(keyword)) : data;
};
</script>
<div>
<div style="margin-right: 16px; width: 280px; display: flex; flex-wrap: wrap; border: 1px solid var(--semi-color-border)">
<List class="component-list-demo-booklist" dataSource={list} size="small" split={false} style="flex-basis: 100%; flex-shrink: 0; border-bottom: 1px solid var(--semi-color-border)">
{#snippet header()}
<Input onChange={(value) => !value && onSearch()} onCompositionEnd={(event) => onSearch((event.target as HTMLInputElement).value)} placeholder="搜索">
{#snippet prefix()}
<IconSearch />
{/snippet}
</Input>
{/snippet}
{#snippet item({ item: book })}
<ListItem class="list-item">{book}</ListItem>
{/snippet}
</List>
</div>
</div>添加删除项
- 围城
- 平凡的世界(全三册)
- 三体(全集)
- 雪中悍刀行(全集)
- 撒哈拉的故事
- 明朝那些事
- 一禅小和尚
- 沙丘
代码
<script lang="ts">
import { IconMinusCircle, IconPlusCircle } from '@fibrae/semi-icons-svelte';
import { Button, List } from '@fibrae/semi-ui-svelte';
const data = ['围城', '平凡的世界(全三册)', '三体(全集)', '雪中悍刀行(全集)', '撒哈拉的故事', '明朝那些事', '一禅小和尚', '沙丘', '被讨厌的勇气', '罪与罚', '月亮与六便士', '沉默的大多数', '第一人称单数'];
let list = $state.raw(data.slice(0, 8));
const removeItem = (book: string) => {
list = list.filter((entry) => entry !== book);
};
const addItem = () => {
list = list.concat(data.slice(list.length, list.length + 1));
};
</script>
<div>
<div style="margin-right: 16px; width: 280px; display: flex; flex-wrap: wrap; border: 1px solid var(--semi-color-border)">
<List class="component-list-demo-booklist" dataSource={list} size="small" split={false} style="flex-basis: 100%; flex-shrink: 0; border-bottom: 1px solid var(--semi-color-border)">
{#snippet item({ item: book })}
<li class="list-item" style="margin: 4px">
<Button aria-label={`删除${book}`} style="margin-right: 4px" theme="borderless" type="danger" onclick={() => removeItem(book)}>
{#snippet icon()}
<IconMinusCircle />
{/snippet}
</Button>
{book}
</li>
{/snippet}
</List>
<div style="margin: 4px; font-size: 14px">
<Button aria-label="新增书籍" style="margin-right: 4px; color: var(--semi-color-info)" theme="borderless" onclick={addItem}>
{#snippet icon()}
<IconPlusCircle />
{/snippet}
</Button>
新增书籍
</div>
</div>
</div>单选或多选
你可以通过组合使用 Radio 或 Checkbox 将 List 增强为一个列表选择器
- 围城
- 平凡的世界(全三册)
- 三体(全集)
- 雪中悍刀行(全集)
- 撒哈拉的故事
- 明朝那些事
- 一禅小和尚
- 沙丘
代码
<script lang="ts">
import { Checkbox, CheckboxGroup, List, ListItem, Radio, RadioGroup, type CheckboxValue, type RadioValue } from '@fibrae/semi-ui-svelte';
const data = ['围城', '平凡的世界(全三册)', '三体(全集)', '雪中悍刀行(全集)', '撒哈拉的故事', '明朝那些事', '一禅小和尚', '沙丘', '被讨厌的勇气', '罪与罚', '月亮与六便士', '沉默的大多数', '第一人称单数'];
const pageData = data.slice(0, 8);
let checkboxVal = $state<CheckboxValue[]>([data[0]]);
let radioVal = $state<RadioValue>(data[0]);
const listStyle = 'border: 1px solid var(--semi-color-border); flex-basis: 100%; flex-shrink: 0';
</script>
<div style="display: flex">
<div style="margin-right: 16px; width: 280px; display: flex; flex-wrap: wrap">
<CheckboxGroup aria-label="书单多选" bind:value={checkboxVal}>
<List class="component-list-demo-booklist" dataSource={pageData} size="small" split={false} style={listStyle}>
{#snippet item({ item: book })}
<ListItem class="list-item">
<Checkbox value={book}>{book}</Checkbox>
</ListItem>
{/snippet}
</List>
</CheckboxGroup>
</div>
<div style="margin-right: 16px; width: 280px; display: flex; flex-wrap: wrap">
<RadioGroup aria-label="书单单选" bind:value={radioVal}>
<List class="component-list-demo-booklist" dataSource={pageData} size="small" split={false} style={listStyle}>
{#snippet item({ item: book })}
<ListItem class="list-item">
<Radio value={book}>{book}</Radio>
</ListItem>
{/snippet}
</List>
</RadioGroup>
</div>
</div>响应键盘事件
你可以自行监听对应按键的键盘事件,实现不同 Item 的选择。如下面这个例子,可以使用上下方向键选择不同Item
- 围城
- 平凡的世界(全三册)
- 三体(全集)
- 雪中悍刀行(全集)
- 撒哈拉的故事
- 明朝那些事
- 一禅小和尚
- 沙丘
- 被讨厌的勇气
- 罪与罚
代码
<script lang="ts">
import { List, ListItem } from '@fibrae/semi-ui-svelte';
const list = ['围城', '平凡的世界(全三册)', '三体(全集)', '雪中悍刀行(全集)', '撒哈拉的故事', '明朝那些事', '一禅小和尚', '沙丘', '被讨厌的勇气', '罪与罚'];
let hoverIndex = $state(-1);
const changeIndex = (offset: number) => {
let index = hoverIndex + offset;
if (index < 0) {
index = list.length - 1;
}
if (index >= list.length) {
index = 0;
}
hoverIndex = index;
};
const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'ArrowUp') {
event.preventDefault();
changeIndex(-1);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
changeIndex(1);
}
};
</script>
<svelte:window onkeydown={onKeydown} />
<div>
<div style="margin-right: 16px; width: 280px; display: flex; flex-wrap: wrap; border: 1px solid var(--semi-color-border)">
<List class="component-list-demo-booklist" dataSource={list} size="small" split={false} style="flex-basis: 100%; flex-shrink: 0; border-bottom: 1px solid var(--semi-color-border)">
{#snippet item({ item: book, index })}
<ListItem class={index === hoverIndex ? 'component-list-demo-booklist-active-item' : undefined}>
{book}
</ListItem>
{/snippet}
</List>
</div>
</div>以上书单例子的Demo中涉及到的自定义样式如下
.component-list-demo-booklist {
.list-item {
&:hover {
background-color: var(--semi-color-fill-0);
}
&:active {
background-color: var(--semi-color-fill-1);
}
}
}
.component-list-demo-booklist-active-item {
background-color: var(--semi-color-fill-0);
}
无障碍
List 支持传入 aria-label 或 aria-labelledby 来表示列表的作用,传入后根元素带 role="group"。
设置了 onClick 或 onRightClick(包括 List 上的)的 ListItem 仍是 <li> 列表项,内部渲染一个原生 <button type="button"> 作为唯一的焦点目标,读屏会读成「按钮」。
- 按钮包住
header和mainsnippet 的内容(没有这两个 snippet 时包住标签内的内容),点击区域铺满整行;Tab 聚焦后按 Enter 或空格触发onClick,上下文菜单键触发onRightClick extra渲染在按钮外面且层级更高,其中的按钮、链接可以正常聚焦和点击,点击它们不会触发 Item 的onClick- 同时设置
header或main和标签内的内容时,标签内的内容在按钮外面,会被铺满整行的按钮覆盖,其中的交互内容请放进extra - 没有点击回调的 ListItem 不渲染按钮,也不可聚焦
List.Item 脱离 List 使用时按没有 grid 的普通列表项渲染。使用 grid 时,承载条目的 Row 带 role="list",包裹每个条目的 Col 带 role="none",读屏把条目读作这个列表的项。
API 参考
List
Props
| 属性 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| bordered | 是否显示边框。 | boolean | false |
| children | ListItem 子节点,写在标签内,渲染在 dataSource 各项之后。 | Snippet | - |
| dataSource | 列表数据源;每一项通过 item snippet 渲染。 | T[] | - |
| emptyContent | 没有数据时展示内容的 snippet,缺省为语言包中的空文案。写了标签内的内容(children)时列表不再算空。 | Snippet | - |
| footer | 列表底部的 snippet。 | Snippet | - |
| grid | 栅格配置:Row 的 align、gutter、justify、type 与 Col 的 span、offset、order、push、pull、响应式断点、class、style,class 与 style 落在每项外层的 Col 上。 | Grid | - |
| header | 列表头部的 snippet。 | Snippet | - |
| item | 渲染 dataSource 中每一项的 snippet,参数为 { item, index },通常返回一个 ListItem。 | TypeSnippet<[ListItemSnippetProps<T>]> | - |
| layout | 列表布局,可选 vertical 或 horizontal。 | 'vertical' | 'horizontal' | "vertical" |
| loading | 是否处于加载中,为 true 时显示 Spin。 | boolean | false |
| loadMore | 加载更多按钮的 snippet。 | Snippet | - |
| onClick | 列表项的点击回调,作用于每个没有自己 onClick 的项;有处理函数的项渲染为原生按钮。它是函数 prop 而不是原生 onclick,因为它的有无决定项是否渲染按钮。 | Type(event: MouseEvent) => void | - |
| onRightClick | 列表项的右键(上下文菜单)回调,作用于每个没有自己 onRightClick 的项,这些项同样渲染为原生按钮。 | Type(event: MouseEvent) => void | - |
| size | 列表尺寸,可选 small、default 或 large。 | Size | "default" |
| split | 是否展示分割线。 | boolean | true |
class、style 与原生属性(如 data-*)落在根元素上;传入 aria-label 或 aria-labelledby 且没有 role 时,根元素带 role="group"。onClick、onRightClick 的参数为原生 MouseEvent,作用于列表项而不是根元素。
List grid props
grid 只接受 Row 的 align、gutter、justify、type 与 Col 的 span、offset、order、push、pull、响应式断点、class、style,不接受其他属性;class 与 style 落在每项外层的 Col 上,style 是字符串。参数含义请参考 Grid
| 属性 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| span | 栅格占位格数 | number | - |
| gutter | 栅格间隔 | number | 0 |
| xs | <576px 响应式栅格,可为栅格数或一个包含其他属性的对象 | number|object | - |
| sm | ≥576px 响应式栅格,可为栅格数或一个包含其他属性的对象 | number|object | - |
| md | ≥768px 响应式栅格,可为栅格数或一个包含其他属性的对象 | number|object | - |
| lg | ≥992px 响应式栅格,可为栅格数或一个包含其他属性的对象 | number|object | - |
| xl | ≥1200px 响应式栅格,可为栅格数或一个包含其他属性的对象 | number|object | - |
| xxl | ≥1600px 响应式栅格,可为栅格数或一个包含其他属性的对象 | number|object | - |
List.Item
Props
| 属性 | 说明 | 类型 | 默认值 |
|---|---|---|---|
| align | header 与 main 的垂直对齐方式,可选 flex-start、flex-end、center、baseline 或 stretch,未传时为 flex-start。 | 'flex-start' | 'flex-end' | 'center' | 'baseline' | 'stretch' | "flex-start" |
| children | 列表项内容,写在标签内;可点击且没有 header 与 main 时放在按钮内,否则放在按钮之外。 | Snippet | - |
| extra | 列表项附加内容的 snippet,渲染在按钮外,其中的交互元素可正常聚焦与点击,点击不会触发整行的点击。 | Snippet | - |
| header | 列表项头部内容的 snippet。 | Snippet | - |
| main | 列表项主体内容的 snippet。 | Snippet | - |
| onClick | 点击回调,优先于 List 的 onClick;有处理函数时项渲染为原生按钮。 | Type(event: MouseEvent) => void | - |
| onRightClick | 右键(上下文菜单)回调,优先于 List 的 onRightClick;只设置它时项同样渲染为原生按钮。 | Type(event: MouseEvent) => void | - |
class、style 与原生属性(如 data-*)落在 li 上,不落在 grid 模式下外层的 Col 上;可点击时 aria-* 与 tabindex 落在内部的按钮上。
文案规范
- 首字母大写
- 结尾不跟随标点符号
- 语法平行:如主动态与被动态、陈述句与祈使句混合使用
设计变量
| Sass 变量 | 值 | 用法 |
|---|---|---|
| $color-list_default-border-default | var(--semi-color-border) | 列表描边颜色 |
| $color-list_empty-text-default | var(--semi-color-text-2) | 空状态下列表文字颜色 |
| $spacing-list_empty-paddingX | 0px | 空状态列表水平内边距 |
| $spacing-list_empty-paddingY | $spacing-base-tight | 空状态列表垂直内边距 |
| $spacing-list_footer-paddingX | $spacing-base-tight | 列表 footer 水平内边距 |
| $spacing-list_footer-paddingY | $spacing-loose | 列表 footer 垂直内边距 |
| $spacing-list_item-paddingX | $spacing-base-tight | 列表 item 水平内边距 - 默认尺寸 |
| $spacing-list_item-paddingY | $spacing-loose | 列表 item 垂直内边距 - 默认尺寸 |
| $spacing-list_small-paddingX | $spacing-tight | 列表 item 水平内边距 - 小尺寸 |
| $spacing-list_small-paddingY | $spacing-base | 列表 item 垂直内边距 - 小尺寸 |
| $spacing-list_large-paddingX | $spacing-base | 列表 item 水平内边距 - 大尺寸 |
| $spacing-list_large-paddingY | $spacing-loose | 列表 item 垂直内边距 - 大尺寸 |
| $spacing-list_header-marginRight | $spacing-base-loose | 列表 header 右侧外边距 |
| $spacing-list_extra-marginLeft | $spacing-super-loose | 列表 extra 额外内容左侧外边距 |