refactor(dynamic-input): ts

This commit is contained in:
07akioni 2021-01-26 16:39:50 +08:00
parent a1b7a6c207
commit 2a9375b88a
15 changed files with 496 additions and 451 deletions

View File

@ -1,2 +0,0 @@
/* istanbul ignore file */
export { default as NDynamicInput } from './src/DynamicInput.vue'

View File

@ -0,0 +1,2 @@
/* istanbul ignore file */
export { default as NDynamicInput } from './src/DynamicInput'

View File

@ -0,0 +1,353 @@
import {
h,
ref,
toRef,
isProxy,
toRaw,
computed,
defineComponent,
renderSlot,
PropType,
inject,
CSSProperties,
provide,
reactive,
VNode
} from 'vue'
import { useMergedState } from 'vooks'
import { createId } from 'seemly'
import { RemoveIcon, AddIcon } from '../../_base/icons'
import { NBaseIcon } from '../../_base'
import { NButton, NButtonGroup } from '../../button'
import { useTheme, useLocale } from '../../_mixins'
import type { ThemeProps } from '../../_mixins'
import { warn, call, MaybeArray } from '../../_utils'
import { dynamicInputLight } from '../styles'
import type { DynamicInputTheme } from '../styles'
import NDynamicInputInputPreset from './InputPreset'
import NDynamicInputPairPreset from './PairPreset'
import style from './styles/index.cssr'
import { OnUpdateValue } from '../../select'
import { DynamicInputInjection } from './interface'
const globalDataKeyMap = new WeakMap()
interface FormItemInjection {
path?: string
}
export default defineComponent({
name: 'DynamicInput',
props: {
...(useTheme.props as ThemeProps<DynamicInputTheme>),
max: Number,
min: {
type: Number,
default: 0
},
value: Array as PropType<any[]>,
// TODO: make it robust for different types
defaultValue: {
type: Array as PropType<any[]>,
default: () => []
},
preset: {
type: String as PropType<'input' | 'pair'>,
default: 'input'
},
keyField: String,
// for preset pair
keyPlaceholder: {
type: String,
default: ''
},
valuePlaceholder: {
type: String,
default: ''
},
// for preset input
placeholder: {
type: String,
default: ''
},
onCreate: Function as PropType<(index: number) => any>,
onRemove: Function as PropType<(index: number) => void>,
// eslint-disable-next-line vue/prop-name-casing
'onUpdate:value': [Function, Array] as PropType<MaybeArray<OnUpdateValue>>,
onUpdateValue: [Function, Array] as PropType<MaybeArray<OnUpdateValue>>,
// deprecated
onClear: {
type: Function as PropType<() => void>,
validator: () => {
warn(
'dynamic-input',
'`on-clear` is deprecated, it is out of usage anymore.'
)
return true
},
default: undefined
},
onInput: {
type: Function as PropType<MaybeArray<OnUpdateValue>>,
validator: () => {
if (__DEV__) {
warn(
'dynamic-input',
'`on-input` is deprecated, please use `on-update:value` instead.'
)
}
return true
},
default: undefined
}
},
setup (props, { slots }) {
const NFormItem = inject<FormItemInjection | null>('NFormItem', null)
const uncontrolledValueRef = ref(props.defaultValue)
const controlledValueRef = toRef(props, 'value')
const mergedValueRef = useMergedState(
controlledValueRef,
uncontrolledValueRef
)
const themeRef = useTheme(
'DynamicInput',
'DynamicInput',
style,
dynamicInputLight,
props
)
const insertionDisabledRef = computed(() => {
const { value: mergedValue } = mergedValueRef
if (Array.isArray(mergedValue)) {
const { max } = props
return max !== undefined && mergedValue.length >= max
}
return false
})
const removeDisabledRef = computed(() => {
const { value: mergedValue } = mergedValueRef
if (Array.isArray(mergedValue)) return mergedValue.length <= props.min
return true
})
function doUpdateValue (value: any[]): void {
const { onInput, 'onUpdate:value': _onUpdateValue, onUpdateValue } = props
if (onInput) call(onInput, value)
if (_onUpdateValue) call(_onUpdateValue, value)
if (onUpdateValue) call(onUpdateValue, value)
uncontrolledValueRef.value = value
}
function ensureKey (value: any, index: number): string | number {
if (value === undefined || value === null) return index
if (typeof value !== 'object') return index
const rawValue = isProxy(value) ? toRaw(value) : value
let key = globalDataKeyMap.get(rawValue)
if (key === undefined) {
globalDataKeyMap.set(rawValue, (key = createId()))
}
return key
}
function handleValueChange (index: number, value: any): void {
const { value: mergedValue } = mergedValueRef
const newValue = Array.from(mergedValue ?? [])
const originalItem = newValue[index]
newValue[index] = value
// update dataKeyMap
if (
originalItem &&
value &&
typeof originalItem === 'object' &&
typeof value === 'object'
) {
const rawOriginal = isProxy(originalItem)
? toRaw(originalItem)
: originalItem
const rawNew = isProxy(value) ? toRaw(value) : value
// inherit key is value position is not change
const originalKey = globalDataKeyMap.get(rawOriginal)
if (originalKey !== undefined) {
globalDataKeyMap.set(rawNew, originalKey)
}
}
doUpdateValue(newValue)
}
function handleCreateClick (): void {
createItem(0)
}
function createItem (index: number): void {
const { value: mergedValue } = mergedValueRef
const { onCreate } = props
const newValue = Array.from(mergedValue ?? [])
if (onCreate) {
newValue.splice(index + 1, 0, onCreate(index + 1))
doUpdateValue(newValue)
} else if (slots.default) {
newValue.splice(index + 1, 0, null)
doUpdateValue(newValue)
} else {
switch (props.preset) {
case 'input':
newValue.splice(index + 1, 0, '')
doUpdateValue(newValue)
break
case 'pair':
newValue.splice(index + 1, 0, { key: '', value: '' })
doUpdateValue(newValue)
break
}
}
}
function remove (index: number): void {
const { value: mergedValue } = mergedValueRef
if (!Array.isArray(mergedValue)) return
const { min } = props
if (mergedValue.length <= min) return
const newValue = Array.from(mergedValue)
newValue.splice(index, 1)
doUpdateValue(newValue)
const { onRemove } = props
if (onRemove) onRemove(index)
}
provide<DynamicInputInjection>(
'NDynamicInput',
reactive({
mergedTheme: themeRef,
keyPlaceholder: toRef(props, 'keyPlaceholder'),
valuePlaceholder: toRef(props, 'valuePlaceholder'),
placeholder: toRef(props, 'placeholder')
})
)
return {
...useLocale('DynamicInput'),
NFormItem,
uncontrolledValue: uncontrolledValueRef,
mergedValue: mergedValueRef,
insertionDisabled: insertionDisabledRef,
removeDisabled: removeDisabledRef,
handleCreateClick,
ensureKey,
handleValueChange,
remove,
createItem,
mergedTheme: themeRef,
cssVars: computed(() => {
const {
self: { actionMargin }
} = themeRef.value
return {
'--action-margin': actionMargin
}
})
}
},
render () {
const {
mergedValue,
locale,
mergedTheme,
keyField,
$slots,
preset,
NFormItem,
ensureKey,
handleValueChange,
remove,
createItem
} = this
return (
<div class="n-dynamic-input" style={this.cssVars as CSSProperties}>
{!Array.isArray(mergedValue) || mergedValue.length === 0 ? (
<NButton
block
ghost
dashed
unstableTheme={mergedTheme.peers.Button}
unstableThemeOverrides={mergedTheme.overrides.Button}
onClick={this.handleCreateClick}
>
{{
default: () => locale.create,
icon: () => (
<NBaseIcon>{{ default: () => <AddIcon /> }}</NBaseIcon>
)
}}
</NButton>
) : (
mergedValue.map((_, index) => (
<div
key={keyField ? _[keyField] : ensureKey(_, index)}
data-key={keyField ? _[keyField] : ensureKey(_, index)}
class="n-dynamic-input-item"
>
{$slots.default ? (
renderSlot($slots, 'default', {
value: mergedValue[index],
index
})
) : preset === 'input' ? (
<NDynamicInputInputPreset
value={mergedValue[index]}
parentPath={NFormItem ? NFormItem.path : undefined}
path={
NFormItem?.path ? `${NFormItem.path}[${index}]` : undefined
}
onUpdateValue={(v) => handleValueChange(index, v)}
/>
) : preset === 'pair' ? (
<NDynamicInputPairPreset
value={mergedValue[index]}
parentPath={NFormItem ? NFormItem.path : undefined}
path={
NFormItem?.path ? `${NFormItem.path}[${index}]` : undefined
}
onUpdateValue={(v) => handleValueChange(index, v)}
/>
) : null}
<div class="n-dynamic-input-item__action">
<NButtonGroup>
{{
default: () =>
[
!this.removeDisabled ? (
<NButton
unstableTheme={mergedTheme.peers.Button}
unstableThemeOverrides={
mergedTheme.overrides.Button
}
circle
onClick={() => remove(index)}
>
{{
icon: () => (
<NBaseIcon>
{{ default: () => <RemoveIcon /> }}
</NBaseIcon>
)
}}
</NButton>
) : null,
<NButton
disabled={this.insertionDisabled}
circle
unstableTheme={mergedTheme.peers.Button}
unstableThemeOverrides={mergedTheme.overrides.Button}
onClick={() => createItem(index)}
>
{{
icon: () => (
<NBaseIcon>
{{ default: () => <AddIcon /> }}
</NBaseIcon>
)
}}
</NButton>
] as VNode[]
}}
</NButtonGroup>
</div>
</div>
))
)}
</div>
)
}
})

