This commit is contained in:
2026-01-05 12:47:14 +08:00
commit 1fc846fae3
1614 changed files with 162035 additions and 0 deletions

View File

@@ -0,0 +1,125 @@
.nut-theme-dark {
.nut-input {
background: $dark-background;
&__input {
color: $dark-color;
}
}
}
.nut-input {
position: relative;
box-sizing: border-box;
display: flex;
width: 100%;
padding: $input-padding;
font-size: $input-font-size;
line-height: $input-line-height;
background: $white;
&__left,
&__right {
position: relative;
display: flex;
align-items: center;
}
&__left {
margin-right: 4px;
}
&__right {
margin-left: 4px;
}
&__value {
position: relative;
display: flex;
flex: 1;
align-items: center;
}
&__input {
flex: 1;
padding: 0;
font-size: inherit;
line-height: inherit;
text-align: left;
text-decoration: none;
resize: none;
background: transparent;
border: 0;
outline: 0 none;
}
&__mask {
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 100%;
height: 100%;
}
&__word-limit {
display: flex;
justify-content: flex-end;
padding: 0 10px;
font-size: $input-limit-font-size;
color: $input-limit-color;
}
&__clear {
display: flex;
align-items: center;
}
&__clear-icon {
width: 16px;
height: 16px;
margin: 0 4px;
line-height: 1;
color: #c8c9cc;
cursor: pointer;
}
&--disabled {
color: $input-disabled-color !important;
input:disabled {
color: $input-disabled-color;
cursor: not-allowed;
background: none;
opacity: 1;
-webkit-text-fill-color: $input-disabled-color;
}
}
&--required {
&::before {
position: absolute;
left: 14px;
color: $input-required-color;
content: "*";
}
}
&--error,
&--error::placeholder {
color: $input-required-color;
-webkit-text-fill-color: $input-required-color;
}
&--border {
border-bottom: 1px solid $input-border-bottom;
}
}
.nut-form-item {
.nut-input {
padding: 0;
margin: 0;
line-height: var(--nut-cell-line-height);
}
}

View File

@@ -0,0 +1,2 @@
export * from './input'
export * from './type'

View File

