feat: 添加响应式输入适配器,优化小屏幕录入控件布局

This commit is contained in:
jxxghp
2026-07-14 12:45:11 +08:00
parent 2df3ce1b86
commit cec9563963
6 changed files with 338 additions and 2 deletions
+153
View File
@@ -0,0 +1,153 @@
import type { Component } from 'vue'
import { defineComponent, getCurrentInstance, h, ref } from 'vue'
import { useDisplay } from 'vuetify'
type ResponsiveInputKind = 'choice' | 'field' | 'group' | 'multiline' | 'range'
interface ResponsiveInputOptions {
kind: ResponsiveInputKind
name: string
}
interface ForwardedInputInstance {
blur?: () => void
focus?: () => void
reset?: () => void
resetValidation?: () => void
validate?: (silent?: boolean) => unknown
}
/**
* 判断标签或提示是否包含可展示的文本。
*/
function hasDisplayText(value: unknown): value is string | number {
return (typeof value === 'string' && value.trim().length > 0) || typeof value === 'number'
}
/**
* 解析包装组件尚未声明的 Vue 布尔属性值。
*/
function isBooleanAttributeEnabled(value: unknown) {
return value === '' || value === true || value === 'true'
}
/**
* 合并控件已有的描述引用与移动端提示、校验信息引用。
*/
function mergeDescribedBy(...values: unknown[]) {
return [...new Set(
values
.filter((value): value is string => typeof value === 'string')
.flatMap(value => value.split(/\s+/))
.filter(Boolean),
)].join(' ')
}
/**
* 为原生 Vuetify 录入组件创建小屏两栏适配器,桌面端保持原组件渲染路径。
*/
export function createResponsiveInputAdapter(component: Component, options: ResponsiveInputOptions) {
return defineComponent({
name: `App${options.name}`,
inheritAttrs: false,
props: {
mobileLayout: {
type: Boolean,
default: true,
},
},
/**
* 根据 Vuetify 断点切换布局,并保留原生控件常用的公开方法。
*/
setup(props, { attrs, expose, slots }) {
const display = useDisplay()
const instanceId = getCurrentInstance()?.uid ?? 0
const controlRef = ref<ForwardedInputInstance>()
/** 聚焦内部原生控件。 */
const focus = () => controlRef.value?.focus?.()
/** 移除内部原生控件的焦点。 */
const blur = () => controlRef.value?.blur?.()
/** 触发内部原生控件校验。 */
const validate = (silent?: boolean) => controlRef.value?.validate?.(silent)
/** 重置内部原生控件值与校验状态。 */
const reset = () => controlRef.value?.reset?.()
/** 仅重置内部原生控件校验状态。 */
const resetValidation = () => controlRef.value?.resetValidation?.()
expose({ blur, focus, reset, resetValidation, validate })
return () => {
const label = attrs.label
const useMobileLayout = props.mobileLayout && display.smAndDown.value && hasDisplayText(label)
if (!useMobileLayout) {
return h(component, { ...attrs, ref: controlRef }, slots)
}
const hint = attrs.hint
const hideDetails = attrs.hideDetails ?? attrs['hide-details']
const showHint = hasDisplayText(hint) && !isBooleanAttributeEnabled(hideDetails)
const controlId = String(attrs.id ?? `app-responsive-input-${instanceId}`)
const hintId = `${controlId}-hint`
const rootClass = attrs.class
const rootStyle = attrs.style
const controlAttrs: Record<string, unknown> = { ...attrs }
for (const key of ['class', 'hint', 'label', 'persistent-hint', 'persistentHint', 'style']) {
delete controlAttrs[key]
}
controlAttrs.id = controlId
controlAttrs.ref = controlRef
controlAttrs.class = 'app-responsive-input__native'
controlAttrs.label = undefined
controlAttrs.hint = undefined
controlAttrs.persistentHint = false
controlAttrs.density ??= 'compact'
controlAttrs['aria-label'] ??= String(label)
if (options.kind === 'field' || options.kind === 'multiline') {
controlAttrs.variant = 'plain'
controlAttrs.singleLine = true
}
if (showHint) {
controlAttrs['aria-describedby'] = mergeDescribedBy(
controlAttrs['aria-describedby'],
hintId,
`${controlId}-messages`,
)
}
const { label: labelSlot, ...controlSlots } = slots
const labelContent = labelSlot?.({ label, props: { for: controlId } }) ?? String(label)
const disabled = isBooleanAttributeEnabled(attrs.disabled)
return h('div', {
class: [
'app-responsive-input',
`app-responsive-input--${options.kind}`,
{ 'app-responsive-input--disabled': disabled },
rootClass,
],
style: rootStyle,
}, [
h('div', { class: 'app-responsive-input__meta' }, [
h('label', { class: 'app-responsive-input__label', for: controlId }, labelContent),
showHint
? h('div', { id: hintId, class: 'app-responsive-input__hint' }, String(hint))
: null,
]),
h('div', { class: 'app-responsive-input__control' }, [
h(component, controlAttrs, controlSlots),
]),
])
}
},
})
}
+20 -1
View File
@@ -3,10 +3,28 @@ import * as components from 'vuetify/components'
import { VBtn } from 'vuetify/components/VBtn'
import * as labsComponents from 'vuetify/labs/components'
import AppDialog from './AppDialog'
import { createResponsiveInputAdapter } from './AppInput'
import defaults from './defaults'
import { icons } from './icons'
import type { ResponsiveInputComponentName } from './responsiveInputNames'
import theme from './theme'
const responsiveInputComponents = {
VAutocomplete: createResponsiveInputAdapter(components.VAutocomplete, { name: 'Autocomplete', kind: 'field' }),
VCheckbox: createResponsiveInputAdapter(components.VCheckbox, { name: 'Checkbox', kind: 'choice' }),
VCombobox: createResponsiveInputAdapter(components.VCombobox, { name: 'Combobox', kind: 'field' }),
VDateInput: createResponsiveInputAdapter(labsComponents.VDateInput, { name: 'DateInput', kind: 'field' }),
VFileInput: createResponsiveInputAdapter(components.VFileInput, { name: 'FileInput', kind: 'field' }),
VNumberInput: createResponsiveInputAdapter(labsComponents.VNumberInput, { name: 'NumberInput', kind: 'field' }),
VRadioGroup: createResponsiveInputAdapter(components.VRadioGroup, { name: 'RadioGroup', kind: 'group' }),
VRangeSlider: createResponsiveInputAdapter(components.VRangeSlider, { name: 'RangeSlider', kind: 'range' }),
VSelect: createResponsiveInputAdapter(components.VSelect, { name: 'Select', kind: 'field' }),
VSlider: createResponsiveInputAdapter(components.VSlider, { name: 'Slider', kind: 'range' }),
VSwitch: createResponsiveInputAdapter(components.VSwitch, { name: 'Switch', kind: 'choice' }),
VTextarea: createResponsiveInputAdapter(components.VTextarea, { name: 'Textarea', kind: 'multiline' }),
VTextField: createResponsiveInputAdapter(components.VTextField, { name: 'TextField', kind: 'field' }),
} satisfies Record<ResponsiveInputComponentName, ReturnType<typeof createResponsiveInputAdapter>>
export default createVuetify({
aliases: {
IconBtn: VBtn,
@@ -16,7 +34,8 @@ export default createVuetify({
theme,
components: {
...components,
VDialog: AppDialog,
...labsComponents,
VDialog: AppDialog,
...responsiveInputComponents,
},
})
@@ -0,0 +1,21 @@
export const responsiveInputCoreComponentNames = [
'VAutocomplete',
'VCheckbox',
'VCombobox',
'VFileInput',
'VRadioGroup',
'VRangeSlider',
'VSelect',
'VSlider',
'VSwitch',
'VTextarea',
'VTextField',
] as const
export const responsiveInputComponentNames = [
...responsiveInputCoreComponentNames,
'VDateInput',
'VNumberInput',
] as const
export type ResponsiveInputComponentName = typeof responsiveInputComponentNames[number]