View File

@ -1,311 +0,0 @@
<template>
<div class="n-dynamic-input" :style="cssVars">
<n-button
v-if="!mergedValue || mergedValue.length === 0"
block
ghost
dashed
:unstable-theme="mergedTheme.peers.Button"
:unstable-theme-overrides="mergedTheme.overrides.Button"
@click="handleCreateClick"
>
<template #icon>
<n-base-icon>
<add-icon />
</n-base-icon>
</template>
{{ locale.create }}
</n-button>
<div
v-for="(_, index) in mergedValue"
v-else
:key="keyField ? _[keyField] : ensureKey(_, index)"
:data-key="keyField ? _[keyField] : ensureKey(_, index)"
class="n-dynamic-input-item"
>
<slot v-if="$slots.default" :value="mergedValue[index]" :index="index" />
<n-dynamic-input-input-preset
v-else-if="preset === 'input'"
:value="mergedValue[index]"
:parent-path="NFormItem && NFormItem.path"
:path="NFormItem && NFormItem.path + '[' + index + ']'"
@update:value="handleValueChange(index, $event)"
/>
<n-dynamic-input-pair-preset
v-else-if="preset === 'pair'"
:value="mergedValue[index]"
:parent-path="NFormItem && NFormItem.path"
:path="NFormItem && NFormItem.path + '[' + index + ']'"
@update:value="handleValueChange(index, $event)"
/>
<div class="n-dynamic-input-item__action">
<n-button-group>
<n-button
v-if="!removeDisabled"
:unstable-theme="mergedTheme.peers.Button"
:unstable-theme-overrides="mergedTheme.overrides.Button"
circle
@click="remove(index)"
>
<template #icon>
<n-base-icon>
<remove-icon />
</n-base-icon>
</template>
</n-button>
<n-button
:disabled="insertionDisabled"
circle
:unstable-theme="mergedTheme.peers.Button"
:unstable-theme-overrides="mergedTheme.overrides.Button"
@click="createItem(index)"
>
<template #icon>
<n-base-icon>
<add-icon />
</n-base-icon>
</template>
</n-button>
</n-button-group>
</div>
</div>
</div>
</template>
<script>
import { ref, toRef, isProxy, toRaw, computed, defineComponent } from 'vue'
import { useMergedState } from 'vooks'
import { createId } from 'seemly'
import { RemoveIcon, AddIcon } from '../../_base/icons'
import { NBaseIcon } from '../../_base'
import { NButton, NButtonGroup } from '../../button'
import { useFormItem, useTheme, useLocale } from '../../_mixins'
import { warn, call } from '../../_utils'
import { dynamicInputLight } from '../styles'
import NDynamicInputInputPreset from './InputPreset.vue'
import NDynamicInputPairPreset from './PairPreset.vue'
import style from './styles/index.cssr.js'
const globalDataKeyMap = new WeakMap()
export default defineComponent({
name: 'DynamicInput',
components: {
NDynamicInputInputPreset,
NDynamicInputPairPreset,
NButtonGroup,
NButton,
NBaseIcon,
AddIcon,
RemoveIcon
},
provide () {
return {
NDynamicInput: this
}
},
props: {
...useTheme.props,
max: {
type: Number,
default: undefined
},
min: {
type: Number,
default: 0
},
value: {
type: Array,
default: undefined
},
// TODO: make it robust for different types
defaultValue: {
type: Array,
default: () => []
},
preset: {
validator (value) {
return ['input', 'pair'].includes(value)
},
default: 'input'
},
keyField: {
type: String,
default: null
},
// for preset pair
keyPlaceholder: {
type: String,
default: ''
},
valuePlaceholder: {
type: String,
default: ''
},
// for preset input
placeholder: {
type: String,
default: ''
},
onCreate: {
type: Function,
default: undefined
},
onRemove: {
type: Function,
default: undefined
},
// eslint-disable-next-line vue/prop-name-casing
'onUpdate:value': {
type: [Function, Array],
default: undefined
},
// deprecated
onClear: {
validator () {
warn(
'dynamic-input',
'`on-clear` is deprecated, it is out of usage anymore.'
)
return true
},
default: undefined
},
onInput: {
validator () {
if (__DEV__) {
warn(
'dynamic-input',
'`on-input` is deprecated, please use `on-update:value` instead.'
)
}
return true
},
default: undefined
}
},
setup (props) {
const uncontrolledValueRef = ref(props.defaultValue)
const controlledValueRef = toRef(props, 'value')
const themeRef = useTheme(
'DynamicInput',
'DynamicInput',
style,
dynamicInputLight,
props
)
return {
...useLocale('DynamicInput'),
uncontrolledValue: uncontrolledValueRef,
mergedValue: useMergedState(controlledValueRef, uncontrolledValueRef),
dataKeyMap: globalDataKeyMap,
...useFormItem(props),
mergedTheme: themeRef,
cssVars: computed(() => {
const {
self: { actionMargin }
} = themeRef.value
return {
'--action-margin': actionMargin
}
})
}
},
computed: {
insertionDisabled () {
const { mergedValue } = this
if (Array.isArray(mergedValue)) {
const { max } = this
return max !== undefined && mergedValue.length >= max
}
return false
},
removeDisabled () {
const { mergedValue } = this
if (Array.isArray(mergedValue)) return mergedValue.length <= this.min
return true
}
},
methods: {
doUpdateValue (value) {
const { onInput, 'onUpdate:value': onUpdateValue } = this
if (onInput) call(onInput, value)
if (onUpdateValue) call(onUpdateValue, value)
this.uncontrolledValue = value
},
ensureKey (value, index) {
if (value === undefined || value === null) return index
if (typeof value !== 'object') return index
const { dataKeyMap } = this
const rawValue = isProxy(value) ? toRaw(value) : value
let key = dataKeyMap.get(rawValue)
if (key === undefined) {
dataKeyMap.set(rawValue, (key = createId()))
}
return key
},
handleValueChange (index, value) {
const { mergedValue } = this
const newValue = Array.from(mergedValue ?? [])
const originalItem = newValue[index]
newValue[index] = value
const { dataKeyMap } = this
// update dataKeyMap
if (
originalItem &&
value &&
typeof originalItem === 'object' &&
typeof value === 'object'
) {
const rawOriginal = isProxy(originalItem)
? toRaw(originalItem)
: originalItem
const rawNew = isProxy(value) ? toRaw(value) : value
// inherit key is value position is not change
const originalKey = dataKeyMap.get(rawOriginal)
if (originalKey !== undefined) {
dataKeyMap.set(rawNew, originalKey)
}
}
this.doUpdateValue(newValue)
},
handleCreateClick () {
this.createItem(0)
},
createItem (index) {
const { onCreate, mergedValue } = this
const newValue = Array.from(mergedValue ?? [])
if (onCreate) {
newValue.splice(index + 1, 0, onCreate(index + 1))
this.doUpdateValue(newValue)
} else if (this.$slots.default) {
newValue.splice(index + 1, 0, null)
this.doUpdateValue(newValue)
} else {
switch (this.preset) {
case 'input':
newValue.splice(index + 1, 0, '')
this.doUpdateValue(newValue)
break
case 'pair':
newValue.splice(index + 1, 0, { key: '', value: '' })
this.doUpdateValue(newValue)
break
}
}
},
remove (index) {
const { mergedValue } = this
if (!Array.isArray(mergedValue)) return
const { min } = this
if (mergedValue.length <= min) return
const newValue = Array.from(mergedValue)
newValue.splice(index, 1)
this.doUpdateValue(newValue)
const { onRemove } = this
if (onRemove) onRemove(index)
}
}
})
</script>