@@ -0,0 +1,177 @@
import type {
BaseEvent,
InputOnBlurEvent,
InputOnConfirmEvent,
InputOnFocusEvent,
InputOnInputEvent,
} from '@uni-helper/uni-app-types'
import type { ExtractPropTypes, PropType, StyleValue } from 'vue'
import { BLUR_EVENT, CLEAR_EVENT, CLICK_EVENT, CONFIRM_EVENT, FOCUS_EVENT, INPUT_EVENT, UPDATE_MODEL_EVENT } from '../_constants'
import type { ClassType } from '../_utils'
import {
commonProps,
isNumber,
isString,
makeNumberProp,
makeNumericProp,
makeStringProp,
nullableBooleanProp,
truthProp,
} from '../_utils'
import type { InputAlignType, InputConfirmType, InputFormatTrigger, InputMode, InputType } from './type'
export const inputProps = {
...commonProps,
/**
* @description 输入框类型,支持原生 `input` 标签的所有 `type` 属性,另外还支持 `number` `digit`
*/
type: makeStringProp<InputType>('text'),
/**
* @description 输入值,双向绑定
*/
modelValue: makeNumericProp(''),
/**
* @description 输入框自定义类名
*/
inputClass: {
type: [String, Object, Array] as PropType<ClassType>,
default: '',
},
/**
* @description 输入框自定义样式
*/
inputStyle: {
type: [String, Object, Array] as PropType<StyleValue>,
default: '',
},
/**
* @description 输入框为空时占位符
*/
placeholder: makeStringProp(''),
/**
* @description 指定 placeholder 的样式
*/
placeholderStyle: makeStringProp(''),
/**
* @description 指定 placeholder 的样式类
*/
placeholderClass: makeStringProp('input-placeholder'),
/**
* @description 输入框内容对齐方式,可选值 `left`、`center`、`right`
*/
inputAlign: makeStringProp<InputAlignType>('left'),
/**
* @description 是否显示必填字段的标签旁边的红色星号
*/
required: Boolean,
/**
* @description 是否禁用
*/
disabled: nullableBooleanProp,
/**
* @description 是否只读
*/
readonly: Boolean,
/**
* @description 是否标红
*/
error: Boolean,
/**
* @description 限制最长输入字符
*/
maxLength: makeNumericProp(140),
/**
* @description 展示清除 `Icon`
*/
clearable: Boolean,
/**
* @description 清除图标的 `font-size` 大小
*/
clearSize: makeNumericProp('14'),
/**
* @description 是否显示下边框
*/
border: truthProp,
/**
* @description 格式化函数触发的时机,可选值为 `onChange`、`onBlur`
*/
formatTrigger: makeStringProp<InputFormatTrigger>('onChange'),
/**
* @description 输入内容格式化函数
*/
formatter: {
type: Function as PropType<(value: string) => string>,
default: null,
},
/**
* @description 是否显示限制最长输入字符,需要设置 `max-length` 属性
*/
showWordLimit: Boolean,
/**
* @description 是否自动获得焦点,`iOS` 系统不支持该属性
*/
autofocus: Boolean,
/**
* @description 键盘右下角按钮的文字,仅在`type='text'`时生效,可选值 `send`:发送、`search`:搜索、`next`:下一个、`go`:前往、`done`:完成
*/
confirmType: makeStringProp<InputConfirmType>('done'),
/**
* @description 键盘弹起时,是否自动上推页面
*/
adjustPosition: truthProp,
/**
* @description 是否强制使用系统键盘和 `Web-view` 创建的 `input` 元素。为 `true` 时,`confirm-type`、`confirm-hold` 可能失效
*/
alwaysSystem: Boolean,
/**
* @description 是否在失去焦点后,继续展示清除按钮,在设置 `clearable` 时生效
*/
showClearIcon: Boolean,
/**
* @description 输入框模式
*/
inputMode: makeStringProp<InputMode>('text'),
/**
* @description 指定光标与键盘的距离,取 input 距离底部的距离和 cursor-spacing 指定的距离的最小值作为光标与键盘的距离
*/
cursorSpacing: makeNumberProp(0),
/**
* @description 强制 input 处于同层状态,默认 focus 时 input 会切到非同层状态 (仅在 iOS 下生效)
*/
alwaysEmbed: Boolean,
/**
* @description 点击键盘右下角按钮时是否保持键盘不收起
*/
confirmHold: Boolean,
/**
* @description 指定focus时的光标位置
*/
cursor: Number,
/**
* @description 光标起始位置自动聚集时有效需与selection-end搭配使用
*/
selectionStart: makeNumberProp(-1),
/**
* @description 光标结束位置自动聚集时有效需与selection-start搭配使用
*/
selectionEnd: makeNumberProp(-1),
/**
* @description focus时点击页面的时候不收起键盘
*/
holdKeyboard: Boolean,
}
export type InputProps = ExtractPropTypes<typeof inputProps>
export const inputEmits = {
[CLICK_EVENT]: (evt: BaseEvent) => evt instanceof Object,
clickInput: (evt: BaseEvent) => evt instanceof Object,
[BLUR_EVENT]: (evt: InputOnBlurEvent) => evt instanceof Object,
[FOCUS_EVENT]: (evt: InputOnFocusEvent) => evt instanceof Object,
[CLEAR_EVENT]: () => true,
[CONFIRM_EVENT]: (evt: InputOnConfirmEvent) => evt instanceof Object,
[UPDATE_MODEL_EVENT]: (val1: string | number, val2?: BaseEvent) => (isString(val1) || isNumber(val1)) && ((val2 instanceof Object) || val2 === undefined),
[INPUT_EVENT]: (val: string | number, evt: InputOnInputEvent) => (isString(val) || isNumber(val)) && evt instanceof Object,
}
export type InputEmits = typeof inputEmits

View File

