Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(headless): implement headless Menu #1460

Merged
merged 49 commits into from
Jul 16, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
28ffcc9
fix
JoCa96 Jun 28, 2024
5c98643
Merge branch 'main' of https://github.com/SchwarzIT/onyx into joca96/…
JoCa96 Jun 28, 2024
8156b8b
implement headless menu
JoCa96 Jul 1, 2024
1391c8c
docs(changeset): sdf
JoCa96 Jul 1, 2024
2daf907
Merge branch 'main' of https://github.com/SchwarzIT/onyx into joca96/…
JoCa96 Jul 1, 2024
963afe5
fix: export and import debounce
JoCa96 Jul 1, 2024
8502edc
Merge branch 'main' of https://github.com/SchwarzIT/onyx into joca96/…
JoCa96 Jul 1, 2024
0735742
fix test
JoCa96 Jul 1, 2024
4c96add
update nav
JoCa96 Jul 1, 2024
8711fc0
Merge branch 'main' of https://github.com/SchwarzIT/onyx into joca96/…
JoCa96 Jul 1, 2024
04c5d19
Merge branch 'main' of https://github.com/SchwarzIT/onyx into joca96/…
JoCa96 Jul 1, 2024
d97d2f4
review: use Ref instead of MaybeRef
JoCa96 Jul 1, 2024
4e9de50
review: update comment
JoCa96 Jul 1, 2024
f966139
Merge branch 'main' of https://github.com/SchwarzIT/onyx into joca96/…
JoCa96 Jul 1, 2024
32d4bb6
Merge branch 'main' of https://github.com/SchwarzIT/onyx into joca96/…
JoCa96 Jul 1, 2024
a72ff74
Merge branch 'main' of https://github.com/SchwarzIT/onyx into joca96/…
JoCa96 Jul 4, 2024
3ff56cb
Merge branch 'main' of https://github.com/SchwarzIT/onyx into joca96/…
JoCa96 Jul 4, 2024
9137905
use user menu from main
JoCa96 Jul 4, 2024
41a06aa
fix generic typing of createBuilder, so that zero arguments are allowed
JoCa96 Jul 5, 2024
40c5b99
fix createMenuButton bug where it closed before triggering a click
JoCa96 Jul 5, 2024
85493e1
Merge branch 'main' into joca96/1434-update-user-menu
BoppLi Jul 5, 2024
a2353ac
implement globalListener util
JoCa96 Jul 8, 2024
75e3de6
rename ct util files to use suffix testing to fix playwright issue
JoCa96 Jul 8, 2024
1accca3
update outsideClick using useGlobalEventListener
JoCa96 Jul 8, 2024
e32765a
move outsideClick and typeAhead to helpers
JoCa96 Jul 8, 2024
6895c1f
removed unused class
JoCa96 Jul 9, 2024
34b6d0b
remove id from tooltip
JoCa96 Jul 9, 2024
13d624a
update flyout menu with new api
JoCa96 Jul 9, 2024
1e2a2d1
fix click bug and make menu button dismissible
JoCa96 Jul 9, 2024
78efca3
remove redundant parameter
JoCa96 Jul 9, 2024
cbe2441
add test for dismissable bug
JoCa96 Jul 9, 2024
291bb3f
Merge branch 'joca96/1434-update-user-menu' of https://github.com/Sch…
JoCa96 Jul 9, 2024
7c64e0f
Merge branch 'main' of https://github.com/SchwarzIT/onyx into joca96/…
JoCa96 Jul 9, 2024
e4ed0a6
rename helpers
JoCa96 Jul 9, 2024
9075ed4
implement global event listener
JoCa96 Jul 9, 2024
b203fd4
docs(changeset): s
JoCa96 Jul 9, 2024
cd95d0f
fix lint issue
JoCa96 Jul 9, 2024
b49722a
fix ts issue
JoCa96 Jul 9, 2024
33129bb
fix tooltip issue: missing root binding
JoCa96 Jul 9, 2024
b980628
Merge branch 'joca96/implement-global-event-listener' into joca96/143…
JoCa96 Jul 9, 2024
e28a74d
Use ref for menu
JoCa96 Jul 9, 2024
ab77f52
Merge branch 'main' into joca96/1434-update-user-menu
BoppLi Jul 11, 2024
46f87b9
Merge branch 'main' of https://github.com/SchwarzIT/onyx into joca96/…
JoCa96 Jul 15, 2024
e5b418d
Merge branch 'main' of https://github.com/SchwarzIT/onyx into joca96/…
JoCa96 Jul 15, 2024
63de9ad
fix merge issue
JoCa96 Jul 15, 2024
2e25c7e
update test
JoCa96 Jul 15, 2024
13e38f7
fix sass deprecation warning
JoCa96 Jul 15, 2024
6d81509
remove redundant headline
JoCa96 Jul 15, 2024
5053295
Merge branch 'main' into joca96/1434-update-user-menu
JoCa96 Jul 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/small-carrots-confess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@sit-onyx/headless": major
"sit-onyx": minor
---

