@sit-onyx/headless 1.0.0-beta.2 → 1.0.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +1 -5
  2. package/package.json +11 -3
  3. package/src/composables/comboBox/SelectOnlyCombobox.vue +15 -8
  4. package/src/composables/comboBox/TestCombobox.ct.tsx +1 -1
  5. package/src/composables/comboBox/TestCombobox.vue +13 -10
  6. package/src/composables/comboBox/createComboBox.ts +34 -28
  7. package/src/composables/helpers/useDismissible.ts +19 -0
  8. package/src/composables/helpers/useGlobalListener.spec.ts +2 -2
  9. package/src/composables/helpers/useGlobalListener.ts +1 -1
  10. package/src/composables/helpers/useOutsideClick.spec.ts +117 -0
  11. package/src/composables/helpers/useOutsideClick.ts +45 -10
  12. package/src/composables/helpers/useTypeAhead.spec.ts +1 -1
  13. package/src/composables/helpers/useTypeAhead.ts +2 -2
  14. package/src/composables/listbox/TestListbox.ct.tsx +1 -1
  15. package/src/composables/listbox/TestListbox.vue +3 -1
  16. package/src/composables/listbox/createListbox.ts +28 -10
  17. package/src/composables/menuButton/TestMenuButton.ct.tsx +1 -1
  18. package/src/composables/menuButton/TestMenuButton.vue +4 -3
  19. package/src/composables/menuButton/createMenuButton.testing.ts +0 -19
  20. package/src/composables/menuButton/createMenuButton.ts +174 -119
  21. package/src/composables/navigationMenu/TestMenu.ct.tsx +1 -1
  22. package/src/composables/navigationMenu/TestMenu.vue +1 -1
  23. package/src/composables/navigationMenu/createMenu.testing.ts +2 -13
  24. package/src/composables/navigationMenu/createMenu.ts +6 -7
  25. package/src/composables/tabs/TestTabs.ct.tsx +12 -0
  26. package/src/composables/tabs/TestTabs.vue +28 -0
  27. package/src/composables/tabs/createTabs.testing.ts +151 -0
  28. package/src/composables/tabs/createTabs.ts +129 -0
  29. package/src/composables/tooltip/createToggletip.ts +58 -0
  30. package/src/composables/tooltip/createTooltip.ts +39 -97
  31. package/src/index.ts +11 -8
  32. package/src/playwright.ts +5 -3
  33. package/src/utils/builder.ts +108 -12
  34. package/src/utils/keyboard.spec.ts +1 -1
  35. package/src/utils/keyboard.ts +1 -1
  36. package/src/utils/math.spec.ts +1 -1
  37. package/src/utils/object.spec.ts +1 -1
  38. package/src/utils/timer.ts +10 -3
  39. package/src/utils/types.ts +10 -0
  40. package/src/utils/vitest.ts +2 -2
  41. package/src/utils/id.ts +0 -14
@@ -1,7 +1,7 @@
1
- import { computed, ref, unref, watchEffect, type MaybeRef, type Ref } from "vue";
2
- import { createId } from "../..";
3
- import { createBuilder, type HeadlessElementAttributes } from "../../utils/builder";
4
- import { useTypeAhead } from "../helpers/useTypeAhead";
1
+ import { computed, nextTick, ref, unref, useId, watchEffect, type MaybeRef, type Ref } from "vue";
2
+ import { createBuilder, type VBindAttributes } from "../../utils/builder.js";
3
+ import type { Nullable } from "../../utils/types.js";
4
+ import { useTypeAhead } from "../helpers/useTypeAhead.js";
5
5
 
6
6
  export type ListboxValue = string | number | boolean;
7
7
 
@@ -10,19 +10,27 @@ export type CreateListboxOptions<TValue extends ListboxValue, TMultiple extends
10
10
  * Aria label for the listbox.
11
11
  */
12
12
  label: MaybeRef<string>;
13
+ /**
14
+ * Aria description for the listbox.
15
+ */
16
+ description?: MaybeRef<Nullable<string>>;
13
17
  /**
14
18
  * Value of currently (visually) active option.
15
19
  */
16
- activeOption: Ref<TValue | undefined>;
20
+ activeOption: Ref<Nullable<TValue>>;
17
21
  /**
18
22
  * Wether the listbox is controlled from the outside, e.g. by a combobox.
19
23
  * This disables keyboard events and makes the listbox not focusable.
20
24
  */
