← 返回蜂巢洞察

如何在 React 中构建遮阳板组件:以购物车和筛选面板为例

“Sheet”是一种从屏幕边缘滑入的界面元素,而不是像模态窗口那样在屏幕中央弹出 … Read More

Socrates

“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) => (
))} 清除所有筛选条件 >>应用过滤条件 ); }; export default AdvancedFiltersDemo;

该组件的工作原理如下:

1. 为每种过滤器类型使用单独的状态存储机制

对于类别和可用性信息,使用数组进行存储,因为用户可以同时选择多个选项;评分只存储一个值(或null),因为一次只能选择一个评分;价格则通过一个包含两个值的数组([最小值, 最大值])来表示用户选择的范围。这种设计使得每个过滤器的状态都显得简单明了,同时也符合用户界面的使用习惯。

2. activeCount是计算得出的,而非实时跟踪的

其原理与上面提到的购物车图标的情况相同:

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

如果手动来跟踪这个计数值,最终很可能会添加一个新的过滤器后忘记更新相应的逻辑,从而导致显示给用户的图标信息出现错误。

3. “应用过滤器”会关闭面板,而“清除所有”则不会

清除所有功能是通过一个普通的Button直接调用clearAll()方法来实现的;而“应用过滤器”功能被封装在SheetClose函数中,因此它只会关闭面板,而不会重置其他设置。用户通常希望在清除过滤器后仍能保持面板打开状态,以便继续选择新的过滤条件;如果每次操作都会导致面板关闭,那么就会给用户带来不便。

4. 使用.filter()方法结合“展开/折叠”功能来切换类别

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

这一行代码根据某个类别是否已经存在于数组中,来决定是将其添加到数组中还是从数组中删除它。这种处理方式非常实用,在任何使用字符串数组来实现多选功能的场景中都可以重复使用。

实时预览:

e3850372-c172-4d49-b973-09192e80bc61

快速参考表

用于设置产品过滤条件及优化搜索结果

组件名称

标识符

使用场景

购物车组件

sheet-03

用于显示购物车中的商品和订单摘要

过滤器面板组件

sheet-04

<如果要安装其中任何一个,请在命令行指令中更换相应的标识符即可:

npx shadcn@latest add @shadcn-space/

使用 Shadcn Sheet 的关键概念

  • 将 Sheet 用于辅助性工作流程:它非常适合用于过滤器、购物车、设置选项、导航菜单和表单等不会干扰主页面功能的组件。

  • 保持布局的一致性:使用固定的页眉、可滚动的内容区域(flex-1 overflow-y-auto)以及固定的页脚,这样即使内容很长,各种操作按钮也能清晰可见。

  • 根据状态动态生成 UI 数据:计数结果、总数以及各种提示图标(如活跃的过滤器或购物车中的商品)应该根据当前的状态来计算生成,而不是单独存储这些数据。

  • 完成操作后关闭 Sheet:应用过滤器保存结账这样的操作应该会关闭 Sheet;而清除过滤器重置之类的操作则应保持 Sheet 打开状态,以便用户可以继续进行修改。

  • 选择合适的状态结构:对于多选选项,使用数组来存储数据(例如类别或可用性);对于单选选项,使用单个值或null来表示未选状态;而对于范围筛选选项(比如价格),则使用包含两个值的数组[最小值, 最大值]

  • 确保操作按钮易于访问:应用保存结账等主要操作按钮放在页脚位置,这样当内容滚动时,用户仍然可以轻松找到这些按钮。

  • 避免让 Sheet 过于复杂:如果某个工作流程步骤较多、需要用户全神贯注地完成,那么考虑使用专门的页面而不是 Sheet 来实现这个功能。

结论

在这份指南中,我们构建了两个实用的 Shadcn Sheet 组件:购物车和过滤器面板,以此来演示在 React 应用程序中最常使用的设计模式。同时,我们也介绍了布局最佳实践、状态管理方法、动态生成的数据以及交互设计原则,这些都有助于让 Sheet 组件显得更加直观且可靠。

这些示例不仅仅是一些简单的演示案例,它们为构建设置面板、移动端导航系统、通知栏以及其他侧边栏组件提供了可复用的基础。通过遵循这些设计模式,你可以创建出结构清晰、响应迅速且易于维护的 Shadcn Sheet 组件,从而帮助你的应用程序更好地发展。

资源链接

相关文章