@sit-onyx/headless 1.0.0-beta.1 → 1.0.0-beta.11

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 (29) hide show
  1. package/package.json +6 -2
  2. package/src/composables/comboBox/SelectOnlyCombobox.vue +6 -2
  3. package/src/composables/comboBox/TestCombobox.vue +9 -6
  4. package/src/composables/comboBox/createComboBox.ts +18 -13
  5. package/src/composables/helpers/useDismissible.ts +19 -0
  6. package/src/composables/helpers/useOutsideClick.spec.ts +83 -0
  7. package/src/composables/helpers/useOutsideClick.ts +13 -7
  8. package/src/composables/listbox/TestListbox.vue +2 -0
  9. package/src/composables/listbox/createListbox.ts +25 -8
  10. package/src/composables/menuButton/TestMenuButton.ct.tsx +1 -1
  11. package/src/composables/menuButton/TestMenuButton.vue +7 -6
  12. package/src/composables/menuButton/createMenuButton.testing.ts +17 -16
  13. package/src/composables/menuButton/createMenuButton.ts +121 -101
  14. package/src/composables/navigationMenu/TestMenu.ct.tsx +12 -0
  15. package/src/composables/navigationMenu/TestMenu.vue +16 -0
  16. package/src/composables/navigationMenu/createMenu.testing.ts +37 -0
  17. package/src/composables/navigationMenu/createMenu.ts +55 -0
  18. package/src/composables/tabs/TestTabs.ct.tsx +13 -0
  19. package/src/composables/tabs/TestTabs.vue +28 -0
  20. package/src/composables/tabs/createTabs.testing.ts +116 -0
  21. package/src/composables/tabs/createTabs.ts +117 -0
  22. package/src/composables/tooltip/createToggletip.ts +61 -0
  23. package/src/composables/tooltip/createTooltip.ts +37 -96
  24. package/src/index.ts +5 -1
  25. package/src/playwright.ts +2 -0
  26. package/src/utils/builder.ts +107 -11
  27. package/src/utils/types.ts +7 -0
  28. package/src/utils/vitest.ts +2 -2
  29. package/src/utils/id.ts +0 -14
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.1",
4
+ "version": "1.0.0-beta.11",
5
5
  "type": "module",
6
6
  "author": "Schwarz IT KG",
7
7
  "license": "Apache-2.0",
@@ -24,7 +24,11 @@
24
24
  },
25
25
  "peerDependencies": {
26
26
  "typescript": ">= 5",
27
- "vue": ">= 3"
27
+ "vue": ">= 3.5.0"
28
+ },
29
+ "devDependencies": {
30
+ "@vue/compiler-dom": "3.5.12",
31
+ "vue": "3.5.12"
28
32
  },