@@ -0,0 +1,273 @@
<script lang="ts" setup>
import type { InputOnBlurEvent, InputOnConfirmEvent, InputOnFocusEvent, InputOnInputEvent } from '@uni-helper/uni-app-types'
import { computed, defineComponent, nextTick, onMounted, ref, toRef, useSlots, watch } from 'vue'
import { BLUR_EVENT, CLEAR_EVENT, CLICK_EVENT, CONFIRM_EVENT, FOCUS_EVENT, INPUT_EVENT, PREFIX, UPDATE_MODEL_EVENT } from '../_constants'
import { getMainClass, isH5 } from '../_utils'
import { useFormDisabled } from '../form/form'
import NutIcon from '../icon/icon.vue'
import { inputEmits, inputProps } from './input'
import type { InputFormatTrigger, InputTarget } from './type'
import { formatNumber } from './util'
const props = defineProps(inputProps)
const emit = defineEmits(inputEmits)
const slots = useSlots()
function hasSlot(name: string) {
return Boolean(slots[name])
}
const formDisabled = useFormDisabled(toRef(props, 'disabled'))
function stringModelValue() {
if (props.modelValue == null)
return ''
return String(props.modelValue)
}
const innerValue = computed<string>(() => {
return stringModelValue()
})
const classes = computed(() => {
return getMainClass(props, componentName, {
[`${componentName}--disabled`]: formDisabled.value,
[`${componentName}--required`]: props.required,
[`${componentName}--error`]: props.error,
[`${componentName}--border`]: props.border,
})
})
const inputStyles = computed(() => {
return [props.inputStyle, {
textAlign: props.inputAlign,
}]
})
const innerMaxLength = computed(() => {
if (props.maxLength == null)
return -1
return Number(props.maxLength)
})
function updateValue(value: string, trigger: InputFormatTrigger = 'onChange') {
if (innerMaxLength.value > 0 && value.length > innerMaxLength.value)
value = value.slice(0, innerMaxLength.value)
if (props.type === 'number')
value = formatNumber(value, false, false)
if (props.type === 'digit')
value = formatNumber(value, true, true)
if (props.formatter && trigger === props.formatTrigger)
value = props.formatter(value)
emit(UPDATE_MODEL_EVENT, value)
}
function _onInput(evt: InputOnInputEvent) {
updateValue(evt.detail.value)
nextTick(() => {
emit(INPUT_EVENT, innerValue.value, evt)
})
}
function handleInput(evt: InputOnInputEvent) {
if (isH5) {
const target = evt.target as InputTarget
if (!target.composing)
_onInput(evt)
}
else {
_onInput(evt)
}
}
function handleClick(evt: any) {
emit(CLICK_EVENT, evt)
}
function handleClickInput(evt: any) {
if (formDisabled.value)
return
emit('clickInput', evt)
}
const active = ref(false)
const clearing = ref(false)
function handleFocus(evt: InputOnFocusEvent) {
if (formDisabled.value || props.readonly)
return
emit(FOCUS_EVENT, evt)
active.value = true
}
function handleBlur(evt: InputOnBlurEvent) {
if (formDisabled.value || props.readonly)
return
emit(BLUR_EVENT, evt)
setTimeout(() => {
active.value = false
}, 200)
if (clearing.value) {
clearing.value = false
return
}
updateValue(evt.detail.value, 'onBlur')
}
function handleConfirm(evt: InputOnConfirmEvent) {
emit(CONFIRM_EVENT, evt)
}
function handleClear(evt: any) {
if (formDisabled.value)
return
emit(UPDATE_MODEL_EVENT, '', evt)
emit(CLEAR_EVENT)
clearing.value = true
}
function startComposing(evt: any) {
if (isH5) {
const target = evt.target as InputTarget
target.composing = true
}
}
function endComposing(evt: any) {
if (isH5) {
const target = evt.target as InputTarget
if (target.composing) {
target.composing = false
target.dispatchEvent(new Event('input'))
}
}
}
watch(
() => props.modelValue,
(value) => {
if (value === innerValue.value)
return
updateValue(stringModelValue())
},
)
onMounted(() => {
updateValue(stringModelValue(), props.formatTrigger)
})
</script>
<script lang="ts">
const componentName = `${PREFIX}-input`
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<view :class="classes" :style="props.customStyle" @click="handleClick">
<view v-if="hasSlot('left')" class="nut-input__left">
<slot name="left" />
</view>
<view class="nut-input__value">
<input
class="nut-input__input"
:class="props.inputClass"
:style="inputStyles"
:value="innerValue"
:type="props.type as any"
:placeholder="props.placeholder"
:placeholder-style="props.placeholderStyle"
:placeholder-class="props.placeholderClass"
:disabled="formDisabled"
:readonly="props.readonly"
:focus="props.autofocus"
:maxlength="innerMaxLength"
:format-trigger="props.formatTrigger"
:auto-blur="props.autofocus ? true : undefined"
:confirm-type="props.confirmType"
:adjust-position="props.adjustPosition"
:always-system="props.alwaysSystem"
:inputmode="props.inputMode"
:cursor-spacing="props.cursorSpacing"
:always-embed="props.alwaysEmbed"
:confirm-hold="props.confirmHold"
:cursor="props.cursor"
:selection-start="props.selectionStart"
:selection-end="props.selectionEnd"
:hold-keyboard="props.holdKeyboard"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@click="handleClickInput"
@change="endComposing"
@compositionstart="startComposing"
@compositionend="endComposing"
@confirm="handleConfirm"
>
<view v-if="props.readonly" class="nut-input__mask" @click="handleClickInput" />
<view v-if="props.showWordLimit && innerMaxLength > 0" class="nut-input__word-limit">
<text class="nut-input__word-num">
{{ innerValue.length }}
</text>/{{ innerMaxLength }}
</view>
</view>
<view
v-if="props.clearable && !props.readonly"
class="nut-input__clear"
:class="{ 'nut-hidden': !((active || props.showClearIcon) && innerValue.length > 0) }"
@click.stop="handleClear"
>
<slot v-if="hasSlot('clear')" name="clear" />
<NutIcon
v-else
name="mask-close"
custom-class="nut-input__clear-icon"
:size="props.clearSize"
:width="props.clearSize"
:height="props.clearSize"
/>
</view>
<view v-if="hasSlot('right')" class="nut-input__right">
<slot name="right" />
</view>
</view>
</template>
<style lang="scss">
@import './index';
</style>

