【手把手搓组件库】从零开始实现Element Plus--组件开发

news2024/10/7 18:28:33

从零开始实现Element Plus--组件开发

  • nvm
    • nvm的作用:
    • nvm的使用方法
  • 需求分析
      • 提示词
      • Kimi 生成产品需求文档
      • kimi 生成测试用例
  • 初始化 vitest
  • 完善 Button 组件
    • 1、定义 types.ts
    • 2、Button.vue 引入 types.ts
    • 3、添加Button样式
    • 点击事件 添加节流
    • 添加 Icon
  • 集成 StoryBook

在当今信息化飞速发展的时代,前端开发已经成为软件开发中不可或缺的一部分。作为提升开发效率、保证项目质量的关键工具,组件库一直备受关注。随着GPT-3.5模型的迅猛发展,普通的CRUD程序员被AI取代正在逐渐成为一种明显的趋势。因此,个人认为除了需要更加复杂的项目经验外,还需要架构设计能力,才能避免过早地被AI“抢走饭碗”。在这样的背景下,前端开发者需要不断强化自身的技术能力,以适应这个快速变化的环境。同时,注重项目经验积累和架构设计能力的提升也势在必行,这将是抵御AI替代的重要策略之一。

nvm

nvm(Node Version Manager)是一个命令行工具,用于在不同的项目之间轻松地管理和切换不同的Node.js版本。
nvm-windows

nvm的作用:

1、多版本共存:允许在同一台机器上安装和切换多个Node.js版本。
2、便捷切换:在不同项目需要不同Node.js版本时,可以快速在这些版本间切换。
3、隔离环境:每个Node.js版本都有自己的全局模块路径,避免版本间的冲突。
4、控制环境:可以轻松安装、卸载、查看和管理Node.js的版本。
5、提高效率:避免了反复手动安装和卸载Node.js的繁琐过程。

nvm的使用方法

1、安装Node.js版本:nvm install <version>
2、切换Node.js版本: nvm use <version>
3、查看已安装的版本:nvm list
4、卸载Node.js版本:nvm uninstall <version>

需求分析

我们可以使用大模型工具,帮助我们完成
ChatGPT
Poe
如果你不会魔法的话,可以使用下面两个
Chandler
Kimi **

提示词

三板斧 “身份定位,前提条件,输出限定”

Kimi 生成产品需求文档

# 身份定位

- **角色**:互联网产品经理
- **目标**:产品需求分析和功能点设计

# 需求"[XXX]"形式定义变量用于对话中不同任务的触发指令
以"/help" 为触发关键词,列出所有定义的变量`**XXX**`以及代表的任务

对话过程用中文交流,专业术语可用英文或缩写。

- [XQFX]:(需求分析) 根据给出的内容输出需求分析文档(md)
- [GNSJ]:(功能设计) 以上文中的 "需求分析文档" 为依据

# 背景
首次可补充提问来完善背景

# 输出规范

- **需求分析**[XQFX]
  - **格式**:用户调研摘要、竞品对比报告、市场趋势分析。
  - **内容**:用户痛点、期望功能、安全性需求。
- **功能点设计**[GNSJ]
  - **格式**:功能描述、api 设计、交互关系。
  - **内容**:功能实现细节、用户操作流程、异常处理。

# 示例指令

- **需求分析**:[XQFX]组件库按钮组件。
- **功能点设计**:[GNSJ]

请在后续对话中使用上述结构和示例指令来指导任务执行。

Kimi 给我们生成完毕后,我们可以叫它直接 给我 md 源码,将生成的 md 源码 直接复制,在Button 组件下 新建一个doc.md 文件 拷贝进去

# 需求分析[XQFX]

## 用户调研摘要
根据提供的项目文档,按钮组件是前端开发中常用的UI元素,用户需要一个既美观又实用的按钮组件来提升界面的交互体验。用户期望按钮组件能够支持多种样式、尺寸、状态以及图标,以适应不同的设计需求和场景。

### 用户痛点
- 缺乏统一的按钮样式和尺寸规范,导致界面风格不一致。
- 现有按钮组件不支持丰富的交互状态,如加载状态、禁用状态等。
- 缺少灵活的图标支持,限制了按钮的表达能力。

### 期望功能
- 支持多种按钮样式,如基础、朴素、圆角、圆形等。
- 提供多种按钮尺寸,以适应不同布局需求。
- 能够自定义按钮图标,增强按钮的可读性和美观性。
- 支持按钮的加载状态和禁用状态,以适应不同的交互场景。

### 安全性需求
- 按钮组件应避免不必要的安全漏洞,如XSS攻击等。
- 应支持对按钮的访问控制,确保只有授权用户才能触发敏感操作。

## 竞品对比报告
竞品分析显示,主流的UI组件库(如Element UI、Ant Design等)提供了类似的按钮组件功能,但在样式定制、图标支持和交互状态上各有特色。我们的按钮组件需要在这些方面进行差异化设计,以满足用户的特殊需求。

## 市场趋势分析
当前市场趋势显示,用户越来越注重产品的用户体验和界面美观性。因此,一个功能全面、样式多样、易于定制的按钮组件将有助于提升产品的市场竞争力。

# 功能点设计[GNSJ]

## 功能描述
设计一个多功能的按钮组件,支持多种样式、尺寸、状态和图标,以满足不同场景下的交互需求。

