“Sheet”是一种从屏幕边缘滑入的界面元素,而不是像模态窗口那样在屏幕中央弹出。当你打开电商网站的购物车页面,或者点击筛选图标时,选项内容通常会从侧面滑入屏幕,这种设计你应该已经体验过。

要构建一个能够正确显示长内容的“Sheet”,同时确保页眉和页脚的位置固定不变,并且能够在没有错误的情况下处理表单或计数器的状态信息,仅仅将一个

元素包裹在滑动动画中是远远不够的。

在本教程中,你将通过Shadcn Space以及Base UI的基本组件,构建两个可用于实际开发的“Sheet”组件:

  1. 一个购物车Sheet,其中包含数量控制功能、删除商品的功能,以及实时显示的小计金额。

  2. 一个筛选面板Sheet,配备类别复选框、价格范围滑块、星级评分系统,以及显示当前激活的筛选条件的数量。

完成本教程后,你将能够在自己的项目中使用这两个组件,并且充分理解其背后的状态管理机制与布局设计原理,从而能够自行开发出新的“Sheet”版本。

目录

先决条件

在开始之前,请确保你已经满足以下要求:

  • 已安装Node.js 18或更高版本。

  • 已在你的项目中初始化shadcn/ui(执行命令:npx shadcn@latest init)。

  • 具备React和TypeScript的基本使用知识。

  • 如果你还没有初始化shadcn/ui,请在项目根目录下运行npx shadcn@latest init,并按照提示完成相关设置后再继续下一步。

    你将构建什么

    本教程使用的组件都是通过Shadcn Space注册表来获取的。这个注册表是一个开源资源库,其中包含了许多适用于shadcn/ui的生产级组件和UI元素。

    你可以在Shadcn Sheet组件库中查看所有这些组件。每个组件都同时支持Radix UI和Base UI的设计规范,并且都提供了复制代码选项,因此你在进行原型设计时可以将这些组件的配置信息直接粘贴到v0、Lovable或Bolt等开发工具中。本教程中使用的是Base UI版本的组件。

    购物车页面模板(sheet-03)

    • 从右侧滑出的面板,其中包含购物车图标以及显示商品数量的信息

    • 可单独调整每件商品的购买数量、减少数量或将其从购物车中移除

    • 根据购物车中的商品信息,实时计算出小计和总金额

    • 页面顶部和底部为固定内容,中间是可滚动显示的商品列表

    筛选面板模板(sheet-04)

    • 从左侧滑出的面板

    • 多选类别的复选框

    • 价格范围滑块,可实时显示最低价和最高价

    • 单选星级评分的筛选选项

    • 会显示当前激活的筛选条件数量,并提供“清除所有”按钮,但点击该按钮不会关闭筛选面板

    如何设置CLI注册表

    在运行任何安装命令之前,请在项目的components.json文件中注册Shadcn Space注册表。

    打开项目根目录下的components.json文件,添加registries字段:

    {
      "registries": {
        "@shadcn-space": {
          "url": "https://shadcnspace.com/r/{name}.json"
        }
      }
    }
    

    这样就能告诉shadcn CLI,哪些以@shadcn-space/为前缀的组件需要被加载。如果不执行这一步骤,后续的安装命令将会失败。

    有关注册表设置的详细步骤,请参阅入门指南MCP服务器,因此如果你的工作流程需要使用MCP工具,就可以直接通过编辑器来浏览和安装组件。如果你更喜欢通过视频来学习,请观看以下链接:

    如何构建购物车页面模板(sheet-03)

    购物车页面的功能

    购物车图标位于导航栏中。点击该图标后,会从右侧滑出一个面板,其中会显示每件商品的图片、名称、变体以及价格,并提供调整数量或完全删除商品的选项。随着你添加或删除商品,小计金额会实时更新;无论列表中包含多少商品,页面底部的信息始终会保持固定位置。

    如何安装购物车页面模板

    根据你使用的包管理器,运行以下命令之一:

    npm:

    npx shadcn@latest add @shadcn-space/sheet-03

    pnpm:

    pnpm dlx shadcn@latest add @shadcn-space/sheet-03

    Yarn:

    yarn dlx shadcn@latest add @shadcn-space/sheet-03

    Bun:

    bunx --bun shadcn@latest add @shadcn-space/sheet-03

    命令行工具会将该组件复制到你的项目中,路径如下:

    components/
      shadcn-space/
        sheet/
          sheet-03.tsx

    组件代码

    "use client";
    import { useState } from "react";
    import { ShoppingCartIcon, PlusIcon, MinusIcon, Trash2Icon } from "lucide-react";
    import {
      Sheet,
      SheetTrigger,
      SheetContent,
      SheetHeader,
      SheetTitle,
      SheetDescription,
      SheetFooter,
      SheetClose,
    } from "@/components/ui/sheet";
    import { Button } from "@components/ui/button";
    import { ButtonGroup, ButtonGroupText } from "@components/ui/button-group";
    import { Badge } from "@components/ui/badge";
    import { Separator } from "@components/ui/separator";
    
    const initialItems = [
      {
        id: 1,
        name: "Apple Watch S9",
        variant: "Midnight / 41mm",
        price: 684.0,
        qty: 1,
        image: "https://images.shadcnspace.com/assets/ecommerce/product-category/product-category-03-1.webp",
      },
      {
        id: 2,
        name: "Beige Jacket",
        variant: "Size M / Beige",
        price: 479.0,
        qty: 1,
        image: "https://images.shadcnspace.com/assets/ecommerce/product-category/product-category-02-2.webp",
      },
      {
        id: 3,
        name: "Glow Serum",
        variant: "30ml / Vitamin C",
        price: 46.0,
        qty: 2,
        image: "https://images.shadcnspace.com/assets/ecommerce/product-category/product-category-03-3.webp",
      },
    ];
    
    const ShoppingCartDemo = () => {
      const [items, setItems] = useState(initialItems);
    
      const updateQty = (id, delta) => {
        setItems((prev) =>
          prev
            .map((item) => (item.id === id ? { ...item, qty: item.qty + delta } : item))
            .filter((item) => itemqty > 0)
        );
      };
    
      const subtotal = items.reduce((sum, item) => sum + item.price * item qty, 0);
      const totalCount = items.reduce((sum, item) => sum + item.qty, 0);
    
      return (
        
          }>
             0 && (
              
    
          您的购物车
              
                {totalCount > 0
                  ? `${totalCount}件商品在您的购物车中`
                  : "您的购物车是空的"
              
            
    
            
    >{item.name}</p>

    >{item.variant}</p>

    >${item.price.toFixed(2)}</p>

    <\/Button> {item qty}</ButtonGroupText>
    ))}
