@xen-orchestra/web-core 0.56.0 → 0.58.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 (80) hide show
  1. package/lib/assets/css/base.pcss +5 -0
  2. package/lib/components/copy-all-menu-item/VtsCopyAllMenuItem.vue +48 -0
  3. package/lib/components/copy-button/VtsCopyButton.vue +2 -2
  4. package/lib/components/input-wrapper/VtsInputWrapper.vue +2 -2
  5. package/lib/components/layout/VtsLayoutSidebar.vue +116 -43
  6. package/lib/components/menu/MenuItem.vue +9 -1
  7. package/lib/components/menu/MenuList.vue +4 -2
  8. package/lib/components/menu/VtsActionsMenu.vue +24 -24
  9. package/lib/components/modal/VtsActionModal.vue +44 -9
  10. package/lib/components/modal/VtsBlockedModal.vue +1 -1
  11. package/lib/components/modal/VtsDeleteModal.vue +1 -1
  12. package/lib/components/overlay/VtsOverlayButton.vue +35 -0
  13. package/lib/components/overlay/VtsOverlayCancelButton.vue +16 -0
  14. package/lib/components/overlay/VtsOverlayConfirmButton.vue +25 -0
  15. package/lib/components/overlay/VtsOverlayList.vue +82 -0
  16. package/lib/components/progress-bar/VtsProgressBar.vue +5 -3
  17. package/lib/components/select/VtsSelect.vue +0 -3
  18. package/lib/components/space-card/VtsSpaceCard.vue +1 -1
  19. package/lib/components/table/cells/VtsProgressBarCell.vue +1 -1
  20. package/lib/components/table/cells/VtsUserNameCell.vue +29 -0
  21. package/lib/components/task/VtsQuickTaskButton.vue +1 -1
  22. package/lib/components/task/VtsQuickTaskList.vue +1 -1
  23. package/lib/components/tree/VtsTreeItem.vue +4 -4
  24. package/lib/components/ui/head-bar/UiHeadBar.vue +1 -1
  25. package/lib/components/ui/input/UiInput.vue +53 -9
  26. package/lib/components/ui/query-search-bar/UiQuerySearchBar.vue +1 -15
  27. package/lib/components/ui/quick-task-item/UiQuickTaskItem.vue +1 -1
  28. package/lib/components/ui/quick-task-panel/UiQuickTaskPanel.vue +1 -1
  29. package/lib/components/ui/task-item/UiTaskItem.vue +14 -6
  30. package/lib/components/ui/task-list/UiTaskList.vue +1 -1
  31. package/lib/components/ui/text-area/UiTextarea.vue +12 -14
  32. package/lib/components/ui/tree-item-label/UiTreeItemLabel.vue +34 -14
  33. package/lib/components/vif-connection-toggle-modal/VtsVifConnectionToggleModal.vue +43 -0
  34. package/lib/composables/copy.composable.ts +6 -0
  35. package/lib/composables/table/multi-select.composable.md +33 -0
  36. package/lib/layouts/CoreLayout.vue +74 -62
  37. package/lib/locales/cs.json +50 -2
  38. package/lib/locales/de.json +3 -0
  39. package/lib/locales/en.json +63 -2
  40. package/lib/locales/fr.json +63 -2
  41. package/lib/locales/ko.json +2 -0
  42. package/lib/locales/nl.json +132 -1
  43. package/lib/locales/pt.json +1 -1
  44. package/lib/locales/sk.json +50 -2
  45. package/lib/packages/form-validation/custom-rules/ip-addresses.rule.ts +15 -0
  46. package/lib/packages/form-validation/custom-rules/ip.regex.ts +26 -0
  47. package/lib/packages/form-validation/custom-rules/ipv4-or-cidr.rule.ts +2 -3
  48. package/lib/packages/form-validation/index.ts +2 -0
  49. package/lib/packages/hide-permanently/README.md +60 -0
  50. package/lib/packages/hide-permanently/types.ts +3 -0
  51. package/lib/packages/hide-permanently/use-hide-permanently.ts +15 -0
  52. package/lib/packages/overlay/OverlayComponent.vue +18 -0
  53. package/lib/packages/overlay/README.md +300 -0
  54. package/lib/packages/overlay/create-event-handler.ts +73 -0
  55. package/lib/packages/overlay/injection-keys.ts +3 -0
  56. package/lib/packages/overlay/is-thenable.ts +3 -0
  57. package/lib/packages/overlay/symbols.ts +5 -0
  58. package/lib/packages/overlay/types.ts +95 -0
  59. package/lib/packages/overlay/use-overlay-escape.ts +34 -0
  60. package/lib/packages/overlay/use-overlay-store.ts +38 -0
  61. package/lib/packages/overlay/use-overlay-trigger.ts +33 -0
  62. package/lib/packages/overlay/use-overlay.ts +114 -0
  63. package/lib/packages/progress/use-progress.ts +12 -2
  64. package/lib/packages/remote-resource/define-remote-resource.ts +55 -26
  65. package/lib/packages/remote-resource/sse.store.ts +4 -4
  66. package/lib/packages/sidebar/index.ts +2 -0
  67. package/lib/packages/sidebar/sidebar.store.ts +55 -0
  68. package/lib/packages/sidebar/types.ts +21 -0
  69. package/lib/packages/sidebar/use-sidebar-resize.ts +47 -0
  70. package/lib/packages/sidebar/use-sidebar-responsive-expand.ts +19 -0
  71. package/lib/tables/column-definitions/user-name-column.ts +10 -0
  72. package/lib/tables/column-sets/server-columns.ts +2 -2
  73. package/lib/tables/column-sets/user-columns.ts +16 -0
  74. package/lib/types/object.type.ts +9 -1
  75. package/lib/types/task.type.ts +5 -12
  76. package/lib/utils/injection-keys.util.ts +3 -1
  77. package/lib/utils/ip-address.utils.ts +7 -0
  78. package/lib/utils/sr.utils.ts +4 -0
  79. package/package.json +2 -1
  80. package/lib/stores/sidebar.store.ts +0 -79