## API 设计
- `size`: 定义按钮的尺寸,可选值包括`large``default``small`。
- `type`: 定义按钮的类型,可选值包括`primary``success``warning``danger``info`。
- `plain`: 是否为朴素按钮,布尔值。
- `round`: 是否为圆角按钮,布尔值。
- `circle`: 是否为圆形按钮,布尔值。
- `loading`: 是否为加载中状态,布尔值。
- `loading-icon`: 自定义加载中状态图标,字符串。
- `disabled`: 按钮是否为禁用状态,布尔值。
- `icon`: 按钮图标,字符串。
- `autofocus`: 是否自动聚焦,布尔值。
- `native-type`: 原生 type 属性,可选值包括`button``submit``reset`。
- `tag`: 自定义元素标签,字符串或组件。
- `use-throttle`: 是否使用节流模式,布尔值,默认为`true`。
- `throttle-duration`: 节流模式下的时间间隔,数值。

## 交互关系
- 用户点击按钮时,根据按钮的状态(如加载中、禁用等)进行相应的交互反馈。
- 用户可以通过点击带有图标的按钮来执行特定的操作。
- 按钮组内的按钮可以进行组合使用,如分页按钮等。

## 功能实现细节
- 按钮组件应支持通过props传入不同的参数来自定义样式和行为。
- 加载状态可以通过`loading`属性和`loading-icon`属性来自定义。
- 禁用状态应通过`disabled`属性来控制,禁用时按钮不应响应点击事件。
- 图标可以通过`icon`属性来自定义,支持常见的图标库如FontAwesome等。

## 用户操作流程
1. 用户根据需要选择按钮的尺寸、类型、样式等。
2. 用户可以通过设置`loading`属性来显示加载状态。
3. 用户可以通过设置`disabled`属性来禁用按钮。
4. 用户可以通过设置`icon`属性来添加按钮图标。
5. 用户可以通过点击按钮来触发相应的事件。

## 异常处理
- 当按钮处于加载状态时,应阻止用户重复点击。
- 当按钮被禁用时,应显示禁用状态并阻止用户点击。
- 对于非法的props值,组件应有相应的校验机制,并给出警告或错误提示。

在这里插入图片描述

kimi 生成测试用例

# 身份定位
- **角色**:优秀的测试开发工程师
- **目标**:根据需求分析文档输出测试用例
- **要求**:vitest技术栈,mount 尽量以返回 JSX 虚拟节点形式, it,test等方法的name属性 用英文描述
# 需求文档内容
上面内容拷贝下来

输出内容:

// 根据提供的需求分析文档,以下是使用 Vitest 技术栈编写的测试用例示例。请注意,实际的测试用例应根据具体的组件实现和业务逻辑来设计,以下仅为示例性质。

import { describe, it, expect, test } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from '@/components/Button.vue' // 假设按钮组件的路径

describe('Button Component', () => {

  // 测试按钮的基本渲染
  it('should render correctly with default props', () => {
    const wrapper = mount(Button)
    expect(wrapper.exists()).toBe(true)
  })

  // 测试按钮尺寸
  describe('Button Size', () => {
    it('should render small size button', () => {
      const wrapper = mount(Button, {
        props: { size: 'small' }
      })
      expect(wrapper.classes()).toContain('small')
    })

    it('should render default size button', () => {
      const wrapper = mount(Button)
      expect(wrapper.classes()).toContain('default')
    })

    it('should render large size button', () => {
      const wrapper = mount(Button, {
        props: { size: 'large' }
      })
      expect(wrapper.classes()).toContain('large')
    })
  })

  // 测试按钮类型
  describe('Button Type', () => {
    it('should render primary type button', () => {
      const wrapper = mount(Button, {
        props: { type: 'primary' }
      })
      expect(wrapper.classes()).toContain('primary')
    })

    // ... 其他类型的按钮测试用例
  })

  // 测试朴素按钮
  it('should render plain button', () => {
    const wrapper = mount(Button, {
      props: { plain: true }
    })
    expect(wrapper.classes()).toContain('plain')
  })

  // 测试圆角按钮
  it('should render round button', () => {
    const wrapper = mount(Button, {
      props: { round: true }
    })
    expect(wrapper.classes()).toContain('round')
  })

  // 测试圆形按钮
  it('should render circle button', () => {
    const wrapper = mount(Button, {
      props: { circle: true }
    })
    expect(wrapper.classes()).toContain('circle')
  })

  // 测试加载状态
  it('should render loading state', async () => {
    const wrapper = mount(Button, {
      props: { loading: true }
    })
    expect(wrapper.find('.loading-icon').exists()).toBe(true)
  })

  // 测试禁用状态
  it('should render disabled state', () => {
    const wrapper = mount(Button, {
      props: { disabled: true }
    })
    expect(wrapper.element).toBeDisabled()
  })

  // 测试图标按钮
  it('should render button with icon', () => {
    const wrapper = mount(Button, {
      props: { icon: 'icon-name' }
    })
    expect(wrapper.find('.icon').exists()).toBe(true)
  })

  // 测试按钮点击事件
  it('should emit click event', async () => {
    const wrapper = mount(Button)
    await wrapper.trigger('click')
    expect(wrapper.emitted('click')).toBeTruthy()
  })

  // 测试节流模式
  it('should throttle click events', async () => {
    const wrapper = mount(Button, {
      props: { useThrottle: true }
    })
    // 假设有一个方法来测试节流效果
    // await testThrottleEffect(wrapper)
    // expect(throttleEffect).toBeTruthy()
  })
  
  // ... 其他测试用例
})

// 请注意,此测试用例仅为示例,实际测试中需要根据组件的具体实现和功能来编写测试逻辑。另外,`Button` 组件的导入路径、类名、图标类选择器等需要根据实际项目进行调整。如果有具体的实现细节或遇到问题,可以进一步提供信息以便给出更具体的帮助。

初始化 vitest

在分包 components 目录下新建 vitest.config.ts

/// <reference types="vitest" />
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue(), vueJsx()],
  test: {
    globals: true,
    environment: "jsdom",
  },
});

完成后修改 package.json 中

  "scripts": {
    "test": "vitest --coverage"
  },

Button 目录 新建 Button.test.tsx

import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";

import Button from "./Button.vue";

describe("Button.vue", () => {
  // Props: type
  it("should has the correct type class when type prop is set", () => {
    const types = ["primary", "success", "warning", "danger", "info"];
    types.forEach((type) => {
      const wrapper = mount(Button, {
        props: { type: type as any },
      });
      expect(wrapper.classes()).toContain(`er-button--${type}`);
    });
  });

  // Props: size
  it("should has the correct size class when size prop is set", () => {
    const sizes = ["large", "default", "small"];
    sizes.forEach((size) => {
      const wrapper = mount(Button, {
        props: { size: size as any },
      });
      expect(wrapper.classes()).toContain(`er-button--${size}`);
    });
  });

  // Props: plain, round, circle
  it.each([
    ["plain", "is-plain"],
    ["round", "is-round"],
    ["circle", "is-circle"],
    ["disabled", "is-disabled"],
    ["loading", "is-loading"],
  ])(
    "should has the correct class when prop %s is set to true",
    (prop, className) => {
      const wrapper = mount(Button, {
        props: { [prop]: true },
        global: {
          stubs: ["ErIcon"],
        },
      });
      expect(wrapper.classes()).toContain(className);
    }
  );

  it("should has the correct native type attribute when native-type prop is set", () => {
    const wrapper = mount(Button, {
      props: { nativeType: "submit" },
    });
    expect(wrapper.element.tagName).toBe("BUTTON");
    expect((wrapper.element as any).type).toBe("submit");
  });

  // Props: tag
  it("should renders the custom tag when tag prop is set", () => {
    const wrapper = mount(Button, {
      props: { tag: "a" },
    });
    expect(wrapper.element.tagName.toLowerCase()).toBe("a");
  });

  // Events: click
  it("should emits a click event when the button is clicked", async () => {
    const wrapper = mount(Button, {});
    await wrapper.trigger("click");
    expect(wrapper.emitted().click).toHaveLength(1);
  });
});

最后修改根目录下的 package.json test


  "scripts": {
    "dev": "pnpm --filter @Wannaer-element/play dev",
    "docs:dev": "pnpm --filter @Wannaer-element/docs dev",
    "docs:build": "pnpm --filter @Wannaer-element/docs build",
    "docs:preview": "pnpm --filter @Wannaer-element/docs preview",
    "test": "pnpm --filter @Wannaer-element/components test"
  },

修改完成后 运行 pnpm test 发现一片红,因为我们的组件还没有完善,所以测试不通过

<template>
  <button style="color: red">this is a button</button>
</template>

<script setup lang="ts">
defineOptions({
  name: "WButton",
});
</script>
<style scoped></style>

在这里插入图片描述

完善 Button 组件

1、定义 types.ts

import type { Component } from "vue";

export type ButtonType = "primary" | "success" | "warning" | "danger" | "info";
export type NativeType = "button" | "submit" | "reset";
export type ButtonSize = "default" | "large" | "small";

export interface ButtonProps {
  tag?: string | Component;
  type?: ButtonType;
  size?: ButtonSize;
  nativeType?: NativeType;
  disabled?: boolean;
  loading?: boolean;
  icon?: string;
  circle?: boolean;
  plain?: boolean;
  round?: boolean;
}

在这里插入图片描述

2、Button.vue 引入 types.ts

<script setup lang="ts">
import { ref } from "vue";
import type { ButtonProps } from "./types";
defineOptions({
  name: "WButton",
});
const props = withDefaults(defineProps<ButtonProps>(), {
  tag: "button",
  nativeType: "button",
});

const slots = defineSlots();

const _ref = ref<HTMLButtonElement>();
</script>

<template>
  <component
    :is="props.tag"
    ref="_ref"
    class="wan-button"
    :class="{
      [`wan-button--${type}`]: type,
      [`wan-button--${size}`]: size,
      'is-plain': plain,
      'is-loading': loading,
      'is-disabled': disabled,
      'is-round': round,
      'is-circle': circle,
    }"
    :type="tag === 'button' ? nativeType : void 0"
    :disabled="disabled || loading ? true : void 0"
  >
    <slot></slot>
  </component>
</template>

<style scoped></style>

测试用例通过
在这里插入图片描述

3、添加Button样式

Button 组件 新建 style.css 文件

// style.css 
.wan-button-group {
  --wan-button-group-border-color: var(--wan-border-color-lighter);
}
.wan-button {
  --wan-button-font-weight: var(--wan-font-weight-primary);
  --wan-button-border-color: var(--wan-border-color);
  --wan-button-bg-color: var(--wan-fill-color-blank);
  --wan-button-text-color: var(--wan-text-color-regular);
  --wan-button-disabled-text-color: var(--wan-disabled-text-color);
  --wan-button-disabled-bg-color: var(--wan-fill-color-blank);
  --wan-button-disabled-border-color: var(--wan-border-color-light);
  --wan-button-hover-text-color: var(--wan-color-primary);
  --wan-button-hover-bg-color: var(--wan-color-primary-light-9);
  --wan-button-hover-border-color: var(--wan-color-primary-light-7);
  --wan-button-active-text-color: var(--wan-button-hover-text-color);
  --wan-button-active-border-color: var(--wan-color-primary);
  --wan-button-active-bg-color: var(--wan-button-hover-bg-color);
  --wan-button-outline-color: var(--wan-color-primary-light-5);
  --wan-button-active-color: var(--wan-text-color-primary);
}

