@xen-orchestra/web-core 0.55.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/lib/assets/css/base.pcss +5 -0
  2. package/lib/components/card-object-title/VtsCardObjectTitle.vue +36 -0
  3. package/lib/components/code-snippet/VtsCodeSnippet.vue +11 -5
  4. package/lib/components/drawer/VtsDrawer.vue +5 -1
  5. package/lib/components/drawer/VtsDrawerButton.vue +4 -3
  6. package/lib/components/drawer/VtsDrawerConfirmButton.vue +4 -2
  7. package/lib/components/icon/VtsIcon.vue +3 -1
  8. package/lib/components/input-wrapper/VtsInputWrapper.vue +2 -2
  9. package/lib/components/layout/VtsContentSidePanel.vue +41 -0
  10. package/lib/components/layout/VtsLayoutSidebar.vue +116 -43
  11. package/lib/components/menu/MenuItem.vue +9 -1
  12. package/lib/components/menu/MenuList.vue +4 -2
  13. package/lib/components/menu/VtsActionsMenu.vue +48 -14
  14. package/lib/components/modal/VtsActionModal.vue +44 -9
  15. package/lib/components/modal/VtsBlockedModal.vue +1 -1
  16. package/lib/components/modal/VtsDeleteModal.vue +1 -1
  17. package/lib/components/operation-card/VtsOperationCard.vue +2 -1
  18. package/lib/components/overlay/VtsOverlayButton.vue +35 -0
  19. package/lib/components/overlay/VtsOverlayCancelButton.vue +16 -0
  20. package/lib/components/overlay/VtsOverlayConfirmButton.vue +25 -0
  21. package/lib/components/overlay/VtsOverlayList.vue +82 -0
  22. package/lib/components/panel/VtsPanel.vue +80 -0
  23. package/lib/components/panel/VtsSidePanel.vue +140 -0
  24. package/lib/components/progress-bar/VtsProgressBar.vue +5 -3
  25. package/lib/components/select/VtsSelect.vue +1 -3
  26. package/lib/components/space-card/VtsSpaceCard.vue +1 -1
  27. package/lib/components/state-hero/VtsStateHero.vue +9 -17
  28. package/lib/components/table/VtsTable.vue +2 -1
  29. package/lib/components/table/cells/VtsActionCell.vue +7 -0
  30. package/lib/components/table/cells/VtsLinkCell.vue +3 -1
  31. package/lib/components/table/cells/VtsProgressBarCell.vue +1 -1
  32. package/lib/components/table/cells/VtsUserNameCell.vue +29 -0
  33. package/lib/components/task/VtsQuickTaskButton.vue +1 -1
  34. package/lib/components/task/VtsQuickTaskList.vue +1 -1
  35. package/lib/components/tree/VtsTreeItem.vue +13 -7
  36. package/lib/components/tree/VtsTreeList.vue +6 -3
  37. package/lib/components/tree/VtsTreeLoadingItem.vue +3 -3
  38. package/lib/components/ui/head-bar/UiHeadBar.vue +2 -2
  39. package/lib/components/ui/input/UiInput.vue +56 -10
  40. package/lib/components/ui/link/UiLink.vue +3 -1
  41. package/lib/components/ui/panel/UiPanel.vue +7 -24
  42. package/lib/components/ui/query-search-bar/UiQuerySearchBar.vue +1 -15
  43. package/lib/components/ui/quick-task-item/UiQuickTaskItem.vue +1 -1
  44. package/lib/components/ui/quick-task-panel/UiQuickTaskPanel.vue +1 -1
  45. package/lib/components/ui/task-item/UiTaskItem.vue +14 -6
  46. package/lib/components/ui/task-list/UiTaskList.vue +1 -1
  47. package/lib/components/ui/text-area/UiTextarea.vue +12 -14
  48. package/lib/components/ui/tree-item-label/UiTreeItemLabel.vue +1 -1
  49. package/lib/components/vif-connection-toggle-modal/VtsVifConnectionToggleModal.vue +43 -0
  50. package/lib/composables/table/multi-select.composable.md +33 -0
  51. package/lib/composables/table-state.composable.ts +1 -1
  52. package/lib/icons/action-icons.ts +14 -0
  53. package/lib/layouts/CoreLayout.vue +74 -62
  54. package/lib/locales/cs.json +56 -1
  55. package/lib/locales/de.json +3 -2
  56. package/lib/locales/en.json +85 -4
  57. package/lib/locales/es.json +11 -1
  58. package/lib/locales/fa.json +0 -1
  59. package/lib/locales/fr.json +85 -4
  60. package/lib/locales/it.json +0 -1
  61. package/lib/locales/ko.json +2 -0
  62. package/lib/locales/nl.json +2 -1
  63. package/lib/locales/pt-BR.json +0 -1
  64. package/lib/locales/pt.json +0 -1
  65. package/lib/locales/ru.json +0 -1
  66. package/lib/locales/sk.json +56 -1
  67. package/lib/locales/sv.json +3 -2
  68. package/lib/locales/uk.json +0 -1
  69. package/lib/locales/zh-Hans.json +79 -4
  70. package/lib/packages/form-validation/custom-rules/ip-addresses.rule.ts +15 -0
  71. package/lib/packages/form-validation/custom-rules/ip.regex.ts +26 -0
  72. package/lib/packages/form-validation/custom-rules/ipv4-or-cidr.rule.ts +2 -3
  73. package/lib/packages/form-validation/index.ts +2 -0
  74. package/lib/packages/hide-permanently/README.md +60 -0
  75. package/lib/packages/hide-permanently/types.ts +3 -0
  76. package/lib/packages/hide-permanently/use-hide-permanently.ts +15 -0
  77. package/lib/packages/overlay/OverlayComponent.vue +18 -0
  78. package/lib/packages/overlay/README.md +285 -0
  79. package/lib/packages/overlay/create-event-handler.ts +80 -0
  80. package/lib/packages/overlay/injection-keys.ts +3 -0
  81. package/lib/packages/overlay/is-thenable.ts +3 -0
  82. package/lib/packages/overlay/symbols.ts +5 -0
  83. package/lib/packages/overlay/types.ts +94 -0
  84. package/lib/packages/overlay/use-overlay-escape.ts +34 -0
  85. package/lib/packages/overlay/use-overlay-store.ts +33 -0
  86. package/lib/packages/overlay/use-overlay-trigger.ts +33 -0
  87. package/lib/packages/overlay/use-overlay.ts +99 -0
  88. package/lib/packages/progress/use-progress.ts +12 -2
  89. package/lib/packages/remote-resource/define-remote-resource.ts +64 -29
  90. package/lib/packages/sidebar/index.ts +2 -0
  91. package/lib/packages/sidebar/sidebar.store.ts +55 -0
  92. package/lib/packages/sidebar/types.ts +21 -0
  93. package/lib/packages/sidebar/use-sidebar-resize.ts +47 -0
  94. package/lib/packages/sidebar/use-sidebar-responsive-expand.ts +19 -0
  95. package/lib/packages/tree/types.ts +6 -0
  96. package/lib/packages/tree/use-tree.ts +38 -37
  97. package/lib/stores/panel.store.ts +56 -5
  98. package/lib/tables/column-definitions/action-column.ts +21 -9
  99. package/lib/tables/column-definitions/user-name-column.ts +10 -0
  100. package/lib/tables/column-sets/server-columns.ts +2 -2
  101. package/lib/tables/column-sets/user-columns.ts +16 -0
  102. package/lib/tables/column-sets/vm-columns.ts +2 -3
  103. package/lib/types/object.type.ts +9 -1
  104. package/lib/types/state-hero.type.ts +19 -0
  105. package/lib/types/task.type.ts +5 -12
  106. package/lib/types/vue-virtual-scroller.d.ts +8 -2
  107. package/lib/utils/injection-keys.util.ts +4 -2
  108. package/lib/utils/ip-address.utils.ts +7 -0
  109. package/lib/utils/sr.utils.ts +4 -0
  110. package/package.json +2 -1
  111. package/lib/stores/sidebar.store.ts +0 -79
