学习Element Plus组件库(四):InputNumber组件

348 阅读3分钟

Input组件

因为input-number组件依赖了Input组件,所以先快速的实现一个input组件。

目录构建

├── packages
│   ├── components
│   │   ├── input
│   │   │   ├── src               # 组件入口目录
│   │   │   │   ├── input.ts      # Ts类型和组件属性
│   │   │   │   └── input.vue     # 组件代码
│   │   │   │   └── use-focus.ts  # hooks
│   │   │   └── index.ts          # 组件入口文件

Ts类型声明和组件属性定义

import { UPDATE_MODEL_EVENT } from "@storm/constants";
import { iconPropType, isString } from "@storm/utils";
import { ExtractPropTypes, PropType } from "vue";

export const inputProps = {
  type: {
    type: String,
    default: 'text'
  },
  modelValue: {
    type: [String, Number, Object] as PropType<string | number | undefined | null>,
    default: ''
  },
  disabled: Boolean,
  placeholder: String,
  readonly: {
    type: Boolean,
    default: false
  },
  clearable: {
    type: Boolean,
    default: false,
  },
  suffixIcon: {
    type: iconPropType
  },
  prefixIcon: {
    type: iconPropType
  },
  maxlength: [String, Number]
}

export const inputEmits = {
  [UPDATE_MODEL_EVENT]: (val: string) => isString(val),
  input: (val: string) => isString(val),
  change: (val: string) => isString(val),
  focus: (e: FocusEvent) => e instanceof FocusEvent,
  blur: (e: FocusEvent) => e instanceof FocusEvent,
  keydown: (e: KeyboardEvent | Event) => e instanceof Event,
  clear: () => true
}

export type InputProps = ExtractPropTypes<typeof inputProps>
export type InputEmits = typeof inputEmits

组件实现

<template>
  <div :class="[
    bem.b(),
    bem.is('disabled', disabled)
  ]">
    <!-- input -->
    <template v-if="type !== 'textarea'">
      <div
        ref="wrapperRef"
        :class="[
          bem.e('wrapper'),
          bem.is('focus', isFocused)
        ]"
      >
        <!-- 输入框头部内容 -->
        <span
          :class="bem.e('prefix')"
          v-if="$slots.prefix || prefixIcon"
        >
          <slot
            :class="bem.e('icon')"
            name="prefix"
            v-if="$slots.prefix"
          />
          <s-icon
            :class="bem.e('icon')"
            v-else-if="prefixIcon"
          >
            <component :is="prefixIcon" />
          </s-icon>
        </span>
        <input
          ref="inputRef"
          :type="type"
          :class="bem.e('inner')"
          v-bind="attrs"
          :disabled="disabled"
          :readonly="readonly"
          :maxlength="maxlength"
          :placeholder="placeholder"
          @input="handleInput"
          @focus="handleFocus"
          @blur="handleBlur"
          @change="handleChange"
          @keydown="handleKeydown"
        >
        <!-- 输入框尾部内容 -->
        <span
          :class="bem.e('suffix')"
          v-if="suffixVisible"
        >
          <s-icon
            :class="[
              bem.e('icon'),
              bem.e('clear')
            ]"
            @mousedown.prevent
            @click="clear"
            v-if="showClear"
          >
            <!-- 这里prevent要阻止接下来blur事件的触发 确保点击事件可以触发 -->
            <circle-close />
          </s-icon>
          <template v-else>
            <slot
              :class="bem.e('icon')"
              name="suffix"
              v-if="$slots.suffix"
            />
            <s-icon
              :class="bem.e('icon')"
              v-else-if="suffixIcon"
            >
              <component :is="suffixIcon" />
            </s-icon>
          </template>
        </span>
      </div>
    </template>
    <!-- textarea -->
    <template v-else>
      <textarea
        ref="textareaRef"
        :class="bem.e('inner')"
        v-bind="attrs"
        :disabled="disabled"
        :readonly="readonly"
        :maxlength="maxlength"
        :placeholder="placeholder"
        @input="handleInput"
        @focus="handleFocus"
        @blur="handleBlur"
        @change="handleChange"
        @keydown="handleKeydown"
      ></textarea>
    </template>
  </div>
</template>

<script setup lang="ts">
import { createNamespace } from '@storm/utils'
import { inputEmits, inputProps } from './input';
import { computed, useSlots, shallowRef, onMounted, nextTick, watch, useAttrs } from 'vue';
import { UPDATE_MODEL_EVENT } from '@storm/constants';
import { useFocus } from './use-focus'
import SIcon from '@storm/components/icon'
import CircleClose from '@storm/components/internal-icon/circle-close.vue'

type TargetElement = HTMLInputElement | HTMLTextAreaElement

defineOptions({
  name: 'SInput',
  inheritAttrs: false
})
const props = defineProps(inputProps)
const emit = defineEmits(inputEmits)
const slots = useSlots()
const attrs = useAttrs()

const bem = createNamespace(props.type === 'textarea' ? 'textarea' : 'input')
const inputRef = shallowRef<HTMLInputElement>()
const textareaRef = shallowRef<HTMLTextAreaElement>()
const _ref = computed(() => inputRef.value || textareaRef.value)
// 如果modelValue不为null或者undefined就强转为字符串
const nativeInputValue = computed(() => props.modelValue == undefined ? '' : String(props.modelValue))
// 清除按钮
const showClear = computed(
  () => props.clearable &&
    !props.disabled &&
    !props.readonly &&
    !!nativeInputValue.value &&
    isFocused.value
)
// 输入框尾部内容
const suffixVisible = computed(() => slots.suffix || !!props.suffixIcon || showClear.value)

const { wrapperRef, isFocused, handleFocus, handleBlur } = useFocus(_ref)

const handleInput = async (e: Event) => {
  const target = e.target as TargetElement
  if (target.value === nativeInputValue.value) {
    setNativeInputValue()
    return
  }
  emit(UPDATE_MODEL_EVENT, target.value)
  emit('input', target.value)
  await nextTick()
  setNativeInputValue()
}
const handleChange = (e: Event) => {
  const target = e.target as TargetElement
  emit('change', target.value)
}
const handleKeydown = (e: KeyboardEvent) => {
  emit('keydown', e)
}
// 设置输入框的值 如果没有传modelValue就无法输入
const setNativeInputValue = () => {
  // 没传modelValue 所以modelValue和input的value值对不上
  const input = _ref.value
  if (!input || input.value === nativeInputValue.value) {
    return
  }
  input.value = nativeInputValue.value
}
watch(nativeInputValue, () => setNativeInputValue())
onMounted(() => setNativeInputValue())

// 暴露出去给外部调用
const focus = () => _ref.value?.focus()
const blur = () => _ref.value?.blur()
const clear = () => {
  emit(UPDATE_MODEL_EVENT, '')
  emit('change', '')
  emit('clear')
  emit('input', '')
}
defineExpose({
  focus,
  blur,
  clear
})
</script>

use-focus.ts

import { ShallowRef, getCurrentInstance, ref, shallowRef } from "vue"
import { useEventListener } from '@vueuse/core'

export const useFocus = <T extends HTMLElement>(target: ShallowRef<T | undefined>) => {
  const { emit } = getCurrentInstance()!
  const wrapperRef = shallowRef<HTMLElement>()
  // 记录是否聚焦
  const isFocused = ref(false)

  const handleFocus = (e: FocusEvent) => {
    if (isFocused.value) return
    isFocused.value = true
    emit('focus', e)
  }
  const handleBlur = (e: FocusEvent) => {
    isFocused.value = false
    emit('blur', e)
  }
  // 点击输入框任何地方都尝试获取焦点
  useEventListener(wrapperRef, 'click', () => {
    target.value?.focus()
  })

  return {
    wrapperRef,
    isFocused,
    handleFocus,
    handleBlur
  }
}

入口文件

import { withInstall } from '@storm/utils'
import _Input from './src/input.vue'
// 添加install方法
export const Input = withInstall(_Input)
export default Input
export * from './src/input'

// 配合volar插件 可以在模版中被解析
declare module 'vue' {
  export interface GlobalComponents {
    SInput: typeof Input
  }
}

样式文件

@use "./mixins/mixins.scss" as *;
@use "./common/var.scss" as *;

@include b(input) {
  display: inline-flex;
  vertical-align: middle;
  width: 100%;
  line-height: 32px;
  font-size: $font-size-base;
  &:not(&.is-disabled):hover {
    .#{$namespace}-input__prefix {
      color: $color-primary;
    }
    .#{$namespace}-input__suffix {
      color: $color-primary;
    }
  }
  @include e(wrapper) {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex-grow: 1;
    padding: 1px 11px;
    border-radius: 4px;
    box-shadow: 0 0 0 1px $color-border inset;
    background-color: $color-white;
    transition: box-shadow 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
    cursor: text;
    @include when(focus) {
      box-shadow: 0 0 0 1px $color-primary inset;
    }
  }
  @include e(prefix) {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    height: 100%;
    margin-right: 8px;
    color: $color-placeholder-text;
    transition: color 0.2s;
  }
  @include e(suffix) {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    height: 100%;
    margin-left: 8px;
    color: $color-placeholder-text;
    transition: color 0.2s;
  }
  @include e(inner) {
    flex-grow: 1;
    width: 100%;
    height: 30px;
    line-height: 30px;
    padding: 0;
    outline: none;
    font-size: $font-size-base;
    color: $color-text;
    border: none;
    background: none;
    -webkit-appearance: none;
    &::-webkit-input-placeholder {
      color: $color-placeholder-text;
    }
    &::-webkit-inner-spin-button {
      -webkit-appearance: none;
    }
  }
  @include e(clear) {
    cursor: pointer;
  }
  @include when(disabled) {
    cursor: not-allowed;
    .#{$namespace}-input__wrapper {
      background-color: $color-bg-disabled;
    }
    .#{$namespace}-input__inner {
      color: $color-border;
      cursor: not-allowed;
    }
  }
}

@include b(textarea) {
  display: inline-block;
  vertical-align: bottom;
  width: 100%;
  font-size: $font-size-base;
  @include e(inner) {
    display: block;
    width: 100%;
    min-height: 30px;
    line-height: 1.5;
    padding: 5px 11px;
    font-size: $font-size-base;
    color: $color-text;
    border-radius: 4px;
    border: none;
    box-shadow: 0 0 0 1px $color-border inset;
    background-image: none;
    background-color: $color-white;
    transition: box-shadow 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
    -webkit-appearance: none;
    resize: vertical;
    &::-webkit-input-placeholder {
      color: $color-placeholder-text;
    }
    &:focus {
      outline: none;
      box-shadow: 0 0 0 1px $color-primary inset;
    }
  }
}

InputNumber组件

目录构建

├── packages
│   ├── components
│   │   ├── input-number
│   │   │   ├── src               # 组件入口目录
│   │   │   │   ├── input-number.ts   # Ts类型和组件属性
│   │   │   │   └── input-number.vue  # 组件代码
│   │   │   └── index.ts          # 组件入口文件

Ts类型声明和组件属性定义

import { UPDATE_MODEL_EVENT } from "@storm/constants"
import { isNil, isNumber } from "@storm/utils"
import { ExtractPropTypes } from "vue"

export const inputNumberProps = {
  modelValue: Number,
  max: {
    type: Number,
    default: Number.POSITIVE_INFINITY
  },
  min: {
    type: Number,
    default: Number.NEGATIVE_INFINITY
  },
  // 计数器步长
  step: {
    type: Number,
    default: 1
  },
  // 数值精度
  precision: {
    type: Number,
    validator: (val: number) => val >= 0
  },
  readonly: Boolean,
  disabled: Boolean,
  // 是否使用控制按钮
  controls: {
    type: Boolean,
    default: true
  },
  name: String,
  label: String,
  placeholder: String
}

export const inputNumberEmits = {
  [UPDATE_MODEL_EVENT]: (val: number | undefined) => isNumber(val) || isNil(val),
  change: (val: number) => isNumber(val) || isNil(val),
  blur: (e: FocusEvent) => e instanceof FocusEvent,
  focus: (e: FocusEvent) => e instanceof FocusEvent,
}
// 提供外部使用
export type InputNumberProps = ExtractPropTypes<typeof inputNumberProps>
export type InputNumberEmits = typeof inputNumberEmits