29
33
  "scripts": {
30
34
  "build": "vue-tsc --build --force",
@@ -26,7 +26,6 @@ const onToggle = () => (isExpanded.value = !isExpanded.value);
26
26
  const onTypeAhead = () => {};
27
27
 
28
28
  const comboBox = createComboBox({
29
- inputValue: selectedOption,
30
29
  autocomplete: "none",
31
30
  label: "some label",
32
31
  listLabel: "List",
@@ -51,7 +50,12 @@ defineExpose({ comboBox });
51
50
 
52
51
  <template>
53
52
  <div ref="comboboxRef">
54
- <input v-bind="input" readonly @keydown.arrow-down="isExpanded = true" />
53
+ <input
54
+ v-bind="input"
55
+ v-model="selectedOption"
56
+ readonly
57
+ @keydown.arrow-down="isExpanded = true"
58
+ />
55
59
 
56
60
  <button v-bind="button">
57
61
  <template v-if="isExpanded">⬆️</template>
@@ -30,7 +30,6 @@ const onAutocomplete = (input: string) => (searchTerm.value = input);
30
30
  const onToggle = () => (isExpanded.value = !isExpanded.value);
31
31
 
32
32
  const comboBox = createComboBox({
33
- inputValue: searchTerm,
34
33
  autocomplete: "list",
35
34
  label: "some label",
36
35
  listLabel: "List",
@@ -55,13 +54,13 @@ defineExpose({ comboBox });
55
54
 
56
55
  <template>
57
56
  <div ref="comboboxRef">
58
- <input v-bind="input" @keydown.arrow-down="isExpanded = true" />
57
+ <input v-bind="input" v-model="searchTerm" @keydown.arrow-down="isExpanded = true" />
59
58
 
60
- <button v-bind="button">
61
- <template v-if="isExpanded">⬆️</template>
62
- <template v-else>⬇️</template>
59
+ <button v-bind="button" type="button">
60
+ <template v-if="isExpanded"> ⬆️ </template>
61
+ <template v-else> ⬇️ </template>
63
62
  </button>
64
- <ul v-bind="listbox" :class="{ hidden: !isExpanded }" style="width: 400px">
63
+ <ul v-bind="listbox" :class="{ list: true, hidden: !isExpanded }">
65
64
  <li
66
65
  v-for="e in filteredOptions"
67
66
  :key="e"
@@ -75,6 +74,10 @@ defineExpose({ comboBox });
75
74
  </template>
76
75
 
77
76
  <style>
77
+ .list {
78
+ width: 400px;
79
+ }
80
+
78
81
  .hidden {
79
82
  display: none;
80
83
  }
@@ -1,6 +1,5 @@
1
- import { computed, unref, type MaybeRef, type Ref } from "vue";
1
+ import { computed, unref, useId, type MaybeRef, type Ref } from "vue";
2
2
  import { createBuilder } from "../../utils/builder";
3
- import { createId } from "../../utils/id";
4
3
  import { isPrintableCharacter, wasKeyPressed, type PressedKey } from "../../utils/keyboard";
5
4
  import { useOutsideClick } from "../helpers/useOutsideClick";
6
5
  import { useTypeAhead } from "../helpers/useTypeAhead";
@@ -10,6 +9,7 @@ import {
10
9
  type ListboxValue,
11
10
  } from "../listbox/createListbox";
12
11
 
12
+ /** See https://w3c.github.io/aria/#aria-autocomplete */
13
13
  export type ComboboxAutoComplete = "none" | "list" | "both";
14
14
 
15
15
  export const OPENING_KEYS: PressedKey[] = ["ArrowDown", "ArrowUp", " ", "Enter", "Home", "End"];
@@ -19,11 +19,15 @@ export const CLOSING_KEYS: PressedKey[] = [
19
19
  "Enter",
20
20
  "Tab",
21
21
  ];
22
- const SELECTING_KEYS_SINGLE: PressedKey[] = ["Enter", " "];
23
- const SELECTING_KEYS_MULTIPLE: PressedKey[] = ["Enter"];
24
22
 
25
- const isSelectingKey = (event: KeyboardEvent, isMultiselect?: boolean) => {
26
- const selectingKeys = isMultiselect ? SELECTING_KEYS_MULTIPLE : SELECTING_KEYS_SINGLE;
23
+ const SELECTING_KEYS: PressedKey[] = ["Enter"];
24
+
25
+ /**
26
+ * if the a search input is included, space should not be used to select
27
+ * TODO: idea for the future: move this distinction to the listbox?
28
+ */
29
+ const isSelectingKey = (event: KeyboardEvent, withSpace?: boolean) => {
30
+ const selectingKeys = withSpace ? [...SELECTING_KEYS, " "] : SELECTING_KEYS;
27
31
  return isKeyOfGroup(event, selectingKeys);
28
32
  };
29
33
 
@@ -42,9 +46,9 @@ export type CreateComboboxOptions<
42
46
  */
43
47
  listLabel: MaybeRef<string>;
44
48
  /**
45
- * The current value of the combobox. Is updated when an option from the controlled listbox is selected or by typing into it.
49
+ * Provides additional description for the listbox which displays the available options.
46
50
  */
47
- inputValue: Ref<string | undefined>;
51
+ listDescription?: MaybeRef<string | undefined>;
48
52
  /**
49
53
  * Controls the opened/visible state of the associated pop-up. When expanded the activeOption can be controlled via the keyboard.
50
54
  */
@@ -105,13 +109,13 @@ export const createComboBox = createBuilder(
105
109
  TAutoComplete extends ComboboxAutoComplete,
106
110
  TMultiple extends boolean = false,
107
111
  >({
108
- inputValue,
109
112
  autocomplete: autocompleteRef,
110
113
  onAutocomplete,
111
114
  onTypeAhead,
112
115
  multiple: multipleRef,
113
116
  label,
114
117
  listLabel,
118
+ listDescription,
115
119
  isExpanded: isExpandedRef,
116
120
  activeOption,
117
121
  onToggle,
@@ -122,7 +126,7 @@ export const createComboBox = createBuilder(
122
126
  onActivatePrevious,
123
127
  templateRef,
124
128
  }: CreateComboboxOptions<TValue, TAutoComplete, TMultiple>) => {
125
- const controlsId = createId("comboBox-control");
129
+ const controlsId = useId();
126
130
 
127
131
  const autocomplete = computed(() => unref(autocompleteRef));
128
132
  const isExpanded = computed(() => unref(isExpandedRef));
@@ -188,7 +192,7 @@ export const createComboBox = createBuilder(
188
192
  }
189
193
  return onActivateFirst?.();
190
194
  }
191
- if (isSelectingKey(event, multiple.value)) {
195
+ if (isSelectingKey(event, autocomplete.value === "none")) {
192
196
  return handleSelect(activeOption.value!);
193
197
  }
194
198
  if (isExpanded.value && isKeyOfGroup(event, CLOSING_KEYS)) {
@@ -218,14 +222,16 @@ export const createComboBox = createBuilder(
218
222
  internals: { getOptionId },
219
223
  } = createListbox({
220
224
  label: listLabel,
225
+ description: listDescription,
221
226
  multiple,
222
227
  controlled: true,
223
228
  activeOption,
229
+ isExpanded,
224
230
  onSelect: handleSelect,
225
231
  });
226
232
 
227
233
  useOutsideClick({
228
- element: templateRef,
234
+ inside: templateRef,
229
235
  onOutsideClick() {
230
236
  if (!isExpanded.value) return;
231
237
  onToggle?.(true);
@@ -250,7 +256,6 @@ export const createComboBox = createBuilder(
250
256
  * The input MAY be either a single-line text field that supports editing and typing or an element that only displays the current value of the combobox.
251
257
  */
252
258
  input: computed(() => ({
253
- value: inputValue.value,
254
259
  role: "combobox",
255
260
  "aria-expanded": isExpanded.value,
256
261
  "aria-controls": controlsId,
@@ -0,0 +1,19 @@
1
+ import { computed, type Ref } from "vue";
2
+ import { useGlobalEventListener } from "./useGlobalListener";
3
+
4
+ type UseDismissibleOptions = { isExpanded: Ref<boolean> };
5
+
6
+ /**
7
+ * Composable that sets `isExpanded` to false, when the `Escape` key is pressed.
8
+ * Addresses the "dismissible" aspect of https://www.w3.org/WAI/WCAG21/Understanding/content-on-hover-or-focus.html
9
+ */
10
+ export const useDismissible = ({ isExpanded }: UseDismissibleOptions) =>
11
+ useGlobalEventListener({
12
+ type: "keydown",
13
+ listener: (e) => {
14
+ if (e.key === "Escape") {
15
+ isExpanded.value = false;
16
+ }
17
+ },
18
+ disabled: computed(() => !isExpanded.value),
19
+ });
@@ -0,0 +1,83 @@
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", () => {
17
+ // ARRANGE
18
+ const inside = ref(document.createElement("button"));
19
+ document.body.appendChild(inside.value);
20
+ const outside = ref(document.createElement("button"));
21
+ document.body.appendChild(outside.value);
22
+
23
+ const onOutsideClick = vi.fn();
24
+ useOutsideClick({ inside, onOutsideClick });
25
+ // ACT
26
+ const event = new MouseEvent("click", { bubbles: true });
27
+ outside.value.dispatchEvent(event);
28
+ // ASSERT
29
+ expect(onOutsideClick).toHaveBeenCalledTimes(1);
30
+ expect(onOutsideClick).toBeCalledWith(event);
31
+ });
32
+
33
+ it("should detect outside clicks correctly for multiple inside elements", () => {
34
+ // ARRANGE
35
+ const inside = [document.createElement("button"), document.createElement("button")];
36
+ inside.forEach((e) => document.body.appendChild(e));
37
+ const outside = ref(document.createElement("button"));
38
+ document.body.appendChild(outside.value);
39
+
40
+ const onOutsideClick = vi.fn();
41
+ useOutsideClick({ inside, onOutsideClick });
42
+ // ACT
43
+ const event = new MouseEvent("click", { bubbles: true });
44
+ inside[0].dispatchEvent(event);
45
+ inside[1].dispatchEvent(event);
46
+ // ASSERT
47
+ expect(onOutsideClick).not.toHaveBeenCalled();
48
+
49
+ // ACT
50
+ outside.value.dispatchEvent(event);
51
+ // ASSERT
52
+ expect(onOutsideClick).toHaveBeenCalledTimes(1);
53
+ expect(onOutsideClick).toBeCalledWith(event);
54
+ });
55
+
56
+ it("should ignore outside clicks when disabled", async () => {
57
+ // ARRANGE
58
+ vi.useFakeTimers();
59
+ const inside = ref(document.createElement("button"));
60
+ document.body.appendChild(inside.value);
61
+ const outside = ref(document.createElement("button"));
62
+ document.body.appendChild(outside.value);
63
+
64
+ const disabled = ref(false);
65
+ const onOutsideClick = vi.fn();
66
+ useOutsideClick({ inside, disabled, onOutsideClick });
67
+
68
+ // ACT
69
+ const event = new MouseEvent("click", { bubbles: true });
70
+ outside.value.dispatchEvent(event);
71
+ // ASSERT
72
+ expect(onOutsideClick).toHaveBeenCalledTimes(1);
73
+ expect(onOutsideClick).toBeCalledWith(event);
74
+
75
+ // ACT
76
+ disabled.value = true;
77
+ await vi.runAllTimersAsync();
78
+ const event2 = new MouseEvent("click", { bubbles: true });
79
+ outside.value.dispatchEvent(event2);
80
+ // ASSERT
81
+ expect(onOutsideClick).toHaveBeenCalledTimes(1);
82
+ });
83
+ });
@@ -1,15 +1,17 @@
1
- import { type Ref } from "vue";
1
+ import type { Arrayable } from "vitest";
2
+ import { toValue, type Ref } from "vue";
3
+ import type { MaybeReactiveSource } from "../../utils/types";
2
4
  import { useGlobalEventListener } from "./useGlobalListener";
3
5
 
4
6
  export type UseOutsideClickOptions = {
5
7
  /**
6
8
  * HTML element of the component where clicks should be ignored
7
9
  */
8
- element: Ref<HTMLElement | undefined>;
10
+ inside: MaybeReactiveSource<Arrayable<HTMLElement | undefined>>;
9
11
  /**
10
12
  * Callback when an outside click occurred.
11
13
  */
12
- onOutsideClick: () => void;
14
+ onOutsideClick: (event: MouseEvent) => void;
13
15
  /**
14
16
  * If `true`, event listeners will be removed and no outside clicks will be captured.
15
17
  */
@@ -20,14 +22,18 @@ export type UseOutsideClickOptions = {
20
22
  * Composable for listening to click events that occur outside of a component.
21
23
  * Useful to e.g. close flyouts or tooltips.
22
24
  */
23
- export const useOutsideClick = ({ element, onOutsideClick, disabled }: UseOutsideClickOptions) => {
25
+ export const useOutsideClick = ({ inside, onOutsideClick, disabled }: UseOutsideClickOptions) => {
24
26
  /**
25
27
  * Document click handle that closes then tooltip when clicked outside.
26
28
  * Should only be called when trigger is "click".
27
29
  */
28
- const listener = ({ target }: MouseEvent) => {
29
- const isOutsideClick = !element.value?.contains(target as HTMLElement);
30
- if (isOutsideClick) onOutsideClick();
30
+ const listener = (event: MouseEvent) => {
31
+ const raw = toValue(inside);
32
+ const elements = Array.isArray(raw) ? raw : [raw];
33
+ const isOutsideClick = !elements.some((element) =>
34
+ element?.contains(event.target as HTMLElement),
35
+ );
36
+ if (isOutsideClick) onOutsideClick(event);
31
37
  };
32
38
 
33
39
  useGlobalEventListener({ type: "click", listener, disabled });
@@ -30,7 +30,9 @@ const {
30
30
  elements: { listbox, option: headlessOption },
31
31
  } = createListbox({
32
32
  label: "Test listbox",
33
+ description: "Test description",
33
34
  activeOption,
35
+ isExpanded: true,
34
36
  onSelect: (id) => {
35
37
  selectedOption.value = selectedOption.value === id ? undefined : id;
36
38
  },
@@ -1,6 +1,5 @@
1
- import { computed, ref, unref, watchEffect, type MaybeRef, type Ref } from "vue";
2
- import { createId } from "../..";
3
- import { createBuilder, type HeadlessElementAttributes } from "../../utils/builder";
1
+ import { computed, nextTick, ref, unref, useId, watchEffect, type MaybeRef, type Ref } from "vue";
2
+ import { createBuilder, type VBindAttributes } from "../../utils/builder";
4
3
  import { useTypeAhead } from "../helpers/useTypeAhead";
5
4
 
6
5
  export type ListboxValue = string | number | boolean;
@@ -10,6 +9,10 @@ export type CreateListboxOptions<TValue extends ListboxValue, TMultiple extends
10
9
  * Aria label for the listbox.
11
10
  */
12
11
  label: MaybeRef<string>;
12
+ /**
13
+ * Aria description for the listbox.
14
+ */
15
+ description?: MaybeRef<string | undefined>;
13
16
  /**
14
17
  * Value of currently (visually) active option.
15
18
  */
@@ -19,6 +22,10 @@ export type CreateListboxOptions<TValue extends ListboxValue, TMultiple extends
19
22
  * This disables keyboard events and makes the listbox not focusable.
20
23
  */
21
24
  controlled?: boolean;
25
+ /**
26
+ * Controls the opened/visible state of the listbox. When expanded the activeOption can be controlled via the keyboard.
27
+ */
28
+ isExpanded?: MaybeRef<boolean>;
22
29
  /**
23
30
  * Whether the listbox is multiselect.
24
31
  */
@@ -77,6 +84,7 @@ export const createListbox = createBuilder(
77
84
  options: CreateListboxOptions<TValue, TMultiple>,
78
85
  ) => {
79
86
  const isMultiselect = computed(() => unref(options.multiple) ?? false);
87
+ const isExpanded = computed(() => unref(options.isExpanded) ?? false);
80
88
 
81
89
  /**
82
90
  * Map for option IDs. key = option value, key = ID for the HTML element
@@ -85,7 +93,7 @@ export const createListbox = createBuilder(
85
93
 
86
94
  const getOptionId = (value: TValue) => {
87
95
  if (!descendantKeyIdMap.has(value)) {
88
- descendantKeyIdMap.set(value, createId("listbox-option"));
96
+ descendantKeyIdMap.set(value, useId());
89
97
  }
90
98
  return descendantKeyIdMap.get(value)!;
91
99
  };
@@ -96,11 +104,18 @@ export const createListbox = createBuilder(
96
104
  const isFocused = ref(false);
97
105
 
98
106
  // scroll currently active option into view if needed
99
- watchEffect(() => {
100
- if (options.activeOption.value == undefined || (!isFocused.value && !options.controlled))
107
+ watchEffect(async () => {
108
+ if (
109
+ !isExpanded.value ||
110
+ options.activeOption.value == undefined ||
111
+ (!isFocused.value && !options.controlled)
112
+ )
101
113
  return;
102
114
  const id = getOptionId(options.activeOption.value);
103
- document.getElementById(id)?.scrollIntoView({ block: "nearest", inline: "nearest" });
115
+
116
+ await nextTick(() => {
117
+ document.getElementById(id)?.scrollIntoView({ block: "end", inline: "nearest" });
118
+ });
104
119
  });
105
120
 
106
121
  const typeAhead = useTypeAhead((inputString) => options.onTypeAhead?.(inputString));
@@ -152,18 +167,20 @@ export const createListbox = createBuilder(
152
167
  }
153
168
  };
154
169
 
155
- const listbox = computed<HeadlessElementAttributes>(() =>
170
+ const listbox = computed<VBindAttributes>(() =>
156
171
  options.controlled
157
172
  ? {
158
173
  role: "listbox",
159
174
  "aria-multiselectable": isMultiselect.value,
160
175
  "aria-label": unref(options.label),
176
+ "aria-description": options.description,
161
177
  tabindex: "-1",
162
178
  }
163
179
  : {
164
180
  role: "listbox",
165
181
  "aria-multiselectable": isMultiselect.value,
166
182
  "aria-label": unref(options.label),
183
+ "aria-description": options.description,
167
184
  tabindex: "0",
168
185
  "aria-activedescendant":
169
186
  options.activeOption.value != undefined
@@ -9,6 +9,6 @@ test("menuButton", async ({ mount, page }) => {
9
9
  page,
10
10
  button: page.getByRole("button"),
11
11
  menu: page.locator("ul"),
12
- menuItems: await page.locator("li").all(),
12
+ menuItems: page.getByRole("menuitem"),
13
13
  });
14
14
  });
@@ -8,18 +8,19 @@ const items = Array.from({ length: 10 }, (_, index) => {
8
8
  });
9
9
 
10
10
  const activeItem = ref<string>();
11
+ const isExpanded = ref(false);
12
+ const onToggle = () => (isExpanded.value = !isExpanded.value);
11
13
 
12
14
  const {
13
- elements: { button, menu, menuItem, listItem, flyout },
14
- state: { isExpanded },
15
- } = createMenuButton();
15
+ elements: { root, button, menu, menuItem, listItem },
16
+ } = createMenuButton({ isExpanded, onToggle });
16
17
  </script>
17
18
 
18
19
  <template>
19
- <button v-bind="button">Toggle nav menu</button>
20
- <div v-bind="flyout">
20
+ <div v-bind="root">
21
+ <button v-bind="button">Toggle nav menu</button>
21
22
  <ul v-show="isExpanded" v-bind="menu">
22
- <li v-for="item in items" v-bind="listItem" :key="item.value" title="item">
23
+ <li v-for="item in items" v-bind="listItem" :key="item.value">
23
24
  <a v-bind="menuItem({ active: activeItem === item.value })" href="#">{{ item.label }}</a>
24
25
  </li>
25
26
  </ul>
@@ -17,7 +17,7 @@ export type MenuButtonTestingOptions = {
17
17
  /**
18
18
  * List items (at least 3).
19
19
  */
20
- menuItems: Locator[];
20
+ menuItems: Locator;
21
21
  };
22
22
 
23
23
  /**
@@ -45,25 +45,26 @@ export const menuButtonTesting = async ({
45
45
  await expect(button).toBeVisible();
46
46
 
47
47
  // ensure correct navigation menu aria attributes
48
- await expect(
49
- button,
50
- 'flyout menu must have an "aria-expanded" attribute set to false',
51
- ).toHaveAttribute("aria-expanded", "false");
52
-
53
- button.hover();
54
-
55
- await expect(
56
- button,
57
- 'flyout menu must have an "aria-expanded" attribute set to true',
58
- ).toHaveAttribute("aria-expanded", "true");
59
-
60
- const firstItem = menuItems[0].getByRole("menuitem");
61
- const secondItem = menuItems[1].getByRole("menuitem");
62
- const lastItem = menuItems[menuItems.length - 1].getByRole("menuitem");
48
+ await expect(button, "button must have arial-controls attribute").toHaveAttribute(
49
+ "aria-controls",
50
+ );
51
+ await expect(button, "button must have aria-expanded attribute").toHaveAttribute(
52
+ "aria-expanded",
53
+ "false",
54
+ );
63
55
 
64
56
  await page.keyboard.press("Tab");
65
57
  await expect(button, "Button should be focused when pressing tab key").toBeFocused();
66
58
 
59
+ const firstItem = menuItems.first();
60
+ const secondItem = menuItems.nth(1);
61
+ const lastItem = menuItems.last();
62
+
63
+ await page.keyboard.press("Enter");
64
+ await expect(button, "button must have aria-expanded attribute").toHaveAttribute(
65
+ "aria-expanded",
66
+ "true",
67
+ );
67
68
  await button.press("ArrowDown");
68
69
  await expect(
69
70
  firstItem,