@@ -0,0 +1,300 @@
1
+ # Overlay
2
+
3
+ Promise-based overlays (modals, drawers…) for Vue.
4
+
5
+ You define an overlay from any component, open it, and `await` the user's decision.
6
+
7
+ `useOverlay` takes a component-loader and the list of its events you want to handle. Events are identified by their handler prop name (`onYes` for a `yes` emit), and the keys are type-checked against the component's actual emits. In the following example, the overlay will automatically close when `ConfirmModal` emits `yes` or `no`:
8
+
9
+ ```ts
10
+ const { open } = useOverlay({
11
+ component: () => import('path/to/ConfirmModal.vue'),
12
+ events: {
13
+ onYes: true,
14
+ onNo: true,
15
+ },
16
+ })
17
+
18
+ async function showConfirmation() {
19
+ const { event } = await open()
20
+
21
+ // At this point, the user has made a decision and the overlay is closed
22
+
23
+ if (event === 'onYes') {
24
+ // The user confirmed
25
+ }
26
+ }
27
+ ```
28
+
29
+ The promise resolves with `{ event, payload }` (see below) when the user makes a decision, and the overlay closes automatically.
30
+
31
+ `true` is a shorthand for "when this event is emitted, close the overlay". The response payload is then `undefined`.
32
+
33
+ ## Passing props
34
+
35
+ If the component has props, pass them to `open()`. They are fully type-checked, and `open()` won't compile without them if any prop is required:
36
+
37
+ ```ts
38
+ const { open } = useOverlay({
39
+ component: () => import('path/to/VmConfirmModal.vue'),
40
+ events: {
41
+ onConfirm: true,
42
+ onCancel: true,
43
+ },
44
+ })
45
+
46
+ async function showConfirmation() {
47
+ const { event } = await open({
48
+ props: {
49
+ message: 'Are you sure you want to do this?',
50
+ },
51
+ })
52
+ }
53
+ ```
54
+
55
+ Props are passed as-is and captured when the overlay opens. To keep an overlay in sync with live data, pass a `reactive()` object containing computeds — or better, use `reactiveComputed` from VueUse:
56
+
57
+ ```ts
58
+ const props = reactiveComputed(() => ({
59
+ message: `${selectedItems.value.length} items will be deleted`,
60
+ }))
61
+
62
+ async function showDeleteConfirmation() {
63
+ const { event } = await open({ props })
64
+ }
65
+ ```
66
+
67
+ ## Custom handlers and payloads
68
+
69
+ Instead of `true`, an event can have a handler. It receives the event's emit arguments, and whatever it returns (awaited) becomes the response payload:
70
+
71
+ ```ts
72
+ const { open: openRenameModal } = useOverlay({
73
+ component: () => import('path/to/RenameModal.vue'),
74
+ events: {
75
+ onSubmit: (newName: string) => newName.trim(),
76
+ onCancel: true,
77
+ },
78
+ })
79
+
80
+ async function renameItem() {
81
+ const response = await openRenameModal()
82
+
83
+ if (response.event === 'onSubmit') {
84
+ response.payload // ← string (trimmed newName)
85
+ }
86
+ }
87
+ ```
88
+
89
+ The response is a discriminated union: narrowing on `response.event` narrows the type of `response.payload` accordingly.
90
+
91
+ Handlers can be async. The overlay stays open (and is marked as busy, see below) while the handler runs, then closes when it resolves:
92
+
93
+ ```ts
94
+ events: {
95
+ onSubmit: async (newName: string) => {
96
+ await api.rename(newName)
97
+ },
98
+ onCancel: true,
99
+ }
100
+ ```
101
+
102
+ While a handler is running, any further event is ignored: a double-click, or a click on Cancel during a save, does nothing.
103
+
104
+ ## Keeping the overlay open with `KEEP_OVERLAY_OPEN`
105
+
106
+ Sometimes handling an event should _not_ close the overlay — a validation failure, a failed API call. Return `KEEP_OVERLAY_OPEN` for that:
107
+
108
+ ```ts
109
+ const { open } = useOverlay({
110
+ component: () => import('path/to/RenameModal.vue'),
111
+ events: {
112
+ onSubmit: async (newName: string) => {
113
+ try {
114
+ await api.rename(newName)
115
+ } catch {
116
+ notify('Failed to rename')
117
+
118
+ return KEEP_OVERLAY_OPEN
119
+ }
120
+ },
121
+ onCancel: true,
122
+ },
123
+ })
124
+ ```
125
+
126
+ The overlay stays open, the promise stays pending, and the user can try again. `KEEP_OVERLAY_OPEN` is automatically excluded from the payload type.
127
+
128
+ ## Per-call handlers
129
+
130
+ `open()` also accepts `events`. A per-call handler for an event that already has a `useOverlay` handler runs _after_ it: the per-call handler receives the payload the `useOverlay` handler produced, and returns the final payload (or `KEEP_OVERLAY_OPEN`):
131
+
132
+ ```ts
133
+ const { open } = useOverlay({
134
+ component: () => import('path/to/RenameModal.vue'),
135
+ events: {
136
+ onSubmit: (newName: string) => newName.trim(),
137
+ onCancel: true,
138
+ },
139
+ })
140
+
141
+ async function renameItem(id: string) {
142
+ await open({
143
+ events: {
144
+ onSubmit: async newName => {
145
+ const success = await api.rename(id, newName)
146
+
147
+ if (!success) {
148
+ notify('Failed to rename')
149
+
150
+ return KEEP_OVERLAY_OPEN
151
+ }
152
+
153
+ return newName
154
+ },
155
+ },
156
+ })
157
+ }
158
+ ```
159
+
160
+ This is what makes composition work: the composable defining the overlay handles the generic part (extracting and cleaning the value), while each call site decides what to do with it.
161
+
162
+ An event that is _not_ declared in `useOverlay` can also be handled at open time. Its handler then receives the raw emit arguments directly, and the event is added to the response union for that call.
163
+
164
+ ## Aborted overlays
165
+
166
+ An overlay is not tied to the lifecycle of the component that opened it: it stays open until the user makes a decision, or until it is aborted. An overlay can be aborted in three ways:
167
+
168
+ - `open()` is called again on the same `useOverlay` instance while the overlay is still open (the previous one is replaced),
169
+ - the `abort()` function returned by `useOverlay` is called,
170
+ - or the store's `abortAll()` is called (see below).
171
+
172
+ In all cases the promise resolves with `OVERLAY_ABORT_EVENT` (a symbol) as its `event`, and no handler runs:
173
+
174
+ ```ts
175
+ const response = await open()
176
+
177
+ if (response.event === OVERLAY_ABORT_EVENT) {
178
+ return
179
+ }
180
+ ```
181
+
182
+ The `OVERLAY_ABORT_EVENT` case is always part of the response union, so exhaustive `event` checks will remind you it exists.
183
+
184
+ Aborting an overlay whose decision is already being resolved is a no-op: the promise resolves with the actual response.
185
+
186
+ An overlay can also be aborted while one of its handlers is still awaiting (for example, navigating away while a save is in progress). In that case the overlay closes right away with `OVERLAY_ABORT_EVENT`. The handler's async work keeps running — it cannot be cancelled — but the value it eventually returns is ignored, and the caller sees an abort.
187
+
188
+ ## Busy and disabled triggers
189
+
190
+ Inside the overlay component, use `useOverlayTrigger()` to wire the controls that emit events (buttons, typically) to the overlay's state:
191
+
192
+ ```html
193
+ <template>
194
+ <SomeModal @confirm="emit('submit')">
195
+ <div class="content">
196
+ <slot />
197
+ </div>
198
+ <div class="buttons">
199
+ <button type="submit" :disabled="isDisabled" @click="trigger">
200
+ <span v-if="isBusy">Saving...</span>
201
+ <span v-else>Save</span>
202
+ </button>
203
+ </div>
204
+ </SomeModal>
205
+ </template>
206
+
207
+ <script lang="ts" setup>
208
+ const emit = defineEmits<{
209
+ submit: []
210
+ }>()
211
+
212
+ const { isBusy, isDisabled, trigger } = useOverlayTrigger()
213
+ </script>
214
+ ```
215
+
216
+ - `isDisabled` is `true` while the overlay is handling an event — use it to disable every control.
217
+ - `isBusy` is `true` while an async handler is running _and_ this trigger initiated it — use it to show a spinner on the right control only. For that, call `trigger()` when the control is activated, so the trigger identifies itself before the event is handled.
218
+
219
+ Each `useOverlayTrigger()` call creates a distinct trigger, so two buttons never show a spinner at the same time. It works from any component rendered inside an overlay, at any depth, and is inert when the component is not rendered in an overlay.
220
+
221
+ ## Escape key
222
+
223
+ `useOverlayEscape(handler)` calls the handler when Escape is pressed, but only while the calling component is the current (topmost) overlay and is not already handling an event. The handler is expected to emit the component's own close event, so that Escape goes through the exact same pipeline as a pointer interaction:
224
+
225
+ ```ts
226
+ useOverlayEscape(() => emit('dismiss'))
227
+ ```
228
+
229
+ Like `useOverlayTrigger()`, it works from any component rendered inside an overlay, and does nothing when the component is not rendered in an overlay.
230
+
231
+ ## Composition: building your own composable
232
+
233
+ `useOverlay` is designed to be wrapped in small, domain-specific composables:
234
+
235
+ ```ts
236
+ // use-user-delete-modal.ts
237
+ export function useUserDeleteModal() {
238
+ return useOverlay({
239
+ component: () => import('path/to/UserDeleteModal.vue'),
240
+ events: {
241
+ onConfirm: true,
242
+ onCancel: true,
243
+ },
244
+ })
245
+ }
246
+
247
+ // use-user-delete.ts
248
+ export function useUserDelete() {
249
+ const { open } = useUserDeleteModal()
250
+
251
+ async function deleteUser(user: User) {
252
+ await open({
253
+ events: {
254
+ onConfirm: () => api.deleteUser(user.id),
255
+ },
256
+ props: { name: user.name },
257
+ })
258
+ }
259
+
260
+ return { deleteUser }
261
+ }
262
+ ```
263
+
264
+ Call sites stay trivial:
265
+
266
+ ```html
267
+ <template>
268
+ <button @click="deleteUser(user)">Delete</button>
269
+ </template>
270
+
271
+ <script lang="ts" setup>
272
+ const { deleteUser } = useUserDelete()
273
+ </script>
274
+ ```
275
+
276
+ ## Rendering the overlays
277
+
278
+ Opened overlays live in a Pinia store (`useOverlayStore`). The app must render them once, near the root, with `OverlayComponent`:
279
+
280
+ ```html
281
+ <template>
282
+ <OverlayComponent v-for="overlay of overlayStore.overlays" :key="overlay.key" :overlay />
283
+ </template>
284
+
285
+ <script lang="ts" setup>
286
+ const overlayStore = useOverlayStore()
287
+ </script>
288
+ ```
289
+
290
+ Styling and animations are up to you. The store also exposes `isCurrent(key)` to style stacked overlays (e.g. dim the ones below the topmost), and each `overlay` carries its current `status`.
291
+
292
+ Since overlays are not tied to the component that opened them, dismissing them on navigation is the app's decision. If that's the behavior you want, wire it once on the router:
293
+
294
+ ```ts
295
+ router.isReady().then(() => {
296
+ router.afterEach(() => useOverlayStore().abortAll())
297
+ })
298
+ ```
299
+
300
+ Register the guard only once the router is ready: the initial navigation also triggers `afterEach`, which would abort any overlay opened while the app was starting up.
@@ -0,0 +1,73 @@
1
+ import { isThenable } from '@core/packages/overlay/is-thenable.ts'
2
+ import { KEEP_OVERLAY_OPEN } from '@core/packages/overlay/symbols.ts'
3
+ import type { Overlay, OverlayEventHandler, OverlayResponse, OverlayStatus } from '@core/packages/overlay/types.ts'
4
+
5
+ export function createEventHandler({
6
+ overlay,
7
+ definitionEvents,
8
+ openEvents,
9
+ settle,
10
+ }: {
11
+ overlay: Overlay
12
+ definitionEvents: Record<string, true | OverlayEventHandler>
13
+ openEvents: Record<string, OverlayEventHandler>
14
+ settle: (response: OverlayResponse<PropertyKey, unknown>) => Promise<void>
15
+ }) {
16
+ const shouldStop = (payload: unknown) => payload === KEEP_OVERLAY_OPEN || overlay.status === 'settled'
17
+
18
+ const runHandler = async (handler: OverlayEventHandler, handlerArgs: unknown[]) => {
19
+ const result = handler(...handlerArgs)
20
+
21
+ if (isThenable(result)) {
22
+ overlay.status = 'busy'
23
+ }
24
+
25
+ return await result
26
+ }
27
+
28
+ return async function handleEvent(eventName: string, emitArgs: unknown[]) {
29
+ if (overlay.status !== 'idle') {
30
+ return
31
+ }
32
+
33
+ try {
34
+ overlay.status = 'locked' as OverlayStatus
35
+
36
+ // The definition handler receives the raw emit arguments, then the open
37
+ // handler receives the payload it produced. An event handled at open
38
+ // time only receives the raw emit arguments directly.
39
+ let handlerArgs = emitArgs
40
+ let payload: unknown
41
+
42
+ const definitionHandler = definitionEvents[eventName]
43
+
44
+ if (definitionHandler !== undefined) {
45
+ payload = definitionHandler === true ? undefined : await runHandler(definitionHandler, handlerArgs)
46
+
47
+ if (shouldStop(payload)) {
48
+ return
49
+ }
50
+
51
+ handlerArgs = [payload]
52
+ }
53
+
54
+ const openHandler = openEvents[eventName]
55
+
56
+ if (openHandler !== undefined) {
57
+ payload = await runHandler(openHandler, handlerArgs)
58
+
59
+ if (shouldStop(payload)) {
60
+ return
61
+ }
62
+ }
63
+
64
+ await settle({ event: eventName, payload })
65
+ } finally {
66
+ if (overlay.status !== 'settled') {
67
+ overlay.status = 'idle'
68
+ }
69
+
70
+ overlay.lastTrigger = undefined
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,3 @@
1
+ import type { ComputedRef, InjectionKey } from 'vue'
2
+
3
+ export const IK_OVERLAY_KEY = Symbol('IK_OVERLAY_KEY') as InjectionKey<ComputedRef<symbol>>
@@ -0,0 +1,3 @@
1
+ export function isThenable(value: unknown): value is PromiseLike<unknown> {
2
+ return typeof (value as PromiseLike<unknown> | undefined)?.then === 'function'
3
+ }
@@ -0,0 +1,5 @@
1
+ /** Returned from an event handler to keep the overlay open (e.g. after a validation failure) */
2
+ export const KEEP_OVERLAY_OPEN = Symbol('keep overlay open')
3
+
4
+ /** `event` of the response resolved when an overlay is torn down without a user decision (e.g. the opener's scope was disposed) */
5
+ export const OVERLAY_ABORT_EVENT = Symbol('overlay aborted')
@@ -0,0 +1,95 @@
1
+ import type { KEEP_OVERLAY_OPEN, OVERLAY_ABORT_EVENT } from '@core/packages/overlay/symbols.ts'
2
+ import type { Component } from 'vue'
3
+ import type { ComponentProps } from 'vue-component-type-helpers'
4
+
5
+ export type Prettify<T> = T extends infer R ? { [K in keyof R]: R[K] } & NonNullable<unknown> : never
6
+
7
+ export type OverlayEvents<TComponent extends Component> = {
8
+ [TKey in ComponentEmitName<TComponent>]?:
9
+ | true
10
+ | ((...args: ExtractHandlerArgs<ComponentProps<TComponent>[TKey]>) => unknown)
11
+ }
12
+
13
+ // Generic inference skips excess property checks, so unknown event keys are
14
+ // forced to `never` to be rejected at the call site
15
+ export type ForbidExtraEvents<TComponent extends Component, TEvents> = Record<
16
+ Exclude<keyof TEvents, ComponentEmitName<TComponent>>,
17
+ never
18
+ >
19
+
20
+ export type OpenEvents<TComponent extends Component, TEvents extends OverlayEvents<TComponent>> = {
21
+ [TKey in ComponentEmitName<TComponent>]?: TKey extends keyof TEvents
22
+ ? (payload: ExtractHandlerPayload<TEvents[TKey]>) => unknown
23
+ : ComponentProps<TComponent>[TKey]
24
+ }
25
+
26
+ export type ResolvedPayload<TValue> = Exclude<Awaited<TValue>, typeof KEEP_OVERLAY_OPEN>
27
+
28
+ export type ExtractHandlerPayload<THandler> = THandler extends (...args: never) => infer TReturn
29
+ ? ResolvedPayload<TReturn>
30
+ : THandler extends true
31
+ ? undefined
32
+ : never
33
+
34
+ export type ExtractOverlayResponse<
35
+ TComponent extends Component,
36
+ TEvents extends OverlayEvents<TComponent>,
37
+ TOpenEvents extends OpenEvents<TComponent, TEvents>,
38
+ > =
39
+ | Prettify<
40
+ {
41
+ [TKey in RequiredKeys<TOpenEvents>]: OverlayResponse<
42
+ TKey,
43
+ ExtractHandlerPayload<TOpenEvents[TKey & keyof TOpenEvents]>
44
+ >
45
+ }[RequiredKeys<TOpenEvents>]
46
+ >
47
+ | Prettify<
48
+ {
49
+ [TKey in Exclude<keyof TEvents, RequiredKeys<TOpenEvents>>]: OverlayResponse<
50
+ TKey,
51
+ ExtractHandlerPayload<TEvents[TKey]>
52
+ >
53
+ }[Exclude<keyof TEvents, RequiredKeys<TOpenEvents>>]
54
+ >
55
+ | OverlayResponse<typeof OVERLAY_ABORT_EVENT, undefined>
56
+
57
+ export type OverlayStatus = 'idle' | 'locked' | 'busy' | 'settled'
58
+
59
+ export interface Overlay {
60
+ key: symbol
61
+ component: Component
62
+ props: Record<string, unknown>
63
+ status: OverlayStatus
64
+ lastTrigger: symbol | undefined
65
+ abort: () => void
66
+ }
67
+
68
+ export type ExtractEventName<TName> = TName extends `onVnode${string}`
69
+ ? never
70
+ : TName extends `on${Capitalize<string>}`
71
+ ? TName
72
+ : never
73
+
74
+ export type ExtractHandlerArgs<THandler> = THandler extends (...args: infer TArgs) => unknown ? TArgs : never
75
+
76
+ export type ComponentEmitName<TComponent extends Component> = ExtractEventName<
77
+ keyof Required<ComponentProps<TComponent>>
78
+ >
79
+
80
+ export type RequiredKeys<TObject> = {
81
+ [TKey in keyof TObject]-?: NonNullable<unknown> extends Pick<TObject, TKey> ? never : TKey
82
+ }[keyof TObject]
83
+
84
+ export type OverlayEventHandler = (...args: unknown[]) => unknown
85
+
86
+ export interface OverlayResponse<TEvent extends PropertyKey, TPayload> {
87
+ event: TEvent
88
+ payload: TPayload
89
+ }
90
+
91
+ export type MaybeOptionalArgs<TObject extends object> =
92
+ Partial<TObject> extends TObject ? [arg?: TObject] : [arg: TObject]
93
+
94
+ export type MaybeOptionalKey<TKey extends PropertyKey, TObject extends object> =
95
+ Partial<TObject> extends TObject ? { [P in TKey]?: TObject } : { [P in TKey]: TObject }
@@ -0,0 +1,34 @@
1
+ import { IK_OVERLAY_KEY } from '@core/packages/overlay/injection-keys.ts'
2
+ import { useOverlayStore } from '@core/packages/overlay/use-overlay-store.ts'
3
+ import { onKeyStroke } from '@vueuse/core'
4
+ import { inject } from 'vue'
5
+
6
+ /**
7
+ * Calls the handler when Escape is pressed while the calling component is the
8
+ * current (topmost) overlay and is not busy. The handler is expected to emit
9
+ * the component's own close event, so that Escape goes through the exact same
10
+ * pipeline as a pointer interaction.
11
+ */
12
+ export function useOverlayEscape(handler: () => void) {
13
+ const overlayKey = inject(IK_OVERLAY_KEY, undefined)
14
+
15
+ if (overlayKey === undefined) {
16
+ return
17
+ }
18
+
19
+ const overlayStore = useOverlayStore()
20
+
21
+ onKeyStroke('Escape', () => {
22
+ if (!overlayStore.isCurrent(overlayKey.value)) {
23
+ return
24
+ }
25
+
26
+ const overlay = overlayStore.get(overlayKey.value)
27
+
28
+ if (overlay?.status !== 'idle') {
29
+ return
30
+ }
31
+
32
+ handler()
33
+ })
34
+ }
@@ -0,0 +1,38 @@
1
+ import type { Overlay } from '@core/packages/overlay/types.ts'
2
+ import { defineStore } from 'pinia'
3
+ import { computed, ref } from 'vue'
4
+
5
+ export const useOverlayStore = defineStore('overlay', () => {
6
+ const registry = ref(new Map<symbol, Overlay>())
7
+
8
+ function register(overlay: Overlay) {
9
+ registry.value.set(overlay.key, overlay)
10
+ }
11
+
12
+ function unregister(overlay: Overlay) {
13
+ if (registry.value.get(overlay.key) === overlay) {
14
+ registry.value.delete(overlay.key)
15
+ }
16
+ }
17
+
18
+ function abortAll() {
19
+ registry.value.forEach(overlay => overlay.abort())
20
+ }
21
+
22
+ function isCurrent(key: symbol): boolean {
23
+ return Array.from(registry.value.keys()).at(-1) === key
24
+ }
25
+
26
+ function get(key: symbol): Overlay | undefined {
27
+ return registry.value.get(key)
28
+ }
29
+
30
+ return {
31
+ overlays: computed(() => Array.from(registry.value.values())),
32
+ register,
33
+ unregister,
34
+ abortAll,
35
+ isCurrent,
36
+ get,
37
+ }
38
+ })
@@ -0,0 +1,33 @@
1
+ import { IK_OVERLAY_KEY } from '@core/packages/overlay/injection-keys.ts'
2
+ import { useOverlayStore } from '@core/packages/overlay/use-overlay-store.ts'
3
+ import { computed, inject } from 'vue'
4
+
5
+ export function useOverlayTrigger() {
6
+ const overlayKey = inject(IK_OVERLAY_KEY, undefined)
7
+
8
+ const triggerKey = Symbol('overlay trigger')
9
+
10
+ const overlayStore = useOverlayStore()
11
+
12
+ const overlay = computed(() => {
13
+ if (overlayKey === undefined) {
14
+ return undefined
15
+ }
16
+
17
+ return overlayStore.get(overlayKey.value)
18
+ })
19
+
20
+ const status = computed(() => overlay.value?.status ?? 'idle')
21
+
22
+ const isBusy = computed(() => overlay.value?.lastTrigger === triggerKey && status.value === 'busy')
23
+
24
+ const isDisabled = computed(() => status.value !== 'idle')
25
+
26
+ function trigger() {
27
+ if (overlay.value) {
28
+ overlay.value.lastTrigger = triggerKey
29
+ }
30
+ }
31
+
32
+ return { isBusy, isDisabled, trigger }
33
+ }