组件实现

<template>
  <div :class="[
    bem.b(),
    bem.is('disabled', disabled),
    bem.is('without-controls', !controls)
  ]">
    <span
      :class="[
        bem.e('controls'),
        bem.e('decrease'),
        bem.is('disabled', minDisabled)
      ]"
      @click="decrease"
      v-if="controls"
    >
      <s-icon>
        <Decrease />
      </s-icon>
    </span>
    <span
      :class="[
        bem.e('controls'),
        bem.e('increase'),
        bem.is('disabled', maxDisabled)
      ]"
      @click="increase"
      v-if="controls"
    >
      <s-icon>
        <Increase />
      </s-icon>
    </span>
    <s-input
      type="number"
      :model-value="displayValue"
      :step="step"
      :placeholder="placeholder"
      :readonly="readonly"
      :disabled="disabled"
      :max="max"
      :min="min"
      :name="name"
      :label="label"
      @input="handleInput"
      @change="handleChange"
      @blur="handleBlur"
      @focus="handleFocus"
    />
  </div>
</template>

<script setup lang="ts">
import { inputNumberEmits, inputNumberProps } from './input-number';
import { createNamespace } from '@storm/utils'
import SIcon from '@storm/components/icon'
import SInput from '@storm/components/input'
// 减号
import Decrease from '@storm/components/internal-icon/decrease.vue';
// 加号
import Increase from '@storm/components/internal-icon/increase.vue';
import { computed, onMounted, ref } from 'vue'
import { isNumber, isNil, isUndefined } from '@storm/utils'
import { UPDATE_MODEL_EVENT } from '@storm/constants';
defineOptions({ name: 'SInputNumber' })
const props = defineProps(inputNumberProps)
const emit = defineEmits(inputNumberEmits)

type valueType = number | string | null | undefined

const bem = createNamespace('input-number')
const inputRef = ref<HTMLInputElement>()

const minDisabled = computed(() => isNumber(props.modelValue) && props.modelValue <= props.min)
const maxDisabled = computed(() => isNumber(props.modelValue) && props.modelValue >= props.max)
// 默认展示的值
const displayValue = computed(() => {
  let value: valueType = props.modelValue
  if (isNil(value)) return ''
  if (isNumber(value)) {
    if (isNaN(value)) return ''
    if (!isUndefined(props.precision)) {
      value = value.toFixed(props.precision)
    }
  }
  return value
})
const numPrecision = computed(() => {
  // 算出step的精度
  const stepPrecision = getPrecision(props.step)
  if (!isUndefined(props.precision)) {
    // 如果传了precision 就用precision
    return props.precision
  } else {
    // 如果没传precision 就使用modelValue精度和step精度两者大的那个
    return Math.max(getPrecision(props.modelValue), stepPrecision)
  }
})
const verifyValue = (val: number | null): number | null => {
  const { max, min, precision } = props
  if (max < min) {
    throw new Error('[inputNumber] min should not be greater than max.')
  }
  let newVal = Number(val)
  if (isNil(val) || Number.isNaN(newVal)) {
    return null
  }
  if (!isUndefined(precision)) {
    newVal = toPrecision(newVal, precision)
  }
  if (newVal > max || newVal < min) {
    val = newVal > max ? max : min
  }
  return newVal
}
const setCurrentValue = (val: number | null) => {
  const value = verifyValue(val)
  emit(UPDATE_MODEL_EVENT, value!)
}
// 算出传入数字的精度
const getPrecision = (num: number | null | undefined) => {
  if (isNil(num)) return 0
  const valueString = num.toString()
  const dotPosition = valueString.indexOf('.')
  let precision = 0
  if (dotPosition !== -1) {
    precision = valueString.length - dotPosition - 1
  }
  return precision
}
const toPrecision = (num: number, precision?: number): number => {
  if (isUndefined(precision)) {
    precision = numPrecision.value
  }
  if (precision === 0) {
    // 精度为0 直接四舍五入
    return Math.round(num)
  }
  let snum = String(num)
  const pointPos = snum.indexOf('.')
  // 没有小数点直接使用原始值
  if (pointPos === -1) return num
  const nums = snum.replace('.', '').split('')
  const datum = nums[pointPos + precision]
  if (!datum) return num
  // 处理一下精度问题
  const length = snum.length
  if (snum.charAt(length - 1) === '5') {
    snum = `${snum.slice(0, Math.max(0, length - 1))}6`
  }
  return Number.parseFloat(Number(snum).toFixed(precision))
}
// coefficient正负数转换
const ensurePrecision = (val: number, coefficient: 1 | -1 = 1) => {
  return toPrecision(val + props.step * coefficient)
}
const decrease = () => {
  if (props.disabled || props.readonly || minDisabled.value) return
  // 将modelValue的值转成number
  const value = Number(displayValue.value) || 0
  // 通过step和precision算出减过之后的值
  const newValue = ensurePrecision(value, -1)
  setCurrentValue(newValue)
}
const increase = () => {
  if (props.disabled || props.readonly || maxDisabled.value) return
  const value = Number(displayValue.value) || 0
  // 通过step和precision算出加过之后的值
  const newVal = ensurePrecision(value)
  setCurrentValue(newVal)
}
const handleChange = (value: string) => {
  const newValue = value === '' ? null : Number(value)
  if ((isNumber(newValue) && !isNaN(newValue)) || value === '') {
    setCurrentValue(newValue)
  }
}
const handleInput = (value: string) => {
  const newValue = value === '' ? null : Number(value)
  setCurrentValue(newValue)
}
const handleFocus = (event: MouseEvent | FocusEvent) => {
  emit('focus', event)
}
const handleBlur = (event: MouseEvent | FocusEvent) => {
  emit('blur', event)
}

