可自定义设置以下属性:
-
数字输入框宽度(width),类型:string | number,单位 px,默认 90
-
最小值(min),类型:number,默认 -Infinity
-
最大值(max),类型:number,默认 Infinity
-
每次改变步数,可以为小数(step),类型:number,默认 1
-
数值精度(precision),类型:number,默认 0
-
前缀图标(prefix),类型:string | slot,默认 undefined
-
指定展示值的格式(formatter),类型:(value: number | string) => string,默认 undefined
-
指定从 formatter 里转换回数字的方式,和 formatter 搭配使用(parser),类型:(value: string) => number,默认 undefined
-
是否启用键盘快捷键行为(上方向键增,下方向键减)(keyboard),类型:boolean,默认 true
-
是否禁用(disabled),类型:boolean,默认 false
-
数字输入的占位符(placeholder),类型:string,默认 undefined
-
当前值(v-model:value),类型:number,默认 undefined
效果如下图:
在线预览
①创建数组输入框组件InputNumber.vue:
其中引入使用了以下工具函数:
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useSlotsExist, add, useInject } from 'components/utils'
export interface Props {
width?: string | number // 数字输入框宽度,单位 px
min?: number // 最小值
max?: number // 最大值
step?: number // 每次改变步数,可以为小数
precision?: number // 数值精度
prefix?: string // 前缀图标 string | slot
formatter?: (value: string | number) => string // 指定展示值的格式
parser?: (value: string) => number // 指定从 formatter 里转换回数字的方式,和 formatter 搭配使用
keyboard?: boolean // 是否启用键盘快捷键行为(上方向键增,下方向键减)
disabled?: boolean // 是否禁用
placeholder?: string // 数字输入的占位符
value?: number // (v-model) 当前值
valueModifiers?: object // 用于访问组件的 v-model 上添加的修饰符
}
const props = withDefaults(defineProps<Props>(), {
width: 90,
min: -Infinity,
max: Infinity,
step: 1,
precision: 0,
prefix: undefined,
formatter: undefined,
parser: undefined,
keyboard: true,
disabled: false,
placeholder: undefined,
value: undefined,
valueModifiers: () => ({})
})
const inputRef = ref() // input 模板引用
const numValue = ref<string>()
const { colorPalettes, shadowColor } = useInject('InputNumber') // 主题色注入
const emits = defineEmits(['update:value', 'change', 'enter'])
const slotsExist = useSlotsExist(['prefix'])
const inputWidth = computed(() => {
if (typeof props.width === 'number') {
return `${props.width}px`
}
return props.width
})
const precision = computed(() => {
// 数值精度取步长和精度中较大者
const stepPrecision = String(props.step).split('.')[1]?.length || 0
return Math.max(props.precision, stepPrecision)
})
const showPrefix = computed(() => {
return slotsExist.prefix || props.prefix
})
const lazyInput = computed(() => {
return 'lazy' in props.valueModifiers
})
watch(
() => [props.value, precision.value, props.formatter],
async () => {
if (props.value !== undefined) {
if (inputRef.value) {
const { selectionStart: start, selectionEnd: end, value } = inputRef.value
const beforeTxt = value.slice(0, start)
const afterTxt = value.slice(end)
numValue.value = getFormatValue() // 获取格式化后的值
await nextTick()
restoreCursor(start, beforeTxt, afterTxt)
} else {
numValue.value = getFormatValue()
}
}
},
{
immediate: true,
flush: 'post',
deep: true
}
)
function restoreCursor(start: number, beforeTxt: string, afterTxt: string): void {
const { value: inputValue } = inputRef.value
let startPos = inputValue.length
if (inputValue.endsWith(afterTxt)) {
startPos = inputValue.length - afterTxt.length
} else if (inputValue.startsWith(beforeTxt)) {
startPos = beforeTxt.length
} else {
const beforeLastChar = beforeTxt[start - 1]
const newIndex = inputValue.indexOf(beforeLastChar, start - 1)
if (newIndex !== -1) {
startPos = newIndex + 1
}
}
inputRef.value.setSelectionRange(startPos, startPos)
}
function emitValue(value: number | undefined): void {
emits('update:value', value) // 保证在 change 回调时能获取到最新数据
emits('change', value)
}
function getFormatValue(): string | undefined {
if (props.formatter) {
return props.formatter(props.value?.toFixed(precision.value) as string)
} else {
return props.value?.toFixed(precision.value)
}
}
function getNumberValue(value: string): number {
let numberValue = parseFloat(value)
if (numberValue > props.max) {
numberValue = props.max
}
if (numberValue < props.min) {
numberValue = props.min
}
return numberValue
}
function updateValue(value: string): void {
// Number.isNaN() 判断传递的值是否为 NaN,并检测器类型是否为 Number
if (!Number.isNaN(parseFloat(value))) {
const numberValue = getNumberValue(value)
if (numberValue !== props.value) {
emitValue(numberValue)
} else {
numValue.value = getFormatValue()
}
} else {
if (!props.value) {
if (props.formatter) {
numValue.value = props.formatter(value)
}
} else {
numValue.value = getFormatValue()
}
}
}
function onInput(e: Event): void {
if (!lazyInput.value) {
const target = e.target as HTMLInputElement
const value = props.parser ? String(props.parser(target.value)) : target.value
if (value && !Number.isNaN(getNumberValue(value)) && getNumberValue(value) !== props.value) {
updateValue(value)
}
if (!value && props.value !== undefined) {
emitValue(undefined)
}
}
}
function onChange(e: Event): void {
const target = e.target as HTMLInputElement
const value = props.parser ? String(props.parser(target.value)) : target.value
updateValue(value)
}
function onKeyboard(e: KeyboardEvent): void {
if (e.key === 'ArrowUp') {
onUp()
}
if (e.key === 'ArrowDown') {
onDown()
}
}
function onEnter(e: KeyboardEvent): void {
emits('enter', e)
if (lazyInput.value) {
const target = e.target as HTMLInputElement
const value = props.parser ? String(props.parser(target.value)) : target.value
updateValue(value)
}
}
function onUp(): void {
const res = Math.min(props.max, add(props.value || 0, +props.step)).toFixed(precision.value)
emitValue(getNumberValue(res))
}
function onDown(): void {
const res = Math.max(props.min, add(props.value || 0, -props.step)).toFixed(precision.value)
emitValue(getNumberValue(res))
}
</script>
<template>
<div
tabindex="1"
class="m-input-number"
:class="{ 'input-number-disabled': disabled }"
:style="`
--input-number-width: ${inputWidth};
--input-number-primary-color: ${colorPalettes[5]};
--input-number-primary-color-hover: ${colorPalettes[4]};
--input-number-primary-color-focus: ${colorPalettes[4]};
--input-number-primary-shadow-color: ${shadowColor};
`"
>
<div class="input-number-wrap">
<span v-if="showPrefix" class="input-prefix">
<slot name="prefix">{{ prefix }}</slot>
</span>
<input
ref="inputRef"
class="input-number"
autocomplete="off"
:disabled="disabled"
:placeholder="placeholder"
v-model="numValue"
@input="onInput"
@change="onChange"
@keydown.up.prevent
@keydown="keyboard ? onKeyboard($event) : () => false"
@keydown.enter.prevent="onEnter"
/>
</div>
<div class="input-number-handler-wrap">
<span
class="input-number-arrow up-arrow"
:class="{ 'arrow-disabled': (value || 0) >= max }"
@click="(value || 0) >= max ? () => false : onUp()"
>
<svg
class="icon-svg"
focusable="false"
data-icon="up"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M890.5 755.3L537.9 269.2c-12.8-17.6-39-17.6-51.7 0L133.5 755.3A8 8 0 00140 768h75c5.1 0 9.9-2.5 12.9-6.6L512 369.8l284.1 391.6c3 4.1 7.8 6.6 12.9 6.6h75c6.5 0 10.3-7.4 6.5-12.7z"
></path>
</svg>
</span>
<span
class="input-number-arrow down-arrow"
:class="{ 'arrow-disabled': (value || 0) <= min }"
@click="(value || 0) <= min ? () => false : onDown()"
>
<svg
class="icon-svg"
focusable="false"
data-icon="down"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M884 256h-75c-5.1 0-9.9 2.5-12.9 6.6L512 654.2 227.9 262.6c-3-4.1-7.8-6.6-12.9-6.6h-75c-6.5 0-10.3 7.4-6.5 12.7l352.6 486.1c12.8 17.6 39 17.6 51.7 0l352.6-486.1c3.9-5.3.1-12.7-6.4-12.7z"
></path>
</svg>
</span>
</div>
</div>
</template>
<style lang="less" scoped>
.m-input-number {
position: relative;
display: inline-block;
width: var(--input-number-width);
height: 32px;
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
line-height: 1.5714285714285714;
padding: 0 11px;
background-color: #ffffff;
border-radius: 6px;
border: 1px solid #d9d9d9;
transition: all 0.2s;
&:hover {
border-color: var(--input-number-primary-color-hover);
.input-number-handler-wrap {
background: #fff;
opacity: 1;
}
}
&:focus-within {
// 激活时样式
border-color: var(--input-number-primary-color-focus);
box-shadow: 0 0 0 2px var(--input-number-primary-shadow-color);
}
.input-number-wrap {
height: 100%;
display: flex;
.input-prefix {
pointer-events: none;
margin-right: 4px;
display: inline-flex;
align-items: center;
}
.input-number {
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
width: 100%;
height: 100%;
background: transparent;
border-radius: 6px;
transition: all 0.2s linear;
appearance: textfield;
border: none;
outline: none;
}
input::-webkit-input-placeholder {
color: rgba(0, 0, 0, 0.25);
}
input:-moz-placeholder {
color: rgba(0, 0, 0, 0.25);
}
input::-moz-placeholder {
color: rgba(0, 0, 0, 0.25);
}
input:-ms-input-placeholder {
color: rgba(0, 0, 0, 0.25);
}
}
.input-number-handler-wrap {
position: absolute;
top: 0;
right: 0;
width: 22px;
height: 100%;
background: transparent;
border-radius: 0 6px 6px 0;
opacity: 0;
display: flex;
flex-direction: column;
align-items: stretch; // 默认值,元素被拉伸以适应容器
transition: all 0.2s linear;
.input-number-arrow {
display: flex;
align-items: center;
justify-content: center;
flex: auto;
height: 40%;
border-left: 1px solid #d9d9d9;
cursor: pointer;
transition: all 0.2s linear;
&:hover {
height: 60%;
.icon-svg {
color: var(--input-number-primary-color);
}
}
.icon-svg {
font-size: 7px;
color: rgba(0, 0, 0, 0.45);
fill: currentColor;
user-select: none;
transition: color 0.2s;
}
}
.up-arrow {
border-top-right-radius: 6px;
}
.down-arrow {
border-top: 1px solid #d9d9d9;
border-bottom-right-radius: 6px;
}
.arrow-disabled {
cursor: not-allowed;
}
}
}
.input-number-disabled {
color: rgba(0, 0, 0, 0.25);
background-color: rgba(0, 0, 0, 0.04);
border-color: #d9d9d9;
box-shadow: none;
cursor: not-allowed;
opacity: 1;
&:hover {
border-color: #d9d9d9;
}
&:focus-within {
// 激活时样式
border-color: #d9d9d9;
box-shadow: none;
}
.input-number-wrap .input-number {
color: rgba(0, 0, 0, 0.25);
cursor: not-allowed;
}
.input-number-handler-wrap {
display: none;
}
}
</style>
②在要使用的页面引入:
其中引入使用了以下工具函数:
<script setup lang="ts">
import InputNumber from './InputNumber.vue'
import { ref, watchEffect } from 'vue'
import { formatNumber } from 'vue-amazing-ui'
const value = ref(3)
const lazyValue = ref(6)
const formatValue1 = ref(1000)
const formatValue2 = ref(100)
const disabled = ref(true)
watchEffect(() => {
console.log('value', value.value)
})
watchEffect(() => {
console.log('lazyValue', lazyValue.value)
})
watchEffect(() => {
console.log('formatValue1', formatValue1.value)
})
watchEffect(() => {
console.log('formatValue2', formatValue2.value)
})
function formatter(value: number): string {
return formatNumber(value, 2) + '%'
}
function parser(value: string): number {
return Number(value.replace(/[,%]/g, ''))
}
function onChange(number: number) {
console.log('change', number)
}
function onEnter(e: KeyboardEvent) {
console.log('enter', e)
}
</script>
<template>
<div>
<h1>{{ $route.name }} {{ $route.meta.title }}</h1>
<h2 class="mt30 mb10">基本使用</h2>
<Space gap="small" vertical>
<Alert type="info">
.lazy:
<br />
默认情况下,v-model 会在每次 input 事件后更新数据 (IME 拼字阶段的状态例外)。
<br />
你可以添加 lazy 修饰符来改为在每次 change 事件后更新数据:
<br />
{{ '<InputNumber v-model:value.lazy="value" />' }}
</Alert>
<InputNumber :width="120" v-model:value="value" placeholder="Basic usage" @change="onChange" @enter="onEnter" />
<InputNumber
:width="120"
v-model:value.lazy="lazyValue"
placeholder="Lazy usage"
@change="onChange"
@enter="onEnter"
/>
</Space>
<h2 class="mt30 mb10">步数为小数</h2>
<InputNumber :step="0.1" v-model:value="value" />
<h2 class="mt30 mb10">设置数值精度</h2>
<InputNumber :min="-10" :max="10" :step="0.6" :precision="2" v-model:value="value" />
<h2 class="mt30 mb10">格式化展示</h2>
<Space>
<InputNumber
:width="120"
v-model:value="formatValue1"
:formatter="(value: number) => `$ ${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')"
:parser="(value: string) => value.replace(/\$\s?|(,*)/g, '')"
/>
<InputNumber :width="120" v-model:value="formatValue2" :formatter="formatter" :parser="parser" />
</Space>
<h2 class="mt30 mb10">自定义最大最小值</h2>
<InputNumber :min="0" :max="10" v-model:value="value" />
<h2 class="mt30 mb10">添加前缀图标 $</h2>
<InputNumber prefix="$" v-model:value="value" />
<h2 class="mt30 mb10">禁用</h2>
<Space vertical>
<Space align="center">disabled: <Switch v-model="disabled" /></Space>
<InputNumber v-model:value="value" :disabled="disabled" />
</Space>
</div>
</template>