.wan-button {
  display: inline-flex;
  justify-content: center;
  align-items: center;
  line-height: 1;
  height: 32px;
  white-space: nowrap;
  cursor: pointer;
  color: var(--wan-button-text-color);
  text-align: center;
  box-sizing: border-box;
  outline: none;
  transition: 0.1s;
  font-weight: var(--wan-button-font-weight);
  user-select: none;
  vertical-align: middle;
  -webkit-appearance: none;
  background-color: var(--wan-button-bg-color);
  border: var(--wan-border);
  border-color: var(--wan-button-border-color);
  padding: 8px 15px;
  font-size: var(--wan-font-size-base);
  border-radius: var(--wan-border-radius-base);
  & + & {
    margin-left: 12px;
  }
  &:hover,
  &:focus {
    color: var(--wan-button-hover-text-color);
    border-color: var(--wan-button-hover-border-color);
    background-color: var(--wan-button-hover-bg-color);
    outline: none;
  }
  &:active {
    color: var(--wan-button-active-text-color);
    border-color: var(--wan-button-active-border-color);
    background-color: var(--wan-button-active-bg-color);
    outline: none;
  }
  /*plain*/
  &.is-plain {
    --wan-button-hover-text-color: var(--wan-color-primary);
    --wan-button-hover-bg-color: var(--wan-fill-color-blank);
    --wan-button-hover-border-color: var(--wan-color-primary);
  }
  /*round*/
  &.is-round {
    border-radius: var(--wan-border-radius-round);
  }
  /*circle*/
  &.is-circle {
    border-radius: 50%;
    padding: 8px;
  }
  /*disabled*/
  &.is-loading,
  &.is-disabled,
  &.is-disabled:hover,
  &.is-disabled:focus,
  &[disabled],
  &[disabled]:hover,
  &[disabled]:focus {
    color: var(--wan-button-disabled-text-color);
    cursor: not-allowed;
    background-image: none;
    background-color: var(--wan-button-disabled-bg-color);
    border-color: var(--wan-button-disabled-border-color);
  }
  [class*="wan-icon"] {
    width: 1em;
    height: 1em;
  }
}
@each $val in primary, success, warning, info, danger {
  .wan-button--$(val) {
    --wan-button-text-color: var(--wan-color-white);
    --wan-button-bg-color: var(--wan-color-$(val));
    --wan-button-border-color: var(--wan-color-$(val));
    --wan-button-outline-color: var(--wan-color-$(val)-light-5);
    --wan-button-active-color: var(--wan-color-$(val)-dark-2);
    --wan-button-hover-text-color: var(--wan-color-white);
    --wan-button-hover-bg-color: var(--wan-color-$(val)-light-3);
    --wan-button-hover-border-color: var(--wan-color-$(val)-light-3);
    --wan-button-active-bg-color: var(--wan-color-$(val)-dark-2);
    --wan-button-active-border-color: var(--wan-color-$(val)-dark-2);
    --wan-button-disabled-text-color: var(--wan-color-white);
    --wan-button-disabled-bg-color: var(--wan-color-$(val)-light-5);
    --wan-button-disabled-border-color: var(--wan-color-$(val)-light-5);
  }
  .wan-button--$(val).is-plain {
    --wan-button-text-color: var(--wan-color-$(val));
    --wan-button-bg-color: var(--wan-color-$(val)-light-9);
    --wan-button-border-color: var(--wan-color-$(val)-light-5);
    --wan-button-hover-text-color: var(--wan-color-white);
    --wan-button-hover-bg-color: var(--wan-color-$(val));
    --wan-button-hover-border-color: var(--wan-color-$(val));
    --wan-button-active-text-color: var(--wan-color-white);

    --wan-button-disabled-text-color: var(--wan-color-$(val)-light-5);
    --wan-button-disabled-bg-color: var(--wan-color-$(val)-light-9);
    --wan-button-disabled-border-color: var(--wan-color-$(val)-light-8);
  }
}
.wan-button--large {
  --wan-button-size: 40px;
  height: var(--wan-button-size);
  padding: 12px 19px;
  font-size: var(--wan-font-size-base);
  border-radius: var(--wan-border-radius-base);
  /*circle*/
  &.is-circle {
    border-radius: 50%;
    padding: 12px;
  }
}
.wan-button--small {
  --wan-button-size: 24px;
  height: var(--wan-button-size);
  padding: 5px 11px;
  font-size: 12px;
  border-radius: calc(var(--wan-border-radius-base) - 1px);
  /*circle*/
  &.is-circle {
    border-radius: 50%;
    padding: 5px;
  }
  [class*="wan-icon"] {
    width: 12px;
    height: 12px;
  }
}