小计: ${subtotal.toFixed(2)}</span>
运费: 免费
总金额: ${subtotal.toFixed(2)}</span>
<\/div> }> 继续购物 ); }; export default ShoppingCartDemo;

让我们来了解一下它的具体工作原理。

1. 使用派生出的总数而非实时跟踪的状态

subtotaltotalCount是在每次渲染时根据items计算得出的,并不会被存储在独立的useState状态中。如果单独对它们进行跟踪,那么在第一次更新items却忘记同时更新这些计数值时,它们的数值就会出现不一致的情况。

2. 删除某项商品只需将其数量设置为0即可

const updateQty = (id, delta) => {
  setItems((prev) =>
    prev
      .map((item) => (item.id === id ? { ...item, qty: itemqty + delta } : item))
      .filter((item) => item.qty > 0)
  );
};

删除某项商品时,系统会调用updateQty(item.id, -itemqty)这个函数,从而将该商品的数量设置为0,随后.filter()方法会将该商品从数组中移除。这样一来,就无需再编写一个重复updateQty中已有逻辑的“删除商品”函数了。

3. 使用render属性而非asChild模式

SheetTriggerSheetClose使用的是render属性(例如render={<Button ... />}),而不是将子元素包裹在asChild结构中。这是Shadcn Space组件所遵循的Base UI规范,这种设计方式能够保持按钮的可访问性设置(如聚焦状态、键盘操作响应等)不变,而不会被自定义包装层覆盖掉。

<SheetContent className="flex flex-col p-0 gap-0">
  <SheetHeader className="border-b" />
  
</SheetContent>

中间部分使用了flex-1 overflow-y-auto样式,这样在商品列表滚动时,页眉和页脚就能保持固定位置。如果省略这一设置,当商品列表内容过长时,结算按钮就会被挤到屏幕之外。

实时预览:

dec70851-3b64-43d4-9611-f75165f7c4ca

如何构建过滤面板表格(sheet-04)

过滤面板的作用

点击“过滤器”按钮后,会从左侧弹出一个面板,其中包含类别复选框、价格范围滑块、星级评分筛选选项以及是否显示可用商品的开关。当前激活的筛选条件会在触发按钮上显示出来,而清除所有筛选条件只会重置状态,而不会关闭该面板。

如何安装过滤组件

npx shadcn@latest add @shadcn-space/sheet-04

命令行工具会将该组件复制到以下路径:

components/
  shadcn-space/
    sheet/
      sheet-04.tsx

组件代码

"use client";

import { useState } from "react";
import { SlidersHorizontalIcon, StarIcon } from "lucide-react";
import {
  Sheet,
  SheetTrigger,
  SheetContent,
  SheetHeader,
  SheetTitle,
  SheetDescription,
  SheetFooter,
  SheetClose,
} from "@/components/ui/sheet";
import { Button } from "@components/ui/button";
import { Checkbox } from "@components/ui/checkbox";
import { Slider } from "@components/ui/slider";
import { Label } from "@components/ui/label";
import { Badge } from "@components/ui/badge";
import { Separator } from "@components/ui/separator";

const CATEGORIES = ["手表", "服装", "美容", "电子产品", "家居用品"];
const RATINGS = [4, 3, 2, 1];
const AVAILABILITY = ["有货", "打折中"];

const AdvancedFiltersDemo = () => {
  const [selectedCategories, setSelectedCategories] = useState [];
  const [priceRange, setPriceRange] = useState([0, 1000]);
  const [selectedRating, setSelectedRating] = useState(null);
  const [selectedAvailability, setSelectedAvailability] = useState([]);

  const toggleCategory = (cat) =>
    setSelectedCategories((prev) => (prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]);

  const toggleAvailability = (val) =>
   设定的Availability((prev) => (prev.includes(val) ? prev.filter((v) => v !== val) : [...prev, val];

  const clearAll = () =>
    setSelectedCategories [];
    setPriceRange([0, 1000]);
   设定的Rating(null);
   设定的Availability([]);

  const activeCount =
    selectedCategories.length +
    (priceRange[0] !== 0 || priceRange[1] !== 1000 ? 1 : 0) +
    (selectedRating !== null ? 1 : 0) +
    selectedAvailability.length;

  return (
    
      }>
         0 && (
          

      过滤条件
          根据您的偏好筛选产品。
        

        
类别

{CATEGORIES.map((cat) => (
{cat}</Label>
))}
价格范围

${priceRange[0]} -- ${priceRange[1]}</span>
评分

{RATINGS.map((rating) => (
)} {Array.from({ length: 5 - rating }).map((_, i) => <StarIcon key={i} size={13} className="text-muted-foreground/40" />>) & 更高评分</span>
))}
库存情况

{AVAILABILITY.map((val) => (
))}