mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-09-10 22:14:48 -05:00
feat(web-core): new menu management system (#8123)
This commit is contained in:
committed by
GitHub
parent
c08f84bf04
commit
708b9b5bc8
@@ -105,6 +105,8 @@ module.exports = {
|
||||
],
|
||||
},
|
||||
],
|
||||
'no-useless-constructor': 'off',
|
||||
'@typescript-eslint/no-useless-constructor': 'error',
|
||||
'no-void': ['error', { allowAsStatement: true }],
|
||||
'n/no-missing-import': 'off', // using 'import' plugin instead, to support TS aliases
|
||||
'no-redeclare': 'off', // automatically checked by the TypeScript compiler
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@csstools/postcss-global-data": "^2.1.1",
|
||||
"@floating-ui/vue": "^1.1.5",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.5.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||
|
||||
@@ -26,3 +26,4 @@
|
||||
# Packages
|
||||
|
||||
- [Job System](../lib/packages/job/README.md)
|
||||
- [Menu System](../lib/packages/menu/README.md)
|
||||
|
||||
194
@xen-orchestra/web-core/lib/packages/menu/README.md
Normal file
194
@xen-orchestra/web-core/lib/packages/menu/README.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# Menu System
|
||||
|
||||
A menu system for Vue applications supporting props binding for actions, links, router links, and nested submenus.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<MenuList>
|
||||
<li><MenuTrigger v-bind="menu.save">Save</MenuTrigger></li>
|
||||
<li><MenuTrigger v-bind="menu.doc">Documentation</MenuTrigger></li>
|
||||
<li><MenuTrigger v-bind="menu.profile">Profile</MenuTrigger></li>
|
||||
<li>
|
||||
<MenuTrigger v-bind="menu.more.$trigger">More...</MenuTrigger>
|
||||
<MenuList v-bind="menu.more.$target">
|
||||
<MenuTrigger v-bind="menu.more.settings">Settings</MenuTrigger>
|
||||
<MenuTrigger v-bind="menu.more.logout">Logout</MenuTrigger>
|
||||
</MenuList>
|
||||
</li>
|
||||
</MenuList>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { action, link, routerLink, toggle, useMenu } from '@core/packages/menu'
|
||||
|
||||
const menu = useMenu({
|
||||
save: action(() => console.log('Saving...')),
|
||||
doc: link('https://docs.example.com'),
|
||||
profile: routerLink({ name: 'profile' }),
|
||||
more: toggle({
|
||||
settings: action(() => console.log('Settings clicked')),
|
||||
logout: action(() => console.log('Logout clicked')),
|
||||
}),
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Core Composables
|
||||
|
||||
### useMenu
|
||||
|
||||
Create a root menu with multiple items and/or submenus:
|
||||
|
||||
```ts
|
||||
const fileMenu = useMenu({
|
||||
// Menu structure
|
||||
edit: action(() => handleEdit()),
|
||||
save: action(() => handleSave(), {
|
||||
disabled: () => !canSave.value,
|
||||
busy: isSaving,
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
### useMenuAction
|
||||
|
||||
Create an action button. It must be attached to a menu.
|
||||
|
||||
Mostly useful when splitting a menu into subcomponents.
|
||||
|
||||
```ts
|
||||
const props = defineProps<{
|
||||
menu: MenuLike
|
||||
}>()
|
||||
|
||||
const action = useMenuAction(menu, () => handleClick(), {
|
||||
disabled: isDisabled,
|
||||
busy: isLoading,
|
||||
})
|
||||
```
|
||||
|
||||
### useMenuToggle
|
||||
|
||||
Create a toggle menu (dropdown) with nested items.
|
||||
|
||||
You can pass a `parent: MenuLike` as option to attach the toggle to a parent Menu.
|
||||
|
||||
Skip this option to create a root menu toggle.
|
||||
|
||||
```ts
|
||||
const dropdown = useMenuToggle(
|
||||
{
|
||||
behavior: 'click', // or 'mouseenter'
|
||||
placement: 'bottom-start',
|
||||
},
|
||||
{
|
||||
edit: action(() => handleEdit()),
|
||||
delete: action(() => handleDelete()),
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Component Integration
|
||||
|
||||
Once you create the menu, you can bind its items as props to your template.
|
||||
|
||||
For now, these bindings are meant to be used with a Vue component configured with the same props
|
||||
|
||||
For other component or HTML Element, you can still bind the props manually.
|
||||
|
||||
`action()` items will generate the following props binding:
|
||||
|
||||
```ts
|
||||
type Props = {
|
||||
as: 'button'
|
||||
type: 'button'
|
||||
disabled: boolean
|
||||
busy: boolean
|
||||
tooltip: string | false
|
||||
onMouseenter: () => void
|
||||
onClick: () => void
|
||||
'data-menu-id': string
|
||||
}
|
||||
```
|
||||
|
||||
`link()` items will generate the following props binding:
|
||||
|
||||
```ts
|
||||
type Props = {
|
||||
as: 'a'
|
||||
href: string
|
||||
rel: 'noreferrer noopener'
|
||||
target: '_blank'
|
||||
onMouseenter: () => void
|
||||
onClick: () => void
|
||||
'data-menu-id': string
|
||||
}
|
||||
```
|
||||
|
||||
`routerLink()` items will generate the following props binding:
|
||||
|
||||
```ts
|
||||
type Props = {
|
||||
as: RouterLink
|
||||
to: RouteLocationRaw
|
||||
onMouseenter: () => void
|
||||
onClick: () => void
|
||||
'data-menu-id': string
|
||||
}
|
||||
```
|
||||
|
||||
`toggle()` items will generate an object containing `$trigger`, `$target` and `$isOpen` (`ComputedRef<boolean>`) properties with the following props binding:
|
||||
|
||||
```ts
|
||||
// $trigger
|
||||
type Props = {
|
||||
as: 'button'
|
||||
type: 'button'
|
||||
submenu: true
|
||||
ref: (el: any) => void
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
onMouseenter: () => void
|
||||
'data-menu-id': string
|
||||
}
|
||||
|
||||
// $target
|
||||
type Props = {
|
||||
ref: (el: any) => void
|
||||
style: object
|
||||
'data-menu-id': string
|
||||
}
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<MenuTrigger v-bind="menu.save">Save</MenuTrigger>
|
||||
<MenuTrigger v-bind="menu.docs">Documentation</MenuTrigger>
|
||||
<MenuTrigger v-bind="menu.profile">Profile</MenuTrigger>
|
||||
|
||||
<!-- Toggle/Dropdown menu -->
|
||||
<MenuTrigger v-bind="menu.more.$trigger">More</MenuTrigger>
|
||||
<div v-bind="menu.more.$target">
|
||||
<MenuTrigger v-bind="menu.more.settings">Settings</MenuTrigger>
|
||||
<MenuTrigger v-bind="menu.more.logout">Logout</MenuTrigger>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
If you want to bind props to an HTML element or a Vue component not supporting all the props,
|
||||
you can use VueUse's `objectOmit` for example or bind the props completely manually.
|
||||
|
||||
```vue
|
||||
<script>
|
||||
import { objectOmit as omit } from '@vueuse/shared'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MyCustomElement v-bind="omit(menu.save, ['as', 'type'])">Save</MyCustomElement>
|
||||
<button v-bind="omit(menu.save, ['as', busy, 'tooltip'])" :class="{ busy: menu.save.busy }">Save</button>
|
||||
</template>
|
||||
```
|
||||
101
@xen-orchestra/web-core/lib/packages/menu/action.ts
Normal file
101
@xen-orchestra/web-core/lib/packages/menu/action.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { BaseItem, type Menu, type MenuLike, parseConfigHolder } from '@core/packages/menu'
|
||||
import { computed, type MaybeRefOrGetter, reactive, ref, toValue } from 'vue'
|
||||
|
||||
export interface MenuActionConfig {
|
||||
handler: () => any
|
||||
disabled?: MaybeRefOrGetter<boolean | string | undefined>
|
||||
busy?: MaybeRefOrGetter<boolean | string | undefined>
|
||||
}
|
||||
|
||||
export class MenuActionConfigHolder {
|
||||
constructor(public config: MenuActionConfig) {}
|
||||
}
|
||||
|
||||
export interface MenuActionProps {
|
||||
as: 'button'
|
||||
type: 'button'
|
||||
disabled: boolean
|
||||
busy: boolean
|
||||
tooltip: string | false
|
||||
onClick: () => void
|
||||
onMouseenter: () => void
|
||||
'data-menu-id': string
|
||||
}
|
||||
|
||||
export class MenuAction extends BaseItem {
|
||||
isRunning = ref(false)
|
||||
|
||||
constructor(
|
||||
public menu: Menu,
|
||||
public config: MenuActionConfig
|
||||
) {
|
||||
super(menu)
|
||||
}
|
||||
|
||||
get busyConfig() {
|
||||
return computed(() => toValue(this.config.busy) ?? false)
|
||||
}
|
||||
|
||||
get isBusy() {
|
||||
return computed(() => this.isRunning.value || this.busyConfig.value !== false)
|
||||
}
|
||||
|
||||
get busyReason() {
|
||||
return computed(() => (typeof this.busyConfig.value === 'string' ? this.busyConfig.value : undefined))
|
||||
}
|
||||
|
||||
get disabledConfig() {
|
||||
return computed(() => toValue(this.config.disabled) ?? false)
|
||||
}
|
||||
|
||||
get isDisabled() {
|
||||
return computed(() => this.isBusy.value || this.disabledConfig.value !== false)
|
||||
}
|
||||
|
||||
get disabledReason() {
|
||||
return computed(() => (typeof this.disabledConfig.value === 'string' ? this.disabledConfig.value : undefined))
|
||||
}
|
||||
|
||||
get tooltip() {
|
||||
return computed(() => this.disabledReason.value ?? this.busyReason.value ?? false)
|
||||
}
|
||||
|
||||
get props(): MenuActionProps {
|
||||
return reactive({
|
||||
as: 'button',
|
||||
type: 'button',
|
||||
onClick: async () => {
|
||||
if (this.isDisabled.value) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isRunning.value = true
|
||||
|
||||
try {
|
||||
await this.config.handler()
|
||||
this.deactivate()
|
||||
} finally {
|
||||
this.isRunning.value = false
|
||||
}
|
||||
},
|
||||
onMouseenter: () => this.activate(),
|
||||
disabled: this.isDisabled,
|
||||
busy: this.isBusy,
|
||||
tooltip: this.tooltip,
|
||||
'data-menu-id': this.menu.context.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function action(handler: () => void, config: Omit<MenuActionConfig, 'handler'> = {}): MenuActionConfigHolder {
|
||||
return new MenuActionConfigHolder({
|
||||
...config,
|
||||
handler,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMenuAction(config: MenuActionConfig & { parent: MenuLike }) {
|
||||
const { parent, handler, ...configRest } = config
|
||||
|
||||
return parseConfigHolder(parent, action(handler, configRest))
|
||||
}
|
||||
26
@xen-orchestra/web-core/lib/packages/menu/base.ts
Normal file
26
@xen-orchestra/web-core/lib/packages/menu/base.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { MenuAction, MenuLink, MenuRouterLink, MenuToggleTrigger, Menu } from '@core/packages/menu'
|
||||
import { computed } from 'vue'
|
||||
|
||||
export type MenuItem = MenuAction | MenuLink | MenuRouterLink | MenuToggleTrigger
|
||||
|
||||
export abstract class BaseItem {
|
||||
id = Symbol('Menu Item')
|
||||
|
||||
protected constructor(public menu: Menu) {
|
||||
menu.addItem(this as any)
|
||||
}
|
||||
|
||||
get isActive() {
|
||||
return computed(() => this.menu.context.activeItemId.value === this.id)
|
||||
}
|
||||
|
||||
activate() {
|
||||
this.menu.context.activeItemId.value = this.id
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
if (this.isActive.value) {
|
||||
this.menu.context.activeItemId.value = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
27
@xen-orchestra/web-core/lib/packages/menu/context.ts
Normal file
27
@xen-orchestra/web-core/lib/packages/menu/context.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { uniqueId } from '@core/utils/unique-id.util'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { type Ref, ref } from 'vue'
|
||||
|
||||
export class MenuContext {
|
||||
id: string
|
||||
|
||||
activeItemId: Ref<symbol | undefined> = ref()
|
||||
|
||||
constructor() {
|
||||
this.id = uniqueId()
|
||||
|
||||
useEventListener(window, 'click', (event: PointerEvent) => {
|
||||
if (this.hasSameController(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.activeItemId.value = undefined
|
||||
})
|
||||
}
|
||||
|
||||
hasSameController(event: PointerEvent) {
|
||||
return Array.from(window.document.querySelectorAll(`[data-menu-id="${this.id}"]`)).some(
|
||||
el => el === event.target || event.composedPath().includes(el)
|
||||
)
|
||||
}
|
||||
}
|
||||
10
@xen-orchestra/web-core/lib/packages/menu/index.ts
Normal file
10
@xen-orchestra/web-core/lib/packages/menu/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export * from './base'
|
||||
export * from './action'
|
||||
export * from './context'
|
||||
export * from './link'
|
||||
export * from './menu'
|
||||
export * from './router-link'
|
||||
export * from './structure'
|
||||
export * from './toggle'
|
||||
export * from './toggle-target'
|
||||
export * from './toggle-trigger'
|
||||
15
@xen-orchestra/web-core/lib/packages/menu/job.ts
Normal file
15
@xen-orchestra/web-core/lib/packages/menu/job.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Job } from '@core/packages/job'
|
||||
import { action } from '@core/packages/menu/action'
|
||||
import type { MenuLike } from '@core/packages/menu/menu'
|
||||
import { parseConfigHolder } from '@core/packages/menu/structure'
|
||||
|
||||
export function job(job: Job<any>) {
|
||||
return action(() => job.run(), {
|
||||
busy: job.isRunning,
|
||||
disabled: job.errorMessage,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMenuJob(config: { job: Job<any>; parent: MenuLike }) {
|
||||
return parseConfigHolder(config.parent, job(config.job))
|
||||
}
|
||||
56
@xen-orchestra/web-core/lib/packages/menu/link.ts
Normal file
56
@xen-orchestra/web-core/lib/packages/menu/link.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { BaseItem, type Menu, type MenuLike, parseConfigHolder } from '@core/packages/menu'
|
||||
import { computed, type MaybeRefOrGetter, reactive, toValue } from 'vue'
|
||||
|
||||
export interface MenuLinkConfig {
|
||||
href: MaybeRefOrGetter<string>
|
||||
rel?: MaybeRefOrGetter<string>
|
||||
target?: MaybeRefOrGetter<string>
|
||||
}
|
||||
|
||||
export class MenuLinkConfigHolder {
|
||||
constructor(public config: MenuLinkConfig) {}
|
||||
}
|
||||
|
||||
export interface MenuLinkProps {
|
||||
as: 'a'
|
||||
href: string
|
||||
rel: string
|
||||
target: string
|
||||
onClick: () => void
|
||||
onMouseenter: () => void
|
||||
'data-menu-id': string
|
||||
}
|
||||
|
||||
export class MenuLink extends BaseItem {
|
||||
constructor(
|
||||
public menu: Menu,
|
||||
public config: MenuLinkConfig
|
||||
) {
|
||||
super(menu)
|
||||
}
|
||||
|
||||
get props(): MenuLinkProps {
|
||||
return reactive({
|
||||
as: 'a',
|
||||
onMouseenter: () => this.activate(),
|
||||
onClick: () => this.deactivate(),
|
||||
href: computed(() => toValue(this.config.href)),
|
||||
rel: computed(() => toValue(this.config.rel) ?? 'noreferrer noopener'),
|
||||
target: computed(() => toValue(this.config.target) ?? '_blank'),
|
||||
'data-menu-id': this.menu.context.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function link(href: MaybeRefOrGetter<string>, config: Omit<MenuLinkConfig, 'href'> = {}) {
|
||||
return new MenuLinkConfigHolder({
|
||||
...config,
|
||||
href,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMenuLink(config: MenuLinkConfig & { parent: MenuLike }) {
|
||||
const { parent, href, ...configRest } = config
|
||||
|
||||
return parseConfigHolder(parent, link(href, configRest))
|
||||
}
|
||||
50
@xen-orchestra/web-core/lib/packages/menu/menu.ts
Normal file
50
@xen-orchestra/web-core/lib/packages/menu/menu.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
MenuAction,
|
||||
MenuContext,
|
||||
MenuLink,
|
||||
MenuRouterLink,
|
||||
type MenuStructure,
|
||||
MenuToggleTrigger,
|
||||
type ParseStructure,
|
||||
parseStructure,
|
||||
} from '@core/packages/menu'
|
||||
import { extendRef } from '@vueuse/core'
|
||||
import { computed, type ComputedRef, type MaybeRefOrGetter, shallowReactive, type ShallowReactive } from 'vue'
|
||||
|
||||
export const MENU_SYMBOL = Symbol('Submenu')
|
||||
|
||||
export type WithMenu = { [MENU_SYMBOL]: Menu }
|
||||
|
||||
export type MenuLike = Menu | WithMenu
|
||||
|
||||
type MenuItem = MenuAction | MenuLink | MenuRouterLink | MenuToggleTrigger
|
||||
|
||||
export class Menu {
|
||||
items: ShallowReactive<Map<symbol, MenuItem>> = shallowReactive(new Map())
|
||||
|
||||
isActive = computed(() => Array.from(this.items.values()).some(item => item.isActive.value))
|
||||
|
||||
constructor(public context: MenuContext) {}
|
||||
|
||||
addItem(item: MenuItem) {
|
||||
this.items.set(item.id, item)
|
||||
}
|
||||
}
|
||||
|
||||
export function useMenu<const TStructure extends MenuStructure>(
|
||||
structure: MaybeRefOrGetter<TStructure>
|
||||
): ComputedRef<ParseStructure<TStructure> & WithMenu> & WithMenu {
|
||||
const context = new MenuContext()
|
||||
|
||||
const menu = new Menu(context)
|
||||
|
||||
return extendRef(
|
||||
computed(() => {
|
||||
return {
|
||||
...parseStructure(menu, structure),
|
||||
[MENU_SYMBOL]: menu,
|
||||
}
|
||||
}),
|
||||
{ [MENU_SYMBOL]: menu }
|
||||
)
|
||||
}
|
||||
51
@xen-orchestra/web-core/lib/packages/menu/router-link.ts
Normal file
51
@xen-orchestra/web-core/lib/packages/menu/router-link.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { BaseItem, type Menu, type MenuLike, parseConfigHolder } from '@core/packages/menu'
|
||||
import { computed, markRaw, type MaybeRefOrGetter, reactive, toValue } from 'vue'
|
||||
import { type RouteLocationRaw, RouterLink } from 'vue-router'
|
||||
|
||||
export interface MenuRouterLinkConfig {
|
||||
to: MaybeRefOrGetter<RouteLocationRaw>
|
||||
}
|
||||
|
||||
export class MenuRouterLinkConfigHolder {
|
||||
constructor(public config: MenuRouterLinkConfig) {}
|
||||
}
|
||||
|
||||
export interface MenuRouterLinkProps {
|
||||
as: typeof RouterLink
|
||||
to: RouteLocationRaw
|
||||
onClick: () => void
|
||||
onMouseenter: () => void
|
||||
'data-menu-id': string
|
||||
}
|
||||
|
||||
export class MenuRouterLink extends BaseItem {
|
||||
constructor(
|
||||
public menu: Menu,
|
||||
public config: MenuRouterLinkConfig
|
||||
) {
|
||||
super(menu)
|
||||
}
|
||||
|
||||
get props(): MenuRouterLinkProps {
|
||||
return reactive({
|
||||
as: markRaw(RouterLink),
|
||||
onMouseenter: () => this.activate(),
|
||||
onClick: () => this.deactivate(),
|
||||
to: computed(() => toValue(this.config.to)),
|
||||
'data-menu-id': this.menu.context.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function routerLink(to: MaybeRefOrGetter<RouteLocationRaw>, config: Omit<MenuRouterLinkConfig, 'to'> = {}) {
|
||||
return new MenuRouterLinkConfigHolder({
|
||||
...config,
|
||||
to,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMenuRouterLink(config: MenuRouterLinkConfig & { parent: MenuLike }) {
|
||||
const { parent, to, ...configRest } = config
|
||||
|
||||
return parseConfigHolder(parent, routerLink(to, configRest))
|
||||
}
|
||||
88
@xen-orchestra/web-core/lib/packages/menu/structure.ts
Normal file
88
@xen-orchestra/web-core/lib/packages/menu/structure.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
Menu,
|
||||
MENU_SYMBOL,
|
||||
MenuAction,
|
||||
type MenuActionProps,
|
||||
MenuActionConfigHolder,
|
||||
type MenuLike,
|
||||
MenuLink,
|
||||
type MenuLinkProps,
|
||||
MenuLinkConfigHolder,
|
||||
MenuRouterLink,
|
||||
type MenuRouterLinkProps,
|
||||
MenuRouterLinkConfigHolder,
|
||||
type MenuToggleProps,
|
||||
MenuToggleConfigHolder,
|
||||
MenuToggleTarget,
|
||||
MenuToggleTrigger,
|
||||
} from '@core/packages/menu'
|
||||
import { type MaybeRefOrGetter, toValue } from 'vue'
|
||||
|
||||
export type MenuStructure = {
|
||||
[K: string]: ConfigHolder
|
||||
}
|
||||
|
||||
export type ConfigHolder =
|
||||
| MenuActionConfigHolder
|
||||
| MenuLinkConfigHolder
|
||||
| MenuRouterLinkConfigHolder
|
||||
| MenuToggleConfigHolder<any>
|
||||
|
||||
export type ParseConfigHolder<TConfigHolder extends ConfigHolder> = TConfigHolder extends MenuActionConfigHolder
|
||||
? MenuActionProps
|
||||
: TConfigHolder extends MenuLinkConfigHolder
|
||||
? MenuLinkProps
|
||||
: TConfigHolder extends MenuRouterLinkConfigHolder
|
||||
? MenuRouterLinkProps
|
||||
: TConfigHolder extends MenuToggleConfigHolder<infer TItems>
|
||||
? MenuToggleProps<TItems>
|
||||
: never
|
||||
|
||||
export type ParseStructure<TStructure extends MenuStructure> = {
|
||||
[K in keyof TStructure]: ParseConfigHolder<TStructure[K]>
|
||||
}
|
||||
|
||||
export function parseConfigHolder<TConfigHolder extends ConfigHolder>(
|
||||
menuLike: MenuLike,
|
||||
configHolder: TConfigHolder
|
||||
): ParseConfigHolder<TConfigHolder> {
|
||||
const menu = menuLike instanceof Menu ? menuLike : menuLike[MENU_SYMBOL]
|
||||
|
||||
if (configHolder instanceof MenuActionConfigHolder) {
|
||||
return new MenuAction(menu, configHolder.config).props as ParseConfigHolder<TConfigHolder>
|
||||
}
|
||||
|
||||
if (configHolder instanceof MenuLinkConfigHolder) {
|
||||
return new MenuLink(menu, configHolder.config).props as ParseConfigHolder<TConfigHolder>
|
||||
}
|
||||
|
||||
if (configHolder instanceof MenuRouterLinkConfigHolder) {
|
||||
return new MenuRouterLink(menu, configHolder.config).props as ParseConfigHolder<TConfigHolder>
|
||||
}
|
||||
|
||||
if (configHolder instanceof MenuToggleConfigHolder) {
|
||||
const trigger = new MenuToggleTrigger(menu, configHolder.config)
|
||||
const target = new MenuToggleTarget(trigger, configHolder.config)
|
||||
|
||||
return {
|
||||
$trigger: trigger.props,
|
||||
$target: target.props,
|
||||
$isOpen: trigger.isOpen,
|
||||
[MENU_SYMBOL]: trigger.subMenu,
|
||||
...parseStructure(trigger.subMenu, configHolder.config.items),
|
||||
} as ParseConfigHolder<TConfigHolder>
|
||||
}
|
||||
|
||||
throw new Error('Unsupported config')
|
||||
}
|
||||
|
||||
export function parseStructure<TStructure extends MenuStructure>(
|
||||
menu: Menu,
|
||||
structure: MaybeRefOrGetter<TStructure>
|
||||
): ParseStructure<TStructure> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(toValue(structure)).map(([key, configHolder]) => {
|
||||
return [key, parseConfigHolder(menu, configHolder)]
|
||||
})
|
||||
) as ParseStructure<TStructure>
|
||||
}
|
||||
59
@xen-orchestra/web-core/lib/packages/menu/toggle-target.ts
Normal file
59
@xen-orchestra/web-core/lib/packages/menu/toggle-target.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { MenuToggleTrigger } from '@core/packages/menu'
|
||||
import { autoUpdate, flip, type Placement, shift, useFloating, type UseFloatingReturn } from '@floating-ui/vue'
|
||||
import { unrefElement } from '@vueuse/core'
|
||||
import { computed, reactive, ref, type Ref, toValue, type UnwrapRef } from 'vue'
|
||||
|
||||
export interface MenuToggleTargetConfig {
|
||||
placement?: Placement
|
||||
}
|
||||
|
||||
export interface MenuToggleTargetProps {
|
||||
ref: (el: any) => void
|
||||
style: {
|
||||
display: string | undefined
|
||||
zIndex: number
|
||||
} & UnwrapRef<UseFloatingReturn['floatingStyles']>
|
||||
'data-menu-id': string
|
||||
}
|
||||
|
||||
export class MenuToggleTarget {
|
||||
element: Ref<HTMLElement | null> = ref(null)
|
||||
|
||||
styles: UseFloatingReturn['floatingStyles']
|
||||
|
||||
constructor(
|
||||
public trigger: MenuToggleTrigger,
|
||||
public config: MenuToggleTargetConfig
|
||||
) {
|
||||
const { floatingStyles } = useFloating(trigger.element, this.element, {
|
||||
placement: computed(() => toValue(config.placement) ?? 'bottom-start'),
|
||||
open: trigger.isOpen,
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [shift(), flip()],
|
||||
})
|
||||
|
||||
this.styles = floatingStyles
|
||||
}
|
||||
|
||||
get props(): MenuToggleTargetProps {
|
||||
return reactive({
|
||||
as: 'button',
|
||||
type: 'button',
|
||||
ref: (element: HTMLElement | null | undefined) => {
|
||||
const newElement = unrefElement(element)
|
||||
|
||||
if (newElement !== this.element.value) {
|
||||
this.element.value = newElement ?? null
|
||||
}
|
||||
},
|
||||
style: computed(() => {
|
||||
return {
|
||||
display: this.trigger.isOpen.value ? undefined : 'none',
|
||||
zIndex: 9999,
|
||||
...this.styles.value,
|
||||
}
|
||||
}),
|
||||
'data-menu-id': this.trigger.menu.context.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
72
@xen-orchestra/web-core/lib/packages/menu/toggle-trigger.ts
Normal file
72
@xen-orchestra/web-core/lib/packages/menu/toggle-trigger.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { BaseItem, Menu } from '@core/packages/menu'
|
||||
import { unrefElement, whenever } from '@vueuse/core'
|
||||
import { computed, type ComputedRef, type MaybeRefOrGetter, reactive, ref, type Ref, toValue } from 'vue'
|
||||
|
||||
export interface MenuToggleTriggerConfig {
|
||||
behavior?: MaybeRefOrGetter<'click' | 'mouseenter'>
|
||||
}
|
||||
|
||||
export interface MenuToggleTriggerProps {
|
||||
ref: (el: any) => void
|
||||
as: 'button'
|
||||
type: 'button'
|
||||
submenu: true
|
||||
onClick: () => void
|
||||
onMouseenter: () => void
|
||||
selected: boolean
|
||||
'data-menu-id': string
|
||||
}
|
||||
|
||||
export class MenuToggleTrigger extends BaseItem {
|
||||
element: Ref<HTMLElement | null> = ref(null)
|
||||
|
||||
subMenu: Menu
|
||||
|
||||
isSelfOpen = ref(false)
|
||||
|
||||
isOpen = computed(() => this.isSelfOpen.value || this.subMenu.isActive.value)
|
||||
|
||||
constructor(
|
||||
public menu: Menu,
|
||||
public config: MenuToggleTriggerConfig
|
||||
) {
|
||||
super(menu)
|
||||
this.subMenu = new Menu(menu.context)
|
||||
|
||||
whenever(
|
||||
() => !this.isActive.value && !this.subMenu.isActive.value,
|
||||
() => {
|
||||
this.isSelfOpen.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
get isActive(): ComputedRef<boolean> {
|
||||
return computed(() => super.isActive.value || this.subMenu?.isActive.value)
|
||||
}
|
||||
|
||||
get props(): MenuToggleTriggerProps {
|
||||
return reactive({
|
||||
ref: (element: HTMLElement | null | undefined) => {
|
||||
const newElement = unrefElement(element)
|
||||
|
||||
if (newElement !== this.element.value) {
|
||||
this.element.value = newElement ?? null
|
||||
}
|
||||
},
|
||||
as: 'button',
|
||||
type: 'button',
|
||||
submenu: true,
|
||||
onClick: () => (this.isSelfOpen.value = !this.isOpen.value),
|
||||
onMouseenter: () => {
|
||||
this.activate()
|
||||
|
||||
if (toValue(this.config.behavior) === 'mouseenter') {
|
||||
this.isSelfOpen.value = true
|
||||
}
|
||||
},
|
||||
selected: computed(() => this.isOpen.value),
|
||||
'data-menu-id': this.menu.context.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
43
@xen-orchestra/web-core/lib/packages/menu/toggle.ts
Normal file
43
@xen-orchestra/web-core/lib/packages/menu/toggle.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Menu,
|
||||
MenuContext,
|
||||
type MenuLike,
|
||||
type MenuStructure,
|
||||
type MenuToggleTargetConfig,
|
||||
type MenuToggleTargetProps,
|
||||
type MenuToggleTriggerConfig,
|
||||
type MenuToggleTriggerProps,
|
||||
parseConfigHolder,
|
||||
type ParseStructure,
|
||||
type WithMenu,
|
||||
} from '@core/packages/menu'
|
||||
import { computed, type ComputedRef, type MaybeRefOrGetter } from 'vue'
|
||||
|
||||
export type MenuToggleConfig<TStructure extends MenuStructure> = MenuToggleTriggerConfig &
|
||||
MenuToggleTargetConfig & {
|
||||
items?: MaybeRefOrGetter<TStructure>
|
||||
}
|
||||
|
||||
export class MenuToggleConfigHolder<TStructure extends MenuStructure> {
|
||||
constructor(public config: MenuToggleConfig<TStructure>) {}
|
||||
}
|
||||
|
||||
export type MenuToggleProps<TStructure extends MenuStructure> = WithMenu &
|
||||
ParseStructure<TStructure> & {
|
||||
$trigger: MenuToggleTriggerProps
|
||||
$target: MenuToggleTargetProps
|
||||
$isOpen: ComputedRef<boolean>
|
||||
}
|
||||
|
||||
export function toggle<const TStructure extends MenuStructure>(config: MenuToggleConfig<TStructure>) {
|
||||
return new MenuToggleConfigHolder(config)
|
||||
}
|
||||
|
||||
export function useMenuToggle<const TStructure extends MenuStructure>(
|
||||
config: MenuToggleConfig<TStructure> & { parent?: MenuLike } = {}
|
||||
) {
|
||||
const { parent, ...toggleConfig } = config
|
||||
const menu = parent ?? new Menu(new MenuContext())
|
||||
|
||||
return computed(() => parseConfigHolder(menu, toggle<TStructure>(toggleConfig)))
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/vue": "^1.1.5",
|
||||
"@fontsource/poppins": "^5.0.14",
|
||||
"@fortawesome/fontawesome-common-types": "^6.5.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.5.1",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@csstools/postcss-global-data": "^2.1.1",
|
||||
"@floating-ui/vue": "^1.1.5",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.5.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||
|
||||
31
yarn.lock
31
yarn.lock
@@ -2064,6 +2064,35 @@
|
||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2"
|
||||
integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==
|
||||
|
||||
"@floating-ui/core@^1.6.0":
|
||||
version "1.6.8"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.8.tgz#aa43561be075815879305965020f492cdb43da12"
|
||||
integrity sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==
|
||||
dependencies:
|
||||
"@floating-ui/utils" "^0.2.8"
|
||||
|
||||
"@floating-ui/dom@^1.0.0":
|
||||
version "1.6.12"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.12.tgz#6333dcb5a8ead3b2bf82f33d6bc410e95f54e556"
|
||||
integrity sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==
|
||||
dependencies:
|
||||
"@floating-ui/core" "^1.6.0"
|
||||
"@floating-ui/utils" "^0.2.8"
|
||||
|
||||
"@floating-ui/utils@^0.2.8":
|
||||
version "0.2.8"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.8.tgz#21a907684723bbbaa5f0974cf7730bd797eb8e62"
|
||||
integrity sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==
|
||||
|
||||
"@floating-ui/vue@^1.1.5":
|
||||
version "1.1.5"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/vue/-/vue-1.1.5.tgz#dcbb5d7a2f9035b0c96a9c30cdf794977d360809"
|
||||
integrity sha512-ynL1p5Z+woPVSwgMGqeDrx6HrJfGIDzFyESFkyqJKilGW1+h/8yVY29Khn0LaU6wHBRwZ13ntG6reiHWK6jyzw==
|
||||
dependencies:
|
||||
"@floating-ui/dom" "^1.0.0"
|
||||
"@floating-ui/utils" "^0.2.8"
|
||||
vue-demi ">=0.13.0"
|
||||
|
||||
"@fontsource/poppins@^5.0.14":
|
||||
version "5.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@fontsource/poppins/-/poppins-5.1.0.tgz#919205f53d99ec4751a945057ef525f06681df8a"
|
||||
@@ -19529,7 +19558,7 @@ vscode-uri@^3.0.8:
|
||||
resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.8.tgz#1770938d3e72588659a172d0fd4642780083ff9f"
|
||||
integrity sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==
|
||||
|
||||
vue-demi@>=0.14.8, vue-demi@^0.14.10:
|
||||
vue-demi@>=0.13.0, vue-demi@>=0.14.8, vue-demi@^0.14.10:
|
||||
version "0.14.10"
|
||||
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04"
|
||||
integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==
|
||||
|
||||
Reference in New Issue
Block a user