.wan-button-group {
  display: inline-block;
  vertical-align: middle;

  &::after {
    clear: both;
  }
  & > :deep(.wan-button) {
    float: left;
    position: relative;
    margin-left: 0;
    &:first-child {
      border-top-right-radius: 0;
      border-bottom-right-radius: 0;
      border-right-color: var(--wan-button-group-border-color);
    }
    &:last-child {
      border-top-left-radius: 0;
      border-bottom-left-radius: 0;
      border-left-color: var(--wan-button-group-border-color);
    }
    &:not(:first-child):not(:last-child) {
      border-radius: 0;

      border-left-color: var(--wan-button-group-border-color);
      border-right-color: var(--wan-button-group-border-color);
    }
    &:not(:last-child) {
      margin-right: -1px;
    }
    &:first-child:last-child {
      border-top-right-radius: var(--wan-border-radius-base);
      border-bottom-right-radius: var(--wan-border-radius-base);
      border-top-left-radius: var(--wan-border-radius-base);
      border-bottom-left-radius: var(--wan-border-radius-base);

      &.is-round {
        border-radius: var(--wan-border-radius-round);
      }

      &.is-circle {
        border-radius: 50%;
      }
    }
  }
}

Button.vue 导入css

<style scoped>
@import "./style.css";
</style>

分包 theme 目录下 index.css 修改

@import "./reset.css";

:root {
  /* colors */
  --wan-color-white: #ffffff;
  --wan-color-black: #000000;
  --colors: (
    primary: #409eff,
    success: #67c23a,
    warning: #e6a23c,
    danger: #f56c6c,
    info: #909399
  );
  --wan-bg-color: #ffffff;
  --wan-bg-color-page: #f2f3f5;
  --wan-bg-color-overlay: #ffffff;
  --wan-text-color-primary: #303133;
  --wan-text-color-regular: #606266;
  --wan-text-color-secondary: #909399;
  --wan-text-color-placeholder: #a8abb2;
  --wan-text-color-disabled: #c0c4cc;
  --wan-border-color: #dcdfe6;
  --wan-border-color-light: #e4e7ed;
  --wan-border-color-lighter: #ebeef5;
  --wan-border-color-extra-light: #f2f6fc;
  --wan-border-color-dark: #d4d7de;
  --wan-border-color-darker: #cdd0d6;
  --wan-fill-color: #f0f2f5;
  --wan-fill-color-light: #f5f7fa;
  --wan-fill-color-lighter: #fafafa;
  --wan-fill-color-extra-light: #fafcff;
  --wan-fill-color-dark: #ebedf0;
  --wan-fill-color-darker: #e6e8eb;
  --wan-fill-color-blank: #ffffff;

  @each $val, $color in var(--colors) {
    --wan-color-$(val): $(color);
    @for $i from 3 to 9 {
      --wan-color-$(val)-light-$(i): mix(#fff, $(color), 0$ (i));
    }
    --wan-color-$(val)-dark-2: mix(#000, $(color), 0.2);
  }

  /* border */
  --wan-border-width: 1px;
  --wan-border-style: solid;
  --wan-border-color-hover: var(--wan-text-color-disabled);
  --wan-border: var(--wan-border-width) var(--wan-border-style)
    var(--wan-border-color);
  --wan-border-radius-base: 4px;
  --wan-border-radius-small: 2px;
  --wan-border-radius-round: 20px;
  --wan-border-radius-circle: 100%;

  /*font*/
  --wan-font-size-extra-large: 20px;
  --wan-font-size-large: 18px;
  --wan-font-size-medium: 16px;
  --wan-font-size-base: 14px;
  --wan-font-size-small: 13px;
  --wan-font-size-extra-small: 12px;
  --wan-font-family: "Helvetica Neue", Helvetica, "PingFang SC",
    "Hiragino Sans GB", "Microsoft YaHei", "\5fae\8f6f\96c5\9ed1", Arial,
    sans-serif;
  --wan-font-weight-primary: 500;

  /*disabled*/
  --wan-disabled-bg-color: var(--wan-fill-color-light);
  --wan-disabled-text-color: var(--wan-text-color-placeholder);
  --wan-disabled-border-color: var(--wan-border-color-light);

  /*animation*/
  --wan-transition-duration: 0.4s;
  --wan-transition-duration-fast: 0.2s;
}

运行查看

在这里插入图片描述

点击事件 添加节流

// Button.vue
<script setup lang="ts">
import { ref } from "vue";
import type { ButtonEmits, ButtonProps } from "./types";
import { throttle } from "lodash-es";
defineOptions({
  name: "WanButton",
});
const props = withDefaults(defineProps<ButtonProps>(), {
  tag: "button",
  nativeType: "button",
  useThrottle: true,
  throttleDuration: 500,
});

const emits = defineEmits<ButtonEmits>();
const slots = defineSlots();

const _ref = ref<HTMLButtonElement>();

const handleBtnClick = (e: MouseEvent) => {
  emits("click", e);
};
const handlBtneCLickThrottle = throttle(handleBtnClick, props.throttleDuration);
</script>

<template>
  <component
    :is="props.tag"
    ref="_ref"
    class="wan-button"
    :class="{
      [`wan-button--${type}`]: type,
      [`wan-button--${size}`]: size,
      'is-plain': plain,
      'is-loading': loading,
      'is-disabled': disabled,
      'is-round': round,
      'is-circle': circle,
    }"
    :type="tag === 'button' ? nativeType : void 0"
    :disabled="disabled || loading ? true : void 0"
    @click="
      (e: MouseEvent) =>
        useThrottle ? handlBtneCLickThrottle(e) : handleBtnClick(e)
    "
  >
    <slot></slot>
  </component>
</template>

<style scoped>
@import "./style.css";
</style>

// types.ts
import type { Component, ComputedRef, Ref } from "vue";

export type ButtonType = "primary" | "success" | "warning" | "danger" | "info";
export type NativeType = "button" | "submit" | "reset";
export type ButtonSize = "default" | "large" | "small";

export interface ButtonProps {
  useThrottle?: boolean;
  throttleDuration?: number;
}

export interface ButtonEmits {
  (e: "click", value: MouseEvent): void;
}

export interface ButtonInstance {
  ref: Ref<HTMLButtonElement | void>;
  disabled: ComputedRef<boolean>;
  size: ComputedRef<string>;
  type: ComputedRef<string>;
}

添加 Icon

先创建一个Icon组件
分包 components 目录下 新建 Icon 文件夹,在文件夹中新建

// Icon.vue
<script setup lang="ts">
import { type IconProps } from "./types";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { omit } from "lodash-es";
import { computed } from "vue";

defineOptions({
  name: "WanIcon",
  inheritAttrs: false,
});

const props = defineProps<IconProps>();

const filterProps = computed(() => omit(props, ["type", "color"]));
const customStyles = computed(() => ({ color: props.color ?? void 0 }));
</script>

<template>
  <i
    class="wan-icon"
    :class="{ [`wan-icon--${type}`]: type }"
    :style="customStyles"
    v-bind="$attrs"
  >
    <font-awesome-icon v-bind="filterProps" />
  </i>
</template>

<style scoped>
@import "./style.css";
</style>
// index.ts
import Icon from "./Icon.vue";
import { withInstall } from "@Wannaer-element/utils";

export const WanIcon = withInstall(Icon);
// style.css
.wan-icon {
  --wan-icon-color: inherit;
  display: inline-flex;
  justify-content: center;
  align-items: center;
  position: relative;
  fill: currentColor;
  color: var(--wan-icon-color);
  font-size: inherit;
}

@each $val in primary, info, success, warning, danger {
  .wan-icon--$(val) {
    --wan-icon-color: var(--wan-color-$(val));
  }
}
// types.ts
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";

export interface IconProps {
  border?: boolean;
  fixedWidth?: boolean;
  flip?: "horizontal" | "vertical" | "both";
  icon: object | Array<string> | string | IconDefinition;
  mask?: object | Array<string> | string;
  listItem?: boolean;
  pull?: "right" | "left";
  pulse?: boolean;
  rotation?: 90 | 180 | 270 | "90" | "180" | "270";
  swapOpacity?: boolean;
  size?:
    | "2xs"
    | "xs"
    | "sm"
    | "lg"
    | "xl"
    | "2xl"
    | "1x"
    | "2x"
    | "3x"
    | "4x"
    | "5x"
    | "6x"
    | "7x"
    | "8x"
    | "9x"
    | "10x";
  spin?: boolean;
  transform?: object | string;
  symbol?: boolean | string;
  title?: string;
  inverse?: boolean;
  bounce?: boolean;
  shake?: boolean;
  beat?: boolean;
  fade?: boolean;
  beatFade?: boolean;
  spinPulse?: boolean;
  spinReverse?: boolean;
  type?: "primary" | "success" | "warning" | "danger" | "info";
  color?: string;
}
// components index.ts
export * from "./Button";
export * from "./Icon";
// Icon index.ts
import { WanButton, WanIcon } from "@Wannaer-element/components";
import type { Plugin } from "vue";

export default [WanButton, WanIcon] as Plugin[];
// 根目录 package.json 添加依赖
  "dependencies": {
    "@fortawesome/fontawesome-svg-core": "^6.5.1",
    "@fortawesome/free-solid-svg-icons": "^6.5.1",
    "@fortawesome/vue-fontawesome": "^3.0.6"
  }
// core index.ts
import { makeInstaller } from "@Wannaer-element/utils";
import { library } from "@fortawesome/fontawesome-svg-core";
import { fas } from "@fortawesome/free-solid-svg-icons";
import components from "./components";
import "@Wannaer-element/theme/index.css";
library.add(fas);
const installer = makeInstaller(components);

export * from "@Wannaer-element/components";
export default installer;

// Button.vue 修改
<script setup lang="ts">
import { computed, ref } from "vue";
import type { ButtonEmits, ButtonInstance, ButtonProps } from "./types";
import { throttle } from "lodash-es";
import WanIcon from "../Icon/Icon.vue";
defineOptions({
  name: "WanButton",
});
const props = withDefaults(defineProps<ButtonProps>(), {
  tag: "button",
  nativeType: "button",
  useThrottle: true,
  throttleDuration: 500,
});

const emits = defineEmits<ButtonEmits>();
const slots = defineSlots();

const _ref = ref<HTMLButtonElement>();
const size = computed(() =>  props.size ?? "");
const type = computed(() => props.type ?? "");
const disabled = computed(
  () => props.disabled || false
);
const iconStyle = computed(() => ({
  marginRight: slots.default ? "6px" : "0px",
}));

const handleBtnClick = (e: MouseEvent) => {
  emits("click", e);
};
const handlBtneCLickThrottle = throttle(handleBtnClick, props.throttleDuration);

defineExpose<ButtonInstance>({
  ref: _ref,
  disabled,
  size,
  type,
});
</script>

<template>
  <component
    :is="props.tag"
    ref="_ref"
    class="wan-button"
    :class="{
      [`wan-button--${type}`]: type,
      [`wan-button--${size}`]: size,
      'is-plain': plain,
      'is-loading': loading,
      'is-disabled': disabled,
      'is-round': round,
      'is-circle': circle,
    }"
    :type="tag === 'button' ? nativeType : void 0"
    :disabled="disabled || loading ? true : void 0"
    @click="
      (e: MouseEvent) =>
        useThrottle ? handlBtneCLickThrottle(e) : handleBtnClick(e)
    "
  >
    <template v-if="loading">
      <slot name="loading">
        <wan-icon
          class="loading-icon"
          :icon="loadingIcon ?? 'spinner'"
          :style="iconStyle"
          size="1x"
          spin
        />
      </slot>
    </template>
    <wan-icon
      :icon="icon"
      size="1x"
      :style="iconStyle"
      v-if="icon && !loading"
    />
    <slot></slot>
  </component>