21
25
  controlled?: boolean;
26
+ /**
27
+ * Controls the opened/visible state of the listbox. When expanded the activeOption can be controlled via the keyboard.
28
+ */
29
+ isExpanded?: MaybeRef<boolean>;
22
30
  /**
23
31
  * Whether the listbox is multiselect.
24
32
  */
25
- multiple?: MaybeRef<TMultiple | undefined>;
33
+ multiple?: MaybeRef<Nullable<TMultiple>>;
26
34
  /**
27
35
  * Hook when an option is selected.
28
36
  */
@@ -77,6 +85,7 @@ export const createListbox = createBuilder(
77
85
  options: CreateListboxOptions<TValue, TMultiple>,
78
86
  ) => {
79
87
  const isMultiselect = computed(() => unref(options.multiple) ?? false);
88
+ const isExpanded = computed(() => unref(options.isExpanded) ?? false);
80
89
 
81
90
  /**
82
91
  * Map for option IDs. key = option value, key = ID for the HTML element
@@ -85,7 +94,7 @@ export const createListbox = createBuilder(
85
94
 
86
95
  const getOptionId = (value: TValue) => {
87
96
  if (!descendantKeyIdMap.has(value)) {
88
- descendantKeyIdMap.set(value, createId("listbox-option"));
97
+ descendantKeyIdMap.set(value, useId());
89
98
  }
90
99
  return descendantKeyIdMap.get(value)!;
91
100
  };
@@ -96,10 +105,17 @@ export const createListbox = createBuilder(
96
105
  const isFocused = ref(false);
97
106
 
98
107
  // scroll currently active option into view if needed
99
- watchEffect(() => {
100
- if (options.activeOption.value == undefined || (!isFocused.value && !options.controlled))
108
+ watchEffect(async () => {
109
+ if (
110
+ !isExpanded.value ||
111
+ options.activeOption.value == undefined ||
112
+ (!isFocused.value && !options.controlled)
113
+ ) {
101
114
  return;
115
+ }
116
+
102
117
  const id = getOptionId(options.activeOption.value);
118
+ await nextTick();
103
119
  document.getElementById(id)?.scrollIntoView({ block: "nearest", inline: "nearest" });
104
120
  });
105
121
 
@@ -152,18 +168,20 @@ export const createListbox = createBuilder(
152
168
  }
153
169
  };
154
170
 
155
- const listbox = computed<HeadlessElementAttributes>(() =>
171
+ const listbox = computed<VBindAttributes>(() =>
156
172
  options.controlled
157
173
  ? {
158
174
  role: "listbox",
159
175
  "aria-multiselectable": isMultiselect.value,
160
176
  "aria-label": unref(options.label),
177
+ "aria-description": options.description,
161
178
  tabindex: "-1",
162
179
  }
163
180
  : {
164
181
  role: "listbox",
165
182
  "aria-multiselectable": isMultiselect.value,
166
183
  "aria-label": unref(options.label),
184
+ "aria-description": options.description,
167
185
  tabindex: "0",
168
186
  "aria-activedescendant":
169
187
  options.activeOption.value != undefined
@@ -1,5 +1,5 @@
1
1
  import { test } from "@playwright/experimental-ct-vue";
2
- import { menuButtonTesting } from "./createMenuButton.testing";
2
+ import { menuButtonTesting } from "./createMenuButton.testing.js";
3
3
  import TestMenuButton from "./TestMenuButton.vue";
4
4
 
5
5
  test("menuButton", async ({ mount, page }) => {
@@ -1,6 +1,6 @@
1
1
  <script lang="ts" setup>
2
2
  import { ref } from "vue";
3
- import { createMenuButton } from "./createMenuButton";
3
+ import { createMenuButton } from "./createMenuButton.js";
4
4
 
5
5
  const items = Array.from({ length: 10 }, (_, index) => {
6
6
  const id = index + 1;
@@ -10,15 +10,16 @@ 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 = ref<"click" | "hover">("hover");
13
14
 
14
15
  const {
15
16
  elements: { root, button, menu, menuItem, listItem },
16
- } = createMenuButton({ isExpanded, onToggle });
17
+ } = createMenuButton({ isExpanded, onToggle, trigger });
17
18
  </script>
18
19
 
19
20
  <template>
20
21
  <div v-bind="root">
21
- <button v-bind="button">Toggle nav menu</button>
22
+ <button v-bind="button" type="button">Toggle nav menu</button>
22
23
  <ul v-show="isExpanded" v-bind="menu">
23
24
  <li v-for="item in items" v-bind="listItem" :key="item.value">
24
25
  <a v-bind="menuItem({ active: activeItem === item.value })" href="#">{{ item.label }}</a>
@@ -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
 
@@ -1,141 +1,195 @@
1
- import { computed, ref, type Ref } from "vue";
2
- import { createBuilder } from "../../utils/builder";
3
- import { createId } from "../../utils/id";
4
- import { debounce } from "../../utils/timer";
5
- import { useGlobalEventListener } from "../helpers/useGlobalListener";
1
+ import { computed, toValue, useId, watch, type MaybeRef, type Ref } from "vue";
2
+ import { createBuilder, createElRef } from "../../utils/builder.js";
3
+ import { debounce } from "../../utils/timer.js";
4
+ import { useGlobalEventListener } from "../helpers/useGlobalListener.js";
5
+ import { useOutsideClick } from "../helpers/useOutsideClick.js";
6
6
 
7
7
  type CreateMenuButtonOptions = {
8
- isExpanded: Ref<boolean>;
8
+ isExpanded: Readonly<Ref<boolean>>;
9
+ trigger: Readonly<MaybeRef<"hover" | "click">>;
9
10
  onToggle: () => void;
11
+ disabled?: Readonly<Ref<boolean>>;
12
+ /**
13
+ * Whether the menu button opens to the top or bottom. Defines the keyboard navigation behavior (e.g. Arrow up and down).
14
+ *
15
+ * @default "bottom"
16
+ */
17
+ position?: MaybeRef<"top" | "bottom">;
10
18
  };
