@sit-onyx/headless 1.0.0-beta.17 → 1.0.0-beta.19
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/package.json +4 -4
- package/src/composables/comboBox/SelectOnlyCombobox.vue +3 -3
- package/src/composables/comboBox/TestCombobox.vue +3 -3
- package/src/composables/comboBox/createComboBox.ts +4 -3
- package/src/composables/helpers/useGlobalListener.ts +1 -1
- package/src/composables/helpers/useOutsideClick.spec.ts +35 -1
- package/src/composables/helpers/useOutsideClick.ts +42 -12
- package/src/composables/listbox/createListbox.ts +4 -3
- package/src/composables/menuButton/TestMenuButton.vue +2 -2
- package/src/composables/menuButton/createMenuButton.testing.ts +0 -19
- package/src/composables/menuButton/createMenuButton.ts +48 -25
- package/src/composables/navigationMenu/createMenu.ts +1 -1
- package/src/composables/tabs/TestTabs.ct.tsx +0 -1
- package/src/composables/tooltip/createToggletip.ts +11 -14
- package/src/composables/tooltip/createTooltip.ts +1 -0
- package/src/utils/builder.ts +1 -1
- package/src/utils/types.ts +5 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sit-onyx/headless",
|
|
3
3
|
"description": "Headless composables for Vue",
|
|
4
|
-
"version": "1.0.0-beta.
|
|
4
|
+
"version": "1.0.0-beta.19",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Schwarz IT KG",
|
|
7
7
|
"license": "Apache-2.0",
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"vue": ">= 3.5.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@vue/compiler-dom": "3.5.
|
|
31
|
-
"vue": "3.5.
|
|
32
|
-
"@sit-onyx/shared": "^1.0.0-beta.
|
|
30
|
+
"@vue/compiler-dom": "3.5.16",
|
|
31
|
+
"vue": "3.5.16",
|
|
32
|
+
"@sit-onyx/shared": "^1.0.0-beta.3"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
35
|
"build": "vue-tsc --build --force",
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, ref } from "vue";
|
|
2
|
+
import { computed, ref, useTemplateRef } from "vue";
|
|
3
3
|
import { createComboBox } from "./createComboBox";
|
|
4
4
|
|
|
5
5
|
const options = ["a", "b", "c", "d"];
|
|
6
6
|
const isExpanded = ref(false);
|
|
7
|
-
const comboboxRef =
|
|
7
|
+
const comboboxRef = useTemplateRef("combobox");
|
|
8
8
|
const activeOption = ref("");
|
|
9
9
|
const selectedOption = ref("");
|
|
10
10
|
const selectedIndex = computed<number | undefined>(() => {
|
|
@@ -49,7 +49,7 @@ defineExpose({ comboBox });
|
|
|
49
49
|
</script>
|
|
50
50
|
|
|
51
51
|
<template>
|
|
52
|
-
<div ref="
|
|
52
|
+
<div ref="combobox">
|
|
53
53
|
<input
|
|
54
54
|
v-bind="input"
|
|
55
55
|
v-model="selectedOption"
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, ref } from "vue";
|
|
2
|
+
import { computed, ref, useTemplateRef } from "vue";
|
|
3
3
|
import { createComboBox } from "./createComboBox";
|
|
4
4
|
|
|
5
5
|
const options = ["a", "b", "c", "d"];
|
|
6
6
|
const isExpanded = ref(false);
|
|
7
7
|
const searchTerm = ref("");
|
|
8
|
-
const comboboxRef =
|
|
8
|
+
const comboboxRef = useTemplateRef("combobox");
|
|
9
9
|
const activeOption = ref("");
|
|
10
10
|
const filteredOptions = computed(() => options.filter((v) => v.includes(searchTerm.value)));
|
|
11
11
|
const selectedIndex = computed<number | undefined>(() => {
|
|
@@ -53,7 +53,7 @@ defineExpose({ comboBox });
|
|
|
53
53
|
</script>
|
|
54
54
|
|
|
55
55
|
<template>
|
|
56
|
-
<div ref="
|
|
56
|
+
<div ref="combobox">
|
|
57
57
|
<input v-bind="input" v-model="searchTerm" @keydown.arrow-down="isExpanded = true" />
|
|
58
58
|
|
|
59
59
|
<button v-bind="button" type="button">
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { computed, unref, useId, type MaybeRef, type Ref } from "vue";
|
|
2
2
|
import { createBuilder } from "../../utils/builder";
|
|
3
3
|
import { isPrintableCharacter, wasKeyPressed, type PressedKey } from "../../utils/keyboard";
|
|
4
|
+
import type { Nullable } from "../../utils/types";
|
|
4
5
|
import { useOutsideClick } from "../helpers/useOutsideClick";
|
|
5
6
|
import { useTypeAhead } from "../helpers/useTypeAhead";
|
|
6
7
|
import {
|
|
@@ -48,7 +49,7 @@ export type CreateComboboxOptions<
|
|
|
48
49
|
/**
|
|
49
50
|
* Provides additional description for the listbox which displays the available options.
|
|
50
51
|
*/
|
|
51
|
-
listDescription?: MaybeRef<string
|
|
52
|
+
listDescription?: MaybeRef<Nullable<string>>;
|
|
52
53
|
/**
|
|
53
54
|
* Controls the opened/visible state of the associated pop-up. When expanded the activeOption can be controlled via the keyboard.
|
|
54
55
|
*/
|
|
@@ -56,11 +57,11 @@ export type CreateComboboxOptions<
|
|
|
56
57
|
/**
|
|
57
58
|
* If expanded, the active option is the currently highlighted option of the controlled listbox.
|
|
58
59
|
*/
|
|
59
|
-
activeOption: Ref<TValue
|
|
60
|
+
activeOption: Ref<Nullable<TValue>>;
|
|
60
61
|
/**
|
|
61
62
|
* Template ref to the component root (required to close combobox on outside click).
|
|
62
63
|
*/
|
|
63
|
-
templateRef: Ref<HTMLElement
|
|
64
|
+
templateRef: Ref<Nullable<HTMLElement>>;
|
|
64
65
|
/**
|
|
65
66
|
* Hook when the popover should toggle.
|
|
66
67
|
*
|
|
@@ -3,7 +3,7 @@ import { onBeforeMount, onBeforeUnmount, reactive, watchEffect, type Ref } from
|
|
|
3
3
|
type DocumentEventType = keyof DocumentEventMap;
|
|
4
4
|
type GlobalListener<K extends DocumentEventType = DocumentEventType> = (
|
|
5
5
|
event: DocumentEventMap[K],
|
|
6
|
-
) =>
|
|
6
|
+
) => unknown;
|
|
7
7
|
|
|
8
8
|
export type UseGlobalEventListenerOptions<K extends DocumentEventType> = {
|
|
9
9
|
type: K;
|
|
@@ -13,8 +13,9 @@ describe("useOutsideClick", () => {
|
|
|
13
13
|
expect(useOutsideClick).toBeDefined();
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
it("should detect outside clicks", () => {
|
|
16
|
+
it("should detect outside clicks", async () => {
|
|
17
17
|
// ARRANGE
|
|
18
|
+
vi.useFakeTimers();
|
|
18
19
|
const inside = ref(document.createElement("button"));
|
|
19
20
|
document.body.appendChild(inside.value);
|
|
20
21
|
const outside = ref(document.createElement("button"));
|
|
@@ -22,12 +23,24 @@ describe("useOutsideClick", () => {
|
|
|
22
23
|
|
|
23
24
|
const onOutsideClick = vi.fn();
|
|
24
25
|
useOutsideClick({ inside, onOutsideClick });
|
|
26
|
+
|
|
25
27
|
// ACT
|
|
26
28
|
const event = new MouseEvent("click", { bubbles: true });
|
|
27
29
|
outside.value.dispatchEvent(event);
|
|
30
|
+
|
|
28
31
|
// ASSERT
|
|
29
32
|
expect(onOutsideClick).toHaveBeenCalledTimes(1);
|
|
30
33
|
expect(onOutsideClick).toBeCalledWith(event);
|
|
34
|
+
|
|
35
|
+
// ACT
|
|
36
|
+
outside.value.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Tab" }));
|
|
37
|
+
await vi.runAllTimersAsync();
|
|
38
|
+
|
|
39
|
+
// ASSERT
|
|
40
|
+
expect(
|
|
41
|
+
onOutsideClick,
|
|
42
|
+
"should not trigger on Tab press when checkOnTab option is disabled",
|
|
43
|
+
).toHaveBeenCalledTimes(1);
|
|
31
44
|
});
|
|
32
45
|
|
|
33
46
|
it("should detect outside clicks correctly for multiple inside elements", () => {
|
|
@@ -80,4 +93,25 @@ describe("useOutsideClick", () => {
|
|
|
80
93
|
// ASSERT
|
|
81
94
|
expect(onOutsideClick).toHaveBeenCalledTimes(1);
|
|
82
95
|
});
|
|
96
|
+
|
|
97
|
+
it("should detect outside tab via keyboard", async () => {
|
|
98
|
+
// ARRANGE
|
|
99
|
+
vi.useFakeTimers();
|
|
100
|
+
const inside = ref(document.createElement("button"));
|
|
101
|
+
document.body.appendChild(inside.value);
|
|
102
|
+
const outside = ref(document.createElement("button"));
|
|
103
|
+
document.body.appendChild(outside.value);
|
|
104
|
+
|
|
105
|
+
const onOutsideClick = vi.fn();
|
|
106
|
+
useOutsideClick({ inside, onOutsideClick, checkOnTab: true });
|
|
107
|
+
|
|
108
|
+
// ACT
|
|
109
|
+
const event = new KeyboardEvent("keydown", { bubbles: true, key: "Tab" });
|
|
110
|
+
outside.value.dispatchEvent(event);
|
|
111
|
+
await vi.runAllTimersAsync();
|
|
112
|
+
|
|
113
|
+
// ASSERT
|
|
114
|
+
expect(onOutsideClick).toHaveBeenCalledTimes(1);
|
|
115
|
+
expect(onOutsideClick).toBeCalledWith(event);
|
|
116
|
+
});
|
|
83
117
|
});
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import type { Arrayable } from "vitest"; // For an unknown reason removing this import will break the build of "demo-app" and "playground"
|
|
2
2
|
import { toValue, type MaybeRefOrGetter, type Ref } from "vue";
|
|
3
|
+
import type { Nullable } from "../../utils/types";
|
|
3
4
|
import { useGlobalEventListener } from "./useGlobalListener";
|
|
4
5
|
|
|
5
|
-
export type UseOutsideClickOptions = {
|
|
6
|
+
export type UseOutsideClickOptions<TCheckOnTab extends boolean | undefined = undefined> = {
|
|
6
7
|
/**
|
|
7
8
|
* HTML element of the component where clicks should be ignored
|
|
8
9
|
*/
|
|
9
|
-
inside: MaybeRefOrGetter<Arrayable<HTMLElement
|
|
10
|
+
inside: MaybeRefOrGetter<Arrayable<Nullable<HTMLElement>>>;
|
|
10
11
|
/**
|
|
11
12
|
* Callback when an outside click occurred.
|
|
12
13
|
*/
|
|
13
|
-
onOutsideClick: (
|
|
14
|
+
onOutsideClick: (
|
|
15
|
+
event: TCheckOnTab extends true ? MouseEvent | KeyboardEvent : MouseEvent,
|
|
16
|
+
) => void;
|
|
17
|
+
/**
|
|
18
|
+
* Whether the outside focus should also be checked when pressing the Tab key.
|
|
19
|
+
*/
|
|
20
|
+
checkOnTab?: TCheckOnTab;
|
|
14
21
|
/**
|
|
15
22
|
* If `true`, event listeners will be removed and no outside clicks will be captured.
|
|
16
23
|
*/
|
|
@@ -21,19 +28,42 @@ export type UseOutsideClickOptions = {
|
|
|
21
28
|
* Composable for listening to click events that occur outside of a component.
|
|
22
29
|
* Useful to e.g. close flyouts or tooltips.
|
|
23
30
|
*/
|
|
24
|
-
export const useOutsideClick =
|
|
31
|
+
export const useOutsideClick = <TCheckOnTab extends boolean | undefined>({
|
|
32
|
+
inside,
|
|
33
|
+
onOutsideClick,
|
|
34
|
+
disabled,
|
|
35
|
+
checkOnTab,
|
|
36
|
+
}: UseOutsideClickOptions<TCheckOnTab>) => {
|
|
37
|
+
const isOutsideClick = (target: EventTarget | null) => {
|
|
38
|
+
if (!target) return true;
|
|
39
|
+
const raw = toValue(inside);
|
|
40
|
+
const elements = Array.isArray(raw) ? raw : [raw];
|
|
41
|
+
return !elements.some((element) => element?.contains(target as Node | null));
|
|
42
|
+
};
|
|
43
|
+
|
|
25
44
|
/**
|
|
26
45
|
* Document click handle that closes then tooltip when clicked outside.
|
|
27
46
|
* Should only be called when trigger is "click".
|
|
28
47
|
*/
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
const elements = Array.isArray(raw) ? raw : [raw];
|
|
32
|
-
const isOutsideClick = !elements.some((element) =>
|
|
33
|
-
element?.contains(event.target as HTMLElement),
|
|
34
|
-
);
|
|
35
|
-
if (isOutsideClick) onOutsideClick(event);
|
|
48
|
+
const clickListener = (event: MouseEvent) => {
|
|
49
|
+
if (isOutsideClick(event.target)) onOutsideClick(event);
|
|
36
50
|
};
|
|
37
51
|
|
|
38
|
-
useGlobalEventListener({ type: "click", listener, disabled });
|
|
52
|
+
useGlobalEventListener({ type: "click", listener: clickListener, disabled });
|
|
53
|
+
|
|
54
|
+
if (checkOnTab) {
|
|
55
|
+
const keydownListener = async (event: KeyboardEvent) => {
|
|
56
|
+
if (event.key !== "Tab") return;
|
|
57
|
+
|
|
58
|
+
// using setTimeout here to guarantee that side effects that might change the document.activeElement have run before checking
|
|
59
|
+
// the activeElement
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve));
|
|
61
|
+
|
|
62
|
+
if (isOutsideClick(document.activeElement)) {
|
|
63
|
+
onOutsideClick(event as Parameters<typeof onOutsideClick>[0]);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
useGlobalEventListener({ type: "keydown", listener: keydownListener, disabled });
|
|
68
|
+
}
|
|
39
69
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { computed, nextTick, ref, unref, useId, watchEffect, type MaybeRef, type Ref } from "vue";
|
|
2
2
|
import { createBuilder, type VBindAttributes } from "../../utils/builder";
|
|
3
|
+
import type { Nullable } from "../../utils/types";
|
|
3
4
|
import { useTypeAhead } from "../helpers/useTypeAhead";
|
|
4
5
|
|
|
5
6
|
export type ListboxValue = string | number | boolean;
|
|
@@ -12,11 +13,11 @@ export type CreateListboxOptions<TValue extends ListboxValue, TMultiple extends
|
|
|
12
13
|
/**
|
|
13
14
|
* Aria description for the listbox.
|
|
14
15
|
*/
|
|
15
|
-
description?: MaybeRef<string
|
|
16
|
+
description?: MaybeRef<Nullable<string>>;
|
|
16
17
|
/**
|
|
17
18
|
* Value of currently (visually) active option.
|
|
18
19
|
*/
|
|
19
|
-
activeOption: Ref<TValue
|
|
20
|
+
activeOption: Ref<Nullable<TValue>>;
|
|
20
21
|
/**
|
|
21
22
|
* Wether the listbox is controlled from the outside, e.g. by a combobox.
|
|
22
23
|
* This disables keyboard events and makes the listbox not focusable.
|
|
@@ -29,7 +30,7 @@ export type CreateListboxOptions<TValue extends ListboxValue, TMultiple extends
|
|
|
29
30
|
/**
|
|
30
31
|
* Whether the listbox is multiselect.
|
|
31
32
|
*/
|
|
32
|
-
multiple?: MaybeRef<TMultiple
|
|
33
|
+
multiple?: MaybeRef<Nullable<TMultiple>>;
|
|
33
34
|
/**
|
|
34
35
|
* Hook when an option is selected.
|
|
35
36
|
*/
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts" setup>
|
|
2
|
-
import { ref
|
|
2
|
+
import { ref } from "vue";
|
|
3
3
|
import { createMenuButton } from "./createMenuButton";
|
|
4
4
|
|
|
5
5
|
const items = Array.from({ length: 10 }, (_, index) => {
|
|
@@ -10,7 +10,7 @@ const items = Array.from({ length: 10 }, (_, index) => {
|
|
|
10
10
|
const activeItem = ref<string>();
|
|
11
11
|
const isExpanded = ref(false);
|
|
12
12
|
const onToggle = () => (isExpanded.value = !isExpanded.value);
|
|
13
|
-
const trigger
|
|
13
|
+
const trigger = ref<"click" | "hover">("hover");
|
|
14
14
|
|
|
15
15
|
const {
|
|
16
16
|
elements: { root, button, menu, menuItem, listItem },
|
|
@@ -30,13 +30,6 @@ export const menuButtonTesting = async ({
|
|
|
30
30
|
menu,
|
|
31
31
|
menuItems,
|
|
32
32
|
}: MenuButtonTestingOptions) => {
|
|
33
|
-
const menuId = await menu.getAttribute("id");
|
|
34
|
-
expect(menuId).toBeDefined();
|
|
35
|
-
await expect(
|
|
36
|
-
button,
|
|
37
|
-
"navigation menu should have set the list ID to the aria-controls",
|
|
38
|
-
).toHaveAttribute("aria-controls", menuId!);
|
|
39
|
-
|
|
40
33
|
await expect(
|
|
41
34
|
button,
|
|
42
35
|
'navigation menu should have an "aria-haspopup" attribute set to true',
|
|
@@ -80,18 +73,6 @@ export const menuButtonTesting = async ({
|
|
|
80
73
|
await menu.press("ArrowUp");
|
|
81
74
|
await expect(firstItem, "First item should be focused when pressing arrow up key").toBeFocused();
|
|
82
75
|
|
|
83
|
-
await menu.press("ArrowRight");
|
|
84
|
-
await expect(
|
|
85
|
-
secondItem,
|
|
86
|
-
"Second item should be focused when pressing arrow right key",
|
|
87
|
-
).toBeFocused();
|
|
88
|
-
|
|
89
|
-
await menu.press("ArrowLeft");
|
|
90
|
-
await expect(
|
|
91
|
-
firstItem,
|
|
92
|
-
"First item should be focused when pressing arrow left key",
|
|
93
|
-
).toBeFocused();
|
|
94
|
-
|
|
95
76
|
await page.keyboard.press("Tab");
|
|
96
77
|
await expect(button, "Button should be focused when pressing tab key").not.toBeFocused();
|
|
97
78
|
|
|
@@ -2,11 +2,13 @@ import { computed, useId, watch, type Ref } from "vue";
|
|
|
2
2
|
import { createBuilder, createElRef } from "../../utils/builder";
|
|
3
3
|
import { debounce } from "../../utils/timer";
|
|
4
4
|
import { useGlobalEventListener } from "../helpers/useGlobalListener";
|
|
5
|
+
import { useOutsideClick } from "../helpers/useOutsideClick";
|
|
5
6
|
|
|
6
7
|
type CreateMenuButtonOptions = {
|
|
7
8
|
isExpanded: Readonly<Ref<boolean>>;
|
|
8
9
|
trigger: Readonly<Ref<"hover" | "click">>;
|
|
9
10
|
onToggle: () => void;
|
|
11
|
+
disabled?: Readonly<Ref<boolean>>;
|
|
10
12
|
};
|
|
11
13
|
|
|
12
14
|
/**
|
|
@@ -15,6 +17,7 @@ type CreateMenuButtonOptions = {
|
|
|
15
17
|
export const createMenuButton = createBuilder((options: CreateMenuButtonOptions) => {
|
|
16
18
|
const rootId = useId();
|
|
17
19
|
const menuId = useId();
|
|
20
|
+
const rootRef = createElRef<HTMLElement>();
|
|
18
21
|
const menuRef = createElRef<HTMLElement>();
|
|
19
22
|
const buttonId = useId();
|
|
20
23
|
|
|
@@ -31,6 +34,7 @@ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions)
|
|
|
31
34
|
watch(options.isExpanded, () => updateDebouncedExpanded.abort()); // manually changing `isExpanded` should abort debounced action
|
|
32
35
|
|
|
33
36
|
const setExpanded = (expanded: boolean, debounced = false) => {
|
|
37
|
+
if (options.disabled?.value) return;
|
|
34
38
|
if (expanded === options.isExpanded.value) {
|
|
35
39
|
updateDebouncedExpanded.abort();
|
|
36
40
|
return;
|
|
@@ -50,7 +54,9 @@ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions)
|
|
|
50
54
|
const currentMenu = currentMenuItem?.closest('[role="menu"]') || menuRef.value;
|
|
51
55
|
if (!currentMenu) return;
|
|
52
56
|
|
|
53
|
-
const menuItems =
|
|
57
|
+
const menuItems = Array.from(currentMenu.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
|
58
|
+
// filter out nested children
|
|
59
|
+
.filter((item) => item.closest('[role="menu"]') === currentMenu);
|
|
54
60
|
let nextIndex = 0;
|
|
55
61
|
|
|
56
62
|
if (currentMenuItem) {
|
|
@@ -78,12 +84,10 @@ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions)
|
|
|
78
84
|
const handleKeydown = (event: KeyboardEvent) => {
|
|
79
85
|
switch (event.key) {
|
|
80
86
|
case "ArrowDown":
|
|
81
|
-
case "ArrowRight":
|
|
82
87
|
event.preventDefault();
|
|
83
88
|
focusRelativeItem("next");
|
|
84
89
|
break;
|
|
85
90
|
case "ArrowUp":
|
|
86
|
-
case "ArrowLeft":
|
|
87
91
|
event.preventDefault();
|
|
88
92
|
focusRelativeItem("prev");
|
|
89
93
|
break;
|
|
@@ -96,6 +100,8 @@ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions)
|
|
|
96
100
|
focusRelativeItem("last");
|
|
97
101
|
break;
|
|
98
102
|
case " ":
|
|
103
|
+
case "Enter":
|
|
104
|
+
if (event.target instanceof HTMLInputElement) break;
|
|
99
105
|
event.preventDefault();
|
|
100
106
|
(event.target as HTMLElement).click();
|
|
101
107
|
break;
|
|
@@ -106,32 +112,29 @@ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions)
|
|
|
106
112
|
}
|
|
107
113
|
};
|
|
108
114
|
|
|
109
|
-
const triggerEvents = () => {
|
|
110
|
-
if (options.trigger.value
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
const triggerEvents = computed(() => {
|
|
116
|
+
if (options.trigger.value !== "hover") return;
|
|
117
|
+
return {
|
|
118
|
+
onMouseenter: () => setExpanded(true),
|
|
119
|
+
onMouseleave: () => setExpanded(false, true),
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
useOutsideClick({
|
|
124
|
+
inside: rootRef,
|
|
125
|
+
onOutsideClick: () => setExpanded(false),
|
|
126
|
+
disabled: computed(() => !options.isExpanded.value),
|
|
127
|
+
checkOnTab: true,
|
|
128
|
+
});
|
|
117
129
|
|
|
118
130
|
return {
|
|
119
131
|
elements: {
|
|
120
|
-
root: {
|
|
132
|
+
root: computed(() => ({
|
|
121
133
|
id: rootId,
|
|
122
134
|
onKeydown: handleKeydown,
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (
|
|
127
|
-
rootId &&
|
|
128
|
-
document.getElementById(rootId)?.contains(event.relatedTarget as HTMLElement)
|
|
129
|
-
) {
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
setExpanded(false);
|
|
133
|
-
},
|
|
134
|
-
},
|
|
135
|
+
ref: rootRef,
|
|
136
|
+
...triggerEvents.value,
|
|
137
|
+
})),
|
|
135
138
|
button: computed(
|
|
136
139
|
() =>
|
|
137
140
|
({
|
|
@@ -142,6 +145,7 @@ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions)
|
|
|
142
145
|
onClick: () =>
|
|
143
146
|
options.trigger.value == "click" ? setExpanded(!options.isExpanded.value) : undefined,
|
|
144
147
|
id: buttonId,
|
|
148
|
+
disabled: options.disabled?.value,
|
|
145
149
|
}) as const,
|
|
146
150
|
),
|
|
147
151
|
menu: {
|
|
@@ -156,7 +160,25 @@ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions)
|
|
|
156
160
|
};
|
|
157
161
|
});
|
|
158
162
|
|
|
159
|
-
|
|
163
|
+
type CreateMenuItemOptions = {
|
|
164
|
+
/**
|
|
165
|
+
* Called when the menu item should be opened (if it has nested children).
|
|
166
|
+
*/
|
|
167
|
+
onOpen?: () => void;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export const createMenuItems = createBuilder((options?: CreateMenuItemOptions) => {
|
|
171
|
+
const onKeydown = (event: KeyboardEvent) => {
|
|
172
|
+
switch (event.key) {
|
|
173
|
+
case "ArrowRight":
|
|
174
|
+
case " ":
|
|
175
|
+
case "Enter":
|
|
176
|
+
event.preventDefault();
|
|
177
|
+
options?.onOpen?.();
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
160
182
|
return {
|
|
161
183
|
elements: {
|
|
162
184
|
listItem: {
|
|
@@ -166,6 +188,7 @@ export const createMenuItems = createBuilder(() => {
|
|
|
166
188
|
"aria-current": data.active ? "page" : undefined,
|
|
167
189
|
"aria-disabled": data.disabled,
|
|
168
190
|
role: "menuitem",
|
|
191
|
+
onKeydown,
|
|
169
192
|
}),
|
|
170
193
|
},
|
|
171
194
|
};
|
|
@@ -19,7 +19,7 @@ export const createNavigationMenu = createBuilder(({ navigationName }: CreateNav
|
|
|
19
19
|
const getMenuButtons = () => {
|
|
20
20
|
const nav = navId ? document.getElementById(navId) : undefined;
|
|
21
21
|
if (!nav) return [];
|
|
22
|
-
return
|
|
22
|
+
return Array.from(nav.querySelectorAll<HTMLElement>("button[aria-expanded][aria-controls]"));
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
const focusRelative = (trigger: HTMLElement, next: "next" | "previous") => {
|
|
@@ -2,7 +2,6 @@ import { test } from "@playwright/experimental-ct-vue";
|
|
|
2
2
|
import TestTabs from "./TestTabs.vue";
|
|
3
3
|
import { tabsTesting } from "./createTabs.testing";
|
|
4
4
|
|
|
5
|
-
// eslint-disable-next-line playwright/expect-expect
|
|
6
5
|
test("tabs", async ({ mount, page }) => {
|
|
7
6
|
const component = await mount(<TestTabs />);
|
|
8
7
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { computed, toRef, toValue, type MaybeRefOrGetter, type Ref } from "vue";
|
|
2
|
-
import { createBuilder
|
|
1
|
+
import { computed, toRef, toValue, useId, type MaybeRefOrGetter, type Ref } from "vue";
|
|
2
|
+
import { createBuilder } from "../../utils/builder";
|
|
3
3
|
import { useDismissible } from "../helpers/useDismissible";
|
|
4
|
-
import { useOutsideClick } from "../helpers/useOutsideClick";
|
|
5
4
|
|
|
6
5
|
export type CreateToggletipOptions = {
|
|
7
6
|
toggleLabel: MaybeRefOrGetter<string>;
|
|
@@ -17,16 +16,9 @@ export type CreateToggletipOptions = {
|
|
|
17
16
|
*/
|
|
18
17
|
export const createToggletip = createBuilder(
|
|
19
18
|
({ toggleLabel, isVisible }: CreateToggletipOptions) => {
|
|
20
|
-
const
|
|
21
|
-
const tooltipRef = createElRef<HTMLElement>();
|
|
22
|
-
const _isVisible = toRef(isVisible ?? false);
|
|
19
|
+
const triggerId = useId();
|
|
23
20
|
|
|
24
|
-
|
|
25
|
-
useOutsideClick({
|
|
26
|
-
inside: computed(() => [triggerRef.value, tooltipRef.value]),
|
|
27
|
-
onOutsideClick: () => (_isVisible.value = false),
|
|
28
|
-
disabled: computed(() => !_isVisible.value),
|
|
29
|
-
});
|
|
21
|
+
const _isVisible = toRef(isVisible ?? false);
|
|
30
22
|
|
|
31
23
|
useDismissible({ isExpanded: _isVisible });
|
|
32
24
|
|
|
@@ -39,7 +31,7 @@ export const createToggletip = createBuilder(
|
|
|
39
31
|
* Preferably a `button` element.
|
|
40
32
|
*/
|
|
41
33
|
trigger: computed(() => ({
|
|
42
|
-
|
|
34
|
+
id: triggerId,
|
|
43
35
|
onClick: toggle,
|
|
44
36
|
"aria-label": toValue(toggleLabel),
|
|
45
37
|
})),
|
|
@@ -48,7 +40,12 @@ export const createToggletip = createBuilder(
|
|
|
48
40
|
* Only simple, textual content is allowed.
|
|
49
41
|
*/
|
|
50
42
|
tooltip: {
|
|
51
|
-
|
|
43
|
+
onToggle: (e: Event) => {
|
|
44
|
+
const tooltip = e.target as HTMLDialogElement;
|
|
45
|
+
_isVisible.value = tooltip.matches(":popover-open");
|
|
46
|
+
},
|
|
47
|
+
anchor: triggerId,
|
|
48
|
+
popover: "auto",
|
|
52
49
|
role: "status",
|
|
53
50
|
tabindex: "-1",
|
|
54
51
|
},
|
package/src/utils/builder.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type IteratedHeadlessElementFunc<
|
|
|
29
29
|
|
|
30
30
|
export type HeadlessElementAttributes<A extends HTMLAttributes> =
|
|
31
31
|
| VBindAttributes<A>
|
|
32
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- the specific type doesn't matter here
|
|
33
33
|
| IteratedHeadlessElementFunc<A, any>;
|
|
34
34
|
|
|
35
35
|
export type HeadlessElements = Record<string, MaybeRef<HeadlessElementAttributes<HTMLAttributes>>>;
|
package/src/utils/types.ts
CHANGED
|
@@ -26,3 +26,8 @@ export type IsArray<TValue, TMultiple extends boolean = false> = TMultiple exten
|
|
|
26
26
|
* A type that can be wrapped in an array.
|
|
27
27
|
*/
|
|
28
28
|
export type Arrayable<T> = T | Array<T>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Either the actual value or a nullish one.
|
|
32
|
+
*/
|
|
33
|
+
export type Nullable<T = never> = T | null | undefined;
|