mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-03 14:37:00 +08:00
feat: 添加响应式输入适配器,优化小屏幕录入控件布局
This commit is contained in:
@@ -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),
|
||||
]),
|
||||
])
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user