@sit-onyx/headless 1.0.0-beta.3 → 1.0.0-beta.5

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.3",
4
+ "version": "1.0.0-beta.5",
5
5
  "type": "module",
6
6
  "author": "Schwarz IT KG",
7
7
  "license": "Apache-2.0",
@@ -220,7 +220,7 @@ export const createComboBox = createBuilder(
220
220
  });
221
221
 
222
222
  useOutsideClick({
223
- element: templateRef,
223
+ inside: templateRef,
224
224
  onOutsideClick() {
225
225
  if (!isExpanded.value) return;
226
226
  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 });
@@ -1,6 +1,6 @@
1
1
  import { computed, ref, unref, watchEffect, type MaybeRef, type Ref } from "vue";
2
2
  import { createId } from "../..";
3
- import { createBuilder, type HeadlessElementAttributes } from "../../utils/builder";
3
+ import { createBuilder, type VBindAttributes } from "../../utils/builder";
4
4
  import { useTypeAhead } from "../helpers/useTypeAhead";
5
5
 
6
6
  export type ListboxValue = string | number | boolean;
@@ -152,7 +152,7 @@ export const createListbox = createBuilder(
152
152
  }
153
153
  };
154
154
 
155
- const listbox = computed<HeadlessElementAttributes>(() =>
155
+ const listbox = computed<VBindAttributes>(() =>
156
156
  options.controlled
157
157
  ? {
158
158
  role: "listbox",
@@ -1,5 +1,5 @@
1
- import { computed, ref, type Ref } from "vue";
2
- import { createBuilder } from "../../utils/builder";
1
+ import { computed, type Ref } from "vue";
2
+ import { createBuilder, createElRef } from "../../utils/builder";
3
3
  import { createId } from "../../utils/id";
4
4
  import { debounce } from "../../utils/timer";
5
5
  import { useGlobalEventListener } from "../helpers/useGlobalListener";
@@ -16,7 +16,7 @@ export const createMenuButton = createBuilder(
16
16
  ({ isExpanded, onToggle }: CreateMenuButtonOptions) => {
17
17
  const rootId = createId("menu-button-root");
18
18
  const menuId = createId("menu-button-list");
19
- const menuRef = ref<HTMLElement>();
19
+ const menuRef = createElRef<HTMLElement>();
20
20
  const buttonId = createId("menu-button-button");
21
21
 
22
22
  useGlobalEventListener({
@@ -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
3
  import { createBuilder } from "../../utils/builder";
4
- import { useOutsideClick } from "../helpers/useOutsideClick";
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 = ref<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,7 +2,9 @@ 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";
7
+ export * from "./utils/builder";
6
8
  export { createId } from "./utils/id";
7
9
  export { isPrintableCharacter, wasKeyPressed } from "./utils/keyboard";
8
10
  export { debounce } from "./utils/timer";
@@ -1,19 +1,38 @@
1
- import type { ComputedRef, HtmlHTMLAttributes, Ref, VNodeRef } from "vue";
1
+ import {
2
+ computed,
3
+ shallowRef,
4
+ type ComponentPublicInstance,
5
+ type HTMLAttributes,
6
+ type MaybeRef,
7
+ type Ref,
8
+ type WritableComputedOptions,
9
+ type WritableComputedRef,
10
+ } from "vue";
2
11
  import type { IfDefined } from "./types";
3
12
 
4
- export type ElementAttributes = HtmlHTMLAttributes & { ref?: VNodeRef };
13
+ /**
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.
16
+ * `ref` is restricted to be a `HeadlessElRef` which only can by created through `createElRef`.
17
+ */
18
+ export type VBindAttributes<
19
+ A extends HTMLAttributes = HTMLAttributes,
20
+ E extends Element = Element,
21
+ > = A & {
22
+ ref?: VueTemplateRef<E>;
23
+ };
5
24
 
6
- export type IteratedHeadlessElementFunc<T extends Record<string, unknown>> = (
7
- opts: T,
8
- ) => ElementAttributes;
25
+ export type IteratedHeadlessElementFunc<
26
+ A extends HTMLAttributes,
27
+ T extends Record<string, unknown>,
28
+ > = (opts: T) => VBindAttributes<A>;
9
29
 
10
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
- export type HeadlessElementAttributes = ElementAttributes | IteratedHeadlessElementFunc<any>;
30
+ export type HeadlessElementAttributes<A extends HTMLAttributes> =
31
+ | VBindAttributes<A>
32
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
33
+ | IteratedHeadlessElementFunc<A, any>;
12
34
 
13
- export type HeadlessElements = Record<
14
- string,
15
- HeadlessElementAttributes | ComputedRef<HeadlessElementAttributes>
16
- >;
35
+ export type HeadlessElements = Record<string, MaybeRef<HeadlessElementAttributes<HTMLAttributes>>>;
17
36
 
18
37
  export type HeadlessState = Record<string, Ref>;
19
38
 
@@ -28,6 +47,39 @@ export type HeadlessComposable<
28
47
 
29
48
  /**
30
49
  * We use this identity function to ensure the correct typings of the headless composables
50
+ * @example
51
+ * ```ts
52
+ * export const createTooltip = createBuilder(({ initialVisible }: CreateTooltipOptions) => {
53
+ * const tooltipId = createId("tooltip");
54
+ * const isVisible = ref(initialVisible);
55
+ *
56
+ * const hoverEvents = {
57
+ * onMouseover: () => (isVisible.value = true),
58
+ * onMouseout: () => (isVisible.value = false),
59
+ * onFocusin: () => (isVisible.value = true),
60
+ * onFocusout: () => (isVisible.value = false),
61
+ * };
62
+ *
63
+ * return {
64
+ * elements: {
65
+ * trigger: {
66
+ * "aria-describedby": tooltipId,
67
+ * ...hoverEvents,
68
+ * },
69
+ * tooltip: {
70
+ * role: "tooltip",
71
+ * id: tooltipId,
72
+ * tabindex: "-1",
73
+ * ...hoverEvents,
74
+ * },
75
+ * },
76
+ * state: {
77
+ * isVisible,
78
+ * },
79
+ * };
80
+ * });
81
+ *
82
+ * ```
31
83
  */
32
84
  export const createBuilder = <
33
85
  Args extends unknown[] = unknown[],
@@ -37,3 +89,47 @@ export const createBuilder = <
37
89
  >(
38
90
  builder: (...args: Args) => HeadlessComposable<Elements, State, Internals>,
39
91
  ) => builder;
92
+
93
+ type VueTemplateRefElement<E extends Element> = E | (ComponentPublicInstance & { $el: E }) | null;
94
+ type VueTemplateRef<E extends Element> = Ref<VueTemplateRefElement<E>>;
95
+
96
+ declare const HeadlessElRefSymbol: unique symbol;
97
+ type HeadlessElRef<E extends Element> = WritableComputedRef<E> & {
98
+ /**
99
+ * type differentiator
100
+ * ensures that only `createElRef` can be used for headless element ref bindings
101
+ */
102
+ [HeadlessElRefSymbol]: true;
103
+ };
104
+
105
+ /**
106
+ * Creates a special writeable computed that references a DOM Element.
107
+ * Vue Component references will be unwrapped.
108
+ * @example
109
+ * ```ts
110
+ * createBuilder() => {
111
+ * const buttonRef = createElRef<HtmlButtonElement>();
112
+ * return {
113
+ * elements: {
114
+ * button: {
115
+ * ref: buttonRef,
116
+ * },
117
+ * }
118
+ * };
119
+ * });
120
+ * ```
121
+ */
122
+ export function createElRef<E extends Element>(): HeadlessElRef<E>;
123
+ export function createElRef<
124
+ E extends Element,
125
+ V extends VueTemplateRefElement<E> = VueTemplateRefElement<E>,
126
+ >() {
127
+ const elementRef = shallowRef<E>();
128
+
129
+ return computed({
130
+ set: (element: V) => {
131
+ elementRef.value = element != null && "$el" in element ? element.$el : (element as E);
132
+ },
133
+ get: () => elementRef.value,
134
+ } as WritableComputedOptions<E>);
135
+ }
@@ -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>;