11
19
 
12
20
  /**
13
21
  * Based on https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/
14
22
  */
15
- export const createMenuButton = createBuilder(
16
- ({ isExpanded, onToggle }: CreateMenuButtonOptions) => {
17
- const rootId = createId("menu-button-root");
18
- const menuId = createId("menu-button-list");
19
- const menuRef = ref<HTMLElement>();
20
- const buttonId = createId("menu-button-button");
21
-
22
- useGlobalEventListener({
23
- type: "keydown",
24
- listener: (e) => e.key === "Escape" && isExpanded.value && onToggle(),
25
- disabled: computed(() => !isExpanded.value),
26
- });
27
-
28
- /**
29
- * Debounced expanded state that will only be toggled after a given timeout.
30
- */
31
- const updateDebouncedExpanded = debounce(
32
- (expanded: boolean) => isExpanded.value !== expanded && onToggle(),
33
- 200,
34
- );
35
-
36
- const focusRelativeItem = (next: "next" | "prev" | "first" | "last") => {
37
- const currentMenuItem = document.activeElement as HTMLElement;
38
-
39
- // Either the current focus is on a "menuitem", then we can just get the parent menu.
40
- // Or the current focus is on the button, then we can get the connected menu using the menuId
41
- const currentMenu = currentMenuItem?.closest('[role="menu"]') || menuRef.value;
42
- if (!currentMenu) return;
43
-
44
- const menuItems = [...currentMenu.querySelectorAll<HTMLElement>('[role="menuitem"]')];
45
- let nextIndex = 0;
46
-
47
- if (currentMenuItem) {
48
- const currentIndex = menuItems.indexOf(currentMenuItem);
49
- switch (next) {
50
- case "next":
51
- nextIndex = currentIndex + 1;
52
- break;
53
- case "prev":
54
- nextIndex = currentIndex - 1;
55
- break;
56
- case "first":
57
- nextIndex = 0;
58
- break;
59
- case "last":
60
- nextIndex = menuItems.length - 1;
61
- break;
62
- }
63
- }
23
+ export const createMenuButton = createBuilder((options: CreateMenuButtonOptions) => {
24
+ const rootId = useId();
25
+ const menuId = useId();
26
+ const rootRef = createElRef<HTMLElement>();
27
+ const menuRef = createElRef<HTMLElement>();
28
+ const buttonId = useId();
64
29
 
65
- const nextMenuItem = menuItems[nextIndex];
66
- nextMenuItem?.focus();
67
- };
30
+ const position = computed(() => toValue(options.position) ?? "bottom");
68
31
 
69
- const handleKeydown = (event: KeyboardEvent) => {
70
- switch (event.key) {
71
- case "ArrowDown":
72
- case "ArrowRight":
73
- event.preventDefault();
74
- focusRelativeItem("next");
75
- break;
76
- case "ArrowUp":
77
- case "ArrowLeft":
78
- event.preventDefault();
79
- focusRelativeItem("prev");
80
- break;
81
- case "Home":
82
- event.preventDefault();
83
- focusRelativeItem("first");
32
+ useGlobalEventListener({
33
+ type: "keydown",
34
+ listener: (e) => e.key === "Escape" && setExpanded(false),
35
+ disabled: computed(() => !options.isExpanded.value),
36
+ });
37
+
38
+ /**
39
+ * Debounced expanded state that will only be toggled after a given timeout.
40
+ */
41
+ const updateDebouncedExpanded = debounce(() => options.onToggle(), 200);
42
+ watch(options.isExpanded, () => updateDebouncedExpanded.abort()); // manually changing `isExpanded` should abort debounced action
43
+
44
+ const setExpanded = (expanded: boolean, debounced = false) => {
45
+ if (options.disabled?.value) return;
46
+ if (expanded === options.isExpanded.value) {
47
+ updateDebouncedExpanded.abort();
48
+ return;
49
+ }
50
+ if (debounced) {
51
+ updateDebouncedExpanded();
52
+ return;
53
+ }
54
+ options.onToggle();
55
+ };
56
+
57
+ const focusRelativeItem = (next: "next" | "prev" | "first" | "last") => {
58
+ const currentMenuItem = document.activeElement as HTMLElement;
59
+
60
+ // Either the current focus is on a "menuitem", then we can just get the parent menu.
61
+ // Or the current focus is on the button, then we can get the connected menu using the menuId
62
+ const currentMenu = currentMenuItem?.closest('[role="menu"]') || menuRef.value;
63
+ if (!currentMenu) return;
64
+
65
+ const menuItems = Array.from(currentMenu.querySelectorAll<HTMLElement>('[role="menuitem"]'))
66
+ // filter out nested children
67
+ .filter((item) => item.closest('[role="menu"]') === currentMenu);
68
+ if (position.value === "top") menuItems.reverse();
69
+ let nextIndex = 0;
70
+
71
+ if (currentMenuItem) {
72
+ const currentIndex = menuItems.indexOf(currentMenuItem);
73
+ switch (next) {
74
+ case "next":
75
+ nextIndex = currentIndex + 1;
84
76
  break;
85
- case "End":
86
- event.preventDefault();
87
- focusRelativeItem("last");
77
+ case "prev":
78
+ nextIndex = currentIndex - 1;
88
79
  break;
89
- case " ":
90
- event.preventDefault();
91
- (event.target as HTMLElement).click();
80
+ case "first":
81
+ nextIndex = 0;
92
82
  break;
93
- case "Escape":
94
- event.preventDefault();
95
- isExpanded.value && onToggle();
83
+ case "last":
84
+ nextIndex = menuItems.length - 1;
96
85
  break;
97
86
  }
98
- };
87
+ }
99
88
 
89
+ const nextMenuItem = menuItems[nextIndex];
90
+ nextMenuItem?.focus();
91
+ };
92
+
93
+ const handleKeydown = (event: KeyboardEvent) => {
94
+ switch (event.key) {
95
+ case "ArrowDown":
96
+ event.preventDefault();
97
+ focusRelativeItem(position.value === "bottom" ? "next" : "prev");
98
+ break;
99
+ case "ArrowUp":
100
+ event.preventDefault();
101
+ focusRelativeItem(position.value === "bottom" ? "prev" : "next");
102
+ break;
103
+ case "Home":
104
+ event.preventDefault();
105
+ focusRelativeItem("first");
106
+ break;
107
+ case "End":
108
+ event.preventDefault();
109
+ focusRelativeItem("last");
110
+ break;
111
+ case " ":
112
+ case "Enter":
113
+ if (event.target instanceof HTMLInputElement) break;
114
+ event.preventDefault();
115
+ (event.target as HTMLElement).click();
116
+ break;
117
+ case "Escape":
118
+ event.preventDefault();
119
+ setExpanded(false);
120
+ break;
121
+ }
122
+ };
123
+
124
+ const triggerEvents = computed(() => {
125
+ if (toValue(options.trigger) !== "hover") return;
100
126
  return {
101
- elements: {
102
- root: {
103
- id: rootId,
104
- onKeydown: handleKeydown,
105
- onMouseover: () => updateDebouncedExpanded(true),
106
- onMouseout: () => updateDebouncedExpanded(false),
107
- onFocusout: (event) => {
108
- // if focus receiving element is not part of the menu button, then close
109
- if (document.getElementById(rootId)?.contains(event.relatedTarget as HTMLElement)) {
110
- return;
111
- }
112
- isExpanded.value && onToggle();
113
- },
114
- },
115
- button: computed(
116
- () =>
117
- ({
118
- "aria-controls": menuId,
119
- "aria-expanded": isExpanded.value,
120
- "aria-haspopup": true,
121
- onFocus: () => !isExpanded.value && onToggle(),
122
- id: buttonId,
123
- }) as const,
124
- ),
125
- menu: {
126
- id: menuId,
127
- ref: menuRef,
128
- role: "menu",
129
- "aria-labelledby": buttonId,
130
- onClick: () => isExpanded.value && onToggle(),
131
- },
132
- ...createMenuItems().elements,
133
- },
127
+ onMouseenter: () => setExpanded(true),
128
+ onMouseleave: () => setExpanded(false, true),
134
129
  };
135
- },
136
- );
130
+ });
131
+
132
+ useOutsideClick({
133
+ inside: rootRef,
134
+ onOutsideClick: () => setExpanded(false),
135
+ disabled: computed(() => !options.isExpanded.value),
136
+ checkOnTab: true,
137
+ });
138
+
139
+ return {
140
+ elements: {
141
+ root: computed(() => ({
142
+ id: rootId,
143
+ onKeydown: handleKeydown,
144
+ ref: rootRef,
145
+ ...triggerEvents.value,
146
+ })),
147
+ button: computed(
148
+ () =>
149
+ ({
150
+ "aria-controls": menuId,
151
+ "aria-expanded": options.isExpanded.value,
152
+ "aria-haspopup": true,
153
+ onFocus: () => setExpanded(true, true),
154
+ onClick: () =>
155
+ toValue(options.trigger) == "click"
156
+ ? setExpanded(!options.isExpanded.value)
157
+ : undefined,
158
+ id: buttonId,
159
+ disabled: options.disabled?.value,
160
+ }) as const,
161
+ ),
162
+ menu: {
163
+ id: menuId,
164
+ ref: menuRef,
165
+ role: "menu",
166
+ "aria-labelledby": buttonId,
167
+ onClick: () => setExpanded(false),
168
+ },
169
+ ...createMenuItems().elements,
170
+ },
171
+ };
172
+ });
173
+
174
+ type CreateMenuItemOptions = {
175
+ /**
176
+ * Called when the menu item should be opened (if it has nested children).
177
+ */
178
+ onOpen?: () => void;
179
+ };
180
+
181
+ export const createMenuItems = createBuilder((options?: CreateMenuItemOptions) => {
182
+ const onKeydown = (event: KeyboardEvent) => {
183
+ switch (event.key) {
184
+ case "ArrowRight":
185
+ case " ":
186
+ case "Enter":
187
+ event.preventDefault();
188
+ options?.onOpen?.();
189
+ break;
190
+ }
191
+ };
137
192
 
138
- export const createMenuItems = createBuilder(() => {
139
193
  return {
140
194
  elements: {
141
195
  listItem: {
@@ -145,6 +199,7 @@ export const createMenuItems = createBuilder(() => {
145
199
  "aria-current": data.active ? "page" : undefined,
146
200
  "aria-disabled": data.disabled,
147
201
  role: "menuitem",
202
+ onKeydown,
148
203
  }),
149
204
  },
150
205
  };
@@ -1,5 +1,5 @@
1
1
  import { test } from "@playwright/experimental-ct-vue";
2
- import { navigationTesting } from "./createMenu.testing";
2
+ import { navigationTesting } from "./createMenu.testing.js";
3
3
  import TestMenu from "./TestMenu.vue";
4
4
 
5
5
  test("navigationMenu", async ({ mount, page }) => {
@@ -1,6 +1,6 @@
1
1
  <script lang="ts" setup>
2
2
  import TestMenuButton from "../menuButton/TestMenuButton.vue";
3
- import { createNavigationMenu } from "./createMenu";
3
+ import { createNavigationMenu } from "./createMenu.js";
4
4
 
5
5
  const {
6
6
  elements: { nav },
@@ -22,22 +22,11 @@ export const navigationTesting = async ({ nav, buttons }: NavigationMenuTestingO
22
22
  */
23
23
  await expect(nav).toHaveRole("navigation");
24
24
  await expect(nav).toHaveAttribute("aria-label");
25
- /**
26
- * Disclosure buttons should have aria attributes
27
- */
28
- for (const button of await buttons.all()) {
29
- await expect(button, "button must have arial-controls attribute").toHaveAttribute(
30
- "aria-controls",
31
- );
32
- await expect(button, "button must have aria-expanded attribute").toHaveAttribute(
33
- "aria-expanded",
34
- );
35
- }
25
+
36
26
  /**
37
27
  * Focus first button
38
28
  */
39
- await nav.press("Tab");
40
- await expect(buttons.nth(0)).toBeFocused();
29
+ await buttons.first().focus();
41
30
  /**
42
31
  * Move keyboard focus among top-level buttons using arrow keys
43
32
  */
@@ -1,7 +1,6 @@
1
- import { unref, type MaybeRef } from "vue";
2
- import { createId } from "../..";
3
- import { createBuilder } from "../../utils/builder";
4
- import { MathUtils } from "../../utils/math";
1
+ import { unref, useId, type MaybeRef } from "vue";
2
+ import { createBuilder } from "../../utils/builder.js";
3
+ import { MathUtils } from "../../utils/math.js";
5
4
 
6
5
  type CreateNavigationMenu = {
7
6
  /**
@@ -15,12 +14,12 @@ type CreateNavigationMenu = {
15
14
  * Based on https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/
16
15
  */
17
16
  export const createNavigationMenu = createBuilder(({ navigationName }: CreateNavigationMenu) => {
18
- const navId = createId("nav");
17
+ const navId = useId();
19
18
 
20
19
  const getMenuButtons = () => {
21
- const nav = document.getElementById(navId);
20
+ const nav = navId ? document.getElementById(navId) : undefined;
22
21
  if (!nav) return [];
23
- return [...nav.querySelectorAll<HTMLElement>("button[aria-expanded][aria-controls]")];
22
+ return Array.from(nav.querySelectorAll<HTMLElement>("button[aria-expanded][aria-controls]"));
24
23
  };
25
24
 
26
25
  const focusRelative = (trigger: HTMLElement, next: "next" | "previous") => {
@@ -0,0 +1,12 @@
1
+ import { test } from "@playwright/experimental-ct-vue";
2
+ import TestTabs from "./TestTabs.vue";
3
+ import { tabsTesting } from "./createTabs.testing.js";
4
+
5
+ test("tabs", async ({ mount, page }) => {
6
+ const component = await mount(<TestTabs />);
7
+
8
+ await tabsTesting({
9
+ page,
10
+ tablist: component.getByRole("tablist"),
11
+ });
12
+ });
@@ -0,0 +1,28 @@
1
+ <script lang="ts" setup>
2
+ import { ref } from "vue";
3
+ import { createTabs } from "./createTabs.js";
4
+
5
+ const selectedTab = ref("tab-1");
6
+
7
+ const {
8
+ elements: { tablist, tab, tabpanel },
9
+ } = createTabs({
10
+ label: "Tablist label",
11
+ selectedTab,
12
+ onSelect: (tab) => (selectedTab.value = tab),
13
+ });
14
+ </script>
15
+
16
+ <template>
17
+ <div>
18
+ <div v-bind="tablist">
19
+ <button v-bind="tab({ value: 'tab-1' })" type="button">Tab 1</button>
20
+ <button v-bind="tab({ value: 'tab-2' })" type="button">Tab 2</button>
21
+ <button v-bind="tab({ value: 'tab-3' })" type="button">Tab 3</button>
22
+ </div>
23
+
24
+ <div v-if="selectedTab === 'tab-1'" v-bind="tabpanel({ value: 'tab-1' })">Tab content 1</div>
25
+ <div v-if="selectedTab === 'tab-2'" v-bind="tabpanel({ value: 'tab-2' })">Tab content 2</div>
26
+ <div v-if="selectedTab === 'tab-3'" v-bind="tabpanel({ value: 'tab-3' })">Tab content 3</div>
27
+ </div>
28
+ </template>