</template>

<style scoped>
@import "./style.css";
</style>

集成 StoryBook

Storybook 是一个 快速开发 UI 组件的工具
它是一个组件驱动的开发环境,可以通过隔离组件使开发更快更容易,一次只处理一个组件
Storybook 可以在已有项目中,无需修改业务逻辑的情况下,给组件自动形成文档,可很好的展示属性和功能
Storybook 可以让开发人员在独立的开发环境中展示组件的交互,使测试和调试组件以及与其他开发人员协作变得更加容易

StoryBook 官方文档 选择 Vue with Vite 复制 指令 pnpm dlx storybook@latest init 到分包 play 目录下运行终端 选择Yes 选择 vue3 自动帮我们构建
在这里插入图片描述
pnpm storybook
在这里插入图片描述
stories 目录下只保留一个 文件 其他多余文件删除

// 自动生成的样例 全部清空 写入我们自己的逻辑
import type { Meta, StoryObj, ArgTypes } from "@storybook/vue3";
import { expect, fn, userEvent, within } from "@storybook/test";

// 引入组件
import { WanButton } from "Wannaer-element";

// 定义以下 Story 类型
type Story = StoryObj<typeof WanButton> & { argTypes: ArgTypes };

const meta: Meta<typeof WanButton> = {
  title: "Example/Button",
  component: WanButton,
  tags: ["autodocs"],
  argTypes: {
    type: {
      control: { type: "select" },
      options: ["primary", "success", "warning", "danger", "info", ""],
    },
    size: {
      control: { type: "select" },
      options: ["large", "default", "small", ""],
    },
    disabled: {
      control: "boolean",
    },
    loading: {
      control: "boolean",
    },
    useThrottle: {
      control: "boolean",
    },
    throttleDuration: {
      control: "number",
    },
    autofocus: {
      control: "boolean",
    },
    tag: {
      control: { type: "select" },
      options: ["button", "a", "div"],
    },
    nativeType: {
      control: { type: "select" },
      options: ["button", "submit", "reset", ""],
    },
    icon: {
      control: { type: "text" },
    },
    loadingIcon: {
      control: { type: "text" },
    },
  },
  args: { onClick: fn() },
};

const container = (val: string) => `
<div style="margin:5px">
  ${val}
</div>
`;

export const Default: Story & { args: { content: string } } = {
  argTypes: {
    content: {
      control: { type: "text" },
    },
  },
  args: {
    type: "primary",
    content: "Button",
  },
  render: (args) => ({
    components: { WanButton },
    setup() {
      return { args };
    },
    template: container(
      `<wan-button v-bind="args">{{args.content}}</wan-button>`
    ),
  }),
  play: async ({ canvasElement, args, step }) => {
    const canvas = within(canvasElement);
    await step("click button", async () => {
      await userEvent.tripleClick(canvas.getByRole("button"));
    });

    expect(args.onClick).toHaveBeenCalled();
  },
};

export default meta;

测试结果
在这里插入图片描述

在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1697621.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

mysql 函数 GROUP_CONCAT 踩坑记录,日志:Row 244 was cut by GROUP_CONCAT()

mysql 函数 GROUP_CONCAT 踩坑记录&#xff0c;报错&#xff1a;Row 244 was cut by GROUP_CONCAT 结论&#xff1a;个人建议还是放在内存中拼接吧~db日志信息&#xff1a;Row 244 was cut by GROUP_CONCAT())根本原因&#xff1a;拼接的字符串长度超过 group_concat_max_len […

Sentinel Dashboard 规则联动持久化方案

一、Sentinel Dashboard 规则联动持久化方案 Sentinel 是阿里开源的一个流量控制组件&#xff0c;它提供了一种流量控制、熔断降级、系统负载保护等功能的解决方案。并且我们通过 Sentinel Dashboard 可以非常便捷的添加或修改规则策略&#xff0c;但是如果细心的小伙伴应该可…

C++语言·list链表

其实现在在讲这些容器的时候&#xff0c;我们的重点已经不是它的接口都有什么&#xff0c;功能都是什么了&#xff0c;这些内容官网上都能查到&#xff0c;而且容器和容器之间接口的不同处很少&#xff0c;我在讲解的话也只是把官网上的东西截图下来复述一下。现在的重点其实都…

【知识图谱】探索攻略:基础、构建、高级应用与相关论文方向

【知识图谱】相关文章汇总 写在最前面一、什么是知识图谱&#xff1f;二、相关历史文章代码实现&#xff1a;简单的知识图谱可视化知识图谱前身&#xff1a;信息抽取知识图谱应用1&#xff1a;社交网络分析知识图谱应用2&#xff1a;威胁情报挖掘知识图谱应用3&#xff1a;Code…

⌈ 传知代码 ⌋ 实现沉浸式交互故事体验

&#x1f49b;前情提要&#x1f49b; 本文是传知代码平台中的相关前沿知识与技术的分享~ 接下来我们即将进入一个全新的空间&#xff0c;对技术有一个全新的视角~ 本文所涉及所有资源均在传知代码平台可获取 以下的内容一定会让你对AI 赋能时代有一个颠覆性的认识哦&#x…

DNS服务的部署与配置(2)

1、dns的安装及开启 dnf install bind.x86_64 -y #安装 #Berkeley Internet Name Domain (BIND) systemctl enable --now named #启用dns服务&#xff0c;服务名称叫named firewall-cmd --permanent --add-servicedns #火墙设置 firewall-cmd --reload …

Linux(三)

Linux&#xff08;三&#xff09; Linux网络配置管理网络基础知识 IP地址A类 由1个字节网络地址3个字节主机地址B类 由2个字节网络地址2个主机地址C类 由3个字节网络地址1个主机地址D类:主要用于组播E类:为将来使用保留 子网掩码子网掩码作用网关DNS服务器 Linux用户管理用户的…

服务器数据恢复—同友存储raid5阵列上层虚拟机数据恢复案例

服务器数据恢复环境&#xff1a; 某市教育局同友存储&#xff0c;存储中有一组由数块磁盘组建的raid5阵列&#xff0c;存储空间划分若干lun。每个lun中有若干台虚拟机&#xff0c;其中有数台linux操作系统的虚拟机为重要数据。 存储结构&#xff1a; 服务器故障&#xff1a; r…

Linux之LLVM、Clang、Clang++区别及用法实例(六十五)

简介&#xff1a; CSDN博客专家&#xff0c;专注Android/Linux系统&#xff0c;分享多mic语音方案、音视频、编解码等技术&#xff0c;与大家一起成长&#xff01; 优质专栏&#xff1a;Audio工程师进阶系列【原创干货持续更新中……】&#x1f680; 优质专栏&#xff1a;多媒…

Java 异步编程——Java内置线程调度器(Executor 框架)

文章目录 Java多线程的两级调度模型Executor 框架Executor 框架的组成概念Executor 框架中任务执行的两个阶段&#xff1a;任务提交和任务执行 在 Java1.5 以前&#xff0c;开发者必须手动实现自己的线程池&#xff1b;从 Java1.5 开始&#xff0c;Java 内部提供了线程池。 在J…

concurrency 并行编程

Goroutine go语言的魅力所在&#xff0c;高并发。 线程是操作系统调度的一种执行路径&#xff0c;用于在处理器执行我们在函数中编写的代码。一个进程从一个线程开始&#xff0c;即主线程&#xff0c;当该线程终止时&#xff0c;进程终止。这是因为主线程是应用程序的原点。然后…

LeetCode题练习与总结:二叉树的层序遍历Ⅱ--107

一、题目描述 给你二叉树的根节点 root &#xff0c;返回其节点值 自底向上的层序遍历 。 &#xff08;即按从叶子节点所在层到根节点所在的层&#xff0c;逐层从左向右遍历&#xff09; 示例 1&#xff1a; 输入&#xff1a;root [3,9,20,null,null,15,7] 输出&#xff1a;[…

springboot3微服务下结合springsecurity的认证授权实现

1. 简介 在微服务架构中&#xff0c;系统被拆分成许多小型、独立的服务&#xff0c;每个服务负责一个功能模块。这种架构风格带来了一系列的优势&#xff0c;如服务的独立性、弹性、可伸缩性等。然而&#xff0c;它也带来了一些挑战&#xff0c;特别是在安全性方面。这时候就体…

YOLOv5改进策略:Focaler-IoU损失函数改进

文章目录 1、前言2、摘要3、Focaler-IoU&#xff1a;4、代码实现5、目标检测系列文章 1、前言 ​ 目标检测是计算机视觉的基本任务之一&#xff0c;旨在识别图像中的目标并定位其位置。目标检测算法可分为基于锚点和无锚点的方法。基于锚点的方法包括Faster R-CNN、YOLO系列、…

数据结构 —— 栈 与 队列

1.栈 1.1栈的结构和概念 栈&#xff08;Stack&#xff09;是一种特殊的线性数据结构&#xff0c;它遵循后进先出&#xff08;LIFO&#xff0c;Last In First Out&#xff09;的原则。栈只允许在一端插入和删除数据&#xff0c;这一端被称为栈顶&#xff08;top&#xff09;&a…

Hudi 多表摄取工具 HoodieMultiTableStreamer 配置方法与示例

博主历时三年精心创作的《大数据平台架构与原型实现&#xff1a;数据中台建设实战》一书现已由知名IT图书品牌电子工业出版社博文视点出版发行&#xff0c;点击《重磅推荐&#xff1a;建大数据平台太难了&#xff01;给我发个工程原型吧&#xff01;》了解图书详情&#xff0c;…

基于SpringBoot和Mybatis实现的留言板案例

目录 一、需求及界面展示 二、准备工作 引入依赖 .yml文件相关配置 数据库数据准备 三、编写后端代码 需求分析 代码结构 Model Mapper Service Controller 前端代码 四、测试 一、需求及界面展示 需求&#xff1a; 1. 输入留言信息&#xff0c;点击提交&…

2024-6-遥远的救世主

2024-6-遥远的救世主 2024-4-18 豆豆 fatux&#xff1a; 2021.5.26 看完电视剧《天道》之后购买本书&#xff0c;断断续续一直没有读完。 非常好奇&#xff0c;一个什么样的作者能写出如此奇书。老丁&#xff0c;一个智者&#xff0c;智者是多么孤独&#xff0c;因为找不到同…

AtCoder Regular Contest 178 A~D

A.Good Permutation 2&#xff08;贪心&#xff09; 题意&#xff1a; 给你一个正整数 N N N和一个由 M M M个正整数 A ( A 1 , A 2 , … , A M ) A(A_{1},A_{2}, \dots,A_{M}) A(A1​,A2​,…,AM​)组成的序列。 在这里&#xff0c; A A A的所有元素都是介于 1 1 1和 N N …