@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.
- package/lib/assets/css/base.pcss +5 -0
- package/lib/components/card-object-title/VtsCardObjectTitle.vue +36 -0
- package/lib/components/code-snippet/VtsCodeSnippet.vue +11 -5
- package/lib/components/drawer/VtsDrawer.vue +5 -1
- package/lib/components/drawer/VtsDrawerButton.vue +4 -3
- package/lib/components/drawer/VtsDrawerConfirmButton.vue +4 -2
- package/lib/components/icon/VtsIcon.vue +3 -1
- package/lib/components/input-wrapper/VtsInputWrapper.vue +2 -2
- package/lib/components/layout/VtsContentSidePanel.vue +41 -0
- package/lib/components/layout/VtsLayoutSidebar.vue +116 -43
- package/lib/components/menu/MenuItem.vue +9 -1
- package/lib/components/menu/MenuList.vue +4 -2
- package/lib/components/menu/VtsActionsMenu.vue +48 -14
- package/lib/components/modal/VtsActionModal.vue +44 -9
- package/lib/components/modal/VtsBlockedModal.vue +1 -1
- package/lib/components/modal/VtsDeleteModal.vue +1 -1
- package/lib/components/operation-card/VtsOperationCard.vue +2 -1
- package/lib/components/overlay/VtsOverlayButton.vue +35 -0
- package/lib/components/overlay/VtsOverlayCancelButton.vue +16 -0
- package/lib/components/overlay/VtsOverlayConfirmButton.vue +25 -0
- package/lib/components/overlay/VtsOverlayList.vue +82 -0
- package/lib/components/panel/VtsPanel.vue +80 -0
- package/lib/components/panel/VtsSidePanel.vue +140 -0
- package/lib/components/progress-bar/VtsProgressBar.vue +5 -3
- package/lib/components/select/VtsSelect.vue +1 -3
- package/lib/components/space-card/VtsSpaceCard.vue +1 -1
- package/lib/components/state-hero/VtsStateHero.vue +9 -17
- package/lib/components/table/VtsTable.vue +2 -1
- package/lib/components/table/cells/VtsActionCell.vue +7 -0
- package/lib/components/table/cells/VtsLinkCell.vue +3 -1
- package/lib/components/table/cells/VtsProgressBarCell.vue +1 -1
- package/lib/components/table/cells/VtsUserNameCell.vue +29 -0
- package/lib/components/task/VtsQuickTaskButton.vue +1 -1
- package/lib/components/task/VtsQuickTaskList.vue +1 -1
- package/lib/components/tree/VtsTreeItem.vue +13 -7
- package/lib/components/tree/VtsTreeList.vue +6 -3
- package/lib/components/tree/VtsTreeLoadingItem.vue +3 -3
- package/lib/components/ui/head-bar/UiHeadBar.vue +2 -2
- package/lib/components/ui/input/UiInput.vue +56 -10
- package/lib/components/ui/link/UiLink.vue +3 -1
- package/lib/components/ui/panel/UiPanel.vue +7 -24
- package/lib/components/ui/query-search-bar/UiQuerySearchBar.vue +1 -15
- package/lib/components/ui/quick-task-item/UiQuickTaskItem.vue +1 -1
- package/lib/components/ui/quick-task-panel/UiQuickTaskPanel.vue +1 -1
- package/lib/components/ui/task-item/UiTaskItem.vue +14 -6
- package/lib/components/ui/task-list/UiTaskList.vue +1 -1
- package/lib/components/ui/text-area/UiTextarea.vue +12 -14
- package/lib/components/ui/tree-item-label/UiTreeItemLabel.vue +1 -1
- package/lib/components/vif-connection-toggle-modal/VtsVifConnectionToggleModal.vue +43 -0
- package/lib/composables/table/multi-select.composable.md +33 -0
- package/lib/composables/table-state.composable.ts +1 -1
- package/lib/icons/action-icons.ts +14 -0
- package/lib/layouts/CoreLayout.vue +74 -62
- package/lib/locales/cs.json +56 -1
- package/lib/locales/de.json +3 -2
- package/lib/locales/en.json +85 -4
- package/lib/locales/es.json +11 -1
- package/lib/locales/fa.json +0 -1
- package/lib/locales/fr.json +85 -4
- package/lib/locales/it.json +0 -1
- package/lib/locales/ko.json +2 -0
- package/lib/locales/nl.json +2 -1
- package/lib/locales/pt-BR.json +0 -1
- package/lib/locales/pt.json +0 -1
- package/lib/locales/ru.json +0 -1
- package/lib/locales/sk.json +56 -1
- package/lib/locales/sv.json +3 -2
- package/lib/locales/uk.json +0 -1
- package/lib/locales/zh-Hans.json +79 -4
- package/lib/packages/form-validation/custom-rules/ip-addresses.rule.ts +15 -0
- package/lib/packages/form-validation/custom-rules/ip.regex.ts +26 -0
- package/lib/packages/form-validation/custom-rules/ipv4-or-cidr.rule.ts +2 -3
- package/lib/packages/form-validation/index.ts +2 -0
- package/lib/packages/hide-permanently/README.md +60 -0
- package/lib/packages/hide-permanently/types.ts +3 -0
- package/lib/packages/hide-permanently/use-hide-permanently.ts +15 -0
- package/lib/packages/overlay/OverlayComponent.vue +18 -0
- package/lib/packages/overlay/README.md +285 -0
- package/lib/packages/overlay/create-event-handler.ts +80 -0
- package/lib/packages/overlay/injection-keys.ts +3 -0
- package/lib/packages/overlay/is-thenable.ts +3 -0
- package/lib/packages/overlay/symbols.ts +5 -0
- package/lib/packages/overlay/types.ts +94 -0
- package/lib/packages/overlay/use-overlay-escape.ts +34 -0
- package/lib/packages/overlay/use-overlay-store.ts +33 -0
- package/lib/packages/overlay/use-overlay-trigger.ts +33 -0
- package/lib/packages/overlay/use-overlay.ts +99 -0
- package/lib/packages/progress/use-progress.ts +12 -2
- package/lib/packages/remote-resource/define-remote-resource.ts +64 -29
- package/lib/packages/sidebar/index.ts +2 -0
- package/lib/packages/sidebar/sidebar.store.ts +55 -0
- package/lib/packages/sidebar/types.ts +21 -0
- package/lib/packages/sidebar/use-sidebar-resize.ts +47 -0
- package/lib/packages/sidebar/use-sidebar-responsive-expand.ts +19 -0
- package/lib/packages/tree/types.ts +6 -0
- package/lib/packages/tree/use-tree.ts +38 -37
- package/lib/stores/panel.store.ts +56 -5
- package/lib/tables/column-definitions/action-column.ts +21 -9
- package/lib/tables/column-definitions/user-name-column.ts +10 -0
- package/lib/tables/column-sets/server-columns.ts +2 -2
- package/lib/tables/column-sets/user-columns.ts +16 -0
- package/lib/tables/column-sets/vm-columns.ts +2 -3
- package/lib/types/object.type.ts +9 -1
- package/lib/types/state-hero.type.ts +19 -0
- package/lib/types/task.type.ts +5 -12
- package/lib/types/vue-virtual-scroller.d.ts +8 -2
- package/lib/utils/injection-keys.util.ts +4 -2
- package/lib/utils/ip-address.utils.ts +7 -0
- package/lib/utils/sr.utils.ts +4 -0
- package/package.json +2 -1
- 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,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,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
|
+
}
|