feat(xo6/core): add ui tree-view components in web-core

This commit is contained in:
Olivier Floch
2024-01-26 09:15:03 +01:00
parent eedaca0195
commit 64bba27923
16 changed files with 536 additions and 1 deletions

View File

@@ -0,0 +1,43 @@
<template>
<UiIcon :class="className" :icon="icon" class="power-state-icon" />
</template>
<script lang="ts" setup>
import UiIcon from '@core/components/ui/icon/UiIcon.vue'
import { VM_POWER_STATE } from '@core/utils/xen-api/xen-api.enums'
import { faMoon, faPause, faPlay, faQuestion, faStop } from '@fortawesome/free-solid-svg-icons'
import { computed } from 'vue'
const props = defineProps<{
state: VM_POWER_STATE
}>()
const icons = {
[VM_POWER_STATE.RUNNING]: faPlay,
[VM_POWER_STATE.PAUSED]: faPause,
[VM_POWER_STATE.SUSPENDED]: faMoon,
[VM_POWER_STATE.HALTED]: faStop,
}
const icon = computed(() => icons[props.state] ?? faQuestion)
const className = computed(() => `state-${props.state.toLocaleLowerCase()}`)
</script>
<style lang="postcss" scoped>
.power-state-icon {
color: var(--color-purple-d60);
&.state-running {
color: var(--color-green-base);
}
&.state-paused {
color: var(--color-purple-l40);
}
&.state-halted {
color: var(--color-red-base);
}
}
</style>

View File