View File

@ -0,0 +1,42 @@
import { h, defineComponent, inject, PropType } from 'vue'
import { NInput } from '../../input'
import { DynamicInputInjection } from './interface'
export default defineComponent({
name: 'DynamicInputInputPreset',
props: {
value: {
type: String,
default: ''
},
parentPath: String,
path: String,
// eslint-disable-next-line vue/prop-name-casing
onUpdateValue: {
type: Function as PropType<(value: string) => void>,
required: true
}
},
setup () {
const NDynamicInput = inject<DynamicInputInjection>(
'NDynamicInput'
) as DynamicInputInjection
return {
NDynamicInput
}
},
render () {
const { NDynamicInput, value, onUpdateValue } = this
return (
<div class="n-dynamic-input-preset-input">
<NInput
unstableTheme={NDynamicInput.mergedTheme.peers.Input}
unstableTheme-overrides={NDynamicInput.mergedTheme.overrides.Input}
value={value}
placeholder={NDynamicInput.placeholder}
onUpdateValue={onUpdateValue}
/>
</div>
)
}
})

View File

@ -1,52 +0,0 @@
<template>
<div class="n-dynamic-input-preset-input">
<n-input
:unstable-theme="NDynamicInput.mergedTheme.peers.Input"
:unstable-theme-overrides="NDynamicInput.mergedTheme.overrides.Input"
:value="value"
:placeholder="NDynamicInput.placeholder"
@update:value="handleInput"
/>
</div>
</template>
<script>
import { defineComponent } from 'vue'
import { NInput } from '../../input'
export default defineComponent({
name: 'DynamicInputInputPreset',
components: {
NInput
},
inject: {
NDynamicInput: {
default: null
}
},
props: {
value: {
type: String,
default: ''
},
parentPath: {
type: String,
default: null
},
path: {
type: String,
default: null
},
// eslint-disable-next-line vue/prop-name-casing
'onUpdate:value': {
type: Function,
required: true
}
},
methods: {
handleInput (value) {
this['onUpdate:value'](value)
}
}
})
</script>

View File

@ -0,0 +1,67 @@
import { defineComponent, h, inject, PropType } from 'vue'
import { NInput } from '../../input'
import { DynamicInputInjection } from './interface'
export default defineComponent({
name: 'DynamicInputPairPreset',
props: {
value: {
type: Object as PropType<{ key: string, value: string }>,
default: () => ({
key: '',
value: ''
})
},
parentPath: String,
path: String,
onUpdateValue: {
type: Function as PropType<
(data: { key: string, value: string }) => void
>,
required: true
}
},
setup (props) {
const NDynamicInput = inject<DynamicInputInjection>(
'NDynamicInput'
) as DynamicInputInjection
return {
NDynamicInput,
handleKeyInput (key: string) {
props.onUpdateValue({
key,
value: props.value.value
})
},
handleValueInput (value: string) {
props.onUpdateValue({
key: props.value.key,
value
})
}
}
},
render () {
const { NDynamicInput, value } = this
return (
<div class="n-dynamic-input-preset-pair">
<NInput
unstableTheme={NDynamicInput.mergedTheme.peers.Input}
unstableTheme-overrides={NDynamicInput.mergedTheme.overrides.Input}
value={value.key}
class="n-dynamic-input-pair-input"
placeholder={NDynamicInput.keyPlaceholder}
onUpdateValue={this.handleKeyInput}
/>
<NInput
unstableTheme={NDynamicInput.mergedTheme.peers.Input}
unstableTheme-overrides={NDynamicInput.mergedTheme.overrides.Input}
value={value.value}
class="n-dynamic-input-pair-input"
placeholder={NDynamicInput.valuePlaceholder}
onUpdateValue={this.handleValueInput}
/>
</div>
)
}
})

