Skip to content

Commit

Permalink
feat(OnyxNavButton): Implement onyx nav button (#1281)
Browse files Browse the repository at this point in the history
<!-- Is your PR related to an issue? Then please link it via the
"Relates to #" below. Else, remove it. -->

Relates to #883 

Implement onyx nav button

## Checklist

- [x] If a new component is added, at least one [Playwright screenshot
test](https://github.com/SchwarzIT/onyx/actions/workflows/playwright-screenshots.yml)
is added
- [x] A changeset is added with `npx changeset add` if your changes
should be released as npm package (because they affect the library
usage)

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
  • Loading branch information
MajaZarkova and github-actions[bot] authored Jun 19, 2024
1 parent 7f41c22 commit d3e9321
Show file tree
Hide file tree
Showing 28 changed files with 946 additions and 3 deletions.
5 changes: 5 additions & 0 deletions .changeset/hungry-beers-admire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sit-onyx": minor
---

Add new OnyxNavButton component
5 changes: 5 additions & 0 deletions .changeset/six-llamas-provide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sit-onyx/headless": minor
---

Add new createMenuButton composable
14 changes: 14 additions & 0 deletions packages/headless/src/composables/menuButton/TestMenuButton.ct.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { test } from "@playwright/experimental-ct-vue";
import { menuButtonTesting } from "./createMenuButton.ct";
import TestMenuButton from "./TestMenuButton.vue";

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

await menuButtonTesting({
page,
button: page.getByRole("button"),
menu: page.locator("ul"),
menuItems: await page.locator("li").all(),
});
});
33 changes: 33 additions & 0 deletions packages/headless/src/composables/menuButton/TestMenuButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<script lang="ts" setup>
import { createMenuButton } from "./createMenuButton";
import { ref } from "vue";
const items = Array.from({ length: 10 }, (_, index) => {
const id = index + 1;
return { label: `Item ${id}`, value: `/href-${id}` };
});
const activeItem = ref<string>();
const {
elements: { button, menu, menuItem, listItem, flyout },
state: { isExpanded },
} = createMenuButton({
onSelect: (value) => {
activeItem.value = value;
},
});
</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, value: item.value })">{{
item.label
}}</a>
</li>
</ul>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { expect } from "@playwright/experimental-ct-vue";
import type { Locator, Page } from "@playwright/test";

export type MenuButtonTestingOptions = {
/**
* Playwright page.
*/
page: Page;
/**
* Locator for the button element.
*/
button: Locator;
/**
* Menu, e.g. a `<ul>` element.
*/
menu: Locator;
/**
* List items (at least 3).
*/
menuItems: Locator[];
};

/**
* Playwright utility for executing accessibility testing for a navigation menu.
* Will check aria attributes and keyboard shortcuts as defined in https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/examples/menu-button-links.
*/
export const menuButtonTesting = async ({ button, menu }: MenuButtonTestingOptions) => {
const menuId = await menu.getAttribute("id");
expect(menuId).toBeDefined();
await expect(
button,
"navigation menu should have set the list ID to the aria-controls",
).toHaveAttribute("aria-controls", menuId!);

await expect(
button,
'navigation menu should have an "aria-haspopup" attribute set to true',
).toHaveAttribute("aria-haspopup", "true");

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");
};
69 changes: 69 additions & 0 deletions packages/headless/src/composables/menuButton/createMenuButton.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { computed, ref } from "vue";
import { createBuilder } from "../../utils/builder";
import { createId } from "../../utils/id";
import { debounce } from "../../utils/timer";

export type CreateMenuButtonOptions = {
/**
* Called when a menu item is selected (via mouse or keyboard).
*/
onSelect: (value: string) => void;
};

export const createMenuButton = createBuilder((options: CreateMenuButtonOptions) => {
const menuId = createId("menu");
const buttonId = createId("menu-button");
const isExpanded = ref<boolean>(false);

/**
* Debounced expanded state that will only be toggled after a given timeout.
*/
const updateDebouncedExpanded = debounce(
(expanded: boolean) => (isExpanded.value = expanded),
200,
);

const hoverEvents = computed(() => {
return {
onMouseover: () => updateDebouncedExpanded(true),
onMouseout: () => updateDebouncedExpanded(false),
onFocusin: () => (isExpanded.value = true),
onFocusout: () => (isExpanded.value = false),
};
});

return {
state: { isExpanded },
elements: {
button: computed(
() =>
({
"aria-controls": menuId,
"aria-expanded": isExpanded.value,
"aria-haspopup": true,
id: buttonId,
...hoverEvents.value,
}) as const,
),
listItem: {
role: "none",
},
flyout: {
...hoverEvents.value,
},
menu: {
id: menuId,
role: "menu",
"aria-labelledby": buttonId,
},
menuItem: (data: { active?: boolean; value: string }) => ({
"aria-current": data.active ? "page" : undefined,
role: "menuitem",
tabindex: -1,
onClick: () => {
options.onSelect(data.value);
},
}),
},
};
});
1 change: 1 addition & 0 deletions packages/headless/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from "./composables/comboBox/createComboBox";
export * from "./composables/listbox/createListbox";
export * from "./composables/tooltip/createTooltip";
export * from "./composables/menuButton/createMenuButton";
export { createId } from "./utils/id";
export { isPrintableCharacter, wasKeyPressed } from "./utils/keyboard";
1 change: 1 addition & 0 deletions packages/headless/src/playwright.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./composables/comboBox/createComboBox.ct";
export * from "./composables/listbox/createListbox.ct";
export * from "./composables/menuButton/createMenuButton.ct";
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { defineStorybookActionsAndVModels } from "@sit-onyx/storybook-utils";
import type { Meta, StoryObj } from "@storybook/vue3";
import { h } from "vue";
import OnyxListItem from "../../OnyxListItem/OnyxListItem.vue";
import OnyxButton from "../../OnyxButton/OnyxButton.vue";
import OnyxFlyoutMenu from "./OnyxFlyoutMenu.vue";

const meta: Meta<typeof OnyxFlyoutMenu> = {
title: "future/FlyoutMenu",
...defineStorybookActionsAndVModels({
component: OnyxFlyoutMenu,
events: [],
decorators: [
(story) => ({
components: { story },
template: `
<div style="height: 28rem;">
<story />
</div>`,
}),
],
argTypes: {
default: { control: { disable: true } },
options: { control: { disable: true } },
header: { control: { disable: true } },
footer: { control: { disable: true } },
},
}),
};

export default meta;
type Story = StoryObj<typeof OnyxFlyoutMenu>;

const listAnimals = [
{ label: "Cat" },
{ label: "Dog" },
{ label: "Tiger" },
{ label: "Reindeer" },
{ label: "Racoon" },
{ label: "Dolphin" },
{ label: "Flounder" },
{ label: "Eel" },
{ label: "Falcon" },
{ label: "Owl" },
];

/**
* This example shows a basic OnyxFlyoutMenu
*/
export const Default = {
args: {
default: () => h(OnyxButton, { label: "Hover me" }),
options: () => listAnimals.map(({ label }) => h(OnyxListItem, label)),
},
} satisfies Story;
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<script setup lang="ts" generic="TValue extends SelectOptionValue = SelectOptionValue">
import { createMenuButton } from "@sit-onyx/headless";
import type { SelectOptionValue } from "../../../types";
import { computed, ref, type VNode } from "vue";
import { injectI18n } from "../../../i18n";
const slots = defineSlots<{
/**
* The trigger for the flyout menu
*/
default(): VNode[];
/**
* OnyxListItems to show
*/
options?(): VNode[];
/**
* Optional header content to display above the options.
*/
header?(): unknown;
/**
* Optional footer content to display below the options (will replace `message` property).
*/
footer?(): unknown;
}>();
const activeItem = ref<string>();
const {
elements: { button, menu, menuItem, listItem, flyout },
state: { isExpanded },
} = createMenuButton({
onSelect: (value) => {
activeItem.value = value;
},
});
const getSlotComponents = (vnodes: VNode[]): VNode[] => {
// if the slot only contains a v-for, we need to use the children here which are the "actual" slot content
const isVFor = vnodes.length === 1 && vnodes[0].type.toString() === "Symbol(v-fgt)";
const allNodes =
isVFor && Array.isArray(vnodes[0].children)
? (vnodes[0].children as Extract<(typeof vnodes)[number]["children"], []>)
: vnodes;
return allNodes;
};
const options = computed(() => {
return getSlotComponents(slots.options?.() ?? []);
});
const { t } = injectI18n();
</script>

<template>
<div class="onyx-future-flyout-menu">
<component :is="slots.default?.()?.[0]" v-bind="button" />
<div
v-if="slots.options || slots.header || slots.footer"
v-show="isExpanded"
v-bind="flyout"
:aria-label="t('navigation.navigationHeadline')"
:class="{
'onyx-future-flyout-menu__list--with-header': !!slots.header,
'onyx-future-flyout-menu__list--with-footer': !!slots.footer,
'onyx-future-flyout-menu__list': true,
}"
>
<slot name="header"></slot>
<ul
v-if="slots.options"
v-bind="menu"
class="onyx-future-flyout-menu__wrapper onyx-flyout-menu__group"
>
<li
v-for="(item, index) in options"
v-bind="listItem"
:key="index"
class="onyx-future-flyout-menu__option"
>
<component
:is="item"
v-bind="
item.props?.href
? menuItem({ active: activeItem === item.props.href, value: item.props.href })
: undefined
"
/>
</li>
</ul>
<slot name="footer"></slot>
</div>
</div>
</template>

<style lang="scss">
@use "../../../styles/mixins/layers";
// TODO: replace 'onyx-future-flyout-menu' class name with 'onyx-flyout-menu' after merging this component
// with the official OnyxFlyoutMenu
.onyx-future-flyout-menu {
@include layers.component() {
// TODO: remove comment after replacing this component with the official OnyxFlyoutMenu
// display: inline-block;
width: min-content;
&__list {
position: absolute;
margin-top: var(--onyx-spacing-sm);
border-radius: var(--onyx-radius-md);
background-color: var(--onyx-color-base-background-blank);
padding: var(--onyx-spacing-2xs) 0;
box-shadow: var(--onyx-shadow-medium-bottom);
box-sizing: border-box;
width: max-content;
min-width: var(--onyx-spacing-4xl);
max-width: 20rem;
font-family: var(--onyx-font-family);
z-index: var(--onyx-z-index-flyout);
&--with-header {
padding-top: 0;
}
&--with-footer {
padding-bottom: 0;
}
}
&__wrapper {
padding: 0;
}
&__option {
list-style: none;
}
}
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# OnyxFlyoutMenu

This is the new (future) version of OnyxFlyoutMenu component. The strucuture of the component is optimized and it will replace the current OnyxFlyoutMenu in the future.
Loading

0 comments on commit d3e9321

Please sign in to comment.