mirror of
https://github.com/tusen-ai/naive-ui.git
synced 2025-02-17 13:20:52 +08:00
refactor(radio): support vue3
This commit is contained in:
parent
b460a9f4b8
commit
f45f06206f
@ -1,25 +1,25 @@
|
||||
# 基础用法
|
||||
```html
|
||||
<n-radio
|
||||
v-model="value"
|
||||
v-model:checked-value="value"
|
||||
value="Definitely Maybe"
|
||||
>
|
||||
Definitely Maybe
|
||||
</n-radio>
|
||||
<n-radio
|
||||
v-model="value"
|
||||
v-model:checked-value="value"
|
||||
value="Be Here Now"
|
||||
>
|
||||
Be Here Now
|
||||
</n-radio>
|
||||
<n-radio
|
||||
v-model="value"
|
||||
v-model:checked-value="value"
|
||||
value="Be Here Now"
|
||||
:disabled="disabled"
|
||||
>
|
||||
Be Here Now
|
||||
</n-radio>
|
||||
<n-switch v-model="disabled"/>
|
||||
<n-switch v-model:value="disabled"/>
|
||||
|
||||
```
|
||||
```js
|
||||
|
@ -2,7 +2,7 @@
|
||||
有的时候用按钮显得更优雅一点。
|
||||
```html
|
||||
<div style="margin-bottom: 12px;">
|
||||
<n-radio-group v-model="value" name="radiobuttongroup">
|
||||
<n-radio-group v-model:value="value" name="radiobuttongroup">
|
||||
<n-radio-button
|
||||
v-for="song in songs"
|
||||
:key="song.value"
|
||||
@ -14,13 +14,13 @@
|
||||
</n-radio-group>
|
||||
</div>
|
||||
<n-checkbox
|
||||
v-model="disabled2"
|
||||
v-model:checked="disabled2"
|
||||
style="margin-right: 12px;"
|
||||
>
|
||||
禁用 Shakemaker
|
||||
</n-checkbox>
|
||||
<n-checkbox
|
||||
v-model="disabled1"
|
||||
v-model:checked="disabled1"
|
||||
>
|
||||
禁用 Live Forever
|
||||
</n-checkbox>
|
||||
|
@ -1,7 +1,10 @@
|
||||
# 选项组
|
||||
一个选项组看起来就挺舒服。
|
||||
```html
|
||||
<n-radio-group v-model="value" name="radiogroup">
|
||||
<n-radio-group
|
||||
v-model:value="value"
|
||||
name="radiogroup"
|
||||
>
|
||||
<n-radio
|
||||
v-for="song in songs"
|
||||
:key="song.value"
|
||||
|
@ -9,52 +9,24 @@ button-group
|
||||
size
|
||||
radio-focus-debug
|
||||
```
|
||||
## V-model
|
||||
### Radio V-model
|
||||
|Prop|Event|
|
||||
|-|-|
|
||||
|checked-value|change|
|
||||
|
||||
### Radio Group V-model
|
||||
|Prop|Event|
|
||||
|-|-|
|
||||
|value|change|
|
||||
|
||||
## Props
|
||||
### Radio Props
|
||||
### Radio Props, Radio Button Props
|
||||
|名称|类型|默认值|说明|
|
||||
|-|-|-|-|
|
||||
|theme|`'light' \| 'dark' \| null \| string`|`null`||
|
||||
|name|`string`|`undefined`|单选 radio 元素的 name 属性。如果没有设定会使用 `radio-group` 的 `name`|
|
||||
|name|`string`|`undefined`|单选按钮 radio 元素的 name 属性。如果没有设定会使用 `n-radio-group` 的 `name`|
|
||||
|checked-value|`string \| number \| boolean`|`null`||
|
||||
|value|`string \| number \| boolean`|`null`||
|
||||
|value|`string \| number \| boolean`|required||
|
||||
|disabled|`boolean`|`false`||
|
||||
|
||||
### Radio Button Props
|
||||
|名称|类型|默认值|说明|
|
||||
|-|-|-|-|
|
||||
|name|`string`|`undefined`|单选按钮 radio 元素的 name 属性。如果没有设定会使用 `radio-group` 的 `name`|
|
||||
|checked-value|`string \| number \| boolean`|`null`||
|
||||
|value|`string \| number \| boolean`|`null`||
|
||||
|disabled|`boolean`|`false`||
|
||||
|size|`'small' \| 'medium' \| 'large'`|`'small'`||
|
||||
|size|`'small' \| 'medium' \| 'large'`|`'medium'`|只用于 `n-radio`|
|
||||
|on-update:checked-value|`(checkedValue: string \| number \| boolean) => any`|`undefined`||
|
||||
|
||||
### Radio Group Props
|
||||
|名称|类型|默认值|说明|
|
||||
|-|-|-|-|
|
||||
|theme|`'light' \| 'dark' \| null \| string`|`null`||
|
||||
|name|`string`|`null`|选项组内部 radio 元素的 name 属性|
|
||||
|size|`'small' \| 'medium' \| 'large'`|`small`||
|
||||
|value|`string \| number \| boolean`|`null`||
|
||||
|disabled|`boolean`|`false`||
|
||||
|
||||
## Events
|
||||
### Radio, Radio Button Events
|
||||
|名称|参数|说明|
|
||||
|-|-|-|
|
||||
|change|`(checkedValue: string \| number \| boolean)`||
|
||||
|
||||
### Radio Group Events
|
||||
|名称|参数|说明|
|
||||
|-|-|-|
|
||||
|change|`(checkedValue: string \| number \| boolean)`||
|
||||
|name|`string`|`null`|选项组内部 radio 元素的 name 属性|
|
||||
|size|`'small' \| 'medium' \| 'large'`|`medium`||
|
||||
|theme|`'light' \| 'dark' \| null \| string`|`null`||
|
||||
|value|`string \| number \| boolean`|`null`||
|
||||
|on-update:value|`(checkedValue: string \| number \| boolean) => any`|`undefined`||
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
```html
|
||||
<n-radio
|
||||
v-model="value"
|
||||
v-model:checked-value="value"
|
||||
value="Definitely Maybe"
|
||||
>
|
||||
<n-input />
|
||||
|
@ -2,7 +2,7 @@
|
||||
任君挑选。
|
||||
```html
|
||||
<div style="margin-bottom: 12px;">
|
||||
<n-radio-group v-model="value" name="radiobuttongroup2" size="medium">
|
||||
<n-radio-group v-model:value="value" name="radiobuttongroup2" size="medium">
|
||||
<n-radio-button
|
||||
v-for="song in songs"
|
||||
:key="song.value"
|
||||
@ -14,7 +14,7 @@
|
||||
</n-radio-group>
|
||||
</div>
|
||||
<div style="margin-bottom: 12px;">
|
||||
<n-radio-group v-model="value" name="radiobuttongroup3" size="large">
|
||||
<n-radio-group v-model:value="value" name="radiobuttongroup3" size="large">
|
||||
<n-radio-button
|
||||
v-for="song in songs"
|
||||
:key="song.value"
|
||||
@ -26,13 +26,13 @@
|
||||
</n-radio-group>
|
||||
</div>
|
||||
<n-checkbox
|
||||
v-model="disabled2"
|
||||
v-model:checked="disabled2"
|
||||
style="margin-right: 12px;"
|
||||
>
|
||||
禁用 Shakemaker
|
||||
</n-checkbox>
|
||||
<n-checkbox
|
||||
v-model="disabled1"
|
||||
v-model:checked="disabled1"
|
||||
>
|
||||
禁用 Live Forever
|
||||
</n-checkbox>
|
||||
|
@ -79,7 +79,7 @@ export function useDisabledUntilMounted (durationTickCount = 0) {
|
||||
|
||||
export function useMemo (valueGenerator, deps) {
|
||||
const valueRef = ref(valueGenerator())
|
||||
watch(deps, () => {
|
||||
watch(deps.filter(dep => dep), () => {
|
||||
valueRef.value = valueGenerator()
|
||||
})
|
||||
return valueRef
|
||||
|
@ -3,3 +3,4 @@ export { getVNodeChildren } from './src/get-v-node-children'
|
||||
export { createId } from './src/create-id'
|
||||
export { keep } from './src/keep'
|
||||
export { omit } from './omit'
|
||||
export { flatten } from './src/flatten'
|
||||
|
13
src/_utils/vue/src/flatten.js
Normal file
13
src/_utils/vue/src/flatten.js
Normal file
@ -0,0 +1,13 @@
|
||||
import { Fragment } from 'vue'
|
||||
|
||||
export function flatten (vNodes) {
|
||||
let result = []
|
||||
vNodes.forEach(vNode => {
|
||||
if (vNode.type === Fragment) {
|
||||
result = result.concat(flatten(vNode.children))
|
||||
} else {
|
||||
result.push(vNode)
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
@ -3,7 +3,7 @@
|
||||
class="n-radio"
|
||||
:class="{
|
||||
'n-radio--disabled': syntheticDisabled,
|
||||
'n-radio--checked': syntheticChecked,
|
||||
'n-radio--checked': renderSafeChecked,
|
||||
'n-radio--focus': focus,
|
||||
[`n-radio--${syntheticSize}-size`]: true,
|
||||
[`n-${syntheticTheme}-theme`]: syntheticTheme
|
||||
@ -17,7 +17,7 @@
|
||||
type="radio"
|
||||
class="n-radio__radio-input"
|
||||
:name="syntheticName"
|
||||
:checked="syntheticChecked"
|
||||
:checked="renderSafeChecked"
|
||||
:disabled="syntheticDisabled"
|
||||
@change="handleRadioInputChange"
|
||||
@focus="handleRadioInputFocus"
|
||||
@ -26,7 +26,7 @@
|
||||
<div
|
||||
class="n-radio__dot"
|
||||
:class="{
|
||||
'n-radio__dot--checked': syntheticChecked
|
||||
'n-radio__dot--checked': renderSafeChecked
|
||||
}"
|
||||
/>
|
||||
<div class="n-radio__label">
|
||||
@ -42,6 +42,7 @@ import themeable from '../../_mixins/themeable'
|
||||
import radioMixin from './radio-mixin'
|
||||
import usecssr from '../../_mixins/usecssr'
|
||||
import styles from './styles/radio/index.js'
|
||||
import setup from './radio-setup'
|
||||
|
||||
export default {
|
||||
name: 'Radio',
|
||||
@ -49,14 +50,9 @@ export default {
|
||||
withapp,
|
||||
themeable,
|
||||
usecssr(styles),
|
||||
asformitem(
|
||||
{
|
||||
change: 'change',
|
||||
blur: 'blur',
|
||||
focus: 'focus'
|
||||
},
|
||||
'medium',
|
||||
function () {
|
||||
asformitem({
|
||||
defaultSize: 'medium',
|
||||
syntheticSize () {
|
||||
const size = this.size
|
||||
if (size) return size
|
||||
const NRadioGroup = this.NRadioGroup
|
||||
@ -66,14 +62,14 @@ export default {
|
||||
const NFormItem = this.NFormItem
|
||||
if (
|
||||
NFormItem &&
|
||||
NFormItem !== '__FORM_ITEM_INNER__' &&
|
||||
NFormItem.syntheticSize
|
||||
NFormItem !== '__FORM_ITEM_INNER__' &&
|
||||
NFormItem.syntheticSize
|
||||
) {
|
||||
return NFormItem.syntheticSize
|
||||
}
|
||||
return 'medium'
|
||||
}
|
||||
),
|
||||
}),
|
||||
radioMixin
|
||||
],
|
||||
props: {
|
||||
@ -81,9 +77,10 @@ export default {
|
||||
validator (value) {
|
||||
return ['small', 'medium', 'large'].includes(value)
|
||||
},
|
||||
default: null
|
||||
default: undefined
|
||||
}
|
||||
},
|
||||
setup,
|
||||
computed: {
|
||||
syntheticTheme () {
|
||||
const theme = this.theme
|
||||
|
@ -3,11 +3,11 @@
|
||||
class="n-radio-button"
|
||||
:class="{
|
||||
'n-radio-button--disabled': syntheticDisabled,
|
||||
'n-radio-button--checked': syntheticChecked,
|
||||
'n-radio-button--checked': renderSafeChecked,
|
||||
'n-radio-button--focus': focus
|
||||
}"
|
||||
:style="{
|
||||
color: syntheticChecked ? syntheticAscendantBackgroundColor : null
|
||||
color: renderSafeChecked ? syntheticAscendantBackgroundColor : null
|
||||
}"
|
||||
@keyup.enter="handleKeyUpEnter"
|
||||
@click="handleClick"
|
||||
@ -18,7 +18,7 @@
|
||||
type="radio"
|
||||
class="n-radio-button__radio-input"
|
||||
:name="syntheticName"
|
||||
:checked="syntheticChecked"
|
||||
:checked="renderSafeChecked"
|
||||
:disabled="syntheticDisabled"
|
||||
@change="handleRadioInputChange"
|
||||
@focus="handleRadioInputFocus"
|
||||
@ -31,6 +31,7 @@
|
||||
|
||||
<script>
|
||||
import radioMixin from './radio-mixin'
|
||||
import setup from './radio-setup'
|
||||
import withapp from '../../_mixins/withapp'
|
||||
import themeable from '../../_mixins/themeable'
|
||||
import usecssr from '../../_mixins/usecssr'
|
||||
@ -46,11 +47,6 @@ export default {
|
||||
radioMixin,
|
||||
usecssr(styles)
|
||||
],
|
||||
created () {
|
||||
this.NRadioGroup && this.NRadioGroup.radioButtonCount++
|
||||
},
|
||||
beforeUnmount () {
|
||||
this.NRadioGroup && this.NRadioGroup.radioButtonCount--
|
||||
}
|
||||
setup
|
||||
}
|
||||
</script>
|
||||
|
@ -3,31 +3,35 @@ import withapp from '../../_mixins/withapp'
|
||||
import themeable from '../../_mixins/themeable'
|
||||
import hollowoutable from '../../_mixins/hollowoutable'
|
||||
import asformitem from '../../_mixins/asformitem'
|
||||
import getDefaultSlot from '../../_utils/vue/getDefaultSlot'
|
||||
import { getSlot, flatten } from '../../_utils/vue'
|
||||
import { warn } from '../../_utils/naive/warn'
|
||||
import usecssr from '../../_mixins/usecssr'
|
||||
import styles from './styles/radio-group/index.js'
|
||||
import { warn } from '../../_utils/naive/warn'
|
||||
|
||||
function mapSlot (h, defaultSlot, groupInstance) {
|
||||
const mappedSlot = []
|
||||
defaultSlot = defaultSlot || []
|
||||
const children = []
|
||||
let isButtonGroup = false
|
||||
for (let i = 0; i < defaultSlot.length; ++i) {
|
||||
const wrappedInstance = defaultSlot[i]
|
||||
const instanceOptions = wrappedInstance.type
|
||||
const name = instanceOptions.name
|
||||
if (
|
||||
__DEV__ && (
|
||||
!instanceOptions ||
|
||||
!['Radio', 'RadioButton'].includes(instanceOptions.name)
|
||||
!['Radio', 'RadioButton'].includes(name)
|
||||
)
|
||||
) {
|
||||
warn('radio-group', '`n-radio-group` only taks `n-radio` and `n-radio-button` as children.')
|
||||
continue
|
||||
}
|
||||
const instanceProps = wrappedInstance.props
|
||||
if (i === 0 || instanceOptions.name === 'Radio') {
|
||||
mappedSlot.push(wrappedInstance)
|
||||
if (name === 'RadioButton') {
|
||||
isButtonGroup = true
|
||||
}
|
||||
if (i === 0 || name === 'Radio') {
|
||||
children.push(wrappedInstance)
|
||||
} else {
|
||||
const lastInstanceProps = mappedSlot[mappedSlot.length - 1].props
|
||||
const lastInstanceProps = children[children.length - 1].props
|
||||
const lastInstanceChecked = groupInstance.$props.value === lastInstanceProps.value
|
||||
const lastInstanceDisabled = lastInstanceProps.disabled
|
||||
const currentInstanceChecked = groupInstance.$props.value === instanceProps.value
|
||||
@ -63,16 +67,21 @@ function mapSlot (h, defaultSlot, groupInstance) {
|
||||
'n-radio-group__splitor--disabled': currentInstanceDisabled,
|
||||
'n-radio-group__splitor--checked': currentInstanceChecked
|
||||
}
|
||||
let splitorClass
|
||||
if (lastInstancePriority < currentInstancePriority) splitorClass = currentInstanceClass
|
||||
else splitorClass = lastInstanceClass
|
||||
mappedSlot.push(h('div', {
|
||||
staticClass: 'n-radio-group__splitor',
|
||||
class: splitorClass
|
||||
const splitorClass = lastInstancePriority < currentInstancePriority
|
||||
? currentInstanceClass
|
||||
: lastInstanceClass
|
||||
children.push(h('div', {
|
||||
class: [
|
||||
'n-radio-group__splitor',
|
||||
splitorClass
|
||||
]
|
||||
}), wrappedInstance)
|
||||
}
|
||||
}
|
||||
return mappedSlot
|
||||
return {
|
||||
children,
|
||||
isButtonGroup
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
@ -85,13 +94,9 @@ export default {
|
||||
hollowoutable,
|
||||
usecssr(styles),
|
||||
asformitem({
|
||||
change: 'change'
|
||||
}, 'small')
|
||||
defaultSize: 'medium'
|
||||
})
|
||||
],
|
||||
model: {
|
||||
prop: 'value',
|
||||
event: 'change'
|
||||
},
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
@ -102,24 +107,35 @@ export default {
|
||||
default: null
|
||||
},
|
||||
size: {
|
||||
default: undefined,
|
||||
validator (value) {
|
||||
return ['small', 'medium', 'large'].includes(value)
|
||||
}
|
||||
},
|
||||
default: undefined
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
'onUpdate:value': {
|
||||
type: Function,
|
||||
default: undefined
|
||||
},
|
||||
// deprecated
|
||||
onChange: {
|
||||
validator () {
|
||||
if (__DEV__) warn('radio-group', '`on-change` is deprecated, please use `on-update:value` instead.')
|
||||
return true
|
||||
},
|
||||
default: undefined
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
radioButtonCount: 0,
|
||||
transitionDisabled: true
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (this.radioButtonCount > 0) {
|
||||
if (this.isButtonGroup) {
|
||||
this.$nextTick().then(() => {
|
||||
this.transitionDisabled = false
|
||||
})
|
||||
@ -127,12 +143,15 @@ export default {
|
||||
},
|
||||
provide () {
|
||||
return {
|
||||
NRadioGroup: this,
|
||||
NFormItem: null
|
||||
NRadioGroup: this
|
||||
}
|
||||
},
|
||||
render () {
|
||||
const isButtonGroup = this.radioButtonCount > 0
|
||||
const {
|
||||
children,
|
||||
isButtonGroup
|
||||
} = mapSlot(h, flatten(getSlot(this)), this)
|
||||
this.isButtonGroup = isButtonGroup
|
||||
return h('div', {
|
||||
class: [
|
||||
'n-radio-group',
|
||||
@ -143,6 +162,6 @@ export default {
|
||||
[`n-radio-group--transition-disabled`]: isButtonGroup && this.transitionDisabled
|
||||
}
|
||||
]
|
||||
}, mapSlot(h, getDefaultSlot(this), this))
|
||||
}, children)
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { warn } from '../../_utils/naive/warn'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
name: {
|
||||
@ -15,15 +17,22 @@ export default {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
model: {
|
||||
prop: 'checkedValue',
|
||||
event: 'change'
|
||||
},
|
||||
inject: {
|
||||
NRadioGroup: {
|
||||
default: null
|
||||
},
|
||||
onClick: {
|
||||
type: Function,
|
||||
default: undefined
|
||||
},
|
||||
'onUpdate:checkedValue': {
|
||||
type: Function,
|
||||
default: undefined
|
||||
},
|
||||
// deprecated
|
||||
onChange: {
|
||||
validator () {
|
||||
if (__DEV__) warn('radio', '`on-change` is deprecated, please use `on-update:checked-value` instead.')
|
||||
return true
|
||||
},
|
||||
default: undefined
|
||||
}
|
||||
},
|
||||
data () {
|
||||
@ -36,13 +45,6 @@ export default {
|
||||
if (this.name !== undefined) return this.name
|
||||
if (this.NRadioGroup) return this.NRadioGroup.name
|
||||
},
|
||||
syntheticChecked () {
|
||||
if (this.NRadioGroup) {
|
||||
return this.NRadioGroup.value === this.value
|
||||
} else {
|
||||
return this.checkedValue === this.value
|
||||
}
|
||||
},
|
||||
syntheticDisabled () {
|
||||
if (this.NRadioGroup && this.NRadioGroup.disabled) return true
|
||||
if (this.disabled) return true
|
||||
@ -84,14 +86,38 @@ export default {
|
||||
}, 0)
|
||||
},
|
||||
handleClick (e) {
|
||||
this.$emit('click', e)
|
||||
const {
|
||||
onClick
|
||||
} = this
|
||||
if (onClick) onClick(e)
|
||||
this.toggle()
|
||||
},
|
||||
emitChangeEvent () {
|
||||
const {
|
||||
value
|
||||
} = this
|
||||
if (this.NRadioGroup) {
|
||||
this.NRadioGroup.$emit('change', this.value)
|
||||
const {
|
||||
onChange,
|
||||
'onUpdate:value': updateValue,
|
||||
__triggerFormInput,
|
||||
__triggerFormChange
|
||||
} = this.NRadioGroup
|
||||
if (updateValue) updateValue(value)
|
||||
if (onChange) onChange(value) // deprecated
|
||||
__triggerFormInput()
|
||||
__triggerFormChange()
|
||||
} else {
|
||||
this.$emit('change', this.value)
|
||||
const {
|
||||
onChange,
|
||||
'onUpdate:checkedValue': updateCheckedValue,
|
||||
__triggerFormInput,
|
||||
__triggerFormChange
|
||||
} = this
|
||||
if (updateCheckedValue) updateCheckedValue(value)
|
||||
if (onChange) onChange(value) // deprecated
|
||||
__triggerFormInput()
|
||||
__triggerFormChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
17
src/radio/src/radio-setup.js
Normal file
17
src/radio/src/radio-setup.js
Normal file
@ -0,0 +1,17 @@
|
||||
import { inject, toRef } from 'vue'
|
||||
import { useMemo } from '../../_utils/composition'
|
||||
|
||||
export default function setup (props) {
|
||||
const NRadioGroup = inject('NRadioGroup', null)
|
||||
return {
|
||||
NRadioGroup,
|
||||
renderSafeChecked: useMemo(() => {
|
||||
if (NRadioGroup) return NRadioGroup.value === props.value
|
||||
return props.checkedValue === props.value
|
||||
}, [
|
||||
NRadioGroup ? toRef(NRadioGroup, 'value') : null,
|
||||
toRef(props, 'value'),
|
||||
toRef(props, 'checkedValue')
|
||||
])
|
||||
}
|
||||
}
|
11
vue3.md
11
vue3.md
@ -155,7 +155,14 @@ placeable 进行了大调整
|
||||
- set default trigger to `null`
|
||||
- [ ] popselect
|
||||
- [x] progress
|
||||
- [ ] radio
|
||||
- [x] radio
|
||||
- radio-group
|
||||
- break
|
||||
- default `size` `'small'` => `'medium'`
|
||||
- deprecate
|
||||
- `on-change` => `on-update:value`
|
||||
- radio & radio-button
|
||||
- `on-change` => `on-update:checked-value`
|
||||
- [x] result
|
||||
- [ ] scrollbar
|
||||
- [ ] select
|
||||
@ -167,7 +174,7 @@ placeable 进行了大调整
|
||||
- remove
|
||||
- `value` => `model-value`
|
||||
- `change` => `on-update:model-value`
|
||||
- [ ] table
|
||||
- [x] table
|
||||
- [x] tabs
|
||||
- deprecate
|
||||
- `active-name` => `value`
|
||||
|
Loading…
Reference in New Issue
Block a user