展示类

列表

基础列表组件

代码演示

如何引入

<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

Header
  • 从明天起,做一个幸福的人
  • 喂马,劈柴,周游世界
  • 从明天起,关心粮食和蔬菜
  • 我有一所房子,面朝大海,春暖花开

Small Size

Header
  • 从明天起,做一个幸福的人
  • 喂马,劈柴,周游世界
  • 从明天起,关心粮食和蔬菜
  • 我有一所房子,面朝大海,春暖花开

Large Size

Header
  • 从明天起,做一个幸福的人
  • 喂马,劈柴,周游世界
  • 从明天起,关心粮食和蔬菜
  • 我有一所房子,面朝大海,春暖花开
代码
<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
    示例标题

    Semi Design 是由抖音前端团队与 UED 团队共同设计开发并维护的设计系统。设计系统包含设计语言以及一整套可复用的前端组件,帮助设计师与开发者更容易地打造高质量的、用户体验一致的、符合设计规范的 Web 应用。

  • 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。

  • SE
    Semi Design Title 1

    Semi Design 设计系统包含设计语言以及一整套可复用的前端组件,帮助设计师与开发者更容易地打造高质量的、用户体验一致的、符合设计规范的 Web 应用。

  • SE
    Semi Design Title 2

    Semi Design 设计系统包含设计语言以及一整套可复用的前端组件,帮助设计师与开发者更容易地打造高质量的、用户体验一致的、符合设计规范的 Web 应用。

  • SE
    Semi 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,拖动重排后节点会对应到别的数据。

    • SE
      Semi Design Title 1

      Semi Design 设计系统包含设计语言以及一整套可复用的前端组件,帮助设计师与开发者更容易地打造高质量的、用户体验一致的、符合设计规范的 Web 应用。

    • SE
      Semi Design Title 2

      Semi Design 设计系统包含设计语言以及一整套可复用的前端组件,帮助设计师与开发者更容易地打造高质量的、用户体验一致的、符合设计规范的 Web 应用。

    • SE
      Semi Design Title 3

      Semi Design 设计系统包含设计语言以及一整套可复用的前端组件,帮助设计师与开发者更容易地打造高质量的、用户体验一致的、符合设计规范的 Web 应用。

    • SE
      Semi Design Title 4

      Semi Design 设计系统包含设计语言以及一整套可复用的前端组件,帮助设计师与开发者更容易地打造高质量的、用户体验一致的、符合设计规范的 Web 应用。

    • SE
      Semi 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 和 main snippet 的内容(没有这两个 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是否显示边框。booleanfalse
    childrenListItem 子节点,写在标签内,渲染在 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。booleanfalse
    loadMore加载更多按钮的 snippet。Snippet-
    onClick列表项的点击回调,作用于每个没有自己 onClick 的项;有处理函数的项渲染为原生按钮。它是函数 prop 而不是原生 onclick,因为它的有无决定项是否渲染按钮。Type(event: MouseEvent) => void-
    onRightClick列表项的右键(上下文菜单)回调,作用于每个没有自己 onRightClick 的项,这些项同样渲染为原生按钮。Type(event: MouseEvent) => void-
    size列表尺寸,可选 small、default 或 large。Size"default"
    split是否展示分割线。booleantrue

    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栅格间隔number0
    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

    属性说明类型默认值
    alignheader 与 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 变量,不能覆写。定制样式请覆写 --semi-* CSS 变量(见 Design Token 页),或写不分层的 CSS 规则。
    Sass 变量值用法
    $color-list_default-border-defaultvar(--semi-color-border)列表描边颜色
    $color-list_empty-text-defaultvar(--semi-color-text-2)空状态下列表文字颜色
    $spacing-list_empty-paddingX0px空状态列表水平内边距
    $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 额外内容左侧外边距