View File

@@ -0,0 +1,19 @@
export const inputAlignType = ['left', 'center', 'right'] as const // text-align
export type InputAlignType = (typeof inputAlignType)[number]
export const inputFormatTrigger = ['onChange', 'onBlur'] as const // onChange: 在输入时执行格式化 ; onBlur: 在失焦时执行格式化
export type InputFormatTrigger = (typeof inputFormatTrigger)[number]
export const inputType
= ['text', 'number', 'idcard', 'digit', 'tel', 'safe-password', 'nickname', 'button', 'checkbox', 'color', 'date', 'datetime-local', 'email', 'file', 'hidden', 'image', 'month', 'password', 'radio', 'range', 'reset', 'search', 'submit', 'time', 'url', 'week'] as const
export type InputType = (typeof inputType)[number]
export const inputMode = ['none', 'text', 'decimal', 'numeric', 'tel', 'search', 'email', 'url'] as const
export type InputMode = (typeof inputMode)[number]
export const inputConfirmType = ['send', 'search', 'next', 'go', 'done'] as const
export type InputConfirmType = (typeof inputConfirmType)[number]
export interface InputTarget extends HTMLInputElement {
composing?: boolean
}

View File

@@ -0,0 +1,29 @@
function trimExtraChar(value: string, char: string, regExp: RegExp) {
const index = value.indexOf(char)
if (index === -1)
return value
if (char === '-' && index !== 0)
return value.slice(0, index)
return value.slice(0, index + 1) + value.slice(index).replace(regExp, '')
}
export function formatNumber(value: string, allowDot = true, allowMinus = true) {
if (allowDot)
value = trimExtraChar(value, '.', /\./g)
else
value = value.split('.')[0]
if (allowMinus)
value = trimExtraChar(value, '-', /-/g)
else
value = value.replace(/-/, '')
const regExp = allowDot ? /[^-0-9.]/g : /[^-0-9]/g
return value.replace(regExp, '')
}