@sit-onyx/headless 1.0.0-beta.7 → 1.0.0-beta.9

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/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.7",
4
+ "version": "1.0.0-beta.9",
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",
@@ -56,11 +56,11 @@ defineExpose({ comboBox });
56
56
  <div ref="comboboxRef">
57
57
  <input v-bind="input" v-model="searchTerm" @keydown.arrow-down="isExpanded = true" />
58
58
 
59
- <button v-bind="button">
60
- <template v-if="isExpanded">⬆️</template>
61
- <template v-else>⬇️</template>
59
+ <button v-bind="button" type="button">
60
+ <template v-if="isExpanded"> ⬆️ </template>
61
+ <template v-else> ⬇️ </template>
62
62
  </button>
63
- <ul v-bind="listbox" :class="{ hidden: !isExpanded }" style="width: 400px">
63
+ <ul v-bind="listbox" :class="{ list: true, hidden: !isExpanded }">
64
64
  <li
65
65
  v-for="e in filteredOptions"
66
66
  :key="e"
@@ -74,6 +74,10 @@ defineExpose({ comboBox });
74
74
  </template>
75
75
 
76
76
  <style>
77
+ .list {
78
+ width: 400px;
79
+ }
80
+
77
81
  .hidden {
78
82
  display: none;
79
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";
@@ -127,7 +126,7 @@ export const createComboBox = createBuilder(
127
126
  onActivatePrevious,
128
127
  templateRef,
129
128
  }: CreateComboboxOptions<TValue, TAutoComplete, TMultiple>) => {
130
- const controlsId = createId("comboBox-control");
129
+ const controlsId = useId();
131
130
 
132
131
  const autocomplete = computed(() => unref(autocompleteRef));
133
132
  const isExpanded = computed(() => unref(isExpandedRef));
@@ -1,5 +1,4 @@
1
- import { computed, nextTick, ref, unref, watchEffect, type MaybeRef, type Ref } from "vue";
2
- import { createId } from "../..";
1
+ import { computed, nextTick, ref, unref, useId, watchEffect, type MaybeRef, type Ref } from "vue";
3
2
  import { createBuilder, type VBindAttributes } from "../../utils/builder";
4
3
  import { useTypeAhead } from "../helpers/useTypeAhead";
5
4
 
@@ -94,7 +93,7 @@ export const createListbox = createBuilder(
94
93
 
95
94
  const getOptionId = (value: TValue) => {
96
95
  if (!descendantKeyIdMap.has(value)) {
97
- descendantKeyIdMap.set(value, createId("listbox-option"));
96
+ descendantKeyIdMap.set(value, useId());
98
97
  }
99
98
  return descendantKeyIdMap.get(value)!;
100
99
  };
@@ -1,6 +1,5 @@
1
- import { computed, type Ref } from "vue";
1
+ import { computed, useId, type Ref } from "vue";
2
2
  import { createBuilder, createElRef } from "../../utils/builder";
3
- import { createId } from "../../utils/id";
4
3
  import { debounce } from "../../utils/timer";
5
4
  import { useGlobalEventListener } from "../helpers/useGlobalListener";
6
5
 
@@ -14,10 +13,10 @@ type CreateMenuButtonOptions = {
14
13
  */
15
14
  export const createMenuButton = createBuilder(
16
15
  ({ isExpanded, onToggle }: CreateMenuButtonOptions) => {
17
- const rootId = createId("menu-button-root");
18
- const menuId = createId("menu-button-list");
16
+ const rootId = useId();
17
+ const menuId = useId();
19
18
  const menuRef = createElRef<HTMLElement>();
20
- const buttonId = createId("menu-button-button");
19
+ const buttonId = useId();
21
20
 
22
21
  useGlobalEventListener({
23
22
  type: "keydown",
@@ -106,7 +105,10 @@ export const createMenuButton = createBuilder(
106
105
  onMouseout: () => updateDebouncedExpanded(false),
107
106
  onFocusout: (event) => {
108
107
  // if focus receiving element is not part of the menu button, then close
109
- if (document.getElementById(rootId)?.contains(event.relatedTarget as HTMLElement)) {
108
+ if (
109
+ rootId &&
110
+ document.getElementById(rootId)?.contains(event.relatedTarget as HTMLElement)
111
+ ) {
110
112
  return;
111
113
  }
112
114
  isExpanded.value && onToggle();
@@ -1,5 +1,4 @@
1
- import { unref, type MaybeRef } from "vue";
2
- import { createId } from "../..";
1
+ import { unref, useId, type MaybeRef } from "vue";
3
2
  import { createBuilder } from "../../utils/builder";
4
3
  import { MathUtils } from "../../utils/math";
5
4
 
@@ -15,10 +14,10 @@ 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
22
  return [...nav.querySelectorAll<HTMLElement>("button[aria-expanded][aria-controls]")];
24
23
  };
@@ -0,0 +1,13 @@
1
+ import { test } from "@playwright/experimental-ct-vue";
2
+ import TestTabs from "./TestTabs.vue";
3
+ import { tabsTesting } from "./createTabs.testing";
4
+
5
+ // eslint-disable-next-line playwright/expect-expect
6
+ test("tabs", async ({ mount, page }) => {
7
+ const component = await mount(<TestTabs />);
8
+
9
+ await tabsTesting({
10
+ page,
11
+ tablist: component.getByRole("tablist"),
12
+ });
13
+ });
@@ -0,0 +1,26 @@
1
+ <script lang="ts" setup>
2
+ import { ref } from "vue";
3
+ import { createTabs } from "./createTabs";
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
+ </div>
22
+
23
+ <div v-if="selectedTab === 'tab-1'" v-bind="tabpanel({ value: 'tab-1' })">Tab content 1</div>
24
+ <div v-if="selectedTab === 'tab-2'" v-bind="tabpanel({ value: 'tab-2' })">Tab content 2</div>
25
+ </div>
26
+ </template>
@@ -0,0 +1,78 @@
1
+ import { expect } from "@playwright/experimental-ct-vue";
2
+ import type { Locator, Page } from "@playwright/test";
3
+
4
+ export type TabsTestingOptions = {
5
+ page: Page;
6
+ /**
7
+ * Locator of the tabs component.
8
+ */
9
+ tablist: Locator;
10
+ };
11
+
12
+ /**
13
+ * Playwright utility for executing accessibility testing for tabs.
14
+ * Will check aria attributes and keyboard shortcuts as defined in https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
15
+ */
16
+ export const tabsTesting = async (options: TabsTestingOptions) => {
17
+ await expect(options.tablist, 'tablist element must have role "tablist"').toHaveRole("tablist");
18
+ await expect(options.tablist, "tablist must have an accessible label").toHaveAttribute(
19
+ "aria-label",
20
+ );
21
+
22
+ const activeTab = options.tablist.locator('[aria-selected="true"]');
23
+ await expect(activeTab, "must have an initially active tab").toBeVisible();
24
+
25
+ const { tabId, panelId } = await expectTabAttributes(activeTab, true);
26
+ await expectPanelAttributes(options.page.locator(`#${panelId}`), tabId);
27
+
28
+ // ACT (switch tab)
29
+ const tab2 = options.tablist.locator('[aria-selected="true"]').first();
30
+ await tab2.click();
31
+
32
+ const { tabId: tabId2, panelId: panelId2 } = await expectTabAttributes(tab2, true);
33
+ await expectPanelAttributes(options.page.locator(`#${panelId2}`), tabId2);
34
+
35
+ await expect(options.page.getByRole("tabpanel"), "should hide previous panel").toHaveCount(1);
36
+ };
37
+
38
+ /**
39
+ * Executes accessibility tests for a single tab.
40
+ *
41
+ * @param tab Locator of the tab.
42
+ * @param selected Whether the tab is expected to be selected
43
+ */
44
+ const expectTabAttributes = async (tab: Locator, selected: boolean) => {
45
+ await expect(tab, 'tab must have role "tab"').toHaveRole("tab");
46
+ await expect(tab, "tab must have an ID").toHaveAttribute("id");
47
+ await expect(tab, 'tab must have "aria-selected" set').toHaveAttribute(
48
+ "aria-selected",
49
+ String(selected),
50
+ );
51
+ await expect(tab, 'tab must have "aria-controls" set').toHaveAttribute("aria-controls");
52
+
53
+ if (selected) {
54
+ await expect(tab, "selected tab should be focusable").toHaveAttribute("tabindex", "0");
55
+ } else {
56
+ await expect(tab, "unselected tab should NOT be focusable").toHaveAttribute("tabindex", "-1");
57
+ }
58
+
59
+ const tabId = (await tab.getAttribute("id"))!;
60
+ const panelId = (await tab.getAttribute("aria-controls"))!;
61
+ return { tabId, panelId };
62
+ };
63
+
64
+ /**
65
+ * Executes accessibility tests for a single tab panel.
66
+ *
67
+ * @param panel Locator of the panel
68
+ * @param tabId Corresponding tab id
69
+ */
70
+ const expectPanelAttributes = async (panel: Locator, tabId: string) => {
71
+ await expect(panel, "panel should be visible").toBeVisible();
72
+ await expect(panel, 'panel must have role "tabpanel"').toHaveRole("tabpanel");
73
+ await expect(panel, "panel must have an ID").toHaveAttribute("id");
74
+ await expect(panel, 'panel must have "aria-labelledby" set').toHaveAttribute(
75
+ "aria-labelledby",
76
+ tabId,
77
+ );
78
+ };
@@ -0,0 +1,72 @@
1
+ import { computed, unref, useId, type MaybeRef, type Ref } from "vue";
2
+ import { createBuilder } from "../../utils/builder";
3
+
4
+ type CreateTabsOptions<TKey extends PropertyKey = PropertyKey> = {
5
+ /**
6
+ * Label of the tablist.
7
+ */
8
+ label: MaybeRef<string>;
9
+ /**
10
+ * Currently selected tab.
11
+ */
12
+ selectedTab: Ref<TKey>;
13
+ /**
14
+ * Called when the user selects a tab.
15
+ */
16
+ onSelect?: (selectedTabValue: TKey) => void;
17
+ };
18
+
19
+ /**
20
+ * Composable for implementing accessible tabs.
21
+ * Based on https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
22
+ */
23
+ export const createTabs = createBuilder(<T extends PropertyKey>(options: CreateTabsOptions<T>) => {
24
+ /**
25
+ * Map for looking up tab and panel IDs for given tab keys/values defined by the user.
26
+ * Key = custom value from the user, value = random generated tab and panel ID
27
+ */
28
+ const idMap = new Map<PropertyKey, { tabId: string; panelId: string }>();
29
+
30
+ const getId = (value: PropertyKey) => {
31
+ if (!idMap.has(value)) {
32
+ idMap.set(value, { tabId: useId(), panelId: useId() });
33
+ }
34
+ return idMap.get(value)!;
35
+ };
36
+
37
+ return {
38
+ elements: {
39
+ tablist: computed(() => ({
40
+ role: "tablist",
41
+ "aria-label": unref(options.label),
42
+ })),
43
+ tab: computed(() => {
44
+ return (data: { value: T }) => {
45
+ const { tabId: selectedTabId } = getId(unref(options.selectedTab));
46
+ const { tabId, panelId } = getId(data.value);
47
+ const isSelected = tabId === selectedTabId;
48
+
49
+ return {
50
+ id: tabId,
51
+ role: "tab",
52
+ "aria-selected": isSelected,
53
+ "aria-controls": panelId,
54
+ onClick: () => options.onSelect?.(data.value),
55
+ tabindex: isSelected ? 0 : -1,
56
+ } as const;
57
+ };
58
+ }),
59
+ tabpanel: computed(() => {
60
+ return (data: { value: T }) => {
61
+ const { tabId, panelId } = getId(data.value);
62
+
63
+ return {
64
+ id: panelId,
65
+ role: "tabpanel",
66
+ "aria-labelledby": tabId,
67
+ } as const;
68
+ };
69
+ }),
70
+ },
71
+ };
72
+ });
@@ -1,5 +1,4 @@
1
- import { computed, toRef, toValue, type MaybeRefOrGetter, type Ref } from "vue";
2
- import { createId } from "../..";
1
+ import { computed, toRef, toValue, useId, type MaybeRefOrGetter, type Ref } from "vue";
3
2
  import { createBuilder } from "../../utils/builder";
4
3
  import { useDismissible } from "../helpers/useDismissible";
5
4
 
@@ -18,7 +17,7 @@ export type CreateTooltipOptions = {
18
17
  * To provide contextual information use the `createToggletip`.
19
18
  */
20
19
  export const createTooltip = createBuilder(({ debounce, isVisible }: CreateTooltipOptions) => {
21
- const tooltipId = createId("tooltip");
20
+ const tooltipId = useId();
22
21
  const _isVisible = toRef(isVisible ?? false);
23
22
  let timeout: ReturnType<typeof setTimeout> | undefined;
24
23
 
package/src/index.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  export * from "./composables/comboBox/createComboBox";
2
+ export * from "./composables/helpers/useGlobalListener";
2
3
  export * from "./composables/listbox/createListbox";
3
4
  export * from "./composables/menuButton/createMenuButton";
4
5
  export * from "./composables/navigationMenu/createMenu";
6
+ export * from "./composables/tabs/createTabs";
5
7
  export * from "./composables/tooltip/createToggletip";
6
8
  export * from "./composables/tooltip/createTooltip";
7
9
  export * from "./utils/builder";
8
- export { createId } from "./utils/id";
9
10
  export { isPrintableCharacter, wasKeyPressed } from "./utils/keyboard";
10
11
  export { debounce } from "./utils/timer";
package/src/playwright.ts CHANGED
@@ -2,3 +2,4 @@ export * from "./composables/comboBox/createComboBox.testing";
2
2
  export * from "./composables/listbox/createListbox.testing";
3
3
  export * from "./composables/menuButton/createMenuButton.testing";
4
4
  export * from "./composables/navigationMenu/createMenu.testing";
5
+ export * from "./composables/tabs/createTabs.testing";
@@ -50,7 +50,7 @@ export type HeadlessComposable<
50
50
  * @example
51
51
  * ```ts
52
52
  * export const createTooltip = createBuilder(({ initialVisible }: CreateTooltipOptions) => {
53
- * const tooltipId = createId("tooltip");
53
+ * const tooltipId = useId();
54
54
  * const isVisible = ref(initialVisible);
55
55
  *
56
56
  * const hoverEvents = {
package/src/utils/id.ts DELETED
@@ -1,14 +0,0 @@
1
- /**
2
- * Returns a unique global id string
3
- */
4
- // ⚠️ we make use of an IIFE to encapsulate the globalCounter so it can never accidentally be used somewhere else.
5
- const nextId = (() => {
6
- let globalCounter = 1;
7
- return () => globalCounter++;
8
- })();
9
-
10
- /**
11
- * Creates a globally unique string using a counter.
12
- * The given name is the prefix.
13
- */
14
- export const createId = (name: string) => `${name}-${nextId()}`;