20260816220132
This commit is contained in:
1 parent
b6f9a4c912
commit
72993ecdcd
179 files changed
+20050
-633
No files matched your search
@@ -0,0 +1,212 @@
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: var(--fms-control-height);
|
||||
padding: 0 var(--fms-control-padding-x);
|
||||
background: var(--fms-card);
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: var(--fms-control-radius);
|
||||
color: var(--fms-text);
|
||||
font-size: var(--fms-font-size);
|
||||
line-height: var(--fms-line-height);
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
box-shadow 150ms ease;
|
||||
|
||||
:deep(svg) {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.input-wrapper:hover:not(.input-disabled) {
|
||||
border-color: color-mix(in srgb, var(--fms-primary) 40%, var(--fms-border));
|
||||
}
|
||||
|
||||
.input-wrapper.input-focused {
|
||||
border-color: var(--fms-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--fms-primary) 20%, transparent);
|
||||
}
|
||||
|
||||
/* 多行模式:紧凑的垂直布局,footer 只保留计数和手柄需要的空间 */
|
||||
.input-textarea {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 6px var(--fms-control-padding-x) 5px;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: calc(var(--fms-line-height) * 3);
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
resize: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input-textarea-with-footer .textarea {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 校验状态 */
|
||||
.input-error {
|
||||
border-color: var(--fms-danger);
|
||||
}
|
||||
|
||||
.input-error:hover:not(.input-disabled) {
|
||||
border-color: color-mix(in srgb, var(--fms-danger) 70%, var(--fms-border));
|
||||
}
|
||||
|
||||
.input-error.input-focused {
|
||||
border-color: var(--fms-danger);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--fms-danger) 20%, transparent);
|
||||
}
|
||||
|
||||
.input-warning {
|
||||
border-color: var(--fms-warning);
|
||||
}
|
||||
|
||||
.input-warning:hover:not(.input-disabled) {
|
||||
border-color: color-mix(in srgb, var(--fms-warning) 70%, var(--fms-border));
|
||||
}
|
||||
|
||||
.input-warning.input-focused {
|
||||
border-color: var(--fms-warning);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--fms-warning) 20%, transparent);
|
||||
}
|
||||
|
||||
/* 禁用态:灰底 + 灰字 + 弱边框,悬停显示禁用光标 */
|
||||
.input-disabled {
|
||||
background: var(--fms-disabled-bg);
|
||||
color: var(--fms-disabled-text);
|
||||
border-color: var(--fms-disabled-border);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.input:disabled,
|
||||
.textarea:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 只读态:外观保持正常,仅用光标提示"可聚焦选中、不能编辑" */
|
||||
.input-readonly {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.input:read-only,
|
||||
.textarea:read-only {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 前后缀 / 密码切换 / 清除按钮 */
|
||||
.input-prefix,
|
||||
.input-suffix,
|
||||
.password-toggle,
|
||||
.input-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
.input-prefix {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.input-suffix {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.password-toggle,
|
||||
.input-clear {
|
||||
margin-left: 6px;
|
||||
padding: 2px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--fms-text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition:
|
||||
color 150ms ease,
|
||||
background-color 150ms ease;
|
||||
}
|
||||
|
||||
.password-toggle:hover:not(:disabled),
|
||||
.input-clear:hover {
|
||||
color: var(--fms-text);
|
||||
background: var(--fms-secondary-hover);
|
||||
}
|
||||
|
||||
.password-toggle:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 字数统计:单行模式下作为行内元素跟随在输入框右侧 */
|
||||
.input-count {
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--fms-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 多行 footer:字数统计 + 拖拽手柄,位于 textarea 下方,右对齐 */
|
||||
.textarea-footer {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.textarea-footer .input-count {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* 自定义拖拽手柄:位于 footer 最右侧 */
|
||||
.input-resize-handle {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
cursor: ns-resize;
|
||||
color: var(--fms-text-secondary);
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.input-resize-handle:hover {
|
||||
color: var(--fms-text);
|
||||
background: var(--fms-secondary-hover);
|
||||
}
|
||||
|
||||
.input-resize-handle:focus-visible {
|
||||
outline: 2px solid var(--fms-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots, watch } from 'vue'
|
||||
import { X, Eye, EyeOff, MoveDiagonal2 } from '@lucide/vue'
|
||||
|
||||
/* ---------- props / emits / slots ---------- */
|
||||
const props = defineProps({
|
||||
/** 双向绑定的值 */
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
/** 输入模式:default | password | number | textarea */
|
||||
type: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: (value) => ['default', 'password', 'number', 'textarea'].includes(value),
|
||||
},
|
||||
disabled: { type: Boolean, default: false },
|
||||
readonly: { type: Boolean, default: false },
|
||||
/** 校验状态:'' | error | warning */
|
||||
status: { type: String, default: '' },
|
||||
/** 有值时展示清除按钮(hover/聚焦时出现) */
|
||||
allowClear: { type: Boolean, default: false },
|
||||
/** 展示字数统计(配合 maxlength 显示 当前 / 上限) */
|
||||
showCount: { type: Boolean, default: false },
|
||||
maxlength: { type: [String, Number], default: undefined },
|
||||
/** 多行时的初始行数 */
|
||||
rows: { type: [String, Number], default: 3 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change', 'blur', 'focus', 'clear'])
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
/* ---------- 公共状态 ---------- */
|
||||
const focused = ref(false)
|
||||
const hovered = ref(false)
|
||||
|
||||
/* ---------- 公共 computed ---------- */
|
||||
const isTextarea = computed(() => props.type === 'textarea')
|
||||
const isPassword = computed(() => props.type === 'password' && !isTextarea.value)
|
||||
const isNumber = computed(() => props.type === 'number')
|
||||
const inputValue = computed(() =>
|
||||
isNumber.value ? normalizeNumberValue(String(props.modelValue ?? '')) : props.modelValue,
|
||||
)
|
||||
|
||||
const hasValue = computed(() => String(inputValue.value ?? '').length > 0)
|
||||
const showClear = computed(
|
||||
() =>
|
||||
props.allowClear &&
|
||||
!props.disabled &&
|
||||
!props.readonly &&
|
||||
hasValue.value &&
|
||||
(focused.value || hovered.value) &&
|
||||
!isTextarea.value,
|
||||
)
|
||||
const showSuffix = computed(() => !!slots.suffix)
|
||||
const hasTextareaFooter = computed(
|
||||
() => isTextarea.value && (props.showCount || (!props.disabled && !props.readonly)),
|
||||
)
|
||||
|
||||
const countText = computed(() => {
|
||||
const len = String(inputValue.value ?? '').length
|
||||
return props.maxlength != null ? `${len} / ${props.maxlength}` : `${len}`
|
||||
})
|
||||
|
||||
const classes = computed(() => [
|
||||
'input-wrapper',
|
||||
isTextarea.value ? 'input-textarea' : '',
|
||||
hasTextareaFooter.value ? 'input-textarea-with-footer' : '',
|
||||
props.status ? `input-${props.status}` : '',
|
||||
props.disabled ? 'input-disabled' : '',
|
||||
props.readonly ? 'input-readonly' : '',
|
||||
focused.value ? 'input-focused' : '',
|
||||
])
|
||||
|
||||
/* ---------- 公共事件 ---------- */
|
||||
function onInput(e) {
|
||||
emit('update:modelValue', getInputValue(e.target))
|
||||
}
|
||||
function onChange(e) {
|
||||
const value = isNumber.value ? commitNumberInput(e.target) : getInputValue(e.target)
|
||||
emit('change', value)
|
||||
}
|
||||
function onFocus(e) {
|
||||
focused.value = true
|
||||
emit('focus', e)
|
||||
}
|
||||
function onBlur(e) {
|
||||
if (isNumber.value) commitNumberInput(e.target)
|
||||
|
||||
focused.value = false
|
||||
emit('blur', e)
|
||||
}
|
||||
function clear() {
|
||||
emit('update:modelValue', '')
|
||||
emit('clear')
|
||||
}
|
||||
|
||||
/* ---------- password 模式 ---------- */
|
||||
const revealed = ref(false)
|
||||
const inputType = computed(() => (isPassword.value && !revealed.value ? 'password' : 'text'))
|
||||
|
||||
function toggleReveal() {
|
||||
revealed.value = !revealed.value
|
||||
}
|
||||
|
||||
/* ---------- number 模式 ---------- */
|
||||
// 保留小数输入中的中间态(例如 "12."),避免用户输入小数点时被立即截断。
|
||||
function normalizeNumberValue(value) {
|
||||
const normalized = value.replace(/[^\d.]/g, '')
|
||||
const firstDot = normalized.indexOf('.')
|
||||
if (firstDot === -1) return normalized
|
||||
|
||||
return `${normalized.slice(0, firstDot + 1)}${normalized.slice(firstDot + 1).replace(/\./g, '')}`
|
||||
}
|
||||
|
||||
// 提交时直接转为 Number;空值单独保留,避免 Number('') 变成 0。
|
||||
function toCommittedNumberValue(value) {
|
||||
const normalized = normalizeNumberValue(value)
|
||||
if (!normalized || normalized === '.') return ''
|
||||
return Number(normalized)
|
||||
}
|
||||
|
||||
function sanitizeNumberInput(target) {
|
||||
const value = target.value
|
||||
const selectionStart = target.selectionStart ?? value.length
|
||||
const nextValue = normalizeNumberValue(value)
|
||||
|
||||
if (value !== nextValue) {
|
||||
const nextSelection = normalizeNumberValue(value.slice(0, selectionStart)).length
|
||||
target.value = nextValue
|
||||
target.setSelectionRange(nextSelection, nextSelection)
|
||||
}
|
||||
|
||||
return nextValue
|
||||
}
|
||||
|
||||
function getInputValue(target) {
|
||||
return isNumber.value ? sanitizeNumberInput(target) : target.value
|
||||
}
|
||||
|
||||
function commitNumberInput(target) {
|
||||
const nextValue = toCommittedNumberValue(target.value)
|
||||
const displayValue = nextValue === '' ? '' : String(nextValue)
|
||||
if (target.value !== displayValue) target.value = displayValue
|
||||
if (!Object.is(nextValue, props.modelValue)) {
|
||||
emit('update:modelValue', nextValue)
|
||||
}
|
||||
return nextValue
|
||||
}
|
||||
|
||||
/* ---------- textarea 模式 ---------- */
|
||||
const textareaEl = ref(null)
|
||||
const defaultTextareaHeight = ref(0)
|
||||
const textareaHeight = ref(null)
|
||||
const showResize = computed(() => isTextarea.value && !props.disabled && !props.readonly)
|
||||
const textareaStyle = computed(() =>
|
||||
textareaHeight.value == null ? undefined : { height: `${textareaHeight.value}px` },
|
||||
)
|
||||
|
||||
function measureDefaultTextareaHeight() {
|
||||
if (!textareaEl.value || textareaHeight.value != null) return
|
||||
defaultTextareaHeight.value = textareaEl.value.offsetHeight
|
||||
}
|
||||
|
||||
// 拖拽状态只在交互期间保存,避免每次 pointermove 都触发组件重渲染。
|
||||
let resizeStartY = 0
|
||||
let resizeStartH = 0
|
||||
let resizePrevUserSelect = ''
|
||||
let resizeMoved = false
|
||||
let resizeActive = false
|
||||
let resizeFrame = 0
|
||||
let pendingResizeHeight = null
|
||||
|
||||
function flushResizeFrame() {
|
||||
if (resizeFrame) {
|
||||
cancelAnimationFrame(resizeFrame)
|
||||
resizeFrame = 0
|
||||
}
|
||||
|
||||
if (pendingResizeHeight != null) {
|
||||
textareaHeight.value = pendingResizeHeight
|
||||
pendingResizeHeight = null
|
||||
}
|
||||
}
|
||||
|
||||
function stopResizeTracking() {
|
||||
flushResizeFrame()
|
||||
if (!resizeActive) return
|
||||
|
||||
document.removeEventListener('pointermove', onResizeMove)
|
||||
document.removeEventListener('pointerup', onResizeEnd)
|
||||
document.removeEventListener('pointercancel', onResizeEnd)
|
||||
document.body.style.userSelect = resizePrevUserSelect
|
||||
resizePrevUserSelect = ''
|
||||
resizeMoved = false
|
||||
resizeActive = false
|
||||
}
|
||||
|
||||
function onResizeStart(e) {
|
||||
if (!textareaEl.value) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
stopResizeTracking()
|
||||
resizeStartY = e.clientY
|
||||
resizeStartH = textareaEl.value.offsetHeight
|
||||
resizeMoved = false
|
||||
resizeActive = true
|
||||
resizePrevUserSelect = document.body.style.userSelect
|
||||
document.body.style.userSelect = 'none'
|
||||
document.addEventListener('pointermove', onResizeMove)
|
||||
document.addEventListener('pointerup', onResizeEnd)
|
||||
document.addEventListener('pointercancel', onResizeEnd)
|
||||
}
|
||||
|
||||
function onResizeMove(e) {
|
||||
if (!resizeActive || !textareaEl.value) return
|
||||
const delta = e.clientY - resizeStartY
|
||||
if (!resizeMoved && Math.abs(delta) < 2) return
|
||||
|
||||
resizeMoved = true
|
||||
const minH = defaultTextareaHeight.value || resizeStartH
|
||||
const nextHeight = Math.max(minH, resizeStartH + delta)
|
||||
pendingResizeHeight = nextHeight
|
||||
if (!resizeFrame) {
|
||||
resizeFrame = requestAnimationFrame(() => {
|
||||
resizeFrame = 0
|
||||
if (pendingResizeHeight != null) {
|
||||
textareaHeight.value = pendingResizeHeight
|
||||
pendingResizeHeight = null
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onResizeEnd() {
|
||||
stopResizeTracking()
|
||||
}
|
||||
|
||||
function onResizeKeydown(e) {
|
||||
if (!textareaEl.value || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown' && e.key !== 'Home')) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
const minH = defaultTextareaHeight.value || textareaEl.value.offsetHeight
|
||||
const currentH = textareaHeight.value || minH
|
||||
const step = Number.parseFloat(getComputedStyle(textareaEl.value).lineHeight) || 20
|
||||
textareaHeight.value =
|
||||
e.key === 'Home' ? minH : Math.max(minH, currentH + (e.key === 'ArrowDown' ? step : -step))
|
||||
}
|
||||
|
||||
/* ---------- 生命周期 ---------- */
|
||||
onMounted(() => {
|
||||
nextTick(measureDefaultTextareaHeight)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.rows, isTextarea.value],
|
||||
() => {
|
||||
if (!isTextarea.value) {
|
||||
stopResizeTracking()
|
||||
defaultTextareaHeight.value = 0
|
||||
textareaHeight.value = null
|
||||
return
|
||||
}
|
||||
|
||||
textareaHeight.value = null
|
||||
nextTick(() => {
|
||||
defaultTextareaHeight.value = textareaEl.value?.offsetHeight || 0
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopResizeTracking()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="classes" @mouseenter="hovered = true" @mouseleave="hovered = false">
|
||||
<span v-if="!isTextarea && $slots.prefix" class="input-prefix">
|
||||
<slot name="prefix" />
|
||||
</span>
|
||||
|
||||
<input
|
||||
v-if="!isTextarea"
|
||||
class="input"
|
||||
data-slot="input"
|
||||
:type="inputType"
|
||||
:value="inputValue"
|
||||
:inputmode="isNumber ? 'decimal' : undefined"
|
||||
:pattern="isNumber ? '[0-9]*[.]?[0-9]*' : undefined"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:maxlength="maxlength"
|
||||
:aria-invalid="status === 'error' || undefined"
|
||||
@input="onInput"
|
||||
@change="onChange"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
v-else
|
||||
ref="textareaEl"
|
||||
class="textarea"
|
||||
data-slot="textarea"
|
||||
:value="modelValue"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:rows="rows"
|
||||
:maxlength="maxlength"
|
||||
:style="textareaStyle"
|
||||
:aria-invalid="status === 'error' || undefined"
|
||||
@input="onInput"
|
||||
@change="onChange"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
></textarea>
|
||||
|
||||
<span v-if="!isTextarea && showSuffix" class="input-suffix">
|
||||
<slot name="suffix" />
|
||||
</span>
|
||||
|
||||
<button
|
||||
v-if="!isTextarea && isPassword"
|
||||
class="password-toggle"
|
||||
type="button"
|
||||
:aria-label="revealed ? '隐藏密码' : '显示密码'"
|
||||
:aria-pressed="revealed"
|
||||
:disabled="disabled || readonly"
|
||||
@click="toggleReveal"
|
||||
>
|
||||
<component :is="revealed ? EyeOff : Eye" />
|
||||
</button>
|
||||
|
||||
<button v-if="showClear" class="input-clear" type="button" aria-label="清除" @click="clear">
|
||||
<X />
|
||||
</button>
|
||||
|
||||
<span v-if="!isTextarea && showCount" class="input-count">{{ countText }}</span>
|
||||
|
||||
<div v-if="isTextarea && (showCount || showResize)" class="textarea-footer">
|
||||
<span v-if="showCount" class="input-count">{{ countText }}</span>
|
||||
|
||||
<span
|
||||
v-if="showResize"
|
||||
class="input-resize-handle"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-label="拖拽调整高度"
|
||||
aria-orientation="vertical"
|
||||
:aria-valuemin="defaultTextareaHeight || undefined"
|
||||
:aria-valuenow="textareaHeight || defaultTextareaHeight || undefined"
|
||||
@pointerdown="onResizeStart"
|
||||
@keydown="onResizeKeydown"
|
||||
>
|
||||
<MoveDiagonal2 aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './index.scss';
|
||||
</style>
|
||||
Reference in new issue
Block a user