View File

@ -1,73 +0,0 @@
<template>
<div class="n-dynamic-input-preset-pair">
<n-input
:unstable-theme="NDynamicInput.mergedTheme.peers.Input"
:unstable-theme-overrides="NDynamicInput.mergedTheme.overrides.Input"
:value="value.key"
class="n-dynamic-input-pair-input"
:placeholder="NDynamicInput.keyPlaceholder"
@update:value="handleKeyInput"
/>
<n-input
:unstable-theme="NDynamicInput.mergedTheme.peers.Input"
:unstable-theme-overrides="NDynamicInput.mergedTheme.overrides.Input"
:value="value.value"
class="n-dynamic-input-pair-input"
:placeholder="NDynamicInput.valuePlaceholder"
@update:value="handleValueInput"
/>
</div>
</template>
<script>
import { defineComponent } from 'vue'
import { NInput } from '../../input'
export default defineComponent({
name: 'DynamicInputPairPreset',
components: {
NInput
},
inject: {
NDynamicInput: {
default: null
}
},
props: {
value: {
type: Object,
default: () => ({
key: null,
value: null
})
},
parentPath: {
type: String,
default: undefined
},
path: {
type: String,
default: undefined
},
// eslint-disable-next-line vue/prop-name-casing
'onUpdate:value': {
type: Function,
required: true
}
},
methods: {
handleKeyInput (key) {
this['onUpdate:value']({
key,
value: this.value.value
})
},
handleValueInput (value) {
this['onUpdate:value']({
key: this.value.key,
value
})
}
}
})
</script>