@@ -0,0 +1,99 @@
1
+ import { createEventHandler } from '@core/packages/overlay/create-event-handler.ts'
2
+ import { OVERLAY_ABORT_EVENT } from '@core/packages/overlay/symbols.ts'
3
+ import type {
4
+ ExtractOverlayResponse,
5
+ ForbidExtraEvents,
6
+ MaybeOptionalArgs,
7
+ MaybeOptionalKey,
8
+ OpenEvents,
9
+ Overlay,
10
+ OverlayEventHandler,
11
+ OverlayEvents,
12
+ Prettify,
13
+ } from '@core/packages/overlay/types.ts'
14
+ import { useOverlayStore } from '@core/packages/overlay/use-overlay-store.ts'
15
+ import { reactiveComputed } from '@vueuse/core'
16
+ import { defineAsyncComponent, markRaw, onScopeDispose, reactive, type AsyncComponentLoader, type Component } from 'vue'
17
+ import type { ComponentProps } from 'vue-component-type-helpers'
18
+
19
+ export function useOverlay<TComponent extends Component, const TEvents extends OverlayEvents<TComponent>>({
20
+ component: componentLoader,
21
+ events,
22
+ }: {
23
+ component: AsyncComponentLoader<TComponent>
24
+ events: TEvents & NoInfer<ForbidExtraEvents<TComponent, TEvents>>
25
+ }) {
26
+ const overlayStore = useOverlayStore()
27
+
28
+ const key = Symbol('new-overlay')
29
+
30
+ const component = defineAsyncComponent(componentLoader)
31
+
32
+ let abortCurrent: (() => void) | undefined
33
+
34
+ onScopeDispose(() => abortCurrent?.(), true)
35
+
36
+ function open<TOpenEvents extends OpenEvents<TComponent, TEvents>>(
37
+ ...args: MaybeOptionalArgs<
38
+ Prettify<
39
+ { events?: TOpenEvents & NoInfer<ForbidExtraEvents<TComponent, TOpenEvents>> } & MaybeOptionalKey<
40
+ 'props',
41
+ ComponentProps<TComponent>
42
+ >
43
+ >
44
+ >
45
+ ) {
46
+ const { events: openEvents, props } = args[0] ?? {}
47
+
48
+ // Reopening while the previous overlay is still open replaces it
49
+ abortCurrent?.()
50
+
51
+ return new Promise<ExtractOverlayResponse<TComponent, TEvents, TOpenEvents>>(resolve => {
52
+ const definitionEvents = events as Record<string, true | OverlayEventHandler>
53
+ const perOpenEvents = (openEvents ?? {}) as Record<string, OverlayEventHandler>
54
+
55
+ // A new object per `open()` call, so that a pending async handler of a
56
+ // replaced overlay cannot interfere with the one currently displayed
57
+ const overlay = reactive<Overlay>({
58
+ key,
59
+ component: markRaw(component),
60
+ props: {},
61
+ status: 'idle',
62
+ lastTrigger: undefined,
63
+ })
64
+
65
+ function settle(response: ExtractOverlayResponse<TComponent, TEvents, TOpenEvents>) {
66
+ overlay.status = 'settled'
67
+ abortCurrent = undefined
68
+ resolve(response)
69
+ overlayStore.unregister(overlay)
70
+ }
71
+
72
+ abortCurrent = () => settle({ event: OVERLAY_ABORT_EVENT, payload: undefined })
73
+
74
+ const handleEvent = createEventHandler({
75
+ overlay,
76
+ definitionEvents,
77
+ openEvents: perOpenEvents,
78
+ settle: response => settle(response as ExtractOverlayResponse<TComponent, TEvents, TOpenEvents>),
79
+ })
80
+
81
+ // Merge all props + events. If an event is provided in both, the `events`
82
+ // handler runs first, then the `openEvents` one, if needed.
83
+ const eventProps: Record<string, OverlayEventHandler> = {}
84
+
85
+ const eventNames = new Set([...Object.keys(definitionEvents), ...Object.keys(perOpenEvents)])
86
+
87
+ eventNames.forEach(eventName => {
88
+ eventProps[eventName] = (...emitArgs: unknown[]) => handleEvent(eventName, emitArgs)
89
+ })
90
+
91
+ // `reactiveComputed` keeps the merged props live when a reactive `props` object is passed
92
+ overlay.props = reactiveComputed(() => ({ ...(props as Record<string, unknown> | undefined), ...eventProps }))
93
+
94
+ overlayStore.register(overlay)
95
+ })
96
+ }
97
+
98
+ return { open }
99
+ }
@@ -2,16 +2,26 @@ import { toComputed } from '@core/utils/to-computed.util.ts'
2
2
  import type { Progress } from './types.ts'