onMounted(() => {
  // 如果modelValue绑定的不是null和undefined 就强转为number类型
  // 如果转出来不是一个数字就赋值为undefined
  if (!isNumber(props.modelValue) && props.modelValue != null) {
    let val: number | undefined = Number(props.modelValue)
    if (isNaN(val)) {
      val = undefined
    }
    emit(UPDATE_MODEL_EVENT, val)
  }
})
// 暴露出去给外部调用
const focus = () => {
  inputRef.value?.focus?.()
}
const blur = () => {
  inputRef.value?.blur?.()
}
defineExpose({
  focus,
  blur
})
</script>

入口文件

import { withInstall } from '@storm/utils'
import _InputNumber from './src/input-number.vue'
// 添加install方法
export const InputNumber = withInstall(_InputNumber)
export default InputNumber
export * from './src/input-number'

// 配合volar插件 可以在模版中被解析
declare module 'vue' {
  export interface GlobalComponents {
    SInputNumber: typeof InputNumber
  }
}

样式文件

@use "./mixins/mixins.scss" as *;
@use "./common/var.scss" as *;

@include b(input-number) {
  position: relative;
  display: inline-flex;
  width: 150px;
  line-height: 30px;
  @include e(controls) {
    position: absolute;
    top: 1px;
    bottom: 1px;
    z-index: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 32px;
    font-size: 13px;
    background-color: $color-bg-disabled;
    user-select: none;
    cursor: pointer;
    &.is-disabled {
      color: $color-border;
      cursor: not-allowed;
    }
  }
  @include e(decrease) {
    left: 1px;
    border-radius: 4px 0 0 4px;
    border-right: 1px solid $color-border;
  }
  @include e(increase) {
    right: 1px;
    border-radius: 0 4px 4px 0;
    border-left: 1px solid $color-border;
  }
  @include when(disabled) {
    .#{$namespace}-input-number__controls {
      color: $color-border;
      cursor: not-allowed;
    }
  }
  .#{$namespace}-input__wrapper {
    padding: 0 42px;
  }
  .#{$namespace}-input__inner {
    line-height: 1;
    text-align: center;
  }
}

最终效果

基础用法:

CPT2312201548-838x62.gif

禁用状态:

CPT2312201548-838x62.gif

步进:

CPT2312201548-838x62.gif

精度:

CPT2312201548-838x62.gif