vue-composable-ui 0.0.1
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/.github/workflows/deploy.yml +21 -0
- package/.github/workflows/docs.yml +62 -0
- package/.vscode/extensions.json +7 -0
- package/LICENSE +21 -0
- package/README.md +3 -0
- package/docs/.vitepress/config.mts +40 -0
- package/docs/.vitepress/theme/index.js +4 -0
- package/docs/.vitepress/theme/style.scss +155 -0
- package/docs/components/combobox.md +174 -0
- package/docs/components/demos/combobox.vue +142 -0
- package/docs/components/demos/combobox_autocomplete.vue +75 -0
- package/docs/components/demos/combobox_tags.vue +127 -0
- package/docs/components/demos/input.vue +35 -0
- package/docs/components/demos/listbox.vue +64 -0
- package/docs/components/demos/menu.vue +93 -0
- package/docs/components/demos/popover.vue +16 -0
- package/docs/components/demos/tabs.vue +59 -0
- package/docs/components/demos/tooltip.vue +27 -0
- package/docs/components/listbox.md +9 -0
- package/docs/components/menu.md +121 -0
- package/docs/components/popover.md +82 -0
- package/docs/components/tabs.md +9 -0
- package/docs/components/tooltip.md +78 -0
- package/docs/composables/form-control.md +130 -0
- package/docs/composables/list-option.md +47 -0
- package/docs/composables/list.md +192 -0
- package/docs/composables/popover.md +90 -0
- package/docs/index.md +24 -0
- package/docs/intro.md +0 -0
- package/package.json +35 -0
- package/src/components/combobox/combobox.vue +229 -0
- package/src/components/combobox/comboboxFormInput.vue +33 -0
- package/src/components/combobox/comboboxOption.vue +52 -0
- package/src/components/combobox/comboboxOptions.vue +17 -0
- package/src/components/injectionKeys.ts +50 -0
- package/src/components/listbox/listbox.vue +91 -0
- package/src/components/listbox/listboxOption.vue +37 -0
- package/src/components/menu/menu.vue +100 -0
- package/src/components/menu/menuItem.vue +86 -0
- package/src/components/menu/menuItems.vue +51 -0
- package/src/components/popover/popover.vue +68 -0
- package/src/components/popover/popoverDialog.vue +35 -0
- package/src/components/tabs/tab.vue +35 -0
- package/src/components/tabs/tabContainer.vue +50 -0
- package/src/components/tabs/tabList.vue +52 -0
- package/src/components/tabs/tabPanel.vue +36 -0
- package/src/components/tooltip.vue +115 -0
- package/src/composables/formControl.ts +144 -0
- package/src/composables/list.ts +326 -0
- package/src/composables/listOption.ts +80 -0
- package/src/composables/popover.ts +306 -0
- package/src/main.ts +65 -0
- package/src/utils/element.ts +19 -0
- package/src/utils/id.ts +5 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +27 -0
- package/tsconfig.node.json +10 -0
- package/vite.config.ts +28 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import type { ComputedRef, MaybeRef, Ref, ShallowRef, InjectionKey } from "vue";
|
|
2
|
+
import {
|
|
3
|
+
ref,
|
|
4
|
+
shallowRef,
|
|
5
|
+
unref,
|
|
6
|
+
provide,
|
|
7
|
+
computed,
|
|
8
|
+
readonly,
|
|
9
|
+
shallowReadonly,
|
|
10
|
+
triggerRef,
|
|
11
|
+
} from "vue";
|
|
12
|
+
import { useHTMLElement, TemplateRefOrSelector } from "../utils/element";
|
|
13
|
+
|
|
14
|
+
export type ItemValue = unknown;
|
|
15
|
+
export interface Item {
|
|
16
|
+
id: number;
|
|
17
|
+
element: Ref<HTMLElement | undefined>;
|
|
18
|
+
value: ItemValue;
|
|
19
|
+
disabled: MaybeRef<boolean>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ActivationOptions {
|
|
23
|
+
/**
|
|
24
|
+
* Focus the corresponding HTML element
|
|
25
|
+
*
|
|
26
|
+
* @defaultValue `false`
|
|
27
|
+
*/
|
|
28
|
+
focus?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface NavigationOptions {
|
|
32
|
+
/**
|
|
33
|
+
* Infinitely cycle throught the items
|
|
34
|
+
*
|
|
35
|
+
* @defaultValue `false`
|
|
36
|
+
*/
|
|
37
|
+
loop?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface UseListReturn {
|
|
41
|
+
/**
|
|
42
|
+
* Array of items
|
|
43
|
+
*/
|
|
44
|
+
items: Readonly<ShallowRef<Item[]>>;
|
|
45
|
+
/**
|
|
46
|
+
* Multiselection flag
|
|
47
|
+
*/
|
|
48
|
+
isMultiselectable: Readonly<Ref<boolean>>;
|
|
49
|
+
/**
|
|
50
|
+
* Currently active item
|
|
51
|
+
*/
|
|
52
|
+
activeItem: ComputedRef<Item | undefined>;
|
|
53
|
+
/**
|
|
54
|
+
* Select the currently active item
|
|
55
|
+
*/
|
|
56
|
+
selectActiveItem: () => void;
|
|
57
|
+
/**
|
|
58
|
+
* Activate the currently selected item
|
|
59
|
+
*/
|
|
60
|
+
activateSelectedItem: (options?: ActivationOptions) => void;
|
|
61
|
+
/**
|
|
62
|
+
* Activate the first item
|
|
63
|
+
*/
|
|
64
|
+
activateFirstItem: (options?: ActivationOptions) => void;
|
|
65
|
+
/**
|
|
66
|
+
* Activate the last item
|
|
67
|
+
*/
|
|
68
|
+
activateLastItem: (options?: ActivationOptions) => void;
|
|
69
|
+
/**
|
|
70
|
+
* Activate next item
|
|
71
|
+
*/
|
|
72
|
+
activateNextItem: (options?: ActivationOptions & NavigationOptions) => void;
|
|
73
|
+
/**
|
|
74
|
+
* Activate previous item
|
|
75
|
+
*/
|
|
76
|
+
activatePrevItem: (options?: ActivationOptions & NavigationOptions) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface UseListInjectionKey {
|
|
80
|
+
/**
|
|
81
|
+
* The list value
|
|
82
|
+
*/
|
|
83
|
+
value: Readonly<Ref<unknown>>;
|
|
84
|
+
/**
|
|
85
|
+
* Select an item by ID
|
|
86
|
+
*/
|
|
87
|
+
selectItem: (itemId: number) => void;
|
|
88
|
+
/**
|
|
89
|
+
* Activate an item by ID
|
|
90
|
+
*/
|
|
91
|
+
activateItem: (itemId: number, options?: ActivationOptions) => void;
|
|
92
|
+
/**
|
|
93
|
+
* Check if an item is selected
|
|
94
|
+
*/
|
|
95
|
+
isSelected: (itemId: number) => boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Check if an item is active
|
|
98
|
+
*/
|
|
99
|
+
isActive: (itemId: number) => boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Add an item to the list
|
|
102
|
+
*
|
|
103
|
+
* @returns New item ID
|
|
104
|
+
*/
|
|
105
|
+
addItem: (
|
|
106
|
+
itemEl: TemplateRefOrSelector,
|
|
107
|
+
itemVal: ItemValue,
|
|
108
|
+
disabled?: MaybeRef<boolean>
|
|
109
|
+
) => number;
|
|
110
|
+
/**
|
|
111
|
+
* Remove an item from the list by ID
|
|
112
|
+
*/
|
|
113
|
+
removeItem: (itemId: number) => void;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const injectList: InjectionKey<UseListInjectionKey> = Symbol();
|
|
117
|
+
|
|
118
|
+
function isValuesEqual(v1: ItemValue, v2: ItemValue) {
|
|
119
|
+
const [_v1, _v2] = [unref(v1), unref(v2)];
|
|
120
|
+
if (typeof _v1 === "object") {
|
|
121
|
+
if (typeof _v2 === "object") {
|
|
122
|
+
return JSON.stringify(_v1) === JSON.stringify(_v2);
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
return _v1 === _v2;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Creates a reactive list of HTML elements with assigned values.
|
|
131
|
+
*
|
|
132
|
+
* @param value A ref to store the list value. Set to an array to enable multiselection.
|
|
133
|
+
*/
|
|
134
|
+
export function useList(value: Ref<unknown>): UseListReturn {
|
|
135
|
+
let lastId = 0;
|
|
136
|
+
|
|
137
|
+
const items: ShallowRef<Item[]> = shallowRef([]);
|
|
138
|
+
const activeItemId: Ref<number | undefined> = ref();
|
|
139
|
+
|
|
140
|
+
const isMultiselectable = computed(() => Array.isArray(value.value));
|
|
141
|
+
|
|
142
|
+
function getItem(itemId: number) {
|
|
143
|
+
const item = items.value.find((_item) => {
|
|
144
|
+
return _item.id === itemId;
|
|
145
|
+
});
|
|
146
|
+
if (!item) throw new Error(`Item not found: (${itemId})`);
|
|
147
|
+
return item;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function focusItem(item: Item) {
|
|
151
|
+
const el = unref(item.element);
|
|
152
|
+
if (!el) return;
|
|
153
|
+
el.focus();
|
|
154
|
+
return el;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function activateItem(
|
|
158
|
+
item?: Item,
|
|
159
|
+
{ focus = false }: ActivationOptions = {}
|
|
160
|
+
) {
|
|
161
|
+
activeItemId.value = item?.id ?? undefined;
|
|
162
|
+
if (item !== undefined && focus) focusItem(item);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function activateItemById(
|
|
166
|
+
itemId: number,
|
|
167
|
+
{ focus = false }: ActivationOptions = {}
|
|
168
|
+
) {
|
|
169
|
+
const item = getItem(itemId);
|
|
170
|
+
return activateItem(item, { focus });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isActive(itemId: number) {
|
|
174
|
+
return activeItemId.value === itemId;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function addItem(
|
|
178
|
+
itemEl: TemplateRefOrSelector,
|
|
179
|
+
itemVal: ItemValue,
|
|
180
|
+
disabled: MaybeRef<boolean> = false
|
|
181
|
+
) {
|
|
182
|
+
const el = useHTMLElement(itemEl);
|
|
183
|
+
const id = lastId++;
|
|
184
|
+
items.value.push({
|
|
185
|
+
id,
|
|
186
|
+
value: itemVal,
|
|
187
|
+
element: el,
|
|
188
|
+
disabled,
|
|
189
|
+
});
|
|
190
|
+
triggerRef(items);
|
|
191
|
+
return id;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function removeItem(itemId: number) {
|
|
195
|
+
const item = getItem(itemId);
|
|
196
|
+
const idx = items.value.indexOf(item);
|
|
197
|
+
items.value.splice(idx, 1);
|
|
198
|
+
triggerRef(items);
|
|
199
|
+
if (isActive(itemId)) {
|
|
200
|
+
activateItem();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function selectItem(itemId: number) {
|
|
205
|
+
const item = getItem(itemId);
|
|
206
|
+
if (unref(item.disabled)) return;
|
|
207
|
+
const v = unref(item.value);
|
|
208
|
+
if (v === undefined) return;
|
|
209
|
+
if (unref(isMultiselectable)) {
|
|
210
|
+
const idx = (value.value as ItemValue[]).findIndex((iv) =>
|
|
211
|
+
isValuesEqual(v, iv)
|
|
212
|
+
);
|
|
213
|
+
if (idx > -1) {
|
|
214
|
+
(value.value as ItemValue[]).splice(idx, 1);
|
|
215
|
+
} else {
|
|
216
|
+
value.value = [...(value.value as ItemValue[]), v];
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
value.value = v;
|
|
220
|
+
}
|
|
221
|
+
activateItem(item);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isSelected(item: Item) {
|
|
225
|
+
return unref(isMultiselectable)
|
|
226
|
+
? !!(value.value as ItemValue[]).find((iv) =>
|
|
227
|
+
isValuesEqual(item.value, iv)
|
|
228
|
+
)
|
|
229
|
+
: isValuesEqual(value, item.value);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isSelectedById(itemId: number) {
|
|
233
|
+
try {
|
|
234
|
+
const item = getItem(itemId);
|
|
235
|
+
return isSelected(item);
|
|
236
|
+
} catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function activateSelectedItem({ focus = false }: ActivationOptions = {}) {
|
|
242
|
+
for (let item of items.value) {
|
|
243
|
+
if (isSelected(item)) {
|
|
244
|
+
return activateItem(item, { focus });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function activateFirstItem({ focus = false }: ActivationOptions = {}) {
|
|
250
|
+
if (!items.value.length) return;
|
|
251
|
+
return activateItem(items.value[0], { focus });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function activateLastItem({ focus = false }: ActivationOptions = {}) {
|
|
255
|
+
if (!items.value.length) return;
|
|
256
|
+
return activateItem(items.value[items.value.length - 1], { focus });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function activateNextItem({
|
|
260
|
+
focus = false,
|
|
261
|
+
loop = false,
|
|
262
|
+
}: ActivationOptions & NavigationOptions = {}) {
|
|
263
|
+
if (activeItemId.value === undefined) return activateFirstItem({ focus });
|
|
264
|
+
const item = getItem(activeItemId.value);
|
|
265
|
+
const idx = items.value.indexOf(item);
|
|
266
|
+
let nextItem: Item;
|
|
267
|
+
if (idx + 1 < items.value.length) {
|
|
268
|
+
nextItem = items.value[idx + 1];
|
|
269
|
+
} else {
|
|
270
|
+
if (loop) {
|
|
271
|
+
nextItem = items.value[0];
|
|
272
|
+
} else {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return activateItem(nextItem, { focus });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function activatePrevItem({
|
|
280
|
+
focus = false,
|
|
281
|
+
loop = false,
|
|
282
|
+
}: ActivationOptions & NavigationOptions = {}) {
|
|
283
|
+
if (activeItemId.value === undefined) return activateLastItem({ focus });
|
|
284
|
+
const item = getItem(activeItemId.value);
|
|
285
|
+
const idx = items.value.indexOf(item);
|
|
286
|
+
let prevItem: Item;
|
|
287
|
+
if (idx - 1 > -1) {
|
|
288
|
+
prevItem = items.value[idx - 1];
|
|
289
|
+
} else {
|
|
290
|
+
if (loop) {
|
|
291
|
+
prevItem = items.value[items.value.length - 1];
|
|
292
|
+
} else {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return activateItem(prevItem, { focus });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
provide(injectList, {
|
|
300
|
+
value: readonly(value),
|
|
301
|
+
selectItem,
|
|
302
|
+
activateItem: activateItemById,
|
|
303
|
+
isSelected: isSelectedById,
|
|
304
|
+
isActive,
|
|
305
|
+
addItem,
|
|
306
|
+
removeItem,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
items: shallowReadonly(items),
|
|
311
|
+
isMultiselectable: readonly(isMultiselectable),
|
|
312
|
+
activeItem: computed(() =>
|
|
313
|
+
activeItemId.value !== undefined ? getItem(activeItemId.value) : undefined
|
|
314
|
+
),
|
|
315
|
+
selectActiveItem: () => {
|
|
316
|
+
if (activeItemId.value !== undefined) {
|
|
317
|
+
selectItem(activeItemId.value);
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
activateSelectedItem,
|
|
321
|
+
activateFirstItem,
|
|
322
|
+
activateLastItem,
|
|
323
|
+
activateNextItem,
|
|
324
|
+
activatePrevItem,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ComputedRef, MaybeRef } from "vue";
|
|
2
|
+
import {
|
|
3
|
+
ref,
|
|
4
|
+
unref,
|
|
5
|
+
inject,
|
|
6
|
+
computed,
|
|
7
|
+
onBeforeUnmount,
|
|
8
|
+
onBeforeMount,
|
|
9
|
+
} from "vue";
|
|
10
|
+
|
|
11
|
+
import type { TemplateRefOrSelector } from "../utils/element";
|
|
12
|
+
|
|
13
|
+
import type { ItemValue, ActivationOptions } from "./list";
|
|
14
|
+
import { injectList } from "./list";
|
|
15
|
+
|
|
16
|
+
interface UseListOptionReturn {
|
|
17
|
+
/**
|
|
18
|
+
* Whether the item is selected
|
|
19
|
+
*/
|
|
20
|
+
isSelected: ComputedRef<boolean>;
|
|
21
|
+
/**
|
|
22
|
+
* Whether the item is active
|
|
23
|
+
*/
|
|
24
|
+
isActive: ComputedRef<boolean>;
|
|
25
|
+
/**
|
|
26
|
+
* Select the item
|
|
27
|
+
*/
|
|
28
|
+
select: () => void;
|
|
29
|
+
/**
|
|
30
|
+
* Activate the item
|
|
31
|
+
*/
|
|
32
|
+
activate: (options?: ActivationOptions) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Registers an HTML element and an associated value in the list created in the parent component
|
|
37
|
+
*
|
|
38
|
+
* @param el A template ref or a CSS selector
|
|
39
|
+
* @param value A value of any type
|
|
40
|
+
* @param disabled Whether the item is disabled (false by default)
|
|
41
|
+
*/
|
|
42
|
+
export function useListOption(
|
|
43
|
+
el: TemplateRefOrSelector,
|
|
44
|
+
value: ItemValue,
|
|
45
|
+
disabled?: MaybeRef<boolean>
|
|
46
|
+
): UseListOptionReturn {
|
|
47
|
+
const list = inject(injectList);
|
|
48
|
+
if (!list) {
|
|
49
|
+
throw new Error("List injectable context is not provided");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const itemId = ref(-1);
|
|
53
|
+
|
|
54
|
+
const _isSelected = computed(() => list.isSelected(itemId.value));
|
|
55
|
+
const _isActive = computed(() => list.isActive(itemId.value));
|
|
56
|
+
|
|
57
|
+
function select() {
|
|
58
|
+
list!.selectItem(itemId.value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function activate({ focus = true } = {}) {
|
|
62
|
+
if (unref(disabled)) return;
|
|
63
|
+
list!.activateItem(itemId.value, { focus });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
onBeforeMount(() => {
|
|
67
|
+
itemId.value = list.addItem(el, value, disabled);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
onBeforeUnmount(() => {
|
|
71
|
+
list.removeItem(itemId.value);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
isSelected: _isSelected,
|
|
76
|
+
isActive: _isActive,
|
|
77
|
+
select,
|
|
78
|
+
activate,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import type { Ref } from "vue";
|
|
2
|
+
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
|
|
3
|
+
import { useHTMLElement, TemplateRefOrSelector } from "../utils/element";
|
|
4
|
+
|
|
5
|
+
interface UsePopoverOptions {
|
|
6
|
+
/**
|
|
7
|
+
* The direction of the popover dialog
|
|
8
|
+
*
|
|
9
|
+
* @defaultValue `"down"`
|
|
10
|
+
*/
|
|
11
|
+
direction?: "up" | "down" | "left" | "right";
|
|
12
|
+
/**
|
|
13
|
+
* The alignment of the popover dialog
|
|
14
|
+
*
|
|
15
|
+
* @defaultValue `"left"`
|
|
16
|
+
*/
|
|
17
|
+
align?: "top" | "bottom" | "left" | "right" | "center" | "stretch";
|
|
18
|
+
/**
|
|
19
|
+
* The CSS position of the popover dialog
|
|
20
|
+
*
|
|
21
|
+
* @defaultValue `"fixed"`
|
|
22
|
+
*/
|
|
23
|
+
position?: "fixed" | "absolute";
|
|
24
|
+
/**
|
|
25
|
+
* Close the popover on a click outside of the dialog
|
|
26
|
+
*
|
|
27
|
+
* @defaultValue `true`
|
|
28
|
+
*/
|
|
29
|
+
closeOnOutsideClick?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Close the popover on a click inside of the dialog
|
|
32
|
+
*
|
|
33
|
+
* @defaultValue `false`
|
|
34
|
+
*/
|
|
35
|
+
closeOnInsideClick?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface FocusOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Focus the popover/activator
|
|
41
|
+
*
|
|
42
|
+
* @defaultValue `true`
|
|
43
|
+
*/
|
|
44
|
+
focus?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface UsePopoverReturn {
|
|
48
|
+
/**
|
|
49
|
+
* The popover state
|
|
50
|
+
*/
|
|
51
|
+
isOpen: Ref<boolean>;
|
|
52
|
+
/**
|
|
53
|
+
* Open the popover
|
|
54
|
+
*/
|
|
55
|
+
open: (options?: FocusOptions) => void;
|
|
56
|
+
/**
|
|
57
|
+
* Close the popover
|
|
58
|
+
*/
|
|
59
|
+
close: (options?: FocusOptions) => void;
|
|
60
|
+
/**
|
|
61
|
+
* Toggle the popover
|
|
62
|
+
*/
|
|
63
|
+
toggle: (options?: FocusOptions) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
*
|
|
68
|
+
* @param activatorEl A template ref or a CSS selector
|
|
69
|
+
* @param popupEl A template ref or a CSS selector
|
|
70
|
+
* @param options Popover options
|
|
71
|
+
*/
|
|
72
|
+
export function usePopover(
|
|
73
|
+
activatorEl: TemplateRefOrSelector,
|
|
74
|
+
popupEl: TemplateRefOrSelector,
|
|
75
|
+
{
|
|
76
|
+
direction = "down",
|
|
77
|
+
align = "left",
|
|
78
|
+
position = "fixed",
|
|
79
|
+
closeOnOutsideClick = true,
|
|
80
|
+
closeOnInsideClick = false,
|
|
81
|
+
}: UsePopoverOptions = {}
|
|
82
|
+
): UsePopoverReturn {
|
|
83
|
+
const isOpen = ref(false);
|
|
84
|
+
|
|
85
|
+
function getActivator() {
|
|
86
|
+
const el = useHTMLElement(activatorEl);
|
|
87
|
+
if (!el.value) throw new Error("Activator element is undefined");
|
|
88
|
+
return el.value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getPopup() {
|
|
92
|
+
const el = useHTMLElement(popupEl);
|
|
93
|
+
if (!el.value) throw new Error("Popover element is undefined");
|
|
94
|
+
return el.value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function open({ focus = true }: FocusOptions = {}) {
|
|
98
|
+
isOpen.value = true;
|
|
99
|
+
if (focus)
|
|
100
|
+
nextTick(() => {
|
|
101
|
+
const popover = getPopup();
|
|
102
|
+
popover.focus();
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function close({ focus = true }: FocusOptions = {}) {
|
|
107
|
+
isOpen.value = false;
|
|
108
|
+
if (focus) {
|
|
109
|
+
const activator = getActivator();
|
|
110
|
+
activator.focus();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function toggle(options?: FocusOptions) {
|
|
115
|
+
if (isOpen.value) {
|
|
116
|
+
close(options);
|
|
117
|
+
} else {
|
|
118
|
+
open(options);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function calcPosition() {
|
|
123
|
+
if (!isOpen.value) return;
|
|
124
|
+
|
|
125
|
+
const activator = getActivator();
|
|
126
|
+
const popover = getPopup();
|
|
127
|
+
|
|
128
|
+
popover.style.position = position;
|
|
129
|
+
const {
|
|
130
|
+
top: aTop,
|
|
131
|
+
right: aRight,
|
|
132
|
+
bottom: aBottom,
|
|
133
|
+
left: aLeft,
|
|
134
|
+
width: aWidth,
|
|
135
|
+
height: aHeight,
|
|
136
|
+
} = activator.getBoundingClientRect();
|
|
137
|
+
|
|
138
|
+
const popoverHeight = popover.offsetHeight;
|
|
139
|
+
const popoverWidth = popover.offsetWidth; //> aWidth ? popover.offsetWidth : aWidth;
|
|
140
|
+
|
|
141
|
+
const viewportHeight = document.documentElement.clientHeight;
|
|
142
|
+
const viewportWidth = document.documentElement.clientWidth;
|
|
143
|
+
|
|
144
|
+
// reverse menu direction if it doesnt fit in the viewport
|
|
145
|
+
let _direction = direction;
|
|
146
|
+
|
|
147
|
+
if (direction == "up") {
|
|
148
|
+
if (aTop - popoverHeight < 0) {
|
|
149
|
+
_direction = "down";
|
|
150
|
+
}
|
|
151
|
+
} else if (direction == "down") {
|
|
152
|
+
if (aBottom + popoverHeight > viewportHeight) {
|
|
153
|
+
_direction = "up";
|
|
154
|
+
}
|
|
155
|
+
} else if (direction == "right") {
|
|
156
|
+
if (aRight + popoverWidth > viewportWidth) {
|
|
157
|
+
_direction = "left";
|
|
158
|
+
}
|
|
159
|
+
} else if (direction == "left") {
|
|
160
|
+
if (aLeft - popoverWidth < 0) {
|
|
161
|
+
_direction = "right";
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let _align = align;
|
|
166
|
+
|
|
167
|
+
if (align == "left") {
|
|
168
|
+
if (aLeft + popoverWidth > viewportWidth) {
|
|
169
|
+
_align = "right";
|
|
170
|
+
}
|
|
171
|
+
} else if (align == "right") {
|
|
172
|
+
if (aRight - popoverWidth < 0) {
|
|
173
|
+
_align = "left";
|
|
174
|
+
}
|
|
175
|
+
} else if (align == "top") {
|
|
176
|
+
if (aTop + popoverHeight > viewportHeight) {
|
|
177
|
+
_align = "bottom";
|
|
178
|
+
}
|
|
179
|
+
} else if (align == "bottom") {
|
|
180
|
+
if (aBottom - popoverHeight < 0) {
|
|
181
|
+
_align = "top";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const _position = {
|
|
186
|
+
top: "auto",
|
|
187
|
+
bottom: "auto",
|
|
188
|
+
left: "auto",
|
|
189
|
+
right: "auto",
|
|
190
|
+
width: "auto",
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const isFixed = position === "fixed";
|
|
194
|
+
|
|
195
|
+
if (_direction == "up") {
|
|
196
|
+
_position.bottom = `${
|
|
197
|
+
aHeight + (isFixed ? viewportHeight - aBottom : 0)
|
|
198
|
+
}px`;
|
|
199
|
+
} else if (_direction == "down") {
|
|
200
|
+
_position.top = `${aHeight + (isFixed ? aTop : 0)}px`;
|
|
201
|
+
} else if (_direction == "right") {
|
|
202
|
+
_position.left = `${aWidth + (isFixed ? aLeft : 0)}px`;
|
|
203
|
+
} else if (_direction == "left") {
|
|
204
|
+
_position.right = `${aWidth + (isFixed ? viewportWidth - aRight : 0)}px`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (["up", "down"].includes(_direction)) {
|
|
208
|
+
if (_align == "right") {
|
|
209
|
+
_position.right = `${isFixed ? viewportWidth - aRight : 0}px`;
|
|
210
|
+
} else if (_align == "left" || _align == "stretch") {
|
|
211
|
+
_position.left = `${isFixed ? aLeft : 0}px`;
|
|
212
|
+
} else if (_align == "center") {
|
|
213
|
+
_position.left = `${
|
|
214
|
+
(isFixed ? aLeft : 0) - (popoverWidth - aWidth) / 2
|
|
215
|
+
}px`;
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
if (_align == "bottom") {
|
|
219
|
+
_position.bottom = `${isFixed ? viewportHeight - aBottom : 0}px`;
|
|
220
|
+
} else if (_align == "top") {
|
|
221
|
+
_position.top = `${isFixed ? aTop : 0}px`;
|
|
222
|
+
} else if (_align == "center") {
|
|
223
|
+
_position.top = `${
|
|
224
|
+
(isFixed ? aTop : 0) - (popoverHeight - aHeight) / 2
|
|
225
|
+
}px`;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (["up", "down"].includes(_direction) && _align === "stretch") {
|
|
230
|
+
_position["width"] = aWidth + "px";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
for (const prop in _position) {
|
|
234
|
+
popover.style[prop as keyof typeof _position] =
|
|
235
|
+
_position[prop as keyof typeof _position];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
watch(
|
|
240
|
+
isOpen,
|
|
241
|
+
(val) => {
|
|
242
|
+
if (val) {
|
|
243
|
+
nextTick(() => {
|
|
244
|
+
calcPosition();
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
{ flush: "pre" }
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
function onWindowClick(evt: MouseEvent) {
|
|
252
|
+
const target = evt.target as HTMLElement;
|
|
253
|
+
|
|
254
|
+
// ignore activator clicks
|
|
255
|
+
const activator = getActivator();
|
|
256
|
+
if (activator.contains(target)) return;
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
const popover = getPopup();
|
|
260
|
+
// inside
|
|
261
|
+
if (popover.contains(target)) {
|
|
262
|
+
if (closeOnInsideClick) {
|
|
263
|
+
close();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// outside
|
|
267
|
+
else {
|
|
268
|
+
if (closeOnOutsideClick) {
|
|
269
|
+
isOpen.value = false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} catch (e) {}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const resizeObserver = new ResizeObserver(calcPosition);
|
|
276
|
+
const abortController = new AbortController();
|
|
277
|
+
|
|
278
|
+
onMounted(() => {
|
|
279
|
+
window.addEventListener("resize", calcPosition, {
|
|
280
|
+
passive: true,
|
|
281
|
+
signal: abortController.signal,
|
|
282
|
+
});
|
|
283
|
+
window.addEventListener("scroll", calcPosition, {
|
|
284
|
+
passive: true,
|
|
285
|
+
//capture: true,
|
|
286
|
+
signal: abortController.signal,
|
|
287
|
+
});
|
|
288
|
+
window.addEventListener("click", onWindowClick, {
|
|
289
|
+
passive: true,
|
|
290
|
+
signal: abortController.signal,
|
|
291
|
+
});
|
|
292
|
+
resizeObserver.observe(getActivator());
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
onBeforeUnmount(() => {
|
|
296
|
+
abortController.abort();
|
|
297
|
+
resizeObserver.disconnect();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
isOpen,
|
|
302
|
+
open,
|
|
303
|
+
close,
|
|
304
|
+
toggle,
|
|
305
|
+
};
|
|
306
|
+
}
|