3
3
  import { computed, type MaybeRefOrGetter } from 'vue'
4
4
 
5
- export function useProgress(rawCurrent: MaybeRefOrGetter<number>, rawTotal: MaybeRefOrGetter<number>): Progress {
5
+ export function useProgress(
6
+ rawCurrent: MaybeRefOrGetter<number>,
7
+ rawTotal: MaybeRefOrGetter<number>,
8
+ rawNoRuler: MaybeRefOrGetter<boolean> = false
9
+ ): Progress {
6
10
  const current = toComputed(rawCurrent)
7
11
 
8
12
  const total = toComputed(rawTotal)
9
13
 
14
+ const noRuler = toComputed(rawNoRuler)
15
+
10
16
  const percentage = computed(() => (total.value === 0 ? 0 : (current.value / total.value) * 100))
11
17
 
12
18
  const percentageCap = computed(() => Math.max(100, Math.ceil(percentage.value / 100) * 100))
13
19
 
14
- const fillWidth = computed(() => `${(percentage.value / percentageCap.value) * 100}%`)
20
+ const fillWidth = computed(() => {
21
+ const max = noRuler.value ? 100 : percentageCap.value
22
+
23
+ return `${(Math.min(percentage.value, max) / max) * 100}%`
24
+ })
15
25
 
16
26
  return {
17
27
  current,
@@ -14,19 +14,19 @@ import {
14
14
  computed,
15
15
  type ComputedRef,
16
16
  type EffectScope,
17
+ effectScope,
17
18
  getCurrentScope,
19
+ type MaybeRef,
18
20
  type MaybeRefOrGetter,
19
21
  onScopeDispose,
20
22
  reactive,
21
23
  type Ref,
22
24
  ref,
23
25
  shallowRef,
24
- triggerRef,
25
26
  toRef,
26
27
  toValue,
28
+ triggerRef,
27
29
  watch,
28
- effectScope,
29
- type MaybeRef,
30
30
  } from 'vue'
31
31
 
32
32
  const DEFAULT_CACHE_EXPIRATION_MS = 10_000
@@ -41,7 +41,12 @@ export function defineRemoteResource<
41
41
  url: string | ((...args: TArgs) => string)
42
42
  initialData: () => TData
43
43
  state?: (data: Ref<NoInfer<TData>>, context: ResourceContext<TArgs>) => TState
44
- onDataReceived?: (data: Ref<NoInfer<TData>>, receivedData: any) => void
44
+ onDataReceived?: (
45
+ data: Ref<NoInfer<TData>>,
46
+ receivedData: any,
47
+ calledFrom: 'execute' | 'update',
48
+ context?: ResourceContext<TArgs>
49
+ ) => void
45
50
  cacheExpirationMs?: number | false
46
51
  pollingIntervalMs?: number | false
47
52
  stream?: boolean
@@ -50,7 +55,12 @@ export function defineRemoteResource<
50
55
  export function defineRemoteResource<TData, TState extends object, TArgs extends any[] = []>(config: {
51
56
  url: string | ((...args: TArgs) => string)
52
57
  state?: (data: Ref<TData | undefined>, context: ResourceContext<TArgs>) => TState
53
- onDataReceived?: (data: Ref<TData | undefined>, receivedData: any) => void
58
+ onDataReceived?: (
59
+ data: Ref<TData | undefined>,
60
+ receivedData: any,
61
+ calledFrom: 'execute' | 'update',
62
+ context?: ResourceContext<TArgs>
63
+ ) => void
54
64
  cacheExpirationMs?: number | false
55
65
  pollingIntervalMs?: number | false
56
66
  stream?: boolean
@@ -64,7 +74,12 @@ export function defineRemoteResource<
64
74
  url: string | ((...args: TArgs) => string)
65
75
  initialData: () => TData
66
76
  state?: (data: Ref<NoInfer<TData>>, context: ResourceContext<TArgs>) => TState
67
- onDataReceived?: (data: Ref<NoInfer<TData>>, receivedData: any) => void
77
+ onDataReceived?: (
78
+ data: Ref<NoInfer<TData>>,
79
+ receivedData: any,
80
+ calledFrom: 'execute' | 'update',
81
+ context?: ResourceContext<TArgs>
82
+ ) => void
68
83
  onDataRemoved?: (data: Ref<NoInfer<TData>>, receivedData: any) => void
69
84
  stream?: boolean
70
85
  initWatchCollection: () => {
@@ -86,7 +101,12 @@ export function defineRemoteResource<
86
101
  url: string | ((...args: TArgs) => string)
87
102
  initialData?: () => TData
88
103
  state?: (data: Ref<TData>, context: ResourceContext<TArgs>) => TState
89
- onDataReceived?: (data: Ref<NoInfer<TData>>, receivedData: any) => void
104
+ onDataReceived?: (
105
+ data: Ref<NoInfer<TData>>,
106
+ receivedData: any,
107
+ calledFrom: 'execute' | 'update',
108
+ context?: ResourceContext<TArgs>
109
+ ) => void
90
110
  onDataRemoved?: (data: Ref<NoInfer<TData>>, receivedData: any) => void
91
111
  cacheExpirationMs?: number | false
92
112
  pollingIntervalMs?: number | false
@@ -161,33 +181,38 @@ export function defineRemoteResource<
161
181
 
162
182
  const onDataReceived =
163
183
  config.onDataReceived ??
164
- ((data: Ref<TData>, receivedData: any, context?: ResourceContext<TArgs>) => {
184
+ ((data: Ref<TData>, receivedData: any, calledFrom: 'execute' | 'update', context?: ResourceContext<TArgs>) => {
165
185
  // allow to ignore some update (like for sub collection. E.g. vms/:id/vdis)
166
186
  if (watchCollection?.predicate?.(receivedData, context) === false) {
167
187
  return
168
188
  }
169
189
 
170
- if (data.value === undefined || (Array.isArray(data.value) && Array.isArray(receivedData))) {
171
- data.value = receivedData
172
-
173
- if (watchCollection !== undefined && Array.isArray(data.value)) {
174
- handleBuffer(data as Ref<TData[]>)
175
- isBufferEventsProcessed = true
190
+ if (!Array.isArray(data.value)) {
191
+ if (data.value === undefined) {
192
+ data.value = receivedData
193
+ } else {
194
+ merge(data.value, receivedData)
176
195
  }
196
+
177
197
  return
178
198
  }
179
199
 
180
- if (Array.isArray(data.value)) {
181
- if (!isBufferEventsProcessed) {
182
- bufferedEvents.push(['update', receivedData])
183
- } else {
184
- removeData(data.value, receivedData)
185
- data.value.push(receivedData)
186
- }
200
+ const store = data.value as TData[]
201
+
202
+ if (Array.isArray(receivedData)) {
203
+ data.value = receivedData as TData
204
+
187
205
  return
188
206
  }
189
207
 
190
- merge(data.value, receivedData)
208
+ if (calledFrom === 'execute') {
209
+ store.push(receivedData)
210
+ } else if (!isBufferEventsProcessed) {
211
+ bufferedEvents.push(['update', receivedData])
212
+ } else {
213
+ removeData(store, receivedData)
214
+ store.push(receivedData)
215
+ }
191
216
  })
192
217
 
193
218
  const onDataRemoved =
@@ -292,13 +317,23 @@ export function defineRemoteResource<
292
317
  }
293
318
 
294
319
  if (config.stream) {
320
+ if (Array.isArray(data.value)) {
321
+ data.value = [] as TData
322
+ }
323
+
295
324
  for await (const event of readNDJSONStream(response.body)) {
296
- onDataReceived(data, event)
297
- flushData()
325
+ onDataReceived(data, event, 'execute', context)
326
+ void flushData()
327
+ }
328
+
329
+ if (watchCollection !== undefined && Array.isArray(data.value)) {
330
+ handleBuffer(data as Ref<TData[]>)
331
+ isBufferEventsProcessed = true
332
+ void flushData()
298
333
  }
299
334
  } else {
300
- onDataReceived(data, await response.json())
301
- flushData()
335
+ onDataReceived(data, await response.json(), 'execute', context)
336
+ void flushData()
302
337
  }
303
338
 
304
339
  isReady.value = true
@@ -324,12 +359,12 @@ export function defineRemoteResource<
324
359
  handlePost,
325
360
  resource,
326
361
  onDataReceived: receivedData => {
327
- onDataReceived(data, receivedData, context)
328
- flushData()
362
+ onDataReceived(data, receivedData, 'update', context)
363
+ void flushData()
329
364
  },
330
365
  onDataRemoved: receivedData => {
331
366
  onDataRemoved(data, receivedData, context)
332
- flushData()
367
+ void flushData()
333
368
  },
334
369
  })
335
370
  await execute()
@@ -0,0 +1,2 @@
1
+ export { useLeftSidebarStore, useRightSidebarStore, useSidebar } from '@core/packages/sidebar/sidebar.store.ts'
2
+ export { SIDES, SIDEBAR_DEFAULTS, type SidebarSide, type SidebarState } from '@core/packages/sidebar/types.ts'
@@ -0,0 +1,55 @@
1
+ import { SIDEBAR_DEFAULTS, type SidebarSide } from '@core/packages/sidebar/types.ts'
2
+ import { useSidebarResize } from '@core/packages/sidebar/use-sidebar-resize.ts'
3
+ import { useSidebarResponsiveExpand } from '@core/packages/sidebar/use-sidebar-responsive-expand.ts'
4
+ import { useUiStore } from '@core/stores/ui.store.ts'
5
+ import { useLocalStorage, useToggle } from '@vueuse/core'
6
+ import { defineStore } from 'pinia'
7
+ import { computed } from 'vue'
8
+
9
+ function createSidebar(side: SidebarSide) {
10
+ const uiStore = useUiStore()
11
+ const prefix = `sidebar.${side}`
12
+ const defaults = SIDEBAR_DEFAULTS[side]
13
+
14
+ const isExpanded = useLocalStorage(`${prefix}.expanded`, defaults.isExpanded)
15
+ const isLocked = useLocalStorage(`${prefix}.locked`, defaults.isLocked)
16
+ const width = useLocalStorage(`${prefix}.width`, defaults.width)
17
+
18
+ const toggleExpand = useToggle(isExpanded)
19
+ const toggleLock = useToggle(isLocked)
20
+ const { startResize, isResizing } = useSidebarResize(side, width)
21
+
22
+ useSidebarResponsiveExpand(isExpanded)
23
+
24
+ const cssWidth = computed(() => (uiStore.isSmall ? '100%' : `${width.value}px`))
25
+
26
+ const cssLockedOffset = computed(() => (isExpanded.value ? '0' : `-${cssWidth.value}`))
27
+
28
+ const cssUnlockedOffset = computed(() => {
29
+ if (isExpanded.value) {
30
+ return '0'
31
+ }
32
+
33
+ return side === 'left' ? `-${cssWidth.value}` : cssWidth.value
34
+ })
35
+
36
+ return {
37
+ isExpanded,
38
+ isLocked,
39
+ width,
40
+ toggleExpand,
41
+ toggleLock,
42
+ startResize,
43
+ isResizing,
44
+ cssWidth,
45
+ cssLockedOffset,
46
+ cssUnlockedOffset,
47
+ }
48
+ }
49
+
50
+ export const useLeftSidebarStore = defineStore('sidebar-left', () => createSidebar('left'))
51
+ export const useRightSidebarStore = defineStore('sidebar-right', () => createSidebar('right'))
52
+
53
+ export function useSidebar(side: SidebarSide = 'left') {
54
+ return side === 'left' ? useLeftSidebarStore() : useRightSidebarStore()
55
+ }
@@ -0,0 +1,21 @@
1
+ export const SIDES = ['left', 'right'] as const
2
+
3
+ export type SidebarSide = (typeof SIDES)[number]
4
+
5
+ export type SidebarState = {
6
+ isExpanded: boolean
7
+ isLocked: boolean
8
+ width: number
9
+ toggleExpand: (value?: boolean) => boolean
10
+ toggleLock: (value?: boolean) => boolean
11
+ startResize: (event: MouseEvent) => void
12
+ isResizing: boolean
13
+ cssWidth: string
14
+ cssLockedOffset: string | number
15
+ cssUnlockedOffset: string | number
16
+ }
17
+
18
+ export const SIDEBAR_DEFAULTS = {
19
+ left: { isExpanded: true, isLocked: true, width: 350 },
20
+ right: { isExpanded: false, isLocked: false, width: 350 },
21
+ } satisfies Record<SidebarSide, Pick<SidebarState, 'isExpanded' | 'isLocked' | 'width'>>
@@ -0,0 +1,47 @@
1
+ import type { SidebarSide } from '@core/packages/sidebar/types.ts'
2
+ import { ifElse } from '@core/utils/if-else.utils.ts'
3
+ import { useRafFn, useStyleTag } from '@vueuse/core'
4
+ import { ref, type Ref } from 'vue'
5
+
6
+ export function useSidebarResize(side: SidebarSide, width: Ref<number>) {
7
+ const isResizing = ref(false)
8
+
9
+ let initialX: number
10
+ let initialWidth: number
11
+ let clientX: number
12
+
13
+ function startResize(event: MouseEvent) {
14
+ clientX = event.clientX
15
+ initialX = clientX
16
+ initialWidth = width.value
17
+ isResizing.value = true
18
+ document.addEventListener('mousemove', handleMouseMove)
19
+ document.addEventListener('mouseup', handleMouseUp)
20
+ }
21
+
22
+ function handleMouseMove(event: MouseEvent) {
23
+ clientX = event.clientX
24
+ }
25
+
26
+ function handleMouseUp() {
27
+ isResizing.value = false
28
+ document.removeEventListener('mousemove', handleMouseMove)
29
+ document.removeEventListener('mouseup', handleMouseUp)
30
+ }
31
+
32
+ const { load, unload } = useStyleTag('body { cursor: col-resize !important; }', { immediate: false })
33
+
34
+ const resizeDelta = () => (side === 'left' ? initialWidth + clientX - initialX : initialWidth + initialX - clientX)
35
+
36
+ const { pause, resume } = useRafFn(
37
+ () => (width.value = Math.min(500, window.innerWidth * 0.3, Math.max(200, resizeDelta()))),
38
+ { immediate: false }
39
+ )
40
+
41
+ ifElse(isResizing, [load, resume], [pause, unload])
42
+
43
+ return {
44
+ startResize,
45
+ isResizing,
46
+ }
47
+ }
@@ -0,0 +1,19 @@
1
+ import { useUiStore } from '@core/stores/ui.store.ts'
2
+ import { watch, type Ref } from 'vue'
3
+
4
+ export function useSidebarResponsiveExpand(isExpanded: Ref<boolean>) {
5
+ const uiStore = useUiStore()
6
+
7
+ let desktopExpanded = isExpanded.value
8
+
9
+ watch(
10
+ () => uiStore.isSmall,
11
+ (isSmall, wasSmall) => {
12
+ if (isSmall && !wasSmall) {
13
+ desktopExpanded = isExpanded.value
14
+ } else if (!isSmall && wasSmall) {
15
+ isExpanded.value = desktopExpanded
16
+ }
17
+ }
18
+ )
19
+ }
@@ -24,6 +24,12 @@ export type TreeNode<
24
24
  TDiscriminator = any,
25
25
  > = Leaf<TData, TDiscriminator> | Branch<TData, TChildNode, TDiscriminator>
26
26
 
27
+ export type FlatTreeNode<TNode extends TreeNode = TreeNode> = {
28
+ id: TreeNodeId
29
+ node: TNode
30
+ depth: number
31
+ }
32
+
27
33
  export type BaseTreeNodeOptions<TData extends object, TDiscriminator> = {
28
34
  discriminator?: TDiscriminator
29
35
  predicate?: (node: TreeNodeBase<TData, TDiscriminator>) => boolean | undefined
@@ -1,14 +1,14 @@
1
1
  import { buildNodes } from '@core/packages/tree/build-nodes'
2
2
  import type {
3
3
  DefinitionToTreeNode,
4
+ FlatTreeNode,
4
5
  TreeContext,
5
6
  TreeNode,
6
7
  TreeNodeDefinition,
7
8
  TreeNodeId,
8
9
  UseTreeOptions,
9
10
  } from '@core/packages/tree/types'
10
- import { useTimeoutFn } from '@vueuse/core'
11
- import { computed, type MaybeRefOrGetter, nextTick, reactive, ref, toValue } from 'vue'
11
+ import { computed, type MaybeRefOrGetter, reactive, ref, toValue } from 'vue'
12
12
 
13
13
  export function useTree<
14
14
  TDefinition extends TreeNodeDefinition,
@@ -53,6 +53,32 @@ export function useTree<
53
53
 
54
54
  const visibleNodes = computed(() => nodes.value.filter(node => !node.isExcluded))
55
55
 
56
+ const flatNodes = computed<FlatTreeNode[]>(() => {
57
+ const result: FlatTreeNode[] = []
58
+
59
+ const flatten = (list: TreeNode[]) => {
60
+ for (const node of list) {
61
+ result.push({ id: node.id, node, depth: node.depth })
62
+ if (node.isBranch && !node.isCollapsed) {
63
+ flatten(node.children)
64
+ }
65
+ }
66
+ }
67
+
68
+ flatten(visibleNodes.value)
69
+
70
+ return result
71
+ })
72
+
73
+ const flatNodeIndexById = computed(() => {
74
+ const index = new Map<TreeNodeId, number>()
75
+ const list = flatNodes.value
76
+ for (let i = 0; i < list.length; i++) {
77
+ index.set(list[i].id, i)
78
+ }
79
+ return index
80
+ })
81
+
56
82
  const getNode = (id: TreeNodeId | undefined) => (id !== undefined ? nodesMap.value.get(id) : undefined)
57
83
  const getNodes = (ids: TreeNodeId[]) => ids.map(getNode).filter(node => node !== undefined) as TreeNode[]
58
84
 
@@ -80,46 +106,19 @@ export function useTree<
80
106
  return undefined
81
107
  }
82
108
 
83
- const scrollToNodeElement = async (id: string | number, options?: ScrollIntoViewOptions) => {
84
- const node = findNodeByObjectId(nodes.value, id)
109
+ const expandToNode = (objectId: string | number): TreeNode | undefined => {
110
+ const node = findNodeByObjectId(nodes.value, objectId)
85
111
  if (!node) {
86
- if (id) {
87
- useTimeoutFn(async () => {
88
- await nextTick()
89
- await scrollToNodeElement(id, options)
90
- }, 200)
91
- }
92
- return
112
+ return undefined
93
113
  }
94
114
 
95
- getHierarchy(node).forEach(node => {
96
- if (node.isBranch) {
97
- node.toggleCollapse(false)
115
+ getHierarchy(node).forEach(ancestor => {
116
+ if (ancestor.isBranch) {
117
+ ancestor.toggleCollapse(false)
98
118
  }
99
119
  })
100
120
 
101
- const nodeElement = document.querySelector<HTMLElement>(`[data-node-id="${id}"]`)
102
-
103
- if (!nodeElement) {
104
- useTimeoutFn(async () => {
105
- await nextTick()
106
- await scrollToNodeElement(id, options)
107
- }, 200)
108
- return
109
- }
110
-
111
- const hasChildren = node.isBranch && node.hasChildren
112
- const cfg: ScrollIntoViewOptions = options ?? { block: hasChildren ? 'start' : 'center', behavior: 'smooth' }
113
-
114
- if (hasChildren) {
115
- nodeElement.style.scrollMarginTop = '0.8rem'
116
-
117
- useTimeoutFn(async () => {
118
- nodeElement.style.scrollMarginTop = ''
119
- }, 1000)
120
- }
121
-
122
- nodeElement.scrollIntoView(cfg)
121
+ return node
123
122
  }
124
123
 
125
124
  const selectedNodes = computed(() => getNodes(Array.from(selectedIds.value.values())))
@@ -140,6 +139,8 @@ export function useTree<
140
139
 
141
140
  return {
142
141
  nodes: visibleNodes,
142
+ flatNodes,
143
+ flatNodeIndexById,
143
144
  activeId,
144
145
  activeNode,
145
146
  selectedIds,
@@ -148,6 +149,6 @@ export function useTree<
148
149
  collapsedIds,
149
150
  expandedIds,
150
151
  options,
151
- scrollToNodeElement,
152
+ expandToNode,
152
153
  }
153
154
  }
@@ -1,12 +1,63 @@
1
1
  import { useUiStore } from '@core/stores/ui.store'
2
+ import { useLocalStorage } from '@vueuse/core'
2
3
  import { defineStore } from 'pinia'
3
- import { computed, ref } from 'vue'
4
+ import { computed, watch } from 'vue'
4
5
 
5
6
  export const usePanelStore = defineStore('panel', () => {
6
7
  const uiStore = useUiStore()
7
- const isExpanded = ref(false)
8
- const open = () => (isExpanded.value = true)
9
- const close = () => (isExpanded.value = false)
8
+ const isExpanded = useLocalStorage('panel.expanded', true)
9
+ const isLocked = useLocalStorage('panel.locked', true)
10
10
 
11
- return { open, close, isExpanded: computed(() => !uiStore.isSmall || isExpanded.value) }
11
+ const cssHorizontalOffset = computed(() => (isExpanded.value ? 0 : '100%'))
12
+ /* Panel closes automatically when it has no content (true on small UI or when not locked) */
13
+ const syncsOpenStateWithSelection = computed(() => uiStore.isSmall || !isLocked.value)
14
+
15
+ function expand() {
16
+ isExpanded.value = true
17
+ }
18
+
19
+ function collapse() {
20
+ isExpanded.value = false
21
+ }
22
+
23
+ function syncWithSelection(hasSelection: boolean) {
24
+ if (!syncsOpenStateWithSelection.value) {
25
+ return
26
+ }
27
+ if (!hasSelection && isExpanded.value) {
28
+ collapse()
29
+ } else if (hasSelection && !isExpanded.value) {
30
+ expand()
31
+ }
32
+ }
33
+
34
+ function toggleLock() {
35
+ const next = !isLocked.value
36
+ isLocked.value = next
37
+ if (next && !uiStore.isSmall && !isExpanded.value) {
38
+ expand()
39
+ }
40
+ }
41
+
42
+ watch(
43
+ () => uiStore.isSmall,
44
+ isSmall => {
45
+ /* Expand when coming back from small to large UI, if locked and not expanded */
46
+ if (!isSmall && isLocked.value && !isExpanded.value) {
47
+ expand()
48
+ }
49
+ },
50
+ { immediate: true }
51
+ )
52
+
53
+ return {
54
+ isExpanded,
55
+ isLocked,
56
+ syncsOpenStateWithSelection,
57
+ expand,
58
+ collapse,
59
+ toggleLock,
60
+ syncWithSelection,
61
+ cssHorizontalOffset,
62
+ }
12
63
  })