@@ -0,0 +1,47 @@
<!-- Adapted from https://www.benmvp.com/blog/how-to-create-circle-svg-gradient-loading-spinner/ -->
<template>
<svg class="ui-spinner" fill="none" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient :id="secondHalfId">
<stop offset="0%" stop-color="currentColor" stop-opacity="0" />
<stop offset="100%" stop-color="currentColor" stop-opacity="0.5" />
</linearGradient>
<linearGradient :id="firstHalfId">
<stop offset="0%" stop-color="currentColor" stop-opacity="1" />
<stop offset="100%" stop-color="currentColor" stop-opacity="0.5" />
</linearGradient>
</defs>
<g stroke-width="40">
<path :stroke="`url(#${secondHalfId})`" d="M 30 200 A 170 170 180 0 1 370 200" />
<path :stroke="`url(#${firstHalfId})`" d="M 370 200 A 170 170 0 0 1 30 200" />
<path d="M 30 200 A 170 170 180 0 1 30 200" stroke="currentColor" stroke-linecap="round" />
</g>
</svg>
</template>
<script lang="ts" setup>
import { uniqueId } from '@core/utils/unique-id.util'
const firstHalfId = uniqueId('spinner-first-half-')
const secondHalfId = uniqueId('spinner-second-half-')
</script>
<style lang="postcss" scoped>
.ui-spinner {
width: 1.2em;
height: 1.2em;
animation: rotate 1s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,24 @@
<template>
<li class="ui-tree-item">
<slot />
<slot v-if="isExpanded" name="sublist" />
</li>
</template>
<script lang="ts" setup>
import { IK_LIST_ITEM_EXPANDED, IK_LIST_ITEM_HAS_CHILDREN, IK_LIST_ITEM_TOGGLE } from '@core/utils/injection-keys.util'
import { useToggle } from '@vueuse/core'
import { computed, provide } from 'vue'
const slots = defineSlots<{
default: () => void
sublist: () => void
}>()
const [isExpanded, toggle] = useToggle(true)
const hasChildren = computed(() => slots.sublist !== undefined)
provide(IK_LIST_ITEM_HAS_CHILDREN, hasChildren)
provide(IK_LIST_ITEM_TOGGLE, toggle)
provide(IK_LIST_ITEM_EXPANDED, isExpanded)
</script>

View File

@@ -0,0 +1,15 @@
<template>
<li class="text-error">
<slot />
</li>
</template>
<style lang="postcss" scoped>
.text-error {
padding-left: 3rem;
font-weight: 700;
font-size: 16px;
line-height: 150%;
color: var(--color-red-base);
}
</style>

View File

@@ -0,0 +1,128 @@
<template>
<RouterLink v-slot="{ isExactActive, href, navigate }" :to="route" custom>
<div
:class="isExactActive ? 'exact-active' : $props.active ? 'active' : undefined"
:style="{ paddingLeft: `${depth * 20}px` }"
class="ui-tree-item-label"
v-bind="$attrs"
>
<UiIcon v-if="hasToggle" :icon="isExpanded ? faAngleDown : faAngleRight" @click="toggle()" />
<a v-tooltip="hasTooltip" :href="href" class="link" @click="navigate">
<slot name="icon">
<UiIcon :icon="icon" class="icon" />
</slot>
<div ref="textElement" class="text">
<slot />
</div>
</a>
<div class="actions">
<slot name="actions" />
</div>
</div>
</RouterLink>
</template>
<script lang="ts" setup>
import UiIcon from '@core/components/ui/icon/UiIcon.vue'
import { vTooltip } from '@core/directives/tooltip.directive'
import { hasEllipsis } from '@core/utils/has-ellipsis.util'
import { IK_LIST_ITEM_EXPANDED, IK_LIST_ITEM_HAS_CHILDREN, IK_LIST_ITEM_TOGGLE } from '@core/utils/injection-keys.util'
import type { IconDefinition } from '@fortawesome/fontawesome-common-types'
import { faAngleDown, faAngleRight } from '@fortawesome/free-solid-svg-icons'
import { computed, inject, ref } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
defineProps<{
icon?: IconDefinition
route: RouteLocationRaw
active?: boolean
}>()
const textElement = ref<HTMLElement>()
const hasTooltip = computed(() => hasEllipsis(textElement.value))
const hasToggle = inject(
IK_LIST_ITEM_HAS_CHILDREN,
computed(() => false)
)
const toggle = inject(IK_LIST_ITEM_TOGGLE, () => undefined)
const isExpanded = inject(IK_LIST_ITEM_EXPANDED, ref(true))
const depth = inject('depth', 0)
</script>
<style lang="postcss" scoped>
.ui-tree-item-label {
display: flex;
align-items: center;
color: var(--color-grey-100);
border-radius: 0.8rem;
background-color: var(--background-color-primary);
column-gap: 0.4rem;
padding: 0 0.8rem;
&:hover {
color: var(--color-grey-100);
background-color: var(--background-color-purple-20);
}
&:active {
background-color: var(--background-color-purple-30);
}
&.exact-active {
background-color: var(--background-color-purple-10);
&:hover {
background-color: var(--background-color-purple-20);
}
> .ui-icon {
color: var(--color-purple-base);
}
&:active {
background-color: var(--background-color-purple-30);
}
}
> .ui-icon {
cursor: pointer;
}
}
.link {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
padding: 0.8rem 0;
text-decoration: none;
color: inherit;
gap: 1.2rem;
font-weight: 500;
font-size: 2rem;
&:hover,
.icon {
color: var(--color-grey-100);
}
}
.text {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 1.4rem;
padding-inline-end: 0.4rem;
}
.actions {
display: flex;
align-items: center;
flex-shrink: 0;
cursor: pointer;
gap: 0.8rem;
}
</style>

View File

@@ -0,0 +1,12 @@
<template>
<ul class="ui-tree-list">
<slot />
</ul>
</template>
<script lang="ts" setup>
import { inject, provide } from 'vue'
const depth = inject('depth', 0)
provide('depth', depth + 1)
</script>

View File

@@ -0,0 +1,16 @@
<template>
<UiSpinner v-if="busy" class="ui-icon" />
<FontAwesomeIcon v-else-if="icon !== undefined" :fixed-width="fixedWidth" :icon="icon" class="ui-icon" />
</template>
<script lang="ts" setup>
import UiSpinner from '@core/components/ui/UiSpinner.vue'
import type { IconDefinition } from '@fortawesome/fontawesome-common-types'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
defineProps<{
busy?: boolean
icon?: IconDefinition
fixedWidth?: boolean
}>()
</script>

View File

@@ -0,0 +1,29 @@
<template>
<FontAwesomeLayers>
<UiIcon :icon="faDisplay" />
<PowerStateIcon :state="state" />
</FontAwesomeLayers>
</template>
<script lang="ts" setup>
import PowerStateIcon from '@core/components/PowerStateIcon.vue'
import UiIcon from '@core/components/ui/icon/UiIcon.vue'
import type { VM_POWER_STATE } from '@core/utils/xen-api/xen-api.enums'
import { faDisplay } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
defineProps<{
state: VM_POWER_STATE
}>()
</script>
<style lang="postcss" scoped>
.fa-layers {
flex-shrink: 0;
}
.power-state-icon {
font-size: 0.7em;
transform: translate(80%, 70%);
}
</style>

View File

@@ -0,0 +1,65 @@
# Tooltip Directive
By default, the tooltip will appear centered above the target element.
## Directive argument
The directive argument can be either:
- The tooltip content
- An object containing the tooltip content and/or placement: `{ content: "...", placement: "..." }` (both optional)
## Tooltip content
The tooltip content can be either:
- `false` or an empty-string to disable the tooltip
- `true` or `undefined` to enable the tooltip and extract its content from the element's innerText.
- Non-empty string to enable the tooltip and use the string as content.
## Tooltip placement
Tooltip can be placed on the following positions:
- `top`
- `top-start`
- `top-end`
- `bottom`
- `bottom-start`
- `bottom-end`
- `left`
- `left-start`
- `left-end`
- `right`
- `right-start`
- `right-end`
## Usage
```vue
<template>
<!-- Boolean / Undefined -->
<span v-tooltip="true">This content will be ellipsized by CSS but displayed entirely in the tooltip</span>
<span v-tooltip>This content will be ellipsized by CSS but displayed entirely in the tooltip</span>
<!-- String -->
<span v-tooltip="'Tooltip content'">Item</span>
<!-- Object -->
<span v-tooltip="{ content: 'Foobar', placement: 'left-end' }">Item</span>
<!-- Dynamic -->
<span v-tooltip="myTooltip">Item</span>
<!-- Conditional -->
<span v-tooltip="isTooltipEnabled && 'Foobar'">Item</span>
</template>
<script setup>
import { ref } from 'vue'
import { vTooltip } from '@/directives/tooltip.directive'
const myTooltip = ref('Content') // or ref({ content: "Content", placement: "left-end" })
const isTooltipEnabled = ref(true)
</script>
```

View File

@@ -0,0 +1,43 @@
import type { TooltipEvents, TooltipOptions } from '@core/stores/tooltip.store'
import { useTooltipStore } from '@core/stores/tooltip.store'
import { isObject } from 'lodash-es'
import type { Options } from 'placement.js'
import type { Directive } from 'vue'
type TooltipDirectiveContent = undefined | boolean | string
type TooltipDirectiveOptions =
| TooltipDirectiveContent
| {
content?: TooltipDirectiveContent
placement?: Options['placement']
}
const parseOptions = (options: TooltipDirectiveOptions, target: HTMLElement): TooltipOptions => {
const { placement, content } = isObject(options) ? options : { placement: undefined, content: options }
return {
placement,
content: content === true || content === undefined ? target.innerText.trim() : content,
}
}
export const vTooltip: Directive<HTMLElement, TooltipDirectiveOptions> = {
mounted(target, binding) {
const store = useTooltipStore()
const events: TooltipEvents = binding.modifiers.focus
? { on: 'focusin', off: 'focusout' }
: { on: 'mouseenter', off: 'mouseleave' }
store.register(target, parseOptions(binding.value, target), events)
},
updated(target, binding) {
const store = useTooltipStore()
store.updateOptions(target, parseOptions(binding.value, target))
},
beforeUnmount(target) {
const store = useTooltipStore()
store.unregister(target)
},
}

View File

@@ -0,0 +1,71 @@
import { useEventListener, type WindowEventName } from '@vueuse/core'
import { uniqueId } from '@core/utils/unique-id.util'
import { defineStore } from 'pinia'
import type { Options } from 'placement.js'
import { computed, type EffectScope, effectScope, ref } from 'vue'
export type TooltipOptions = {
content: string | false
placement: Options['placement']
}
export type TooltipEvents = { on: WindowEventName; off: WindowEventName }
export const useTooltipStore = defineStore('tooltip', () => {
const targetsScopes = new WeakMap<HTMLElement, EffectScope>()
const targets = ref(new Set<HTMLElement>())
const targetsOptions = ref(new Map<HTMLElement, TooltipOptions>())
const targetsIds = ref(new Map<HTMLElement, string>())
const register = (target: HTMLElement, options: TooltipOptions, events: TooltipEvents) => {
const scope = effectScope()
targetsScopes.set(target, scope)
targetsOptions.value.set(target, options)
targetsIds.value.set(target, uniqueId('tooltip-'))
scope.run(() => {
useEventListener(target, events.on, () => {
targets.value.add(target)
scope.run(() => {
useEventListener(
target,
events.off,
() => {
targets.value.delete(target)
},
{ once: true }
)
})
})
})
}
const updateOptions = (target: HTMLElement, options: TooltipOptions) => {
targetsOptions.value.set(target, options)
}
const unregister = (target: HTMLElement) => {
targets.value.delete(target)
targetsOptions.value.delete(target)
targetsScopes.get(target)?.stop()
targetsScopes.delete(target)
targetsIds.value.delete(target)
}
return {
register,
unregister,
updateOptions,
tooltips: computed(() => {
return Array.from(targets.value.values()).map(target => {
return {
target,
options: targetsOptions.value.get(target)!,
key: targetsIds.value.get(target)!,
}
})
}),
}
})

View File

@@ -0,0 +1,11 @@
export const hasEllipsis = (target: Element | undefined | null, { vertical = false }: { vertical?: boolean } = {}) => {
if (target == null) {
return false
}
if (vertical) {
return target.clientHeight < target.scrollHeight
}
return target.clientWidth < target.scrollWidth
}

View File

@@ -0,0 +1,7 @@
import type { ComputedRef, InjectionKey, Ref } from 'vue'
export const IK_LIST_ITEM_HAS_CHILDREN = Symbol('IK_LIST_ITEM_HAS_CHILDREN') as InjectionKey<ComputedRef<boolean>>
export const IK_LIST_ITEM_TOGGLE = Symbol('IK_LIST_ITEM_TOGGLE') as InjectionKey<(force?: boolean) => void>
export const IK_LIST_ITEM_EXPANDED = Symbol('IK_LIST_ITEM_EXPANDED') as InjectionKey<Ref<boolean>>

View File

@@ -0,0 +1,8 @@
const uniqueIds = new Map<string | undefined, number>()
export const uniqueId = (prefix?: string) => {
const id = uniqueIds.get(prefix) || 0
uniqueIds.set(prefix, id + 1)
return prefix !== undefined ? `${prefix}-${id}` : `${id}`
}

View File

@@ -0,0 +1,6 @@
export enum VM_POWER_STATE {
HALTED = 'Halted',
PAUSED = 'Paused',
RUNNING = 'Running',
SUSPENDED = 'Suspended',
}

View File

@@ -10,8 +10,18 @@
}
},
"devDependencies": {
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-regular-svg-icons": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.5",
"@types/lodash-es": "^4.17.12",
"@vue/tsconfig": "^0.5.1",
"@vueuse/core": "^10.7.1",
"lodash-es": "^4.17.21",
"pinia": "^2.1.7",
"placement.js": "^1.0.0-beta.5",
"vue": "^3.4.13",
"@vue/tsconfig": "^0.5.1"
"vue-router": "^4.2.5"
},
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/web-core",
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",