- implemented headless feature: `createNavigationMenu`
- headless MenuButton:
- now takes an `isExpandedRef` and `onToggle` via it's options
- `flyout` element is removed as it is not needed
- removed hover and focus toggle features and moved them to the onyx component directly as these are non spec features
- update headless implementation in `sit-onyx`
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ test("menuButton", async ({ mount, page }) => {
page,
button: page.getByRole("button"),
menu: page.locator("ul"),
menuItems: await page.locator("li").all(),
menuItems: page.getByRole("menuitem"),
});
});
19 changes: 9 additions & 10 deletions packages/headless/src/composables/menuButton/TestMenuButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,19 @@ const items = Array.from({ length: 10 }, (_, index) => {
});

const activeItem = ref<string>();
const isExpanded = ref(false);
const onToggle = () => (isExpanded.value = !isExpanded.value);

const {
elements: { button, menu, menuItem, listItem, flyout },
state: { isExpanded },
} = createMenuButton({});
elements: { button, menu, menuItem, listItem },
} = createMenuButton({ isExpanded, onToggle });
</script>

<template>
<button v-bind="button">Toggle nav menu</button>
<div v-bind="flyout">
<ul v-show="isExpanded" v-bind="menu">
<li v-for="item in items" v-bind="listItem" :key="item.value" title="item">
<a v-bind="menuItem({ active: activeItem === item.value })" href="#">{{ item.label }}</a>
</li>
</ul>
</div>
<ul v-show="isExpanded" v-bind="menu">
<li v-for="item in items" v-bind="listItem" :key="item.value">
<a v-bind="menuItem({ active: activeItem === item.value })" href="#">{{ item.label }}</a>
</li>
</ul>
</template>
33 changes: 17 additions & 16 deletions packages/headless/src/composables/menuButton/createMenuButton.ct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export type MenuButtonTestingOptions = {
/**
* List items (at least 3).
*/
menuItems: Locator[];
menuItems: Locator;
};

