Repository: IduxFE/components (ts)

Issue:
[comp: badge]: feature badge component
- [x] I have searched the [issues](https://github.com/IduxFE/components/issues) of this repository and believe that this is not a duplicate.

徽标组件
- 支持圆点徽标
- 支持数字徽标和自定义最大数字徽标
- 支持自定义徽标(组件徽标)

### What problem does this feature solve?
<!-- Explain your use case, context, and rationale behind this feature request. More importantly, what is the end user experience you are trying to build that led to the need for this feature? -->

徽标组件

### What does the proposed API look like?
<!-- Describe how you propose to solve the problem and provide code samples of how the API would work once implemented. Note that you can use Markdown to format your code blocks. -->
#### API
| 参数 | 说明 | 类型 | 全局配置 |
| --- | --- | --- | --- |
| `dot`| 圆点徽标 | `boolean` | `false` |
| `count` | 数值徽标的值 | `number\|string` | `-` |
| `overflowCount` | 最大数值徽标的值，超出以`${overflowCount}+`显示 | `number\|string` | `99` |
| `showZero` | 数值为0时是否显示 | `boolean` | `false` |
| `color` | 徽标的颜色，数值徽标和圆点徽标为设置背景色，组件徽标为字体颜色 | `hex\|rgb?a` | `-` |

#### SLOTS
| 方法 | 说明 |
| --- | --- |
| `defalut` | 需要添加徽标的内容 |
| `count` | 自定义徽标 |

Pull request:
feat(comp:badge): add component badge
fix #64

## PR Checklist

Please check if your PR fulfills the following requirements:

- [x] The commit message follows [our guidelines](https://github.com/IduxFE/components/blob/main/docs/contributing.zh.md#commit)
- [x] Tests for the changes have been added/updated or not needed
- [x] Docs and demo have been added/updated or not needed

## PR Type

What kind of change does this PR introduce?

- [ ] Bugfix
- [x] Feature
- [ ] Component style update
- [ ] Code style update (formatting, local variables)
- [ ] Refactoring (no functional changes, no api changes)
- [ ] Build related changes
- [ ] CI related changes
- [ ] Documentation content changes
- [ ] Application (the showcase website) / infrastructure changes
- [ ] Other... Please describe:

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying, or link to a relevant issue. -->

## What is the new behavior?

## Other information


Patch:
diff --git a/packages/components/badge/demo/Basic.vue b/packages/components/badge/demo/Basic.vue
new file mode 100644
index 00000000..7b2b3432
--- /dev/null
+++ b/packages/components/badge/demo/Basic.vue
@@ -0,0 +1,45 @@
+<template>
+  <div class="badge-demo">
+    <ix-badge count="1000"></ix-badge>
+
+    <ix-badge>
+      <template #count>
+        <ix-icon name="clock-circle"></ix-icon>
+      </template>
+    </ix-badge>
+
+    <ix-badge count="100" color="#00f">
+      <a href="#"></a>
+    </ix-badge>
+
+    <ix-badge count="1000" overflowCount="999">
+      <a href="#"></a>
+    </ix-badge>
+
+    <ix-badge dot>
+      <a href="#"></a>
+    </ix-badge>
+
+    <ix-badge>
+      <a href="#"></a>
+      <template #count>
+        <ix-icon name="clock-circle"></ix-icon>
+      </template>
+    </ix-badge>
+  </div>
+</template>
+
+<style lang="less" scoped>
+.badge-demo {
+  & > * {
+    margin-right: 20px;
+    a {
+      display: inline-block;
+      width: 40px;
+      height: 40px;
+      border-radius: 4px;
+      background-color: gainsboro;
+    }
+  }
+}
+</style>
diff --git a/packages/components/badge/demo/basic.md b/packages/components/badge/demo/basic.md
new file mode 100644
index 00000000..7e35281c
--- /dev/null
+++ b/packages/components/badge/demo/basic.md
@@ -0,0 +1,9 @@
+---
+order: 0
+title:
+  zh: 徽标数
+---
+
+## zh
+
+徽标数组件
diff --git a/packages/components/badge/docs/index.zh.md b/packages/components/badge/docs/index.zh.md
new file mode 100644
index 00000000..b778a3b6
--- /dev/null
+++ b/packages/components/badge/docs/index.zh.md
@@ -0,0 +1,36 @@
+---
+category: components
+type: 数据展示
+title: Badge
+subtitle: 徽标数
+cover:
+---
+
+徽标数，红点提示
+
+## 何时使用
+
+徽标数组件
+
+- 支持圆点徽标
+- 支持数字徽标和自定义最大数字徽标
+- 支持自定义徽标(组件徽标)
+
+## API
+
+### `ix-badge`
+
+| 属性            | 说明                                                           | 类型            | 全局配置 |
+| --------------- | -------------------------------------------------------------- | --------------- | -------- |
+| `dot`           | 圆点徽标                                                       | `boolean`       | `false`  |
+| `count`         | 数值徽标的值                                                   | `number/string` | `-`      |
+| `overflowCount` | 最大数值徽标的值，超出以`${overflowCount}+`显示                | `number/string` | `99`     |
+| `showZero`      | 数值为 0 时是否显示                                            | `boolean`       | `false`  |
+| `color`         | 徽标的颜色，数值徽标和圆点徽标为设置背景色，组件徽标为字体颜色 | `hex/rgb?a`     | `-`      |
+
+### `slots`
+
+| 方法      | 说明               | 参数 |
+| --------- | ------------------ | ---- |
+| `defalut` | 需要添加徽标的内容 |
+| `count`   | 自定义徽标         |
diff --git a/packages/components/badge/index.ts b/packages/components/badge/index.ts
new file mode 100644
index 00000000..8bdc265b
--- /dev/null
+++ b/packages/components/badge/index.ts
@@ -0,0 +1,7 @@
+import { installComponent } from '@idux/components/core/utils'
+import IxBadge from './src/Badge.vue'
+
+IxBadge.install = installComponent(IxBadge)
+
+export { IxBadge }
+export type { IxBadgeComponent } from './src/types'
diff --git a/packages/components/badge/src/Badge.vue b/packages/components/badge/src/Badge.vue
new file mode 100644
index 00000000..f788bb58
--- /dev/null
+++ b/packages/components/badge/src/Badge.vue
@@ -0,0 +1,104 @@
+<template>
+  <span class="ix-badge-wrapper">
+    <slot />
+    <sup class="ix-badge" :class="classes" :style="styles">
+      <slot v-if="slotsExist.count" name="count" />
+      <template v-else-if="countValue">{{ countValue }}</template>
+    </sup>
+  </span>
+</template>
+<script lang="ts">
+import { computed, ComputedRef, defineComponent, onUpdated, reactive } from 'vue'
+import { BadgeProps, SlotsExist } from './types'
+import { isNumeric } from './utils'
+import { useGlobalConfig } from '@idux/components/core/config'
+
+export default defineComponent({
+  name: 'IxBadge',
+  props: {
+    count: { type: [Number, String], default: 0 },
+    showZero: Boolean,
+    overflowCount: { type: [Number, String], default: undefined },
+    dot: Boolean,
+    color: { type: String, default: undefined },
+  },
+  setup(props: BadgeProps, { slots }) {
+    const badgeConfig = useGlobalConfig('badge')
+
+    const showZero = computed(() => props.showZero ?? badgeConfig.showZero)
+    const dot = computed(() => props.dot ?? badgeConfig.dot)
+    const overflowCount = computed(() => props.overflowCount ?? badgeConfig.overflowCount)
+
+    const slotsExist = reactive({
+      default: !!slots.default,
+      count: !!slots.count,
+    })
+    onUpdated(() => {
+      slotsExist.default = !!slots.default
+      slotsExist.count = !!slots.count
+    })
+
+    const countValue = useCountValue(props, slotsExist, showZero, dot, overflowCount)
+
+    const classes = useClasses(props, slotsExist, showZero, dot)
+
+    const styles = useStyles(props, slotsExist, dot)
+
+    return {
+      countValue,
+      classes,
+      styles,
+      slotsExist,
+    }
+  },
+})
+
+const useCountValue = (
+  props: BadgeProps,
+  slots: SlotsExist,
+  showZero: ComputedRef<boolean>,
+  dot: ComputedRef<boolean>,
+  overflowCount: ComputedRef<string | number>,
+) => {
+  return computed(() => {
+    if (!slots.count && !dot.value) {
+      if (!showZero.value && +props.count === 0) return false
+      if (isNumeric(props.count) && isNumeric(overflowCount.value)) {
+        return +props.count > +overflowCount.value ? `${overflowCount.value}+` : `${props.count}`
+      }
+      return props.count
+    }
+    return false
+  })
+}
+
+const useStyles = (props: BadgeProps, slots: SlotsExist, dot: ComputedRef<boolean>) => {
+  return computed(() => {
+    const color = props.color ?? ''
+    if (slots.count) {
+      return { color }
+    } else if (dot.value) {
+      return { backgroundColor: color }
+    } else {
+      return { backgroundColor: color }
+    }
+  })
+}
+
+const useClasses = (
+  props: BadgeProps,
+  slots: SlotsExist,
+  showZero: ComputedRef<boolean>,
+  dot: ComputedRef<boolean>,
+) => {
+  return computed(() => {
+    return {
+      'ix-badge-empty': !slots.default,
+      'ix-badge-slot-count': slots.count,
+      'ix-badge-dot': !slots.count && dot.value,
+      'ix-badge-count': !slots.count && !dot.value,
+      'ix-badge-hide-zero': !slots.count && !dot.value && !showZero.value && +props.count === 0,
+    }
+  })
+}
+</script>
diff --git a/packages/components/badge/src/types.ts b/packages/components/badge/src/types.ts
new file mode 100644
index 00000000..89e2a6ae
--- /dev/null
+++ b/packages/components/badge/src/types.ts
@@ -0,0 +1,19 @@
+export interface BadgeProps {
+  // Badge显示的数字
+  readonly count: number | string
+  // 最大显示的数字
+  readonly overflowCount?: number | string
+  // 是否显示0
+  readonly showZero?: boolean
+  // 是否以点的形式显示，优先级大于以上属性
+  readonly dot?: boolean
+  readonly color?: string
+}
+
+export interface SlotsExist {
+  default: boolean
+  count: boolean
+}
+
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface IxBadgeComponent extends BadgeProps {}
diff --git a/packages/components/badge/src/utils.ts b/packages/components/badge/src/utils.ts
new file mode 100644
index 00000000..18c16986
--- /dev/null
+++ b/packages/components/badge/src/utils.ts
@@ -0,0 +1,7 @@
+export const isDefined = <T>(val: T): val is NonNullable<T> => {
+  return val !== undefined && val !== null
+}
+
+export const isNumeric = (val: string | number): val is number => {
+  return typeof val === 'number' || /^(-|\+)?\d+(\.\d+)?$/.test(val)
+}
diff --git a/packages/components/badge/style/index.less b/packages/components/badge/style/index.less
new file mode 100644
index 00000000..fd22283a
--- /dev/null
+++ b/packages/components/badge/style/index.less
@@ -0,0 +1,54 @@
+@import '../../style/default.less';
+
+@badge-prefix: ~'@{idux-prefix}-badge';
+@badge-size: 16px;
+@badge-color: #f5222d;
+@badge-padding: 0 3px;
+@badge-dot-size: 6px;
+
+.@{badge-prefix} {
+  display: inline-block;
+  position: absolute;
+  top: 0;
+  right: 0;
+  z-index: 1;
+  transform: translate(50%, -50%);
+  transform-origin: 100% 0;
+  font-size: @font-size-sm;
+  color: @badge-color;
+  &-wrapper {
+    position: relative;
+    display: inline-block;
+    line-height: 1;
+  }
+  &-slot-count {
+    font-size: @font-size-base;
+  }
+  &-dot {
+    width: @badge-dot-size;
+    height: @badge-dot-size;
+    border-radius: 100%;
+    box-shadow: 0 0 0 1px #fff;
+    background-color: @badge-color;
+  }
+  &-count {
+    padding: @badge-padding;
+    min-width: @badge-size;
+    line-height: 1.2;
+    white-space: nowrap;
+    text-align: center;
+    border-radius: 999px;
+    box-shadow: 0 0 0 1px #fff;
+    background-color: @badge-color;
+    color: #fff;
+  }
+  &-hide-zero {
+    display: none;
+  }
+  &-empty {
+    position: unset;
+    box-shadow: none;
+    transform: none;
+    transform-origin: unset;
+  }
+}
diff --git a/packages/components/components.less b/packages/components/components.less
index 86c99a28..41338dcc 100644
--- a/packages/components/components.less
+++ b/packages/components/components.less
@@ -1,2 +1,3 @@
 @import './button/style/index.less';
 @import './icon/style/index.less';
+@import './badge/style/index.less';
diff --git a/packages/components/core/config/types.ts b/packages/components/core/config/types.ts
index cf225278..bc90a15a 100644
--- a/packages/components/core/config/types.ts
+++ b/packages/components/core/config/types.ts
@@ -5,6 +5,7 @@ export type GlobalConfigKey = keyof GlobalConfig
 export interface GlobalConfig {
   button: ButtonConfig
   icon: IconConfig
+  badge: BadgeConfig
 }
 
 export interface ButtonConfig {
@@ -15,3 +16,9 @@ export interface ButtonConfig {
 export interface IconConfig {
   loadIconDynamically?: (iconName: string) => Promise<string>
 }
+
+export interface BadgeConfig {
+  showZero: boolean
+  dot: boolean
+  overflowCount: number | string
+}
diff --git a/packages/components/core/config/useGlobalConfig.ts b/packages/components/core/config/useGlobalConfig.ts
index 83221062..6360ad28 100644
--- a/packages/components/core/config/useGlobalConfig.ts
+++ b/packages/components/core/config/useGlobalConfig.ts
@@ -1,18 +1,21 @@
 import { inject, shallowReactive, provide, readonly } from 'vue'
 import type { DeepReadonly } from 'vue'
-import type { ButtonConfig, GlobalConfig, GlobalConfigKey, IconConfig } from './types'
+import type { BadgeConfig, ButtonConfig, GlobalConfig, GlobalConfigKey, IconConfig } from './types'
 
 const button: ButtonConfig = shallowReactive({ mode: 'default', size: 'medium' })
 const icon: IconConfig = shallowReactive({})
+const badge: BadgeConfig = shallowReactive({ showZero: false, dot: false, overflowCount: 99 })
 
 const defaultConfig: GlobalConfig = {
   button,
   icon,
+  badge,
 }
 
 const globalConfigToken: Record<GlobalConfigKey, symbol> = {
   button: Symbol(),
   icon: Symbol(),
+  badge: Symbol(),
 }
 
 /**
diff --git a/packages/components/index.ts b/packages/components/index.ts
index 2b7bc99b..ee5e62b0 100644
--- a/packages/components/index.ts
+++ b/packages/components/index.ts
@@ -1,8 +1,9 @@
 import type { App } from 'vue'
 import { IxButton, IxButtonGroup } from './button'
 import { IxIcon } from './icon'
+import { IxBadge } from './badge'
 
-const components = [IxButton, IxButtonGroup, IxIcon]
+const components = [IxButton, IxButtonGroup, IxIcon, IxBadge]
 
 const install = (app: App): void => {
   components.forEach(component => {
@@ -23,3 +24,4 @@ export * from './core/types'
 export * from './button'
 export * from './i18n'
 export * from './icon'
+export * from './badge'


Test patch:
diff --git a/packages/components/badge/__tests__/__snapshots__/badge.spec.ts.snap b/packages/components/badge/__tests__/__snapshots__/badge.spec.ts.snap
new file mode 100644
index 00000000..2c940ab3
--- /dev/null
+++ b/packages/components/badge/__tests__/__snapshots__/badge.spec.ts.snap
@@ -0,0 +1,23 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Badge.vue color work 1`] = `"<sup class=\\"ix-badge ix-badge-empty ix-badge-count ix-badge-hide-zero\\" style=\\"background-color: rgb(255, 0, 0);\\"><!--v-if--></sup>"`;
+
+exports[`Badge.vue count slot work 1`] = `"<span class=\\"ix-badge-wrapper\\"><sup class=\\"ix-badge ix-badge-empty ix-badge-slot-count\\"><div class=\\"count-slot\\"></div></sup></span>"`;
+
+exports[`Badge.vue count work 1`] = `"<sup class=\\"ix-badge ix-badge-empty ix-badge-count\\">10</sup>"`;
+
+exports[`Badge.vue count work 2`] = `"<sup class=\\"ix-badge ix-badge-empty ix-badge-count\\">50</sup>"`;
+
+exports[`Badge.vue dot work 1`] = `"<sup class=\\"ix-badge ix-badge-empty ix-badge-count ix-badge-hide-zero\\"><!--v-if--></sup>"`;
+
+exports[`Badge.vue dot work 2`] = `"<sup class=\\"ix-badge ix-badge-empty ix-badge-dot\\"><!--v-if--></sup>"`;
+
+exports[`Badge.vue overflowCount work 1`] = `"<sup class=\\"ix-badge ix-badge-empty ix-badge-count\\">99+</sup>"`;
+
+exports[`Badge.vue overflowCount work 2`] = `"<sup class=\\"ix-badge ix-badge-empty ix-badge-count\\">999+</sup>"`;
+
+exports[`Badge.vue render work 1`] = `"<span class=\\"ix-badge-wrapper\\"><sup class=\\"ix-badge ix-badge-empty ix-badge-count ix-badge-hide-zero\\"><!--v-if--></sup></span>"`;
+
+exports[`Badge.vue showZero work 1`] = `"<sup class=\\"ix-badge ix-badge-empty ix-badge-count ix-badge-hide-zero\\"><!--v-if--></sup>"`;
+
+exports[`Badge.vue showZero work 2`] = `"<sup class=\\"ix-badge ix-badge-empty ix-badge-count\\">0</sup>"`;
diff --git a/packages/components/badge/__tests__/badge.spec.ts b/packages/components/badge/__tests__/badge.spec.ts
new file mode 100644
index 00000000..ab7a7c39
--- /dev/null
+++ b/packages/components/badge/__tests__/badge.spec.ts
@@ -0,0 +1,86 @@
+import { mount, MountingOptions, VueWrapper } from '@vue/test-utils'
+import { DefineComponent } from 'vue'
+import Badge from '../src/Badge.vue'
+import { BadgeProps } from '../src/types'
+
+describe('Badge.vue', () => {
+  let BadgeMount: (
+    options?: MountingOptions<Partial<BadgeProps>>,
+  ) => VueWrapper<InstanceType<DefineComponent<BadgeProps>>>
+
+  beforeEach(() => {
+    BadgeMount = (options = {}) => {
+      return mount<BadgeProps>(Badge, {
+        ...options,
+      })
+    }
+  })
+
+  test('render work', () => {
+    const wrapper = BadgeMount()
+    expect(wrapper.html()).toMatchSnapshot()
+  })
+
+  // 基本功能测试
+  test('count work', async () => {
+    const wrapper = BadgeMount({ props: { count: 10 } })
+    const badge = wrapper.find('.ix-badge')
+    expect(badge.text()).toBe('10')
+    expect(badge.html()).toMatchSnapshot()
+    await wrapper.setProps({ count: '50' })
+    expect(badge.text()).toBe('50')
+    expect(badge.html()).toMatchSnapshot()
+  })
+
+  // 是否显示0
+  test('showZero work', async () => {
+    const wrapper = BadgeMount({ props: { count: 0 } })
+    const badge = wrapper.find('.ix-badge')
+    expect(badge.classes()).toContain('ix-badge-hide-zero')
+    expect(badge.text()).toBe('')
+    expect(badge.html()).toMatchSnapshot()
+    await wrapper.setProps({ showZero: true })
+    expect(badge.classes()).not.toContain('ix-badge-hide-zero')
+    expect(badge.text()).toBe('0')
+    expect(badge.html()).toMatchSnapshot()
+  })
+
+  // 超出的数据显示
+  test('overflowCount work', async () => {
+    const wrapper = BadgeMount({ props: { count: 100 } })
+    const badge = wrapper.find('.ix-badge')
+    expect(badge.text()).toBe('99+')
+    expect(badge.html()).toMatchSnapshot()
+    await wrapper.setProps({ count: '1000', overflowCount: 999 })
+    expect(badge.text()).toBe('999+')
+    expect(badge.html()).toMatchSnapshot()
+  })
+
+  // 圆点徽标呈现
+  test('dot work', async () => {
+    const wrapper = BadgeMount()
+    const badge = wrapper.find('.ix-badge')
+    expect(badge.classes()).not.toContain('ix-badge-dot')
+    expect(badge.html()).toMatchSnapshot()
+    await wrapper.setProps({ dot: true })
+    expect(badge.classes()).toContain('ix-badge-dot')
+    expect(badge.text()).toBe('')
+    expect(badge.html()).toMatchSnapshot()
+  })
+
+  // 设置颜色属性
+  test('color work', async () => {
+    const wrapper = BadgeMount({ props: { color: '#f00' } })
+    const badge = wrapper.find('.ix-badge')
+    expect(badge.attributes().style).toContain('background-color: rgb(255, 0, 0)')
+    expect(badge.html()).toMatchSnapshot()
+  })
+
+  // 自定义插槽
+  test('count slot work', async () => {
+    const wrapper = BadgeMount({ slots: { count: '<div class="count-slot"></div>' } })
+    const countSlot = wrapper.find('.count-slot')
+    expect(countSlot.exists()).toBe(true)
+    expect(wrapper.html()).toMatchSnapshot()
+  })
+})
diff --git a/packages/components/badge/__tests__/badgeUtils.spec.ts b/packages/components/badge/__tests__/badgeUtils.spec.ts
new file mode 100644
index 00000000..085a6c43
--- /dev/null
+++ b/packages/components/badge/__tests__/badgeUtils.spec.ts
@@ -0,0 +1,38 @@
+import { isDefined, isNumeric } from '../src/utils'
+
+describe('utils.ts', () => {
+  test('isDefined', async () => {
+    const testCases = [
+      { q: '', r: true },
+      { q: 'string', r: true },
+      { q: 1, r: true },
+      { q: {}, r: true },
+      { q: [], r: true },
+      { q: null, r: false },
+      { q: undefined, r: false },
+    ]
+    for (let i = 0; i < testCases.length; i++) {
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const { q, r }: any = testCases[i]
+
+      expect(isDefined(q)).toEqual(r)
+    }
+  })
+
+  test('isNumeric', async () => {
+    const testCases = [
+      { q: '1', r: true },
+      { q: '-1', r: true },
+      { q: '+1', r: true },
+      { q: '1-', r: false },
+      { q: '1-1', r: false },
+      { q: 1, r: true },
+      { q: -1, r: true },
+    ]
+    for (let i = 0; i < testCases.length; i++) {
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const { q, r }: any = testCases[i]
+      expect(isNumeric(q)).toEqual(r)
+    }
+  })
+})