View File

@ -0,0 +1,11 @@
import type { MergedTheme } from '../../_mixins'
import type { DynamicInputTheme } from '../styles'
export interface DynamicInputInjection {
mergedTheme: MergedTheme<DynamicInputTheme>
keyPlaceholder?: string
valuePlaceholder?: string
placeholder?: string
}
export type OnUpdateValue = <T>(value: T[]) => void

View File

@ -2,8 +2,9 @@ import { inputDark } from '../../input/styles'
import { buttonDark } from '../../button/styles'
import { commonDark } from '../../_styles/new-common'
import commonVariables from './_common'
import { DynamicInputTheme } from './light'
export default {
const dynamicInputDark: DynamicInputTheme = {
name: 'DynamicInput',
common: commonDark,
peers: {
@ -11,8 +12,8 @@ export default {
Button: buttonDark
},
self () {
return {
...commonVariables
}
return commonVariables
}
}
export default dynamicInputDark

View File

@ -1,2 +0,0 @@
export { default as dynamicInputDark } from './dark.js'
export { default as dynamicInputLight } from './light.js'

View File

@ -0,0 +1,3 @@
export { default as dynamicInputDark } from './dark'
export { default as dynamicInputLight } from './light'
export type { DynamicInputTheme, DynamicInputThemeVars } from './light'

View File

@ -2,17 +2,23 @@ import { inputLight } from '../../input/styles'
import { buttonLight } from '../../button/styles'
import { commonLight } from '../../_styles/new-common'
import commonVariables from './_common'
import { createTheme } from '../../_mixins'
export default {
const self = () => {
return commonVariables
}
export type DynamicInputThemeVars = ReturnType<typeof self>
const dynamicInputLight = createTheme({
name: 'DynamicInput',
common: commonLight,
peers: {
Input: inputLight,
Button: buttonLight
},
self () {
return {
...commonVariables
}
}
}
self
})
export default dynamicInputLight
export type DynamicInputTheme = typeof dynamicInputLight