* Copyright (c) 2022 - present TinyVue Authors.
* Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd.
*
* Use of this source code is governed by an MIT-style license.
*
* THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL,
* BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR
* A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS.
*
*/
import type {
INavMenuRenderlessParams,
INavMenuProps,
INavMenuApi,
INavMenuState,
menuItemType,
whitchSubMenuType
} from '@/types'
import { omitText } from '@opentiny/utils'
import { isEmptyObject, isObject } from '@opentiny/utils'
import { PopupManager } from '@opentiny/utils'
import { mapTree } from '../grid/static'
import { transformTreeData } from '@opentiny/utils'
import { on, off } from '@opentiny/utils'
import { xss } from '@opentiny/utils'
import { isServer } from '@opentiny/utils'
const { nextZIndex } = PopupManager
export const computedIsShowMore =
({ props, state }: Pick<INavMenuRenderlessParams, 'props' | 'state'>) =>
(): boolean | number =>
!/^(retract|fixed|hidden)$/.test(props.overflow) && state.more && state.more.length
export const computedPopClass = (state: INavMenuState) => (): string => {
const popClass = !state.showMore && state.subMenus && state.subMenus.length === 1 ? 'single' : ''
return popClass + ' ' + state.menuClass
}
export const computedSubMenus = (state: INavMenuState) => (): menuItemType[] => {
let arr: menuItemType[] = state.subMenu
if (state.subMenu && !isEmptyObject(state.subMenu)) {
if (!state.subMenu.map((item) => item.children && !isEmptyObject(item.children)).reduce((pre, cur) => pre || cur)) {
arr = [{ children: state.subMenu }]
}
}
return arr
}
export const computedMenuStyle =
({ props, state }: Pick<INavMenuRenderlessParams, 'props' | 'state'>) =>
(): Object => {
const result: Record<string, string> = {}
if (state.width >= 0) {
result.maxWidth = `${state.width}px`
}
if (props.overflow === 'retract') {
result.width = '0px'
}
return result
}
export const computedPopStyle = (state: INavMenuState) => (): object => ({
top: `${state.popMenuTop}px`
})
export const watchWidth =
({ api, nextTick }: Pick<INavMenuRenderlessParams, 'api' | 'nextTick'>) =>
() =>
nextTick(() => api.classify())
class CloneObject {
constructor(json, props) {
this.props = props
if (json) {
this.data = this.getType(json)
this.traverse(json, this.data)
}
}
traverse(node, newObject) {
if (/^(string|undefined)$/.test(typeof node) || node === null) {
newObject = node
} else if (Array.isArray(node)) {
this.traverseArray(node, newObject)
} else if (typeof node === 'object') {
this.traverseObject(node, newObject)
}
return this
}
traverseArray(node, newObject) {
for (let i = 0; i < node.length; i++) {
newObject.push(this.getType(node[i]))
if (node[i] && typeof node[i] === 'object') {
this.traverse(node[i], newObject[i])
}
}
}
traverseObject(node, newObject) {
Object.keys(node).forEach((key) => {
if (/^(name|url|route|title|name|children)$/.test(key)) {
let alias = key
if (key === 'url') {
if (!(this.props.prevent || this.props.allowFullUrl)) {
alias = 'route'
}
}
if (key === 'name') {
alias = 'title'
}
newObject[alias] = this.getType(node[key])
if (newObject[alias] && typeof newObject[alias] === 'object') {
this.traverse(node[key], newObject[alias])
}
}
})
}
getType(object) {
let result
if (/^(string|undefined)$/.test(typeof object) || object === null) {
result = object
} else if (Array.isArray(object)) {
result = []
} else if (typeof object === 'object') {
result = {}
}
return result
}
}
export const initData =
({
fetchMenuData,
fields,
props,
state
}: Pick<INavMenuRenderlessParams, 'fetchMenuData' | 'fields' | 'props' | 'state'>) =>
() => {
if (props.defaultActive) {
state.defaultActiveId = props.defaultActive
}
const { textField = 'title', urlField = 'url', key = 'id' } = fields || {}
const { parentKey, data } = props
const isFullUrl = (url: string): boolean => /^(https?:\/\/|\/\/)[\s\S]+$/.test(url)
const buildData = (item: menuItemType) => {
const router = item[urlField] || item.route
return {
title: item[textField],
route: router ? router.replace(/^#\/?/, '') : null,
url: item.url,
id: item.id,
pid: item.pid,
isFullUrl: props.allowFullUrl && isFullUrl(router),
target: item.target
}
}
if (data) {
state.data = mapTree(parentKey ? transformTreeData(data, key, parentKey) : data, buildData)
return
}
const menuData = props.fetchMenuData && fetchMenuData()
if (isObject(menuData) && menuData?.then) {
menuData.then((data) => {
state.data = mapTree(props.parentKey ? transformTreeData(data, key, props.parentKey) : data, buildData)
})
return
}
if (!menuData) {
state.data = []
return
}
let arr: menuItemType[] = []
if (menuData && menuData.length) {
let getMenuData = new CloneObject(menuData, props).data
arr = typeof getMenuData === 'object' && Array.isArray(getMenuData) ? getMenuData : [getMenuData]
}
state.data = mapTree(parentKey ? transformTreeData(arr, key, parentKey) : arr, buildData)
}
export const mounted =
({
api,
nextTick,
props,
router,
route,
state
}: Pick<INavMenuRenderlessParams, 'api' | 'nextTick' | 'props' | 'router' | 'route' | 'state'>) =>
(): void => {
nextTick(() => {
requestAnimationFrame(() => {
api.calcWidth()
})
})
on(window, 'resize', api.calcWidth)
if (router) {
state.afterEach = (to) => {
api.setActiveMenu(api.getSelectedIndex(to.path))
}
router.afterEach(state.afterEach)
}
props.data && props.data.length && route && api.setActiveMenu(api.getSelectedIndex(route.path))
}
export const unMounted =
({ api, state, router }: Pick<INavMenuRenderlessParams, 'api' | 'state' | 'router'>) =>
(): void => {
if (router && router.afterHooks) {
const index = router.afterHooks.indexOf(state.afterEach)
router.afterHooks.splice(index, 1)
}
state.afterEach = null
off(window, 'resize', api.calcWidth)
}
export const getSelectedIndex =
(state: INavMenuState) =>
(path: string): number => {
if (!path) return
let length = state.data.length
let index = -1
if (path !== '/') {
const queryIndex = path.indexOf('?')
if (queryIndex !== -1) {
path = path.slice(0, queryIndex)
}
path = path.replace(/^#?\//, '')
let exp = new RegExp('("url":"#/?' + path + '"|"url":"/?' + path + '"|"route":"/?' + path + '")', 'i')
for (let i = 0; i < length; i++) {
if (exp.test(JSON.stringify(state.data[i]))) {
index = i
break
}
}
}
return index
}
export const showSetting =
({ parent, state }: Pick<INavMenuRenderlessParams, 'parent' | 'state'>) =>
(): void => {
state.isShowSetting = true
const setting = parent.$el.querySelector('.more-setting') as HTMLElement
setting.style.zIndex = nextZIndex()
}
export const willHideSetting = (state: INavMenuState) => (): boolean => (state.isShowSetting = false)
export const showSubMenu =
({
api,
nextTick,
parent,
state,
vm
}: Pick<INavMenuRenderlessParams, 'api' | 'nextTick' | 'parent' | 'state' | 'vm'>) =>
(list: Array<menuItemType>, { more, index }: whitchSubMenuType, event: MouseEvent): void => {
if (event) {
api.handleTitleMouseenter(event)
}
state.enterMenu = true
if (list || more) {
state.subMenu !== list ? api.hideSubMenu() : api.stopHideSubMenu()
state.showMore = !!more
state.subMenu = list
state.showPopmenu = true
state.subItemSelectedIndex = -1
state.subIndex = -1
state.moreItemSelectedIndex = -1
nextTick(() => {
const popmenu = parent.$el.querySelector('.popmenu') as HTMLElement
if (state.isSaaSTheme) {
setPopmenuStyleForSaas(popmenu, vm, event, state)
} else {
setPopmenuStyleForAurora(popmenu, event)
}
})
if (index !== undefined) {
state.activeIndex = index
}
if (more && list && list.length && state.subActiveIndex === -1) {
state.subActiveIndex = 0
}
} else {
api.hideSubMenu()
}
}
const setPopmenuStyleForSaas = (
popmenu: HTMLElement,
vm: INavMenuRenderlessParams['vm'],
event: MouseEvent,
state: INavMenuRenderlessParams['state']
) => {
if (!popmenu) return
const navMenu = vm.$refs.navMenu
const itemWidth = 216
const margin = window.outerWidth >= 1440 ? 96 : 56
const safeMargin = 32
popmenu.style.zIndex = String(nextZIndex())
popmenu.style.display = 'block'
popmenu.style.left = '32px'
const popmenuWidth =
state.subMenus && state.subMenus.length
? state.subMenus.length * itemWidth + (state.subMenus.length - 1) * margin
: 0
if (event) {
if (popmenuWidth > navMenu.offsetWidth - safeMargin * 4) {
state.menuClass = 'display-gird'
} else {
state.menuClass = 'display-flex'
if (popmenuWidth + safeMargin * 2 > navMenu.offsetWidth - (event.target as HTMLElement).offsetLeft) {
popmenu.style.right = '32px'
popmenu.style.left = 'unset'
} else {
popmenu.style.left = `${(event.target as HTMLElement).offsetLeft - 28}px`
popmenu.style.right = 'unset'
}
}
} else {
state.menuClass = 'display-gird'
}
}
const setPopmenuStyleForAurora = (popmenu: HTMLElement, event: MouseEvent) => {
if (popmenu) {
popmenu.style.zIndex = String(nextZIndex())
if (popmenu.classList.contains('single') && event) {
popmenu.style.left = `${(event.target as HTMLElement).offsetLeft}px`
} else {
popmenu.style.left = '0'
}
popmenu.style.height = 'auto'
popmenu.style.display = 'block'
}
}
export const hideSubMenu =
({ api, parent, state }: Pick<INavMenuRenderlessParams, 'api' | 'parent' | 'state'>) =>
(): void => {
api.stopHideSubMenu()
state.showMore = false
state.showPopmenu = false
state.activeIndex = -1
state.subActiveIndex = -1
const popmenu = parent.$el.querySelector('.popmenu') as HTMLElement
popmenu.style.height = 'auto'
popmenu.style.display = 'none'
}
export const willHideSubMenu =
({ api, state }: Pick<INavMenuRenderlessParams, 'api' | 'state'>) =>
() => {
api.stopHideSubMenu()
api.handleTitleMouseleave()
state.enterMenu = false
state.timer = window.setTimeout(() => {
api.hideSubMenu()
}, 20)
}
export const stopHideSubMenu = (state: INavMenuState) => (): void => {
clearTimeout(state.timer)
}
export const setSubMenu =
(state: INavMenuState) =>
(value: Array<menuItemType>, index: number): void => {
state.subActiveIndex = index
state.subMenu = value
state.enterMoreMenu = true
}
export const leaveMoreMune = (state: INavMenuState) => (): void => {
state.enterMoreMenu = false
}
export const isHide =
({ parent, state }: Pick<INavMenuRenderlessParams, 'parent' | 'state'>) =>
(event: HTMLElement): boolean =>
!state.width || event.offsetTop >= parent.$el.offsetHeight
export const hidePopmenu = (api: INavMenuApi) => (item: menuItemType) => {
if (item.url || item.route) {
api.setActiveMenu(api.getSelectedIndex(item.url || item.route))
api.hideSubMenu()
}
}
const updateMenuSelectedState = (
state: INavMenuState,
api: INavMenuApi,
item: menuItemType,
index: number,
parentIndex: number
): void => {
if (state.defaultActiveId) {
state.defaultActiveId = item.id
state.selectedIndex = -1
}
if (state.enterMenu) {
state.subIndex = -1
state.subItemSelectedIndex = -1
api.setActiveMenu(index)
}
if (state.enterMoreMenu) {
state.selectedIndex = -1
state.moreItemSelectedIndex = index
} else {
state.subItemSelectedIndex = index
state.subIndex = parentIndex
}
}
export const clickMenu =
({ api, props, state }: Pick<INavMenuRenderlessParams, 'api' | 'props' | 'state'>) =>
(item: menuItemType, index: number, parentIndex: number): void => {
if (index === undefined) {
return
}
if (item.url || item.route) {
const canSkip = props.beforeSkip ? props.beforeSkip(item) : true
if (!canSkip) {
api.hideSubMenu()
return
}
updateMenuSelectedState(state, api, item, index, parentIndex)
if (props.beforeSkip) {
api.skip(item, true)
} else {
api.skip(item, false)
}
api.hidePopmenu(item)
return
}
updateMenuSelectedState(state, api, item, index, parentIndex)
}
export const skip =
({ api, router, fields }: Pick<INavMenuRenderlessParams, 'api' | 'router' | 'fields'>) =>
(item: menuItemType, flag = false): string | null => {
if (!router) return
if (item.isFullUrl) {
const { urlField = 'url' } = fields || {}
const router = item[urlField] || item.route
return (window.open(xss.filterUrl(router)).opener = null)
}
const address =
!item.route || !flag
? api.getUrl(item).replace(/^#/, '')
: `/${item.route || ''}`.replace(/^\/+/, '/').replace('#/', '')
if (address) {
return router?.push(address)
} else {
return ''
}
}
export const getPoint =
({ api, parent }: Pick<INavMenuRenderlessParams, 'api' | 'parent'>) =>
(): number => {
if (isServer) return 0
else {
const items = parent.$el.querySelectorAll('.menu>li') as NodeListOf<HTMLElement>
let index = 0
if (items) {
index = items.length
for (let i = 0; i < items.length; i++) {
if (api.isHide(items[i])) {
index = index - (items.length - i)
break
}
}
}
return index
}
}
export const classify =
({ api, props, state }: Pick<INavMenuRenderlessParams, 'api' | 'props' | 'state'>) =>
(): void => {
const isRetractOrFixed = /^(retract|fixed)$/.test(props.overflow)
const menuCount = isRetractOrFixed ? 0 : props.overflow === 'hidden' ? props.data?.length : api.getPoint()
state.more = state.data.slice(menuCount)
}
export const calcWidth =
({ parent, props, state }: Pick<INavMenuRenderlessParams, 'parent' | 'props' | 'state'>) =>
(): void => {
const el = parent.$el
if (!el) {
return
}
const menuWidth = el.offsetWidth
if (!menuWidth && props.overflow !== 'retract') {
return
}
const logoWidth = parent.$slots.logo ? el.querySelector('.slot-logo')?.offsetWidth || 0 : 0
const toolbarWidth = parent.$slots.toolbar ? el.querySelector('.slot-toolbar')?.offsetWidth || 0 : 0
let width = props.overflow === 'retract' ? 0 : menuWidth - toolbarWidth - logoWidth
width = width - 90
state.width = width < 200 ? 0 : width
state.popMenuTop = el.offsetHeight
}
export const getTag =
(props: INavMenuProps) =>
(item: menuItemType): string =>
(item.url && 'a') || (item.route && (!props.beforeSkip ? 'router-link' : 'a')) || 'span'
export const getUrl =
() =>
(item: menuItemType): string =>
item.url || ''
export const getRoute =
(props: INavMenuProps) =>
(item: menuItemType): string =>
!props.beforeSkip ? `/${item.route || ''}`.replace(/^\/+/, '/').replace('#/', '') : ''
export const setActiveMenu =
(state: INavMenuState) =>
(index: number): number =>
(state.selectedIndex = typeof index !== 'undefined' ? index : -1)
export const initService = ({ props, service }: Pick<INavMenuRenderlessParams, 'props' | 'service'>): Object => {
const fetchMenuData = () =>
Promise.reject(
new Error('[TINY Error][NavMenu] Prop fetchMenuData is mandatory when the framework service is not used')
)
const { base = {}, setting = {} } = service || {}
const { options = {} } = setting
return {
fetchMenuData: props.fetchMenuData || base.getMenuDataSync || fetchMenuData,
fields: props.fields || options.NavMenu
}
}
export const handleTitleMouseenter =
({ state, vm }: Pick<INavMenuRenderlessParams, 'state' | 'vm'>) =>
($event: MouseEvent): void => {
state.tooltipContent = ''
const target = $event.target as HTMLElement
const text = target.textContent
const font = window.getComputedStyle(target).font
const rect = target.getBoundingClientRect()
const res = omitText(text, font, rect.width + 2)
if (target && res.o) {
const tooltip = vm.$refs.tooltip
tooltip.state.referenceElm = target
tooltip.state.popperElm && (tooltip.state.popperElm.style.display = 'none')
tooltip.doDestroy()
state.tooltipVisible = true
state.tooltipContent = text
setTimeout(tooltip.updatePopper, 20)
}
}
export const handleTitleMouseleave =
({ state }: Pick<INavMenuRenderlessParams, 'state'>) =>
(): void => {
state.tooltipVisible = false
}
export const getMoreSelected =
({ state }: Pick<INavMenuRenderlessParams, 'state'>) =>
(): void => {
let isSelected = false
if (state?.more.length) {
state.more.forEach((item) => {
if (item?.id === state.defaultActiveId) {
isSelected = true
} else if (item?.children?.length) {
isSelected = getSelectedState(item.children, state.defaultActiveId)
}
})
}
return isSelected
}
export const getTabSelected =
({ state }: Pick<INavMenuRenderlessParams, 'state'>) =>
(item, index): void => {
let isChildSelected = false
if (item?.id && item?.children?.length) {
isChildSelected = getSelectedState(item.children, state.defaultActiveId)
}
let isSelected = item?.id === state.defaultActiveId || isChildSelected || index === state.selectedIndex
return isSelected
}
export const getLeftSelected =
({ state }: Pick<INavMenuRenderlessParams, 'state'>) =>
(item, index): void => {
let isLeftChildSelected = false
if (item?.id && item?.children?.length) {
isLeftChildSelected = getSelectedState(item.children, state.defaultActiveId)
}
let isSelected = item?.id === state.defaultActiveId || isLeftChildSelected || index === state.moreItemSelectedIndex
return isSelected
}
export const getLastChildSelected =
({ state }: Pick<INavMenuRenderlessParams, 'state'>) =>
(item, i, index): void => {
let isSelected =
item?.id === state.defaultActiveId || (i === state.subItemSelectedIndex && index === state.subIndex)
return isSelected
}
const getSelectedState = (itemData, id) => {
let isSelected = false
itemData.forEach((data) => {
if (data?.id === id) {
isSelected = true
} else if (data?.children?.length) {
data?.children.forEach((dataChildren) => {
if (dataChildren?.id === id) {
isSelected = true
}
})
}
})
return isSelected
}