/**
Expand Down Expand Up @@ -45,25 +45,26 @@ export const menuButtonTesting = async ({
await expect(button).toBeVisible();

// ensure correct navigation menu aria attributes
await expect(
button,
'flyout menu must have an "aria-expanded" attribute set to false',
).toHaveAttribute("aria-expanded", "false");

button.hover();

await expect(
button,
'flyout menu must have an "aria-expanded" attribute set to true',
).toHaveAttribute("aria-expanded", "true");

const firstItem = menuItems[0].getByRole("menuitem");
const secondItem = menuItems[1].getByRole("menuitem");
const lastItem = menuItems[menuItems.length - 1].getByRole("menuitem");
await expect(button, "button must have arial-controls attribute").toHaveAttribute(
"aria-controls",
);
await expect(button, "button must have aria-expanded attribute").toHaveAttribute(
"aria-expanded",
"false",
);

await page.keyboard.press("Tab");
await expect(button, "Button should be focused when pressing tab key").toBeFocused();

const firstItem = menuItems.first();
const secondItem = menuItems.nth(1);
const lastItem = menuItems.last();

await page.keyboard.press("Enter");
await expect(button, "button must have aria-expanded attribute").toHaveAttribute(
"aria-expanded",
"true",
);
await button.press("ArrowDown");
await expect(
firstItem,
Expand Down
201 changes: 95 additions & 106 deletions packages/headless/src/composables/menuButton/createMenuButton.ts
Original file line number Diff line number Diff line change
@@ -1,125 +1,114 @@
import { computed, ref } from "vue";
import { computed, unref, type MaybeRef } from "vue";
import { createBuilder } from "../../utils/builder";
import { createId } from "../../utils/id";
import { debounce } from "../../utils/timer";

type CreateMenuButtonOptions = {
isExpanded: MaybeRef<boolean>;
onToggle: () => void;
JoCa96 marked this conversation as resolved.
Show resolved Hide resolved
};

/**
* Based on https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/
*/
export const createMenuButton = createBuilder(() => {
const menuId = createId("menu");
const buttonId = createId("menu-button");
const isExpanded = ref(false);
export const createMenuButton = createBuilder(
({ isExpanded: isExpandedRef, onToggle }: CreateMenuButtonOptions) => {
const menuId = createId("menu");
const buttonId = createId("menu-button");
const isExpanded = computed(() => unref(isExpandedRef));

/**
* Debounced expanded state that will only be toggled after a given timeout.
*/
const updateDebouncedExpanded = debounce(
(expanded: boolean) => (isExpanded.value = expanded),
200,
);
const focusRelativeItem = (next: "next" | "prev" | "first" | "last") => {
const currentMenuItem = document.activeElement as HTMLElement;

const hoverEvents = computed(() => {
return {
onMouseover: () => updateDebouncedExpanded(true),
onMouseout: () => updateDebouncedExpanded(false),
onFocusin: () => (isExpanded.value = true),
onFocusout: () => (isExpanded.value = false),
};
});
// Either the current focus is on a "menuitem", then we can just get the parent menu.
// Or the current focus is on the button, then we can get the connected menu using the menuId
const currentMenu =
currentMenuItem?.closest('[role="menu"]') || document.getElementById(menuId);
if (!currentMenu) return;

const focusRelativeItem = (next: "next" | "prev" | "first" | "last") => {
const currentMenuItem = document.activeElement as HTMLElement;
const menuItems = [...currentMenu.querySelectorAll<HTMLElement>('[role="menuitem"]')];
let nextIndex = 0;

// Either the current focus is on a "menuitem", then we can just get the parent menu.
// Or the current focus is on the button, then we can get the connected menu using the menuId
const currentMenu =
currentMenuItem?.closest('[role="menu"]') || document.getElementById(menuId);
if (!currentMenu) return;
if (currentMenuItem) {
const currentIndex = menuItems.indexOf(currentMenuItem);
switch (next) {
case "next":
nextIndex = currentIndex + 1;
break;
case "prev":
nextIndex = currentIndex - 1;
break;
case "first":
nextIndex = 0;
break;
case "last":
nextIndex = menuItems.length - 1;
break;
}
}

const menuItems = [...currentMenu.querySelectorAll<HTMLElement>('[role="menuitem"]')];
let nextIndex = 0;
const nextMenuItem = menuItems[nextIndex];
nextMenuItem?.focus();
};

if (currentMenuItem) {
const currentIndex = menuItems.indexOf(currentMenuItem);
switch (next) {
case "next":
nextIndex = currentIndex + 1;
const handleKeydown = (event: KeyboardEvent) => {
switch (event.key) {
case "ArrowDown":
case "ArrowRight":
event.preventDefault();
focusRelativeItem("next");
larsrickert marked this conversation as resolved.
Show resolved Hide resolved
break;
case "ArrowUp":
case "ArrowLeft":
event.preventDefault();
focusRelativeItem("prev");
break;
case "Home":
event.preventDefault();
focusRelativeItem("first");
break;
case "prev":
nextIndex = currentIndex - 1;
case "End":
event.preventDefault();
focusRelativeItem("last");
break;
case "first":
nextIndex = 0;
case " ":
event.preventDefault();
(event.target as HTMLElement).click();
break;
case "last":
nextIndex = menuItems.length - 1;
case "Escape":
event.preventDefault();
isExpanded.value && onToggle();
break;
}
}

const nextMenuItem = menuItems[nextIndex];
nextMenuItem?.focus();
};

const handleKeydown = (event: KeyboardEvent) => {
switch (event.key) {
case "ArrowDown":
case "ArrowRight":
event.preventDefault();
focusRelativeItem("next");
break;
case "ArrowUp":
case "ArrowLeft":
event.preventDefault();
focusRelativeItem("prev");
break;
case "Home":
event.preventDefault();
focusRelativeItem("first");
break;
case "End":
event.preventDefault();
focusRelativeItem("last");
break;
case " ":
event.preventDefault();
(event.target as HTMLElement).click();
break;
}
};
};

return {
state: { isExpanded },
elements: {
button: computed(
() =>
({
"aria-controls": menuId,
"aria-expanded": isExpanded.value,
"aria-haspopup": true,
id: buttonId,
...hoverEvents.value,
onKeydown: handleKeydown,
}) as const,
),
flyout: {
...hoverEvents.value,
},
menu: {
id: menuId,
role: "menu",
"aria-labelledby": buttonId,
onKeydown: handleKeydown,
},
listItem: {
role: "none",
return {
elements: {
button: computed(
() =>
({
"aria-controls": menuId,
"aria-expanded": isExpanded.value,
"aria-haspopup": true,
id: buttonId,
onKeydown: handleKeydown,
onClick: onToggle,
}) as const,
),
menu: {
id: menuId,
role: "menu",
"aria-labelledby": buttonId,
onKeydown: handleKeydown,
},
listItem: {
role: "none",
},
menuItem: (data: { active?: boolean; disabled?: boolean }) => ({
"aria-current": data.active ? "page" : undefined,
"aria-disabled": data.disabled,
role: "menuitem",
}),
},
menuItem: (data: { active?: boolean; disabled?: boolean }) => ({
"aria-current": data.active ? "page" : undefined,
"aria-disabled": data.disabled,
role: "menuitem",
}),
},
};
});
};
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { test } from "@playwright/experimental-ct-vue";
import { navigationTesting } from "./createMenu.ct";
import TestMenu from "./TestMenu.vue";

test("navigationMenu", async ({ mount, page }) => {
await mount(<TestMenu />);

await navigationTesting({
buttons: page.getByRole("button"),
nav: page.getByRole("navigation"),
});
});
16 changes: 16 additions & 0 deletions packages/headless/src/composables/navigationMenu/TestMenu.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<script lang="ts" setup>
import TestMenuButton from "../menuButton/TestMenuButton.vue";
import { createNavigationMenu } from "./createMenu";

const {
elements: { nav },
} = createNavigationMenu({ navigationName: "test menu" });
</script>

<template>
<nav v-bind="nav">
<TestMenuButton />
<TestMenuButton />
<TestMenuButton />
</nav>
</template>
Loading
Loading