@sit-onyx/headless 1.0.0-beta.20 → 1.0.0-beta.22
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/dist/composables/comboBox/SelectOnlyCombobox.d.vue.ts +299 -0
- package/dist/composables/comboBox/TestCombobox.ct.d.ts +1 -0
- package/dist/composables/comboBox/TestCombobox.d.vue.ts +299 -0
- package/dist/composables/comboBox/createComboBox.d.ts +370 -0
- package/dist/composables/comboBox/createComboBox.testing.d.ts +10 -0
- package/dist/composables/helpers/useDismissible.d.ts +10 -0
- package/dist/composables/helpers/useGlobalListener.d.ts +10 -0
- package/dist/composables/helpers/useGlobalListener.spec.d.ts +1 -0
- package/dist/composables/helpers/useOutsideClick.d.ts +26 -0
- package/dist/composables/helpers/useOutsideClick.spec.d.ts +1 -0
- package/dist/composables/helpers/useTypeAhead.d.ts +11 -0
- package/dist/composables/helpers/useTypeAhead.spec.d.ts +1 -0
- package/dist/composables/listbox/TestListbox.ct.d.ts +1 -0
- package/dist/composables/listbox/TestListbox.d.vue.ts +2 -0
- package/dist/composables/listbox/createListbox.d.ts +102 -0
- package/dist/composables/listbox/createListbox.testing.d.ts +24 -0
- package/dist/composables/menuButton/TestMenuButton.ct.d.ts +1 -0
- package/dist/composables/menuButton/TestMenuButton.d.vue.ts +2 -0
- package/dist/composables/menuButton/createMenuButton.d.ts +78 -0
- package/dist/composables/menuButton/createMenuButton.testing.d.ts +24 -0
- package/dist/composables/navigationMenu/TestMenu.ct.d.ts +1 -0
- package/dist/composables/navigationMenu/TestMenu.d.vue.ts +2 -0
- package/dist/composables/navigationMenu/createMenu.d.ts +21 -0
- package/dist/composables/navigationMenu/createMenu.testing.d.ts +16 -0
- package/dist/composables/tabs/TestTabs.ct.d.ts +1 -0
- package/dist/composables/tabs/TestTabs.d.vue.ts +2 -0
- package/dist/composables/tabs/createTabs.d.ts +48 -0
- package/dist/composables/tabs/createTabs.testing.d.ts +13 -0
- package/dist/composables/tooltip/createToggletip.d.ts +36 -0
- package/dist/composables/tooltip/createTooltip.d.ts +42 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +1088 -0
- package/dist/playwright.d.ts +5 -0
- package/dist/playwright.js +369 -0
- package/dist/utils/builder.d.ts +85 -0
- package/dist/utils/keyboard.d.ts +26 -0
- package/dist/utils/keyboard.spec.d.ts +1 -0
- package/dist/utils/math.d.ts +6 -0
- package/dist/utils/math.spec.d.ts +1 -0
- package/dist/utils/object.d.ts +5 -0
- package/dist/utils/object.spec.d.ts +1 -0
- package/dist/utils/timer.d.ts +10 -0
- package/{src/utils/types.ts → dist/utils/types.d.ts} +4 -12
- package/dist/utils/vitest.d.ts +12 -0
- package/package.json +20 -8
- package/src/composables/comboBox/SelectOnlyCombobox.vue +0 -90
- package/src/composables/comboBox/TestCombobox.ct.tsx +0 -24
- package/src/composables/comboBox/TestCombobox.vue +0 -84
- package/src/composables/comboBox/createComboBox.testing.ts +0 -168
- package/src/composables/comboBox/createComboBox.ts +0 -280
- package/src/composables/helpers/useDismissible.ts +0 -19
- package/src/composables/helpers/useGlobalListener.spec.ts +0 -93
- package/src/composables/helpers/useGlobalListener.ts +0 -64
- package/src/composables/helpers/useOutsideClick.spec.ts +0 -117
- package/src/composables/helpers/useOutsideClick.ts +0 -69
- package/src/composables/helpers/useTypeAhead.spec.ts +0 -29
- package/src/composables/helpers/useTypeAhead.ts +0 -26
- package/src/composables/listbox/TestListbox.ct.tsx +0 -17
- package/src/composables/listbox/TestListbox.vue +0 -92
- package/src/composables/listbox/createListbox.testing.ts +0 -141
- package/src/composables/listbox/createListbox.ts +0 -234
- package/src/composables/menuButton/TestMenuButton.ct.tsx +0 -14
- package/src/composables/menuButton/TestMenuButton.vue +0 -29
- package/src/composables/menuButton/createMenuButton.testing.ts +0 -91
- package/src/composables/menuButton/createMenuButton.ts +0 -206
- package/src/composables/navigationMenu/TestMenu.ct.tsx +0 -12
- package/src/composables/navigationMenu/TestMenu.vue +0 -16
- package/src/composables/navigationMenu/createMenu.testing.ts +0 -37
- package/src/composables/navigationMenu/createMenu.ts +0 -55
- package/src/composables/tabs/TestTabs.ct.tsx +0 -12
- package/src/composables/tabs/TestTabs.vue +0 -28
- package/src/composables/tabs/createTabs.testing.ts +0 -151
- package/src/composables/tabs/createTabs.ts +0 -129
- package/src/composables/tooltip/createToggletip.ts +0 -58
- package/src/composables/tooltip/createTooltip.ts +0 -71
- package/src/index.ts +0 -11
- package/src/playwright.ts +0 -5
- package/src/utils/builder.ts +0 -135
- package/src/utils/keyboard.spec.ts +0 -53
- package/src/utils/keyboard.ts +0 -351
- package/src/utils/math.spec.ts +0 -14
- package/src/utils/math.ts +0 -6
- package/src/utils/object.spec.ts +0 -33
- package/src/utils/object.ts +0 -8
- package/src/utils/timer.ts +0 -22
- package/src/utils/vitest.ts +0 -36
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { ref, type Ref } from "vue";
|
|
3
|
-
import { mockVueLifecycle } from "../../utils/vitest";
|
|
4
|
-
import { useGlobalEventListener } from "./useGlobalListener";
|
|
5
|
-
|
|
6
|
-
let unmount: () => Promise<void> | undefined;
|
|
7
|
-
|
|
8
|
-
describe("useGlobalEventListener", () => {
|
|
9
|
-
let target: Ref<HTMLButtonElement>;
|
|
10
|
-
|
|
11
|
-
beforeEach(() => {
|
|
12
|
-
vi.clearAllMocks();
|
|
13
|
-
unmount = mockVueLifecycle();
|
|
14
|
-
target = ref(document.createElement("button"));
|
|
15
|
-
document.body.appendChild(target.value);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("should be defined", () => {
|
|
19
|
-
expect(useGlobalEventListener).toBeDefined();
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("should listen to global events", () => {
|
|
23
|
-
// ARRANGE
|
|
24
|
-
const listener = vi.fn();
|
|
25
|
-
useGlobalEventListener({ type: "click", listener });
|
|
26
|
-
// ACT
|
|
27
|
-
const event = new MouseEvent("click", { bubbles: true });
|
|
28
|
-
target.value.dispatchEvent(event);
|
|
29
|
-
// ASSERT
|
|
30
|
-
expect(listener).toHaveBeenCalledTimes(1);
|
|
31
|
-
expect(listener).toBeCalledWith(event);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("should stop to listen to global events after unmount", async () => {
|
|
35
|
-
// ARRANGE
|
|
36
|
-
const listener = vi.fn();
|
|
37
|
-
useGlobalEventListener({ type: "click", listener });
|
|
38
|
-
// ACT
|
|
39
|
-
await unmount();
|
|
40
|
-
expect(listener).toHaveBeenCalledTimes(0);
|
|
41
|
-
target.value.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
42
|
-
// ASSERT
|
|
43
|
-
expect(listener).toHaveBeenCalledTimes(0);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("should allow for multiple of the same listener types", async () => {
|
|
47
|
-
// ARRANGE
|
|
48
|
-
vi.useFakeTimers();
|
|
49
|
-
const listener = vi.fn();
|
|
50
|
-
const disabled = ref(false);
|
|
51
|
-
const listener2 = vi.fn();
|
|
52
|
-
useGlobalEventListener({ type: "click", listener, disabled });
|
|
53
|
-
useGlobalEventListener({ type: "click", listener: listener2 });
|
|
54
|
-
// ACT
|
|
55
|
-
target.value.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
56
|
-
// ASSERT
|
|
57
|
-
expect(listener).toHaveBeenCalledTimes(1);
|
|
58
|
-
expect(listener2).toHaveBeenCalledTimes(1);
|
|
59
|
-
// ACT
|
|
60
|
-
disabled.value = true;
|
|
61
|
-
await vi.runAllTimersAsync();
|
|
62
|
-
// ACT
|
|
63
|
-
target.value.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
64
|
-
// ASSERT
|
|
65
|
-
expect(listener).toHaveBeenCalledTimes(1);
|
|
66
|
-
expect(listener2).toHaveBeenCalledTimes(2);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("should not listen to events when disabled", async () => {
|
|
70
|
-
// ARRANGE
|
|
71
|
-
vi.useFakeTimers();
|
|
72
|
-
const disabled = ref(false);
|
|
73
|
-
const listener = vi.fn();
|
|
74
|
-
useGlobalEventListener({ type: "click", listener, disabled });
|
|
75
|
-
// ACT
|
|
76
|
-
await vi.runAllTimersAsync();
|
|
77
|
-
target.value.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
78
|
-
// ASSERT
|
|
79
|
-
expect(listener).toHaveBeenCalledTimes(1);
|
|
80
|
-
// ACT
|
|
81
|
-
disabled.value = true;
|
|
82
|
-
await vi.runAllTimersAsync();
|
|
83
|
-
target.value.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
84
|
-
// ASSERT
|
|
85
|
-
expect(listener).toHaveBeenCalledTimes(1);
|
|
86
|
-
// ACT
|
|
87
|
-
disabled.value = false;
|
|
88
|
-
await vi.runAllTimersAsync();
|
|
89
|
-
target.value.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
90
|
-
// ASSERT
|
|
91
|
-
expect(listener).toHaveBeenCalledTimes(2);
|
|
92
|
-
});
|
|
93
|
-
});
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { onBeforeMount, onBeforeUnmount, reactive, watchEffect, type Ref } from "vue";
|
|
2
|
-
|
|
3
|
-
type DocumentEventType = keyof DocumentEventMap;
|
|
4
|
-
type GlobalListener<K extends DocumentEventType = DocumentEventType> = (
|
|
5
|
-
event: DocumentEventMap[K],
|
|
6
|
-
) => unknown;
|
|
7
|
-
|
|
8
|
-
export type UseGlobalEventListenerOptions<K extends DocumentEventType> = {
|
|
9
|
-
type: K;
|
|
10
|
-
listener: GlobalListener<K>;
|
|
11
|
-
disabled?: Ref<boolean>;
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
const GLOBAL_LISTENERS = reactive(new Map<DocumentEventType, Set<GlobalListener>>());
|
|
15
|
-
|
|
16
|
-
const updateRemainingListeners = (type: DocumentEventType, remaining?: Set<GlobalListener>) => {
|
|
17
|
-
if (remaining?.size) {
|
|
18
|
-
GLOBAL_LISTENERS.set(type, remaining);
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
GLOBAL_LISTENERS.delete(type);
|
|
22
|
-
document.removeEventListener(type, GLOBAL_HANDLER);
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
const removeGlobalListener = <K extends DocumentEventType>(
|
|
26
|
-
type: K,
|
|
27
|
-
listener: GlobalListener<K>,
|
|
28
|
-
) => {
|
|
29
|
-
const globalListener = GLOBAL_LISTENERS.get(type);
|
|
30
|
-
globalListener?.delete(listener as GlobalListener);
|
|
31
|
-
|
|
32
|
-
updateRemainingListeners(type, globalListener);
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
const addGlobalListener = <K extends DocumentEventType>(type: K, listener: GlobalListener<K>) => {
|
|
36
|
-
const globalListener = GLOBAL_LISTENERS.get(type) ?? new Set();
|
|
37
|
-
globalListener.add(listener as GlobalListener);
|
|
38
|
-
GLOBAL_LISTENERS.set(type, globalListener);
|
|
39
|
-
|
|
40
|
-
document.addEventListener(type, GLOBAL_HANDLER);
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* A single and unique function for all event types.
|
|
45
|
-
* We use the fact that `addEventListener` and `removeEventListener` are idempotent when called with the same function reference.
|
|
46
|
-
*/
|
|
47
|
-
const GLOBAL_HANDLER = (event: Event) => {
|
|
48
|
-
const type = event.type as DocumentEventType;
|
|
49
|
-
GLOBAL_LISTENERS.get(type)?.forEach((cb) => cb(event));
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
export const useGlobalEventListener = <K extends DocumentEventType>({
|
|
53
|
-
type,
|
|
54
|
-
listener,
|
|
55
|
-
disabled,
|
|
56
|
-
}: UseGlobalEventListenerOptions<K>) => {
|
|
57
|
-
onBeforeMount(() =>
|
|
58
|
-
watchEffect(() =>
|
|
59
|
-
disabled?.value ? removeGlobalListener(type, listener) : addGlobalListener(type, listener),
|
|
60
|
-
),
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
onBeforeUnmount(() => removeGlobalListener(type, listener));
|
|
64
|
-
};
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { ref } from "vue";
|
|
3
|
-
import { mockVueLifecycle } from "../../utils/vitest";
|
|
4
|
-
import { useOutsideClick } from "./useOutsideClick";
|
|
5
|
-
|
|
6
|
-
describe("useOutsideClick", () => {
|
|
7
|
-
beforeEach(() => {
|
|
8
|
-
vi.clearAllMocks();
|
|
9
|
-
mockVueLifecycle();
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
it("should be defined", () => {
|
|
13
|
-
expect(useOutsideClick).toBeDefined();
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it("should detect outside clicks", async () => {
|
|
17
|
-
// ARRANGE
|
|
18
|
-
vi.useFakeTimers();
|
|
19
|
-
const inside = ref(document.createElement("button"));
|
|
20
|
-
document.body.appendChild(inside.value);
|
|
21
|
-
const outside = ref(document.createElement("button"));
|
|
22
|
-
document.body.appendChild(outside.value);
|
|
23
|
-
|
|
24
|
-
const onOutsideClick = vi.fn();
|
|
25
|
-
useOutsideClick({ inside, onOutsideClick });
|
|
26
|
-
|
|
27
|
-
// ACT
|
|
28
|
-
const event = new MouseEvent("mousedown", { bubbles: true });
|
|
29
|
-
outside.value.dispatchEvent(event);
|
|
30
|
-
|
|
31
|
-
// ASSERT
|
|
32
|
-
expect(onOutsideClick).toHaveBeenCalledTimes(1);
|
|
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);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("should detect outside clicks correctly for multiple inside elements", () => {
|
|
47
|
-
// ARRANGE
|
|
48
|
-
const inside = [document.createElement("button"), document.createElement("button")];
|
|
49
|
-
inside.forEach((e) => document.body.appendChild(e));
|
|
50
|
-
const outside = ref(document.createElement("button"));
|
|
51
|
-
document.body.appendChild(outside.value);
|
|
52
|
-
|
|
53
|
-
const onOutsideClick = vi.fn();
|
|
54
|
-
useOutsideClick({ inside, onOutsideClick });
|
|
55
|
-
// ACT
|
|
56
|
-
const event = new MouseEvent("mousedown", { bubbles: true });
|
|
57
|
-
inside[0].dispatchEvent(event);
|
|
58
|
-
inside[1].dispatchEvent(event);
|
|
59
|
-
// ASSERT
|
|
60
|
-
expect(onOutsideClick).not.toHaveBeenCalled();
|
|
61
|
-
|
|
62
|
-
// ACT
|
|
63
|
-
outside.value.dispatchEvent(event);
|
|
64
|
-
// ASSERT
|
|
65
|
-
expect(onOutsideClick).toHaveBeenCalledTimes(1);
|
|
66
|
-
expect(onOutsideClick).toBeCalledWith(event);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("should ignore outside clicks when disabled", async () => {
|
|
70
|
-
// ARRANGE
|
|
71
|
-
vi.useFakeTimers();
|
|
72
|
-
const inside = ref(document.createElement("button"));
|
|
73
|
-
document.body.appendChild(inside.value);
|
|
74
|
-
const outside = ref(document.createElement("button"));
|
|
75
|
-
document.body.appendChild(outside.value);
|
|
76
|
-
|
|
77
|
-
const disabled = ref(false);
|
|
78
|
-
const onOutsideClick = vi.fn();
|
|
79
|
-
useOutsideClick({ inside, disabled, onOutsideClick });
|
|
80
|
-
|
|
81
|
-
// ACT
|
|
82
|
-
const event = new MouseEvent("mousedown", { bubbles: true });
|
|
83
|
-
outside.value.dispatchEvent(event);
|
|
84
|
-
// ASSERT
|
|
85
|
-
expect(onOutsideClick).toHaveBeenCalledTimes(1);
|
|
86
|
-
expect(onOutsideClick).toBeCalledWith(event);
|
|
87
|
-
|
|
88
|
-
// ACT
|
|
89
|
-
disabled.value = true;
|
|
90
|
-
await vi.runAllTimersAsync();
|
|
91
|
-
const event2 = new MouseEvent("mousedown", { bubbles: true });
|
|
92
|
-
outside.value.dispatchEvent(event2);
|
|
93
|
-
// ASSERT
|
|
94
|
-
expect(onOutsideClick).toHaveBeenCalledTimes(1);
|
|
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
|
-
});
|
|
117
|
-
});
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import type { Arrayable } from "vitest"; // For an unknown reason removing this import will break the build of "demo-app" and "playground"
|
|
2
|
-
import { toValue, type MaybeRefOrGetter, type Ref } from "vue";
|
|
3
|
-
import type { Nullable } from "../../utils/types";
|
|
4
|
-
import { useGlobalEventListener } from "./useGlobalListener";
|
|
5
|
-
|
|
6
|
-
export type UseOutsideClickOptions<TCheckOnTab extends boolean | undefined = undefined> = {
|
|
7
|
-
/**
|
|
8
|
-
* HTML element of the component where clicks should be ignored
|
|
9
|
-
*/
|
|
10
|
-
inside: MaybeRefOrGetter<Arrayable<Nullable<HTMLElement>>>;
|
|
11
|
-
/**
|
|
12
|
-
* Callback when an outside click occurred.
|
|
13
|
-
*/
|
|
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;
|
|
21
|
-
/**
|
|
22
|
-
* If `true`, event listeners will be removed and no outside clicks will be captured.
|
|
23
|
-
*/
|
|
24
|
-
disabled?: Ref<boolean>;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Composable for listening to click events that occur outside of a component.
|
|
29
|
-
* Useful to e.g. close flyouts or tooltips.
|
|
30
|
-
*/
|
|
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
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Document click handle that closes then tooltip when clicked outside.
|
|
46
|
-
* Should only be called when trigger is "click".
|
|
47
|
-
*/
|
|
48
|
-
const clickListener = (event: MouseEvent) => {
|
|
49
|
-
if (isOutsideClick(event.target)) onOutsideClick(event);
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
useGlobalEventListener({ type: "mousedown", 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
|
-
}
|
|
69
|
-
};
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { beforeAll, expect, test, vi } from "vitest";
|
|
2
|
-
import { useTypeAhead } from "./useTypeAhead";
|
|
3
|
-
|
|
4
|
-
beforeAll(() => {
|
|
5
|
-
vi.useFakeTimers();
|
|
6
|
-
});
|
|
7
|
-
|
|
8
|
-
test("useTypeAhead", () => {
|
|
9
|
-
const spy = vi.fn();
|
|
10
|
-
const typeAhead = useTypeAhead(spy);
|
|
11
|
-
|
|
12
|
-
typeAhead({ key: "a" });
|
|
13
|
-
expect(spy).toHaveBeenCalledOnce();
|
|
14
|
-
expect(spy).toHaveBeenLastCalledWith("a");
|
|
15
|
-
|
|
16
|
-
typeAhead({ key: "Alt" });
|
|
17
|
-
expect(spy).toHaveBeenCalledOnce();
|
|
18
|
-
expect(spy).toHaveBeenLastCalledWith("a");
|
|
19
|
-
|
|
20
|
-
typeAhead({ key: "b" });
|
|
21
|
-
expect(spy).toHaveBeenCalledTimes(2);
|
|
22
|
-
expect(spy).toHaveBeenLastCalledWith("ab");
|
|
23
|
-
|
|
24
|
-
vi.runAllTimers();
|
|
25
|
-
|
|
26
|
-
typeAhead({ key: "c" });
|
|
27
|
-
expect(spy).toBeCalledTimes(3);
|
|
28
|
-
expect(spy).toHaveBeenLastCalledWith("c");
|
|
29
|
-
});
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { isPrintableCharacter } from "../../utils/keyboard";
|
|
2
|
-
import { debounce } from "../../utils/timer";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Enhances typeAhead to combine multiple inputs in quick succession and filter out non-printable characters.
|
|
6
|
-
*
|
|
7
|
-
* @example
|
|
8
|
-
* ```ts
|
|
9
|
-
* const typeAhead = useTypeAhead((inputString) => console.log("Typed string:", inputString));
|
|
10
|
-
* // ...
|
|
11
|
-
* addEventListener("keydown", typeAhead);
|
|
12
|
-
* ```
|
|
13
|
-
*/
|
|
14
|
-
export const useTypeAhead = (callback: (input: string) => void, timeout = 500) => {
|
|
15
|
-
let inputString = "";
|
|
16
|
-
const debouncedReset = debounce(() => (inputString = ""), timeout);
|
|
17
|
-
|
|
18
|
-
return (event: Pick<KeyboardEvent, "key">) => {
|
|
19
|
-
if (!isPrintableCharacter(event.key)) {
|
|
20
|
-
return;
|
|
21
|
-
}
|
|
22
|
-
debouncedReset();
|
|
23
|
-
inputString = `${inputString}${event.key}`;
|
|
24
|
-
callback(inputString);
|
|
25
|
-
};
|
|
26
|
-
};
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import { test } from "@playwright/experimental-ct-vue";
|
|
2
|
-
import TestListbox from "./TestListbox.vue";
|
|
3
|
-
import { listboxTesting } from "./createListbox.testing";
|
|
4
|
-
|
|
5
|
-
test("listbox", async ({ mount, page }) => {
|
|
6
|
-
await mount(<TestListbox />);
|
|
7
|
-
|
|
8
|
-
await listboxTesting({
|
|
9
|
-
page,
|
|
10
|
-
listbox: page.getByRole("listbox"),
|
|
11
|
-
options: page.getByRole("option"),
|
|
12
|
-
isOptionActive: async (locator) => {
|
|
13
|
-
const className = await locator.getAttribute("class");
|
|
14
|
-
return className?.includes("focused") ?? false;
|
|
15
|
-
},
|
|
16
|
-
});
|
|
17
|
-
});
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
<script lang="ts" setup>
|
|
2
|
-
import { ref } from "vue";
|
|
3
|
-
import { createListbox } from "./createListbox";
|
|
4
|
-
|
|
5
|
-
type Options = (typeof options)[number];
|
|
6
|
-
|
|
7
|
-
const selectedOption = ref<Options>();
|
|
8
|
-
const activeOption = ref<Options>();
|
|
9
|
-
|
|
10
|
-
const options = [
|
|
11
|
-
"Apple",
|
|
12
|
-
"Banana",
|
|
13
|
-
"Mango",
|
|
14
|
-
"Kiwi",
|
|
15
|
-
"Orange",
|
|
16
|
-
"Papaya",
|
|
17
|
-
"Apricot",
|
|
18
|
-
"Lemon",
|
|
19
|
-
"Cranberry",
|
|
20
|
-
"Avocado",
|
|
21
|
-
"Cherry",
|
|
22
|
-
"Coconut",
|
|
23
|
-
"Lychee",
|
|
24
|
-
"Melon",
|
|
25
|
-
"Raspberry",
|
|
26
|
-
"Strawberry",
|
|
27
|
-
] as const;
|
|
28
|
-
|
|
29
|
-
const {
|
|
30
|
-
elements: { listbox, option: headlessOption },
|
|
31
|
-
} = createListbox({
|
|
32
|
-
label: "Test listbox",
|
|
33
|
-
description: "Test description",
|
|
34
|
-
activeOption,
|
|
35
|
-
isExpanded: true,
|
|
36
|
-
onSelect: (id) => {
|
|
37
|
-
selectedOption.value = selectedOption.value === id ? undefined : id;
|
|
38
|
-
},
|
|
39
|
-
onActivateFirst: () => (activeOption.value = options[0]),
|
|
40
|
-
onActivateLast: () => (activeOption.value = options.at(-1)),
|
|
41
|
-
onActivateNext: (currentValue) => {
|
|
42
|
-
const currentIndex = options.findIndex((i) => i === currentValue);
|
|
43
|
-
if (currentIndex < options.length - 1) {
|
|
44
|
-
activeOption.value = options[currentIndex + 1];
|
|
45
|
-
}
|
|
46
|
-
},
|
|
47
|
-
onActivatePrevious: (currentValue) => {
|
|
48
|
-
const currentIndex = options.findIndex((i) => i === currentValue);
|
|
49
|
-
if (currentIndex > 0) activeOption.value = options[currentIndex - 1];
|
|
50
|
-
},
|
|
51
|
-
onTypeAhead: (label) => {
|
|
52
|
-
const firstMatch = options.find((i) => {
|
|
53
|
-
return i.toLowerCase().trim().startsWith(label.toLowerCase());
|
|
54
|
-
});
|
|
55
|
-
if (!firstMatch) return;
|
|
56
|
-
activeOption.value = firstMatch;
|
|
57
|
-
},
|
|
58
|
-
});
|
|
59
|
-
</script>
|
|
60
|
-
|
|
61
|
-
<template>
|
|
62
|
-
<ul v-bind="listbox">
|
|
63
|
-
<li
|
|
64
|
-
v-for="option in options"
|
|
65
|
-
:key="option"
|
|
66
|
-
v-bind="
|
|
67
|
-
headlessOption({
|
|
68
|
-
value: option,
|
|
69
|
-
label: option,
|
|
70
|
-
selected: option === selectedOption,
|
|
71
|
-
})
|
|
72
|
-
"
|
|
73
|
-
:class="{ focused: option === activeOption, selected: option === selectedOption }"
|
|
74
|
-
>
|
|
75
|
-
{{ option }}
|
|
76
|
-
</li>
|
|
77
|
-
</ul>
|
|
78
|
-
</template>
|
|
79
|
-
|
|
80
|
-
<style lang="scss" scoped>
|
|
81
|
-
li {
|
|
82
|
-
height: 1.5rem;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
.focused {
|
|
86
|
-
background-color: orange;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
.selected {
|
|
90
|
-
background-color: red;
|
|
91
|
-
}
|
|
92
|
-
</style>
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import { expect } from "@playwright/experimental-ct-vue";
|
|
2
|
-
import type { Locator, Page } from "@playwright/test";
|
|
3
|
-
|
|
4
|
-
export type ListboxTestingOptions = {
|
|
5
|
-
/**
|
|
6
|
-
* Playwright page.
|
|
7
|
-
*/
|
|
8
|
-
page: Page;
|
|
9
|
-
/**
|
|
10
|
-
* Locator for the listbox element.
|
|
11
|
-
*/
|
|
12
|
-
listbox: Locator;
|
|
13
|
-
/**
|
|
14
|
-
* Options (at least 3).
|
|
15
|
-
*/
|
|
16
|
-
options: Locator;
|
|
17
|
-
/**
|
|
18
|
-
* Function that returns whether the given option locator is visually active.
|
|
19
|
-
*/
|
|
20
|
-
isOptionActive: (locator: Locator) => Promise<boolean>;
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Playwright utility for executing accessibility testing for a listbox.
|
|
25
|
-
* Will check aria attributes and keyboard shortcuts as defined in https://www.w3.org/WAI/ARIA/apg/patterns/listbox/examples/listbox-scrollable.
|
|
26
|
-
*/
|
|
27
|
-
export const listboxTesting = async ({
|
|
28
|
-
page,
|
|
29
|
-
listbox,
|
|
30
|
-
options,
|
|
31
|
-
isOptionActive,
|
|
32
|
-
}: ListboxTestingOptions) => {
|
|
33
|
-
const expectOptionToBeActive = async (locator: Locator, message: string) => {
|
|
34
|
-
expect(await isOptionActive(locator), message).toBeTruthy();
|
|
35
|
-
const optionId = await locator.getAttribute("id");
|
|
36
|
-
expect(optionId).toBeDefined();
|
|
37
|
-
await expect(
|
|
38
|
-
listbox,
|
|
39
|
-
"listbox should have set aria-activedescendant to the ID of the currently visually active option",
|
|
40
|
-
).toHaveAttribute("aria-activedescendant", optionId!);
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
await expect(listbox).toBeVisible();
|
|
44
|
-
|
|
45
|
-
// ensure correct listbox aria attributes
|
|
46
|
-
await expect(
|
|
47
|
-
listbox,
|
|
48
|
-
'listbox must have a "aria-label" attribute with an existing id',
|
|
49
|
-
).toHaveAttribute("aria-label");
|
|
50
|
-
|
|
51
|
-
await listbox
|
|
52
|
-
.getAttribute("aria-label")
|
|
53
|
-
.then((label) => expect(page.locator(`#${label}`)).toBeDefined());
|
|
54
|
-
|
|
55
|
-
await expect(listbox, "listbox must have role attribute with value listbox").toHaveAttribute(
|
|
56
|
-
"role",
|
|
57
|
-
"listbox",
|
|
58
|
-
);
|
|
59
|
-
|
|
60
|
-
// ensure that all options have correct aria attributes
|
|
61
|
-
for (const option of await options.all()) {
|
|
62
|
-
await expect(option, "option must have arial-label attribute").toHaveAttribute("aria-label");
|
|
63
|
-
await expect(option, "option must have role attribute with value option").toHaveAttribute(
|
|
64
|
-
"role",
|
|
65
|
-
"option",
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
await page.keyboard.press("Tab");
|
|
70
|
-
await expect(listbox, "Listbox should be focused when pressing tab key").toBeFocused();
|
|
71
|
-
|
|
72
|
-
await listbox.press("ArrowDown");
|
|
73
|
-
|
|
74
|
-
await expectOptionToBeActive(
|
|
75
|
-
options.first(),
|
|
76
|
-
"Pressing arrow down key when no option is active should activate the first option",
|
|
77
|
-
);
|
|
78
|
-
await expect(
|
|
79
|
-
listbox,
|
|
80
|
-
"When option is visually active, DOM focus should still be on the listbox",
|
|
81
|
-
).toBeFocused();
|
|
82
|
-
|
|
83
|
-
await listbox.press("ArrowDown");
|
|
84
|
-
await expectOptionToBeActive(
|
|
85
|
-
options.nth(1),
|
|
86
|
-
"Pressing arrow down key should activate the next option",
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
await listbox.press(" ");
|
|
90
|
-
await expect(
|
|
91
|
-
options.nth(1),
|
|
92
|
-
"Pressing space key should select the currently active option",
|
|
93
|
-
).toHaveAttribute("aria-selected", "true");
|
|
94
|
-
|
|
95
|
-
await listbox.press("ArrowUp");
|
|
96
|
-
await expectOptionToBeActive(
|
|
97
|
-
options.first(),
|
|
98
|
-
"Pressing arrow up key should activate the previous option",
|
|
99
|
-
);
|
|
100
|
-
|
|
101
|
-
await listbox.press("End");
|
|
102
|
-
await expectOptionToBeActive(options.last(), "Pressing End key should activate the last option");
|
|
103
|
-
|
|
104
|
-
const secondOptionText = await options.nth(1).textContent();
|
|
105
|
-
expect(secondOptionText).toBeDefined();
|
|
106
|
-
|
|
107
|
-
const firstCharacter = secondOptionText!.charAt(0);
|
|
108
|
-
await listbox.press(firstCharacter);
|
|
109
|
-
|
|
110
|
-
await expectOptionToBeActive(
|
|
111
|
-
listbox.getByLabel(firstCharacter).first(),
|
|
112
|
-
"Pressing any other printable character should activate the fist option starting with the pressed key",
|
|
113
|
-
);
|
|
114
|
-
|
|
115
|
-
await listbox.press("Home");
|
|
116
|
-
await expectOptionToBeActive(
|
|
117
|
-
options.first(),
|
|
118
|
-
"Pressing Home key should activate the first option",
|
|
119
|
-
);
|
|
120
|
-
|
|
121
|
-
const firstOptionHeight = await options.first().evaluate((element) => element.clientHeight);
|
|
122
|
-
|
|
123
|
-
await listbox.evaluate((element, height) => {
|
|
124
|
-
element.style.height = `${height}px`;
|
|
125
|
-
element.style.overflow = "hidden";
|
|
126
|
-
}, firstOptionHeight);
|
|
127
|
-
|
|
128
|
-
await expect(options.nth(1)).not.toBeInViewport();
|
|
129
|
-
|
|
130
|
-
await listbox.press("ArrowDown");
|
|
131
|
-
await expect(
|
|
132
|
-
options.nth(1),
|
|
133
|
-
"activating an option should scroll it into viewport if not visible",
|
|
134
|
-
).toBeInViewport();
|
|
135
|
-
|
|
136
|
-
// reset temporary styles
|
|
137
|
-
await listbox.evaluate((element) => {
|
|
138
|
-
element.style.height = "";
|
|
139
|
-
element.style.overflow = "";
|
|
140
|
-
});
|
|
141
|
-
};
|