refactor(modal): correct draggable implementation

This commit is contained in:
07akioni 2025-01-04 20:21:56 +08:00
parent 11ee8fcbdc
commit 2992da0217
24 changed files with 414 additions and 227 deletions

View File

@ -6,6 +6,12 @@
- (**Vue 3.3+ required**) Add slot type for all components.
### Features
- `n-modal` adds `draggable` prop, closes [#6525](https://github.com/tusen-ai/naive-ui/issues/6525), [#5792](https://github.com/tusen-ai/naive-ui/issues/5792), [#5711](https://github.com/tusen-ai/naive-ui/issues/5711), [#5501](https://github.com/tusen-ai/naive-ui/issues/5501) and [#2152](https://github.com/tusen-ai/naive-ui/issues/2152).
- `useDialog` supports `draggable` option.
- `useModal` supports `draggable` option.
### Fixes
- Fix `n-data-table` may have multiple expand trigger with tree data.

View File

@ -9,6 +9,8 @@
### Features
- `n-modal` 新增 `draggable` 属性,关闭 [#6525](https://github.com/tusen-ai/naive-ui/issues/6525)[#5792](https://github.com/tusen-ai/naive-ui/issues/5792)[#5711](https://github.com/tusen-ai/naive-ui/issues/5711)[#5501](https://github.com/tusen-ai/naive-ui/issues/5501)[#2152](https://github.com/tusen-ai/naive-ui/issues/2152)
- `useDialog` 支持 `draggable` 参数
- `useModal` 支持 `draggable` 参数
### Fixes

View File

@ -1,4 +1,3 @@
export { color2Class } from './color-to-class'
export { formatLength } from './format-length'
export { mergeClass } from './merge-class'
export { rtlInset } from './rtl-inset'

View File

@ -1,18 +0,0 @@
import { isArray, isString } from 'lodash-es'
export function mergeClass(
...args: Array<string | undefined | boolean | (string | undefined)[]>
) {
return args
.reduce<Array<string | undefined>>((p, c) => {
if (isString(c)) {
p.push(c)
}
if (isArray(c)) {
p.push(...c)
}
return p
}, [])
.filter(Boolean)
.join(' ')
}

View File

@ -25,9 +25,12 @@ export function getFirstSlotVNode(
export function getFirstSlotVNodeWithTypedProps<T>(
slotName: string,
slot: (props: T) => VNode[],
slot: ((props: T) => VNode[]) | undefined,
props: T
): VNode | null {
if (!slot) {
return null
}
const slotContent = flatten(slot(props))
// vue will normalize the slot, so slot must be an array
if (slotContent.length === 1) {

View File

@ -19,6 +19,7 @@ export default defineComponent({
content: 'Are you sure?',
positiveText: 'Sure',
negativeText: 'Not Sure',
draggable: true,
onPositiveClick: () => {
message.success('Sure')
},

View File

@ -65,8 +65,8 @@ use-dialog-reactive-list.vue
| Name | Type | Default | Description | Version |
| --- | --- | --- | --- | --- |
| action | `() => VNodeChild` | `undefined` | Content of the operation area, must be a render function. | |
| actionClass | `string` | The class name of the action area. | 2.38.2 |
| actionStyle | `Object \| string` | The style of the action area. | 2.38.2 |
| actionClass | `string` | `undefined` | The class name of the action area. | 2.38.2 |
| actionStyle | `Object \| string` | `undefined` | The style of the action area. | 2.38.2 |
| autoFocus | `boolean` | `true` | Whether to focus the first focusable element inside modal. | 2.28.3 |
| blockScroll | `boolean` | `true` | Whether to disabled body scrolling when it's active. | 2.28.3 |
| bordered | `boolean` | `false` | Whether to show `border`. | |
@ -74,8 +74,9 @@ use-dialog-reactive-list.vue
| closable | `boolean` | `true` | Whether to show `close` icon. | |
| closeOnEsc | `boolean` | `true` | Whether to close the dialog when the Esc key is pressed | 2.26.4 |
| content | `string \| (() => VNodeChild)` | `undefined` | Content, can be a render function. | |
| contentClass | `string` | The class name of the content. | 2.38.2 |
| contentStyle | `Object \| string` | The style of the content. | 2.38.2 |
| contentClass | `string` | `undefined` | The class name of the content. | 2.38.2 |
| contentStyle | `Object \| string` | `undefined` | The style of the content. | 2.38.2 |
| draggable | `boolean \| { bounds?: 'none' }` | `false` | Whether it is draggable. | NEXT_VERSION |
| iconPlacement | `'left' \| 'top'` | `'left'` | Icon placement. | |
| icon | `() => VNodeChild` | `undefined` | `Render` function of `icon`. | |
| loading | `boolean` | `false` | Whether to display `loading` status. | |
@ -87,8 +88,8 @@ use-dialog-reactive-list.vue
| showIcon | `boolean` | `true` | Whether to show `icon`. | |
| style | `string \| Object` | `undefined` | Style of the dialog. | |
| title | `string \| (() => VNodeChild)` | `undefined` | Title, can be a render function. | |
| titleClass | `string` | The class name of the content. | 2.38.2 |
| titleStyle | `Object \| string` | The style of the content. | 2.38.2 |
| titleClass | `string` | `undefined` | The class name of the content. | 2.38.2 |
| titleStyle | `Object \| string` | `undefined` | The style of the content. | 2.38.2 |
| transformOrigin | `'mouse' \| 'center'` | `'mouse'` | The transform origin of the dialog's display animation. | 2.34.0 |
| type | `'error \| 'success' \| 'warning'` | `'warning'` | Dialog type. | |
| onAfterEnter | `() => void` | `undefined` | Callback on enter animation ends. | 2.33.0 |

View File

@ -19,6 +19,7 @@ export default defineComponent({
content: '你确定?',
positiveText: '确定',
negativeText: '不确定',
draggable: true,
onPositiveClick: () => {
message.success('确定')
},

View File

@ -67,8 +67,8 @@ rtl-debug.vue
| 名称 | 类型 | 默认值 | 说明 | 版本 |
| --- | --- | --- | --- | --- |
| action | `() => VNodeChild` | `undefined` | 操作区域的内容,需要是渲染函数 | |
| actionClass | `string` | 操作区域的类名 | 2.38.2 |
| actionStyle | `Object \| string` | 操作区域的样式 | 2.38.2 |
| actionClass | `string` | `undefined` | 操作区域的类名 | 2.38.2 |
| actionStyle | `Object \| string` | `undefined` | 操作区域的样式 | 2.38.2 |
| autoFocus | `boolean` | `true` | 是否自动聚焦 Modal 第一个可聚焦的元素 | 2.28.3 |
| blockScroll | `boolean` | `true` | 是否在打开时禁用 body 滚动 | 2.28.3 |
| bordered | `boolean` | `false` | 是否显示 `border` | |
@ -76,8 +76,9 @@ rtl-debug.vue
| closable | `boolean` | `true` | 是否显示 `close` 图标 | |
| closeOnEsc | `boolean` | `true` | 是否在摁下 Esc 键的时候关闭对话框 | 2.26.4 |
| content | `string \| (() => VNodeChild)` | `undefined` | 对话框内容,可以是渲染函数 | |
| contentClass | `string` | 内容的类名 | 2.38.2 |
| contentStyle | `Object \| string` | 内容的样式 | 2.38.2 |
| contentClass | `string` | `undefined` | 内容的类名 | 2.38.2 |
| contentStyle | `Object \| string` | `undefined` | 内容的样式 | 2.38.2 |
| draggable | `boolean \| { bounds?: 'none' }` | `false` | 是否可拖拽 | NEXT_VERSION |
| iconPlacement | `'left' \| 'top'` | `'left'` | 图标的位置 | |
| icon | `() => VNodeChild` | `undefined` | 对话框 `icon`, 需要是渲染函数 | |
| loading | `boolean` | `false` | 是否显示 `loading` 状态 | |
@ -89,8 +90,8 @@ rtl-debug.vue
| showIcon | `boolean` | `true` | 是否显示 `icon` | |
| style | `string \| Object` | `undefined` | 样式 | |
| title | `string \| (() => VNodeChild)` | `undefined` | 标题,可以是渲染函数 | |
| titleClass | `string` | 标题的类名 | 2.38.2 |
| titleStyle | `Object \| string` | 标题的样式 | 2.38.2 |
| titleClass | `string` | `undefined` | 标题的类名 | 2.38.2 |
| titleStyle | `Object \| string` | `undefined` | 标题的样式 | 2.38.2 |
| transformOrigin | `'mouse' \| 'center'` | `'mouse'` | 对话框动画出现的位置 | 2.34.0 |
| type | `'error \| 'success' \| 'warning'` | `'warning'` | 对话框类型 | |
| onAfterEnter | `() => void` | `undefined` | 出现动画完成执行的回调 | 2.33.0 |

View File

@ -1,6 +1,14 @@
import type { ModalDraggableOptions } from '../../modal/src/interface'
// use absolute path to make sure no circular ref of style
// this -> modal-index -> modal-style
import { type CSSProperties, defineComponent, h, type PropType, ref } from 'vue'
import {
type CSSProperties,
defineComponent,
h,
normalizeClass,
type PropType,
ref
} from 'vue'
import { keep } from '../../_utils'
import NModal from '../../modal/src/Modal'
import { NDialog } from './Dialog'
@ -30,7 +38,8 @@ export const exposedDialogEnvProps = {
(e: MouseEvent) => Promise<unknown> | unknown
>,
onClose: Function as PropType<() => Promise<unknown> | unknown>,
onMaskClick: Function as PropType<(e: MouseEvent) => void>
onMaskClick: Function as PropType<(e: MouseEvent) => void>,
draggable: [Boolean, Object] as PropType<boolean | ModalDraggableOptions>
} as const
export const NDialogEnvironment = defineComponent({
@ -156,12 +165,15 @@ export const NDialogEnvironment = defineComponent({
blockScroll={this.blockScroll}
autoFocus={this.autoFocus}
transformOrigin={this.transformOrigin}
draggable={this.draggable}
internalAppear
internalDialog
>
{{
default: () => (
default: ({ draggableClass }: { draggableClass: string }) => (
<NDialog
{...keep(this.$props, dialogPropKeys)}
titleClass={normalizeClass([this.titleClass, draggableClass])}
style={this.internalStyle}
onClose={handleCloseClick}
onNegativeClick={handleNegativeClick}

View File

@ -1,5 +1,6 @@
import type { ExtractPublicPropTypes, Mutable } from '../../_utils'
import { createId } from 'seemly'
import { useClicked, useClickPosition } from 'vooks'
import {
type CSSProperties,
defineComponent,
@ -15,6 +16,7 @@ import {
import { omit } from '../../_utils'
import {
dialogApiInjectionKey,
dialogProviderInjectionKey,
dialogReactiveListInjectionKey
} from './context'
import {
@ -125,6 +127,10 @@ export const NDialogProvider = defineComponent({
error: typedApi[3]
}
provide(dialogApiInjectionKey, api)
provide(dialogProviderInjectionKey, {
clickedRef: useClicked(64),
clickedPositionRef: useClickPosition()
})
provide(dialogReactiveListInjectionKey, dialogListRef)
return {
...api,

View File

@ -1,9 +1,13 @@
import type {
DialogApiInjection,
DialogProviderInjection,
DialogReactiveListInjection
} from './DialogProvider'
import { createInjectionKey } from '../../_utils'
export const dialogProviderInjectionKey
= createInjectionKey<DialogProviderInjection>('n-dialog-provider')
export const dialogApiInjectionKey
= createInjectionKey<DialogApiInjection>('n-dialog-api')

View File

@ -0,0 +1,129 @@
<markdown>
# Draggable
Set `draggable` to `true` to make modal draggable. If you want it to be dragged out of the window, you can set `draggable` to `{ bounds: 'none' }`.
If you want to completely customize the content of the modal, you can use the `draggableClass` in the `default` slot to set it on the element you want to trigger the drag.
</markdown>
<script lang="ts">
import { useModal } from 'naive-ui'
import { defineComponent, ref } from 'vue'
export default defineComponent({
data() {
const modal = useModal()
function showDialogPreset() {
modal.create({
title: 'Dialog preset',
draggable: true,
preset: 'dialog',
content: 'Placeholder....'
})
}
function showCardPreset() {
modal.create({
title: 'Card preset',
draggable: true,
preset: 'card',
content: 'Placeholder....'
})
}
return {
showModal1: ref(false),
showModal2: ref(false),
showModal3: ref(false),
showModal4: ref(false),
showCardPreset,
showDialogPreset
}
}
})
</script>
<template>
<n-flex>
<n-button @click="showModal1 = !showModal1">
Card preset
</n-button>
<n-button @click="showModal2 = !showModal2">
Dialog preset
</n-button>
<n-button @click="showModal3 = !showModal3">
No preset
</n-button>
<n-button @click="showDialogPreset">
Imperative dialog preset
</n-button>
<n-button @click="showCardPreset">
Imperative card preset
</n-button>
<n-button @click="showModal4 = !showModal4">
Nested draggable
</n-button>
</n-flex>
<n-modal
v-model:show="showModal1"
title="card 预设拖拽"
preset="card"
draggable
:style="{ width: '800px' }"
>
<div>Placeholder...</div>
<div>Placeholder...</div>
<div>Placeholder...</div>
<div>Placeholder...</div>
<div>Placeholder...</div>
<div>Placeholder...</div>
<div>Placeholder...</div>
<div>Placeholder...</div>
<div>Placeholder...</div>
<div>Placeholder...</div>
</n-modal>
<n-modal
v-model:show="showModal2"
title="Dialog preset draggable"
preset="dialog"
draggable
:style="{ width: '800px' }"
>
<div>Placeholder...</div>
<div>Placeholder...</div>
<div>Placeholder...</div>
<div>Placeholder...</div>
<div>Placeholder...</div>
<div>Placeholder...</div>
<div>Placeholder...</div>
<div>Placeholder...</div>
<div>Placeholder...</div>
<div>Placeholder...</div>
</n-modal>
<n-modal
v-model:show="showModal3"
title="No preset draggable"
draggable
:style="{ width: '800px' }"
>
<template #default="{ draggableClass }">
<n-card>
<div :class="draggableClass">
Mouse down here to drag
</div>
</n-card>
</template>
</n-modal>
<n-modal
v-model:show="showModal4"
title="Nested draggable"
preset="card"
:draggable="{ bounds: 'none' }"
:style="{ width: '800px' }"
>
<n-button @click="showDialogPreset">
Create a new modal
</n-button>
</n-modal>
</template>

View File

@ -27,6 +27,7 @@ preset-card.vue
preset-confirm.vue
preset-confirm-slot.vue
transform-origin.vue
draggable.vue
```
## API
@ -58,6 +59,7 @@ Provided since `2.38.0`.
| block-scroll | `boolean` | `true` | Whether to disabled body scrolling when it's active. | 2.28.3 |
| close-on-esc | `boolean` | `true` | Whether to close modal on Esc is pressed. | 2.24.2 |
| display-directive | `'if' \| 'show'` | `'if'` | Use which directive to control the rendering of modal body. | |
| draggable | `boolean \| { bounds?: 'window' }` | `false` | Whether the modal is draggable. Make its position not bound inside window using `bounds === 'none'`. | NEXT_VERSION |
| mask-closable | `boolean` | `true` | Whether to emit `hide` event when click mask. | |
| preset | `'dialog' \| 'card'` | `undefined` | The preset of `n-modal`. | |
| show | `boolean` | `false` | Whether to show modal. | |
@ -97,6 +99,10 @@ See [Dialog props](dialog#Dialog-Props)
See [Card slots](card#Card-Slots)
`default` slot's parameter is different, which is `(props: { draggableClass: string })`.
### Modal with Preset Dialog Slots
See [Dialog slots](dialog#Dialog-Slots)
`default` slot's parameter is different, which is `(props: { draggableClass: string })`.

View File

@ -1,7 +1,9 @@
<markdown>
# 可拖拽
有人需要它便有了
设定 `draggable` 属性为 `true`弹窗即可拖拽如果你希望弹窗可以被拖出 window 的范围可以设置 `draggable` `{ bounds: 'none' }`
如果你希望拖拽完全自定义 modal 的内容你可以使用 `default` 插槽内的 `draggableClass`设定在你希望触发拖拽的元素上
</markdown>
<script lang="ts">
@ -54,10 +56,10 @@ export default defineComponent({
无预设
</n-button>
<n-button @click="showDialogPreset">
dialog 预设[命令式 Api]
dialog 预设命令式 Api
</n-button>
<n-button @click="showCardPreset">
card 预设[命令式 Api]
card 预设命令式 Api
</n-button>
<n-button @click="showModal4 = !showModal4">
弹窗嵌套
@ -106,32 +108,22 @@ export default defineComponent({
:style="{ width: '800px' }"
>
<template #default="{ draggableClass }">
<div :style="{ background: '#fff', padding: '20px' }">
<h1 :class="draggableClass" :style="{ margin: 0 }">
自定义标题
</h1>
<div>无意义的内容...</div>
<div>无意义的内容...</div>
<div>无意义的内容...</div>
<div>无意义的内容...</div>
<div>无意义的内容...</div>
<div>无意义的内容...</div>
<div>无意义的内容...</div>
<div>无意义的内容...</div>
<div>无意义的内容...</div>
<div>无意义的内容...</div>
</div>
<n-card>
<div :class="draggableClass">
点我拖拽
</div>
</n-card>
</template>
</n-modal>
<n-modal
v-model:show="showModal4"
title="嵌套弹窗拖拽"
preset="card"
:draggable="{ sticky: false }"
:draggable="{ bounds: 'none' }"
:style="{ width: '800px' }"
>
<n-button @click="showDialogPreset">
开一个弹窗
开一个弹窗
</n-button>
</n-modal>
</template>

View File

@ -73,8 +73,8 @@ mask-click-debug.vue
| auto-focus | `boolean` | `true` | 是否自动聚焦 Modal 第一个可聚焦的元素 | 2.24.2 |
| block-scroll | `boolean` | `true` | 是否在打开时禁用 body 滚动 | 2.28.3 |
| close-on-esc | `boolean` | `true` | 是否在摁下 Esc 键的时候关闭 Modal | 2.24.2 |
| draggable | `boolean \| {sticky?: boolean}` | `false` | 是否可拖拽,`sticky` 限制拖拽时不可超出视口 | NEXT_VERSION |
| display-directive | `'if' \| 'show'` | `'if'` | 使用何种指令控制模态框主体的条件渲染 | |
| draggable | `boolean \| { bounds?: 'none' }` | `false` | 是否可拖拽,`bounds === 'none'` 时拖拽可超出视口 | NEXT_VERSION |
| mask-closable | `boolean` | `true` | 点击遮罩时是否发出 `update:show` 事件 | |
| preset | `'dialog' \| 'card'` | `undefined` | 模态框使用何种预设 | |
| show | `boolean` | `false` | 是否展示 Modal | |
@ -106,6 +106,10 @@ mask-click-debug.vue
参考 [Card slots](card#Card-Slots)
注意,`default` slot 参数类型为 `(props: { draggableClass: string })`
### ModalDialog 预设Slots
参考 [Dialog slots](dialog#Dialog-Slots)
注意,`default` slot 参数类型为 `(props: { draggableClass: string })`

View File

@ -1,4 +1,5 @@
import type { ModalDraggableOptions } from './interface'
import type { ModalSlots } from './Modal'
import { clickoutside } from 'vdirs'
import {
cloneVNode,
@ -10,9 +11,11 @@ import {
inject,
mergeProps,
nextTick,
normalizeClass,
type PropType,
provide,
ref,
type SlotsType,
toRef,
Transition,
type VNode,
@ -23,8 +26,12 @@ import {
} from 'vue'
import { VFocusTrap } from 'vueuc'
import { NScrollbar, type ScrollbarInst } from '../../_internal'
import { getFirstSlotVNode, keep, useLockHtmlScroll, warn } from '../../_utils'
import { mergeClass } from '../../_utils/css'
import {
getFirstSlotVNodeWithTypedProps,
keep,
useLockHtmlScroll,
warn
} from '../../_utils'
import { NCard } from '../../card'
import { cardBasePropKeys } from '../../card/src/Card'
import { NDialog } from '../../dialog/src/Dialog'
@ -38,6 +45,7 @@ import { presetProps } from './presetProps'
export default defineComponent({
name: 'ModalBody',
inheritAttrs: false,
slots: Object as SlotsType<ModalSlots>,
props: {
show: {
type: Boolean,
@ -94,20 +102,33 @@ export default defineComponent({
const displayedRef = ref(props.show)
const transformOriginXRef = ref<number | null>(null)
const transformOriginYRef = ref<number | null>(null)
const { stopDrag, startDrag, canDraggable, draggableClass } = useDragModal(
toRef(props, 'draggable'),
const NModal = inject(modalInjectionKey)!
let mousePosition: { x: number, y: number } | null = null
watch(
toRef(props, 'show'),
(value) => {
if (value) {
mousePosition = NModal.getMousePosition()
}
},
{
onEnd: syncTransformOrigin
immediate: true
}
)
const dialogTitleClass = computed(() => {
return mergeClass(props.titleClass, draggableClass.value)
const { stopDrag, startDrag, draggableRef, draggableClassRef }
= useDragModal(toRef(props, 'draggable'), {
onEnd: (el) => {
syncTransformOrigin(el)
}
})
const dialogTitleClassRef = computed(() => {
return normalizeClass([props.titleClass, draggableClassRef.value])
})
const cardHeaderClass = computed(() => {
return mergeClass(props.headerClass, draggableClass.value)
const cardHeaderClassRef = computed(() => {
return normalizeClass([props.headerClass, draggableClassRef.value])
})
watch(toRef(props, 'show'), (value) => {
@ -116,7 +137,7 @@ export default defineComponent({
})
useLockHtmlScroll(computed(() => props.blockScroll && displayedRef.value))
const NModal = inject(modalInjectionKey)!
function styleTransformOrigin(): string {
if (NModal.transformOriginRef.value === 'center') {
return ''
@ -132,11 +153,11 @@ export default defineComponent({
}
return ''
}
function syncTransformOrigin(el: HTMLElement): void {
if (NModal.transformOriginRef.value === 'center') {
return
}
const mousePosition = NModal.getMousePosition()
if (!mousePosition) {
return
}
@ -144,12 +165,10 @@ export default defineComponent({
return
const scrollTop = scrollbarRef.value.containerScrollTop
const { offsetLeft, offsetTop } = el
if (mousePosition) {
const top = mousePosition.y
const left = mousePosition.x
transformOriginXRef.value = -(offsetLeft - left)
transformOriginYRef.value = -(offsetTop - top - scrollTop)
}
const top = mousePosition.y
const left = mousePosition.x
transformOriginXRef.value = -(offsetLeft - left)
transformOriginYRef.value = -(offsetTop - top - scrollTop)
el.style.transformOrigin = styleTransformOrigin()
}
function handleEnter(el: HTMLElement): void {
@ -163,7 +182,7 @@ export default defineComponent({
}
function handleAfterEnter(el: Element): void {
const element = el as HTMLElement
canDraggable.value && startDrag(element)
draggableRef.value && startDrag(element)
props.onAfterEnter && props.onAfterEnter(element)
}
function handleAfterLeave(): void {
@ -207,11 +226,11 @@ export default defineComponent({
mergedClsPrefix: NModal.mergedClsPrefixRef,
bodyRef,
scrollbarRef,
draggableClass,
draggableClass: draggableClassRef,
displayed: displayedRef,
childNodeRef,
cardHeaderClass,
dialogTitleClass,
cardHeaderClass: cardHeaderClassRef,
dialogTitleClass: dialogTitleClassRef,
handlePositiveClick,
handleNegativeClick,
handleCloseClick,
@ -234,7 +253,7 @@ export default defineComponent({
} = this
let childNode: VNode | null = null
if (!preset) {
childNode = getFirstSlotVNode($slots, 'default', {
childNode = getFirstSlotVNodeWithTypedProps('default', $slots.default, {
draggableClass: this.draggableClass
})
if (!childNode) {
@ -252,101 +271,100 @@ export default defineComponent({
}
return this.displayDirective === 'show' || this.displayed || this.show
? withDirectives(
<div role="none" class={`${mergedClsPrefix}-modal-body-wrapper`}>
<NScrollbar
ref="scrollbarRef"
theme={this.mergedTheme.peers.Scrollbar}
themeOverrides={this.mergedTheme.peerOverrides.Scrollbar}
contentClass={`${mergedClsPrefix}-modal-scroll-content`}
>
{{
default: () => [
this.renderMask?.(),
<VFocusTrap
disabled={!this.trapFocus}
active={this.show}
onEsc={this.onEsc}
autoFocus={this.autoFocus}
>
{{
default: () => (
<Transition
name="fade-in-scale-up-transition"
appear={this.appear ?? this.isMounted}
onEnter={handleEnter as any}
onAfterEnter={handleAfterEnter}
onAfterLeave={handleAfterLeave}
onBeforeLeave={handleBeforeLeave as any}
>
{{
default: () => {
const dirs: DirectiveArguments = [
[vShow, this.show]
]
const { onClickoutside } = this
if (onClickoutside) {
dirs.push([
clickoutside,
this.onClickoutside,
undefined as unknown as string,
{ capture: true }
])
<div role="none" class={`${mergedClsPrefix}-modal-body-wrapper`}>
<NScrollbar
ref="scrollbarRef"
theme={this.mergedTheme.peers.Scrollbar}
themeOverrides={this.mergedTheme.peerOverrides.Scrollbar}
contentClass={`${mergedClsPrefix}-modal-scroll-content`}
>
{{
default: () => [
this.renderMask?.(),
<VFocusTrap
disabled={!this.trapFocus}
active={this.show}
onEsc={this.onEsc}
autoFocus={this.autoFocus}
>
{{
default: () => (
<Transition
name="fade-in-scale-up-transition"
appear={this.appear ?? this.isMounted}
onEnter={handleEnter as any}
onAfterEnter={handleAfterEnter}
onAfterLeave={handleAfterLeave}
onBeforeLeave={handleBeforeLeave as any}
>
{{
default: () => {
const dirs: DirectiveArguments = [
[vShow, this.show]
]
const { onClickoutside } = this
if (onClickoutside) {
dirs.push([
clickoutside,
this.onClickoutside,
undefined as unknown as string,
{ capture: true }
])
}
return withDirectives(
(this.preset === 'confirm'
|| this.preset === 'dialog' ? (
<NDialog
{...this.$attrs}
class={[
`${mergedClsPrefix}-modal`,
this.$attrs.class
]}
ref="bodyRef"
theme={this.mergedTheme.peers.Dialog}
themeOverrides={
this.mergedTheme.peerOverrides.Dialog
}
{...keep(this.$props, dialogPropKeys)}
titleClass={this.dialogTitleClass}
aria-modal="true"
>
{$slots}
</NDialog>
) : this.preset === 'card' ? (
<NCard
{...this.$attrs}
ref="bodyRef"
class={[
`${mergedClsPrefix}-modal`,
this.$attrs.class
]}
theme={this.mergedTheme.peers.Card}
themeOverrides={
this.mergedTheme.peerOverrides.Card
}
{...keep(this.$props, cardBasePropKeys)}
headerClass={this.cardHeaderClass}
aria-modal="true"
role="dialog"
>
{$slots}
</NCard>
) : (
(this.childNodeRef = childNode)
)) as any,
dirs
)
}
return withDirectives(
(this.preset === 'confirm'
|| this.preset === 'dialog' ? (
<NDialog
{...this.$attrs}
class={[
`${mergedClsPrefix}-modal`,
this.$attrs.class
]}
ref="bodyRef"
theme={this.mergedTheme.peers.Dialog}
themeOverrides={
this.mergedTheme.peerOverrides.Dialog
}
{...keep(this.$props, dialogPropKeys)}
titleClass={this.dialogTitleClass}
aria-modal="true"
>
{$slots}
</NDialog>
) : this.preset === 'card' ? (
<NCard
{...this.$attrs}
ref="bodyRef"
class={[
`${mergedClsPrefix}-modal`,
this.$attrs.class
]}
theme={this.mergedTheme.peers.Card}
themeOverrides={
this.mergedTheme.peerOverrides.Card
}
{...keep(this.$props, cardBasePropKeys)}
headerClass={this.cardHeaderClass}
aria-modal="true"
role="dialog"
>
{$slots}
</NCard>
) : (
(this.childNodeRef = childNode)
)) as any,
dirs
)
}
}}
</Transition>
)
}}
</VFocusTrap>
]
}}
</NScrollbar>
</div>,
[
}}
</Transition>
)
}}
</VFocusTrap>
]
}}
</NScrollbar>
</div>,
[
[
vShow,

View File

@ -6,18 +6,20 @@ import type { ModalTheme } from '../styles'
import type { ModalDraggableOptions } from './interface'
import { getPreciseEventTarget } from 'seemly'
import { zindexable } from 'vdirs'
import { useIsMounted } from 'vooks'
import { useClicked, useClickPosition, useIsMounted } from 'vooks'
import {
computed,
type CSSProperties,
defineComponent,
h,
inject,
type PropType,
provide,
ref,
type SlotsType,
toRef,
Transition,
type VNode,
withDirectives
} from 'vue'
import { VLazyTeleport } from 'vueuc'
@ -29,10 +31,10 @@ import {
useIsComposing,
warnOnce
} from '../../_utils'
import { dialogProviderInjectionKey } from '../../dialog/src/context'
import { modalLight } from '../styles'
import NModalBodyWrapper from './BodyWrapper'
import { useCaptureOpenModalElementPosition } from './composables'
import { modalInjectionKey } from './interface'
import { modalInjectionKey, modalProviderInjectionKey } from './interface'
import { presetProps, presetPropsKeys } from './presetProps'
import style from './styles/index.cssr'
@ -89,6 +91,8 @@ export const modalProps = {
onNegativeClick: Function as PropType<() => Promise<boolean> | boolean | any>,
onMaskClick: Function as PropType<(e: MouseEvent) => void>,
// private
internalDialog: Boolean,
internalModal: Boolean,
internalAppear: {
type: Boolean as PropType<boolean | undefined>,
default: undefined
@ -102,7 +106,9 @@ export const modalProps = {
export type ModalProps = ExtractPublicPropTypes<typeof modalProps>
export interface ModalSlots extends CardSlots, DialogSlots {}
export type ModalSlots = Omit<CardSlots & DialogSlots, 'default'> & {
default?: (props: { draggableClass: string }) => VNode[]
}
export default defineComponent({
name: 'Modal',
@ -144,11 +150,16 @@ export default defineComponent({
props,
mergedClsPrefixRef
)
const clickedRef = useClicked(64)
const clickedPositionRef = useClickPosition()
const isMountedRef = useIsMounted()
const NDialogProvider = props.internalDialog
? inject(dialogProviderInjectionKey, null)
: null
const NModalProvider = props.internalModal
? inject(modalProviderInjectionKey, null)
: null
const isComposingRef = useIsComposing()
const openModalElPosition = useCaptureOpenModalElementPosition(
toRef(props, 'show')
)
function doUpdateShow(show: boolean): void {
const { onUpdateShow, 'onUpdate:show': _onUpdateShow, onHide } = props
@ -238,7 +249,17 @@ export default defineComponent({
}
provide(modalInjectionKey, {
getMousePosition: () => {
return openModalElPosition.value
const mergedProvider = NDialogProvider || NModalProvider
if (mergedProvider) {
const { clickedRef, clickedPositionRef } = mergedProvider
if (clickedRef.value && clickedPositionRef.value) {
return clickedPositionRef.value
}
}
if (clickedRef.value) {
return clickedPositionRef.value
}
return null
},
mergedClsPrefixRef,
mergedThemeRef: themeRef,

View File

@ -115,6 +115,7 @@ export const NModalEnvironment = defineComponent({
onEsc={handleEsc}
onAfterLeave={handleAfterLeave}
internalAppear
internalModal
>
</NModal>
)

View File

@ -8,9 +8,14 @@ import type {
import type { ExtractPublicPropTypes, Mutable } from '../../_utils'
import type { modalProps } from './Modal'
import { createId } from 'seemly'
import { useClicked, useClickPosition } from 'vooks'
import { defineComponent, Fragment, h, provide, reactive, ref } from 'vue'
import { omit } from '../../_utils'
import { modalApiInjectionKey, modalReactiveListInjectionKey } from './context'
import {
modalApiInjectionKey,
modalProviderInjectionKey,
modalReactiveListInjectionKey
} from './context'
import { NModalEnvironment } from './ModalEnvironment'
export type ModalOptions = Mutable<
@ -99,6 +104,10 @@ export const NModalProvider: DefineComponent<{ to?: string | HTMLElement }>
}
provide(modalApiInjectionKey, api)
provide(modalProviderInjectionKey, {
clickedRef: useClicked(64),
clickedPositionRef: useClickPosition()
})
provide(modalReactiveListInjectionKey, modalListRef)
return {
...api,

View File

@ -2,9 +2,7 @@ import type { Ref } from 'vue'
import type { ModalDraggableOptions } from './interface'
import type { ModalApiInjection, ModalReactive } from './ModalProvider'
import { off, on } from 'evtd'
import { isBoolean } from 'lodash-es'
import { useClickPosition } from 'vooks'
import { computed, inject, onUnmounted, ref, watch } from 'vue'
import { computed, inject, onUnmounted } from 'vue'
import { throwError } from '../../_utils'
import { modalApiInjectionKey, modalReactiveListInjectionKey } from './context'
@ -27,35 +25,41 @@ export function useModalReactiveList(): Ref<readonly ModalReactive[]> {
return modalReactiveList
}
export const DRAGGABLE_CLASS = 'modal-body--draggable'
export const DRAGGABLE_CLASS = 'n-draggable'
interface UseDragModalOptions {
onEnd?: (el: HTMLElement, event: MouseEvent) => void
onEnd: (el: HTMLElement) => void
}
export function useDragModal(
draggableProps: Ref<boolean | ModalDraggableOptions>,
options: UseDragModalOptions = {}
draggablePropsRef: Ref<boolean | ModalDraggableOptions>,
options: UseDragModalOptions
) {
let cleanup: undefined | (() => void)
const canDraggable = computed(() => {
return draggableProps.value !== false
const draggableRef = computed(() => {
return draggablePropsRef.value !== false
})
const draggableClass = computed(() => {
return canDraggable.value ? DRAGGABLE_CLASS : ''
const draggableClassRef = computed(() => {
return draggableRef.value ? DRAGGABLE_CLASS : ''
})
const sticky = computed(() => {
if (isBoolean(draggableProps.value)) {
const boundsToWindowRef = computed(() => {
const draggableProps = draggablePropsRef.value
if (draggableProps === true || draggableProps === false) {
return true
}
else if (draggableProps) {
return draggableProps.bounds !== 'none'
}
else {
return true
}
return draggableProps.value.sticky !== false
})
function startDrag(modal: HTMLElement) {
const header = modal.querySelector(`.${DRAGGABLE_CLASS}`) as HTMLElement
if (!header || !canDraggable.value) {
if (!header || !draggableClassRef.value) {
return
}
@ -90,7 +94,7 @@ export function useDragModal(
let moveX = event.clientX - downX
let moveY = event.clientY - downY
if (sticky.value) {
if (boundsToWindowRef.value) {
if (moveX > maxMoveX) {
moveX = maxMoveX
}
@ -111,9 +115,9 @@ export function useDragModal(
modal.style.left = `${x}px`
}
function handleMouseUp(event: MouseEvent) {
function handleMouseUp() {
mousedownEvent = undefined
options.onEnd && options.onEnd(modal, event)
options.onEnd(modal)
}
on('mousedown', header, handleMouseDown)
@ -138,28 +142,7 @@ export function useDragModal(
return {
stopDrag,
startDrag,
canDraggable,
draggableClass
draggableRef,
draggableClassRef
}
}
export function useCaptureOpenModalElementPosition(show: Ref<boolean>) {
const positionRef = useClickPosition()
const elementPositionRef = ref<{ x: number, y: number } | null>(null)
watch(
show,
(value) => {
if (value && positionRef.value) {
const { x, y } = positionRef.value
elementPositionRef.value = {
x,
y
}
}
},
{ immediate: true }
)
return elementPositionRef
}

View File

@ -1,9 +1,13 @@
import type {
ModalApiInjection,
ModalProviderInjection,
ModalReactiveListInjection
} from './ModalProvider'
import { createInjectionKey } from '../../_utils'
export const modalProviderInjectionKey
= createInjectionKey<ModalProviderInjection>('n-modal-provider')
export const modalApiInjectionKey
= createInjectionKey<ModalApiInjection>('n-modal-api')

View File

@ -15,6 +15,9 @@ export interface ModalProviderInjection {
clickedPositionRef: Ref<{ x: number, y: number } | null>
}
export const modalProviderInjectionKey
= createInjectionKey<ModalProviderInjection>('n-modal-provider')
export interface ModalInjection {
getMousePosition: () => {
x: number
@ -31,8 +34,7 @@ export const modalInjectionKey = createInjectionKey<ModalInjection>('n-modal')
export interface ModalDraggableOptions {
/**
*
* @default true
* If set to 'none', the modal's position will not be bounded to the window.
*/
sticky?: boolean
bounds?: 'none'
}

View File

@ -60,6 +60,6 @@ export default c([
c(`.${DRAGGABLE_CLASS}`, `
cursor: move;
user-select: none;
`),
`),
])
])