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;
}
}
最终效果
基础用法:
禁用状态:
步进:
精度: