@sit-onyx/headless 1.0.0-beta.4 → 1.0.0-beta.6

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.4",
4
+ "version": "1.0.0-beta.6",
5
5
  "type": "module",
6
6
  "author": "Schwarz IT KG",
7
7
  "license": "Apache-2.0",
@@ -10,6 +10,7 @@ import {
10
10
  type ListboxValue,
11
11
  } from "../listbox/createListbox";
12
12
 
13
+ /** See https://w3c.github.io/aria/#aria-autocomplete */
13
14
  export type ComboboxAutoComplete = "none" | "list" | "both";
14
15
 
15
16
  export const OPENING_KEYS: PressedKey[] = ["ArrowDown", "ArrowUp", " ", "Enter", "Home", "End"];
@@ -19,11 +20,15 @@ export const CLOSING_KEYS: PressedKey[] = [
19
20
  "Enter",
20
21
  "Tab",
21
22
  ];
22
- const SELECTING_KEYS_SINGLE: PressedKey[] = ["Enter", " "];
23
- const SELECTING_KEYS_MULTIPLE: PressedKey[] = ["Enter"];
24
23
 
25
- const isSelectingKey = (event: KeyboardEvent, isMultiselect?: boolean) => {
26
- const selectingKeys = isMultiselect ? SELECTING_KEYS_MULTIPLE : SELECTING_KEYS_SINGLE;
24
+ const SELECTING_KEYS: PressedKey[] = ["Enter"];
25
+
26
+ /**
27
+ * if the a search input is included, space should not be used to select
28
+ * TODO: idea for the future: move this distinction to the listbox?
29
+ */
30
+ const isSelectingKey = (event: KeyboardEvent, withSpace?: boolean) => {
31
+ const selectingKeys = withSpace ? [...SELECTING_KEYS, " "] : SELECTING_KEYS;
27
32
  return isKeyOfGroup(event, selectingKeys);
28
33
  };
29
34
 
@@ -41,6 +46,10 @@ export type CreateComboboxOptions<
41
46
  * Labels the listbox which displays the available options. E.g. the list label could be "Countries" for a combobox which is labelled "Country".
42
47
  */
43
48
  listLabel: MaybeRef<string>;
49
+ /**
50
+ * Provides additional description for the listbox which displays the available options.
51
+ */
52
+ listDescription?: MaybeRef<string | undefined>;
44
53
  /**
45
54
  * Controls the opened/visible state of the associated pop-up. When expanded the activeOption can be controlled via the keyboard.
46
55
  */
@@ -107,6 +116,7 @@ export const createComboBox = createBuilder(
107
116
  multiple: multipleRef,
108
117
  label,
109
118
  listLabel,
119
+ listDescription,
110
120
  isExpanded: isExpandedRef,
111
121
  activeOption,
112
122
  onToggle,
@@ -183,7 +193,7 @@ export const createComboBox = createBuilder(
183
193
  }
184
194
  return onActivateFirst?.();
185
195
  }
186
- if (isSelectingKey(event, multiple.value)) {
196
+ if (isSelectingKey(event, autocomplete.value === "none")) {
187
197
  return handleSelect(activeOption.value!);
188
198
  }
189
199
  if (isExpanded.value && isKeyOfGroup(event, CLOSING_KEYS)) {
@@ -213,14 +223,16 @@ export const createComboBox = createBuilder(
213
223
  internals: { getOptionId },
214
224
  } = createListbox({
215
225
  label: listLabel,
226
+ description: listDescription,
216
227
  multiple,
217
228
  controlled: true,
218
229
  activeOption,
230
+ isExpanded,
219
231
  onSelect: handleSelect,
220
232
  });
221
233
 
222
234
  useOutsideClick({
223
- element: templateRef,
235
+ inside: templateRef,
224
236
  onOutsideClick() {
225
237
  if (!isExpanded.value) return;
226
238
  onToggle?.(true);
@@ -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
  },
@@ -10,6 +10,10 @@ 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<string | undefined>;
13
17
  /**
14
18
  * Value of currently (visually) active option.
15
19
  */
@@ -19,6 +23,10 @@ export type CreateListboxOptions<TValue extends ListboxValue, TMultiple extends
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
  */
@@ -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
@@ -97,7 +106,11 @@ export const createListbox = createBuilder(
97
106
 
98
107
  // scroll currently active option into view if needed
99
108
  watchEffect(() => {
100
- if (options.activeOption.value == undefined || (!isFocused.value && !options.controlled))
109
+ if (
110
+ !isExpanded.value ||
111
+ options.activeOption.value == undefined ||
112
+ (!isFocused.value && !options.controlled)
113
+ )
101
114
  return;
102
115
  const id = getOptionId(options.activeOption.value);
103
116
  document.getElementById(id)?.scrollIntoView({ block: "nearest", inline: "nearest" });
@@ -158,12 +171,14 @@ export const createListbox = createBuilder(
158
171
  role: "listbox",
159
172
  "aria-multiselectable": isMultiselect.value,
160
173
  "aria-label": unref(options.label),
174
+ "aria-description": options.description,
161
175
  tabindex: "-1",
162
176
  }
163
177
  : {
164
178
  role: "listbox",
165
179
  "aria-multiselectable": isMultiselect.value,
166
180
  "aria-label": unref(options.label),
181
+ "aria-description": options.description,
167
182
  tabindex: "0",
168
183
  "aria-activedescendant":
169
184
  options.activeOption.value != undefined
@@ -0,0 +1,61 @@
1
+ import { computed, toRef, toValue, type MaybeRefOrGetter, type Ref } from "vue";
2
+ import { createBuilder, createElRef } from "../../utils/builder";
3
+ import { useDismissible } from "../helpers/useDismissible";
4
+ import { useOutsideClick } from "../helpers/useOutsideClick";
5
+
6
+ export type CreateToggletipOptions = {
7
+ toggleLabel: MaybeRefOrGetter<string>;
8
+ isVisible?: Ref<boolean>;
9
+ };
10
+
11
+ /**
12
+ * Create a toggletip as described in https://inclusive-components.design/tooltips-toggletips/
13
+ * Its visibility is toggled on click.
14
+ * Therefore a toggletip MUST NOT be used to describe the associated trigger element.
15
+ * Commonly this pattern uses a button with the ⓘ as the trigger element.
16
+ * To describe the associated element use `createTooltip`.
17
+ */
18
+ export const createToggletip = createBuilder(
19
+ ({ toggleLabel, isVisible }: CreateToggletipOptions) => {
20
+ const triggerRef = createElRef<HTMLButtonElement>();
21
+ const tooltipRef = createElRef<HTMLElement>();
22
+ const _isVisible = toRef(isVisible ?? false);
23
+
24
+ // close tooltip on outside click
25
+ useOutsideClick({
26
+ inside: computed(() => [triggerRef.value, tooltipRef.value]),
27
+ onOutsideClick: () => (_isVisible.value = false),
28
+ disabled: computed(() => !_isVisible.value),
29
+ });
30
+
31
+ useDismissible({ isExpanded: _isVisible });
32
+
33
+ const toggle = () => (_isVisible.value = !_isVisible.value);
34
+
35
+ return {
36
+ elements: {
37
+ /**
38
+ * The element which controls the toggletip visibility:
39
+ * Preferably a `button` element.
40
+ */
41
+ trigger: computed(() => ({
42
+ ref: triggerRef,
43
+ onClick: toggle,
44
+ "aria-label": toValue(toggleLabel),
45
+ })),
46
+ /**
47
+ * The element with the relevant toggletip content.
48
+ * Only simple, textual content is allowed.
49
+ */
50
+ tooltip: {
51
+ ref: tooltipRef,
52
+ role: "status",
53
+ tabindex: "-1",
54
+ },
55
+ },
56
+ state: {
57
+ isVisible: _isVisible,
58
+ },
59
+ };
60
+ },
61
+ );
@@ -1,44 +1,27 @@
1
- import { computed, onBeforeMount, onBeforeUnmount, ref, unref, type MaybeRef } from "vue";
1
+ import { computed, toRef, toValue, type MaybeRefOrGetter, type Ref } from "vue";
2
2
  import { createId } from "../..";
3
- import { createBuilder, createElRef } from "../../utils/builder";
4
- import { useOutsideClick } from "../helpers/useOutsideClick";
3
+ import { createBuilder } from "../../utils/builder";
4
+ import { useDismissible } from "../helpers/useDismissible";
5
5
 
6
6
  export type CreateTooltipOptions = {
7
- open: MaybeRef<TooltipOpen>;
7
+ /**
8
+ * Number of milliseconds to use as debounce when showing/hiding the tooltip.
9
+ */
10
+ debounce: MaybeRefOrGetter<number>;
11
+ isVisible?: Ref<boolean>;
8
12
  };
9
13
 
10
- export type TooltipOpen =
11
- | TooltipTrigger
12
- | boolean
13
- | {
14
- type: "hover";
15
- /**
16
- * Number of milliseconds to use as debounce when showing/hiding the tooltip
17
- */
18
- debounce: number;
19
- };
20
-
21
- export const TOOLTIP_TRIGGERS = ["hover", "click"] as const;
22
- export type TooltipTrigger = (typeof TOOLTIP_TRIGGERS)[number];
23
-
24
- export const createTooltip = createBuilder((options: CreateTooltipOptions) => {
25
- const rootRef = createElRef<HTMLElement>();
14
+ /**
15
+ * Create a tooltip as described in https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/tooltip_role
16
+ * Its visibility is toggled on hover or focus.
17
+ * A tooltip MUST be used to describe the associated trigger element. E.g. The usage with the ⓘ would be incorrect.
18
+ * To provide contextual information use the `createToggletip`.
19
+ */
20
+ export const createTooltip = createBuilder(({ debounce, isVisible }: CreateTooltipOptions) => {
26
21
  const tooltipId = createId("tooltip");
27
- const _isVisible = ref(false);
22
+ const _isVisible = toRef(isVisible ?? false);
28
23
  let timeout: ReturnType<typeof setTimeout> | undefined;
29
24
 
30
- const debounce = computed(() => {
31
- const open = unref(options.open);
32
- if (typeof open !== "object") return 200;
33
- return open.debounce;
34
- });
35
-
36
- const openType = computed(() => {
37
- const open = unref(options.open);
38
- if (typeof open !== "object") return open;
39
- return open.type;
40
- });
41
-
42
25
  /**
43
26
  * Debounced visible state that will only be toggled after a given timeout.
44
27
  */
@@ -48,82 +31,41 @@ export const createTooltip = createBuilder((options: CreateTooltipOptions) => {
48
31
  clearTimeout(timeout);
49
32
  timeout = setTimeout(() => {
50
33
  _isVisible.value = newValue;
51
- }, debounce.value);
34
+ }, toValue(debounce));
52
35
  },
53
36
  });
54
37
 
55
- /**
56
- * Whether the tooltip should be currently visible.
57
- * If openMode is set as boolean it will prefer it over the hover/click state.
58
- */
59
- const isVisible = computed(() => {
60
- if (typeof openType.value === "boolean") return openType.value;
61
- return debouncedVisible.value;
62
- });
63
-
64
- /**
65
- * Toggles the tooltip if element is clicked.
66
- */
67
- const handleClick = () => {
68
- _isVisible.value = !_isVisible.value;
69
- };
70
-
71
- const hoverEvents = computed(() => {
72
- if (openType.value !== "hover") return;
73
- return {
74
- onMouseover: () => (debouncedVisible.value = true),
75
- onMouseout: () => (debouncedVisible.value = false),
76
- onFocusin: () => (_isVisible.value = true),
77
- onFocusout: () => (_isVisible.value = false),
78
- };
79
- });
80
-
81
- /**
82
- * Closes the tooltip if Escape is pressed.
83
- */
84
- const handleDocumentKeydown = (event: KeyboardEvent) => {
85
- if (event.key !== "Escape") return;
86
- _isVisible.value = false;
38
+ const hoverEvents = {
39
+ onMouseover: () => (debouncedVisible.value = true),
40
+ onMouseout: () => (debouncedVisible.value = false),
41
+ onFocusin: () => (_isVisible.value = true),
42
+ onFocusout: () => (_isVisible.value = false),
87
43
  };
88
44
 
89
- // close tooltip on outside click
90
- useOutsideClick({
91
- element: rootRef,
92
- onOutsideClick: () => (_isVisible.value = false),
93
- disabled: computed(() => openType.value !== "click"),
94
- });
95
-
96
- // add global document event listeners only on/before mounted to also work in server side rendering
97
- onBeforeMount(() => {
98
- document.addEventListener("keydown", handleDocumentKeydown);
99
- });
100
-
101
- /**
102
- * Clean up global event listeners to prevent dangling events.
103
- */
104
- onBeforeUnmount(() => {
105
- document.removeEventListener("keydown", handleDocumentKeydown);
106
- });
45
+ useDismissible({ isExpanded: _isVisible });
107
46
 
108
47
  return {
109
48
  elements: {
110
- root: {
111
- ref: rootRef,
112
- },
113
- trigger: computed(() => ({
49
+ /**
50
+ * The element which controls the tooltip visibility on hover.
51
+ */
52
+ trigger: {
114
53
  "aria-describedby": tooltipId,
115
- onClick: openType.value === "click" ? handleClick : undefined,
116
- ...hoverEvents.value,
117
- })),
118
- tooltip: computed(() => ({
54
+ ...hoverEvents,
55
+ },
56
+ /**
57
+ * The element describing the tooltip.
58
+ * Only simple, textual and non-focusable content is allowed.
59
+ */
60
+ tooltip: {
119
61
  role: "tooltip",
120
62
  id: tooltipId,
121
63
  tabindex: "-1",
122
- ...hoverEvents.value,
123
- })),
64
+ ...hoverEvents,
65
+ },
124
66
  },
125
67
  state: {
126
- isVisible,
68
+ isVisible: _isVisible,
127
69
  },
128
70
  };
129
71
  });
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ export * from "./composables/comboBox/createComboBox";
2
2
  export * from "./composables/listbox/createListbox";
3
3
  export * from "./composables/menuButton/createMenuButton";
4
4
  export * from "./composables/navigationMenu/createMenu";
5
+ export * from "./composables/tooltip/createToggletip";
5
6
  export * from "./composables/tooltip/createTooltip";
6
7
  export * from "./utils/builder";
7
8
  export { createId } from "./utils/id";
@@ -11,7 +11,8 @@ import {
11
11
  import type { IfDefined } from "./types";
12
12
 
13
13
  /**
14
- * `v-bind`able attributes as they are provided by the headless composables.
14
+ * Properties as they can be used by `v-bind` on an HTML element.
15
+ * This includes generic html attributes and the vue reserved `ref` property.
15
16
  * `ref` is restricted to be a `HeadlessElRef` which only can by created through `createElRef`.
16
17
  */
17
18
  export type VBindAttributes<
@@ -1,3 +1,5 @@
1
+ import type { ComputedRef, MaybeRefOrGetter } from "vue";
2
+
1
3
  /**
2
4
  * Adds the entry with the key `Key` and the value of type `TValue` to a record when it is defined.
3
5
  * Then the entry is either undefined or exists without being optional.
@@ -21,3 +23,8 @@ export type IfDefined<Key extends string, TValue> =
21
23
  export type IsArray<TValue, TMultiple extends boolean = false> = TMultiple extends true
22
24
  ? TValue[]
23
25
  : TValue;
26
+
27
+ /**
28
+ * Type for any kind of ref source. Preferably used in combination with vue's `toValue` method
29
+ */
30
+ export type MaybeReactiveSource<T> = MaybeRefOrGetter<T> | ComputedRef<T>;
@@ -1,6 +1,6 @@
1
- import { vi, type Awaitable } from "vitest";
1
+ import { vi } from "vitest";
2
2
 
3
- type Callback = () => Awaitable<void>;
3
+ type Callback = () => void | (() => Promise<void>);
4
4
 
5
5
  /**
6
6
  * Mocks the following vue lifecycle functions: