@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
@@ -1,5 +1,7 @@
1
1
  export * from './custom-rules/out-of-range.rule.ts'
2
2
  export * from './custom-rules/ipv4-or-cidr.rule.ts'
3
+ export * from './custom-rules/ip-addresses.rule.ts'
4
+ export * from './custom-rules/ip.regex.ts'
3
5
  export * from './merge-validation-configs.ts'
4
6
  export * from './types.ts'
5
7
  export * from './use-form-validation.ts'
@@ -0,0 +1,60 @@
1
+ # Hide Permanently
2
+
3
+ A composable to permanently hide an element (e.g. an informational alert) once the user dismisses it.
4
+
5
+ Hidden IDs are stored in Local Storage (`permanently-hidden-items` key), so the element stays hidden across page reloads and sessions.
6
+
7
+ ## Registering IDs
8
+
9
+ Each hideable element is identified by a unique ID, to prevent collisions.
10
+
11
+ The application must register IDs by augmenting the `PermanentlyHideableItems` interface in a `.d.ts` file:
12
+
13
+ ```ts
14
+ declare module '@xen-orchestra/web-core/packages/hide-permanently/types.ts' {
15
+ interface PermanentlyHideableItems {
16
+ 'my-first-id': true
17
+ 'my-second-id': true
18
+ }
19
+ }
20
+
21
+ export {}
22
+ ```
23
+
24
+ ## `useHidePermanently`
25
+
26
+ ```ts
27
+ const { isVisible: isUserInfoVisible, hidePermanently: hideUserInfoPermanently } = useHidePermanently(id)
28
+
29
+ // or, with array destructuring:
30
+ const [isUserInfoVisible, hideUserInfoPermanently] = useHidePermanently(id)
31
+ ```
32
+
33
+ ### Arguments
34
+
35
+ | Argument | Type | Required | Description |
36
+ | -------- | ----------------------- | :------: | --------------------------------- |
37
+ | `id` | `PermanentlyHideableId` | ✓ | The registered ID of the element. |
38
+
39
+ ### Returns
40
+
41
+ | Property | Type | Description |
42
+ | ----------------- | ---------------------- | ----------------------------------------------------- |
43
+ | `isVisible` | `ComputedRef<boolean>` | Whether the element is visible (i.e. not hidden yet). |
44
+ | `hidePermanently` | `() => void` | Hides the element permanently. |
45
+
46
+ ## Example
47
+
48
+ ```vue
49
+ <template>
50
+ <div v-if="isHelpTextVisible">
51
+ Some dismissible help text
52
+
53
+ <button type="button" @click="hideHelpTextPermanently()">Don't show again</button>
54
+ </div>
55
+ </template>
56
+
57
+ <script lang="ts" setup>
58
+ const [isHelpTextVisible, hideHelpTextPermanently] = useHidePermanently('my-first-id')
59
+ </script>
60
+ ```
@@ -0,0 +1,3 @@
1
+ export interface PermanentlyHideableItems {}
2
+
3
+ export type PermanentlyHideableId = keyof PermanentlyHideableItems & string
@@ -0,0 +1,15 @@
1
+ import type { PermanentlyHideableId } from '@core/packages/hide-permanently/types.ts'
2
+ import { makeDestructurable, useLocalStorage } from '@vueuse/core'
3
+ import { computed } from 'vue'
4
+
5
+ export function useHidePermanently(id: PermanentlyHideableId) {
6
+ const hiddenItems = useLocalStorage('permanently-hidden-items', new Set<PermanentlyHideableId>())
7
+
8
+ const isVisible = computed(() => !hiddenItems.value.has(id))
9
+
10
+ function hidePermanently() {
11
+ hiddenItems.value.add(id)
12
+ }
13
+
14
+ return makeDestructurable({ isVisible, hidePermanently } as const, [isVisible, hidePermanently] as const)
15
+ }
@@ -0,0 +1,18 @@
1
+ <template>
2
+ <component :is="overlay.component" v-bind="overlay.props" />
3
+ </template>
4
+
5
+ <script lang="ts" setup>
6
+ import { IK_OVERLAY_KEY } from '@core/packages/overlay/injection-keys.ts'
7
+ import type { Overlay } from '@core/packages/overlay/types.ts'
8
+ import { computed, provide } from 'vue'
9
+
10
+ const { overlay } = defineProps<{
11
+ overlay: Overlay
12
+ }>()
13
+
14
+ provide(
15
+ IK_OVERLAY_KEY,
16
+ computed(() => overlay.key)
17
+ )
18
+ </script>
@@ -0,0 +1,285 @@
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 can be torn down without a user decision:
167
+
168
+ - the component that created it (where `useOverlay` was called) is unmounted (its scope is disposed),
169
+ - or `open()` is called again on the same `useOverlay` instance while the overlay is still open (the previous one is replaced).
170
+
171
+ In both cases the promise resolves with `OVERLAY_ABORT_EVENT` (a symbol) as its `event`, and no handler runs:
172
+
173
+ ```ts
174
+ const response = await open()
175
+
176
+ if (response.event === OVERLAY_ABORT_EVENT) {
177
+ return
178
+ }
179
+ ```
180
+
181
+ The `OVERLAY_ABORT_EVENT` case is always part of the response union, so exhaustive `event` checks will remind you it exists.
182
+
183
+ ## Busy and disabled triggers
184
+
185
+ Inside the overlay component, use `useOverlayTrigger()` to wire the controls that emit events (buttons, typically) to the overlay's state:
186
+
187
+ ```html
188
+ <template>
189
+ <SomeModal @confirm="emit('submit')">
190
+ <div class="content">
191
+ <slot />
192
+ </div>
193
+ <div class="buttons">
194
+ <button type="submit" :disabled="isDisabled" @click="trigger">
195
+ <span v-if="isBusy">Saving...</span>
196
+ <span v-else>Save</span>
197
+ </button>
198
+ </div>
199
+ </SomeModal>
200
+ </template>
201
+
202
+ <script lang="ts" setup>
203
+ const emit = defineEmits<{
204
+ submit: []
205
+ }>()
206
+
207
+ const { isBusy, isDisabled, trigger } = useOverlayTrigger()
208
+ </script>
209
+ ```
210
+
211
+ - `isDisabled` is `true` while the overlay is handling an event — use it to disable every control.
212
+ - `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.
213
+
214
+ 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.
215
+
216
+ ## Escape key
217
+
218
+ `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:
219
+
220
+ ```ts
221
+ useOverlayEscape(() => emit('dismiss'))
222
+ ```
223
+
224
+ Like `useOverlayTrigger()`, it works from any component rendered inside an overlay, and does nothing when the component is not rendered in an overlay.
225
+
226
+ ## Composition: building your own composable
227
+
228
+ `useOverlay` is designed to be wrapped in small, domain-specific composables:
229
+
230
+ ```ts
231
+ // use-user-delete-modal.ts
232
+ export function useUserDeleteModal() {
233
+ return useOverlay({
234
+ component: () => import('path/to/UserDeleteModal.vue'),
235
+ events: {
236
+ onConfirm: true,
237
+ onCancel: true,
238
+ },
239
+ })
240
+ }
241
+
242
+ // use-user-delete.ts
243
+ export function useUserDelete() {
244
+ const { open } = useUserDeleteModal()
245
+
246
+ async function deleteUser(user: User) {
247
+ await open({
248
+ events: {
249
+ onConfirm: () => api.deleteUser(user.id),
250
+ },
251
+ props: { name: user.name },
252
+ })
253
+ }
254
+
255
+ return { deleteUser }
256
+ }
257
+ ```
258
+
259
+ Call sites stay trivial:
260
+
261
+ ```html
262
+ <template>
263
+ <button @click="deleteUser(user)">Delete</button>
264
+ </template>
265
+
266
+ <script lang="ts" setup>
267
+ const { deleteUser } = useUserDelete()
268
+ </script>
269
+ ```
270
+
271
+ ## Rendering the overlays
272
+
273
+ Opened overlays live in a Pinia store (`useOverlayStore`). The app must render them once, near the root, with `OverlayComponent`:
274
+
275
+ ```html
276
+ <template>
277
+ <OverlayComponent v-for="overlay of overlayStore.overlays" :key="overlay.key" :overlay />
278
+ </template>
279
+
280
+ <script lang="ts" setup>
281
+ const overlayStore = useOverlayStore()
282
+ </script>
283
+ ```
284
+
285
+ 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`.
@@ -0,0 +1,80 @@
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 } from '@core/packages/overlay/types.ts'
4
+ import { nextTick } from 'vue'
5
+
6
+ export function createEventHandler({
7
+ overlay,
8
+ definitionEvents,
9
+ openEvents,
10
+ settle,
11
+ }: {
12
+ overlay: Overlay
13
+ definitionEvents: Record<string, true | OverlayEventHandler>
14
+ openEvents: Record<string, OverlayEventHandler>
15
+ settle: (response: OverlayResponse<PropertyKey, unknown>) => void
16
+ }) {
17
+ const shouldStop = (payload: unknown) => payload === KEEP_OVERLAY_OPEN || overlay.status === 'settled'
18
+
19
+ const runHandler = async (handler: OverlayEventHandler, handlerArgs: unknown[]) => {
20
+ const result = handler(...handlerArgs)
21
+
22
+ if (isThenable(result)) {
23
+ overlay.status = 'busy'
24
+ }
25
+
26
+ return await result
27
+ }
28
+
29
+ return async function handleEvent(eventName: string, emitArgs: unknown[]) {
30
+ if (overlay.status !== 'idle') {
31
+ return
32
+ }
33
+
34
+ try {
35
+ overlay.status = 'locked'
36
+
37
+ // The definition handler receives the raw emit arguments, then the open
38
+ // handler receives the payload it produced. An event handled at open
39
+ // time only receives the raw emit arguments directly.
40
+ let handlerArgs = emitArgs
41
+ let payload: unknown
42
+
43
+ const definitionHandler = definitionEvents[eventName]
44
+
45
+ if (definitionHandler !== undefined) {
46
+ payload = definitionHandler === true ? undefined : await runHandler(definitionHandler, handlerArgs)
47
+
48
+ if (shouldStop(payload)) {
49
+ return
50
+ }
51
+
52
+ handlerArgs = [payload]
53
+ }
54
+
55
+ const openHandler = openEvents[eventName]
56
+
57
+ if (openHandler !== undefined) {
58
+ payload = await runHandler(openHandler, handlerArgs)
59
+
60
+ if (shouldStop(payload)) {
61
+ return
62
+ }
63
+ }
64
+
65
+ overlay.status = 'settled'
66
+
67
+ // The leave transition freezes the DOM in its last painted state,
68
+ // so paint a non-busy frame before removing the overlay
69
+ await nextTick()
70
+
71
+ settle({ event: eventName, payload })
72
+ } finally {
73
+ if (overlay.status !== 'settled') {
74
+ overlay.status = 'idle'
75
+ }
76
+
77
+ overlay.lastTrigger = undefined
78
+ }
79
+ }
80
+ }
@@ -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,94 @@
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
+ }
66
+
67
+ export type ExtractEventName<TName> = TName extends `onVnode${string}`
68
+ ? never
69
+ : TName extends `on${Capitalize<string>}`
70
+ ? TName
71
+ : never
72
+
73
+ export type ExtractHandlerArgs<THandler> = THandler extends (...args: infer TArgs) => unknown ? TArgs : never
74
+
75
+ export type ComponentEmitName<TComponent extends Component> = ExtractEventName<
76
+ keyof Required<ComponentProps<TComponent>>
77
+ >
78
+
79
+ export type RequiredKeys<TObject> = {
80
+ [TKey in keyof TObject]-?: NonNullable<unknown> extends Pick<TObject, TKey> ? never : TKey
81
+ }[keyof TObject]
82
+
83
+ export type OverlayEventHandler = (...args: unknown[]) => unknown
84
+
85
+ export interface OverlayResponse<TEvent extends PropertyKey, TPayload> {
86
+ event: TEvent
87
+ payload: TPayload
88
+ }
89
+
90
+ export type MaybeOptionalArgs<TObject extends object> =
91
+ Partial<TObject> extends TObject ? [arg?: TObject] : [arg: TObject]
92
+
93
+ export type MaybeOptionalKey<TKey extends PropertyKey, TObject extends object> =
94
+ 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,33 @@
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 isCurrent(key: symbol): boolean {
19
+ return Array.from(registry.value.keys()).at(-1) === key
20
+ }
21
+
22
+ function get(key: symbol): Overlay | undefined {
23
+ return registry.value.get(key)
24
+ }
25
+
26
+ return {
27
+ overlays: computed(() => Array.from(registry.value.values())),
28
+ register,
29
+ unregister,
30
+ isCurrent,
31
+ get,
32
+ }
33
+ })
@@ -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
+ }