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

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.2",
4
+ "version": "1.0.0-beta.4",
5
5
  "type": "module",
6
6
  "author": "Schwarz IT KG",
7
7
  "license": "Apache-2.0",
@@ -26,7 +26,6 @@ const onToggle = () => (isExpanded.value = !isExpanded.value);
26
26
  const onTypeAhead = () => {};
27
27
 
28
28
  const comboBox = createComboBox({
29
- inputValue: selectedOption,
30
29
  autocomplete: "none",
31
30
  label: "some label",
32
31
  listLabel: "List",
@@ -51,7 +50,12 @@ defineExpose({ comboBox });
51
50
 
52
51
  <template>
53
52
  <div ref="comboboxRef">
54
- <input v-bind="input" readonly @keydown.arrow-down="isExpanded = true" />
53
+ <input
54
+ v-bind="input"
55
+ v-model="selectedOption"
56
+ readonly
57
+ @keydown.arrow-down="isExpanded = true"
58
+ />
55
59
 
56
60
  <button v-bind="button">
57
61
  <template v-if="isExpanded">⬆️</template>
@@ -30,7 +30,6 @@ const onAutocomplete = (input: string) => (searchTerm.value = input);
30
30
  const onToggle = () => (isExpanded.value = !isExpanded.value);
31
31
 
32
32
  const comboBox = createComboBox({
33
- inputValue: searchTerm,
34
33
  autocomplete: "list",
35
34
  label: "some label",
36
35
  listLabel: "List",
@@ -55,7 +54,7 @@ defineExpose({ comboBox });
55
54
 
56
55
  <template>
57
56
  <div ref="comboboxRef">
58
- <input v-bind="input" @keydown.arrow-down="isExpanded = true" />
57
+ <input v-bind="input" v-model="searchTerm" @keydown.arrow-down="isExpanded = true" />
59
58
 
60
59
  <button v-bind="button">
61
60
  <template v-if="isExpanded">⬆️</template>
@@ -41,10 +41,6 @@ export type CreateComboboxOptions<
41
41
  * Labels the listbox which displays the available options. E.g. the list label could be "Countries" for a combobox which is labelled "Country".
42
42
  */
43
43
  listLabel: MaybeRef<string>;
44
- /**
45
- * The current value of the combobox. Is updated when an option from the controlled listbox is selected or by typing into it.
46
- */
47
- inputValue: Ref<string | undefined>;
48
44
  /**
49
45
  * Controls the opened/visible state of the associated pop-up. When expanded the activeOption can be controlled via the keyboard.
50
46
  */
@@ -105,7 +101,6 @@ export const createComboBox = createBuilder(
105
101
  TAutoComplete extends ComboboxAutoComplete,
106
102
  TMultiple extends boolean = false,
107
103
  >({
108
- inputValue,
109
104
  autocomplete: autocompleteRef,
110
105
  onAutocomplete,
111
106
  onTypeAhead,
@@ -250,7 +245,6 @@ export const createComboBox = createBuilder(
250
245
  * The input MAY be either a single-line text field that supports editing and typing or an element that only displays the current value of the combobox.
251
246
  */
252
247
  input: computed(() => ({
253
- value: inputValue.value,
254
248
  role: "combobox",
255
249
  "aria-expanded": isExpanded.value,
256
250
  "aria-controls": controlsId,
@@ -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({
@@ -22,22 +22,11 @@ export const navigationTesting = async ({ nav, buttons }: NavigationMenuTestingO
22
22
  */
23
23
  await expect(nav).toHaveRole("navigation");
24
24
  await expect(nav).toHaveAttribute("aria-label");
25
- /**
26
- * Disclosure buttons should have aria attributes
27
- */
28
- for (const button of await buttons.all()) {
29
- await expect(button, "button must have arial-controls attribute").toHaveAttribute(
30
- "aria-controls",
31
- );
32
- await expect(button, "button must have aria-expanded attribute").toHaveAttribute(
33
- "aria-expanded",
34
- );
35
- }
25
+
36
26
  /**
37
27
  * Focus first button
38
28
  */
39
- await nav.press("Tab");
40
- await expect(buttons.nth(0)).toBeFocused();
29
+ await buttons.first().focus();
41
30
  /**
42
31
  * Move keyboard focus among top-level buttons using arrow keys
43
32
  */
@@ -1,6 +1,6 @@
1
1
  import { computed, onBeforeMount, onBeforeUnmount, ref, unref, type MaybeRef } from "vue";
2
2
  import { createId } from "../..";
3
- import { createBuilder } from "../../utils/builder";
3
+ import { createBuilder, createElRef } from "../../utils/builder";
4
4
  import { useOutsideClick } from "../helpers/useOutsideClick";
5
5
 
6
6
  export type CreateTooltipOptions = {
@@ -22,7 +22,7 @@ export const TOOLTIP_TRIGGERS = ["hover", "click"] as const;
22
22
  export type TooltipTrigger = (typeof TOOLTIP_TRIGGERS)[number];
23
23
 
24
24
  export const createTooltip = createBuilder((options: CreateTooltipOptions) => {
25
- const rootRef = ref<HTMLElement>();
25
+ const rootRef = createElRef<HTMLElement>();
26
26
  const tooltipId = createId("tooltip");
27
27
  const _isVisible = ref(false);
28
28
  let timeout: ReturnType<typeof setTimeout> | undefined;
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export * from "./composables/listbox/createListbox";
3
3
  export * from "./composables/menuButton/createMenuButton";
4
4
  export * from "./composables/navigationMenu/createMenu";
5
5
  export * from "./composables/tooltip/createTooltip";
6
+ export * from "./utils/builder";
6
7
  export { createId } from "./utils/id";
7
8
  export { isPrintableCharacter, wasKeyPressed } from "./utils/keyboard";
8
9
  export { debounce } from "./utils/timer";
package/src/playwright.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./composables/comboBox/createComboBox.testing";
2
2
  export * from "./composables/listbox/createListbox.testing";
3
3
  export * from "./composables/menuButton/createMenuButton.testing";
4
+ export * from "./composables/navigationMenu/createMenu.testing";
@@ -1,19 +1,37 @@
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
+ * `v-bind`able attributes as they are provided by the headless composables.
15
+ * `ref` is restricted to be a `HeadlessElRef` which only can by created through `createElRef`.
16
+ */
17
+ export type VBindAttributes<
18
+ A extends HTMLAttributes = HTMLAttributes,
19
+ E extends Element = Element,
20
+ > = A & {
21
+ ref?: VueTemplateRef<E>;
22
+ };
5
23
 
6
- export type IteratedHeadlessElementFunc<T extends Record<string, unknown>> = (
7
- opts: T,
8
- ) => ElementAttributes;
24
+ export type IteratedHeadlessElementFunc<
25
+ A extends HTMLAttributes,
26
+ T extends Record<string, unknown>,
27
+ > = (opts: T) => VBindAttributes<A>;
9
28
 
10
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
- export type HeadlessElementAttributes = ElementAttributes | IteratedHeadlessElementFunc<any>;
29
+ export type HeadlessElementAttributes<A extends HTMLAttributes> =
30
+ | VBindAttributes<A>
31
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
+ | IteratedHeadlessElementFunc<A, any>;
12
33
 
13
- export type HeadlessElements = Record<
14
- string,
15
- HeadlessElementAttributes | ComputedRef<HeadlessElementAttributes>
16
- >;
34
+ export type HeadlessElements = Record<string, MaybeRef<HeadlessElementAttributes<HTMLAttributes>>>;
17
35
 
18
36
  export type HeadlessState = Record<string, Ref>;
19
37
 
@@ -28,6 +46,39 @@ export type HeadlessComposable<
28
46
 
29
47
  /**
30
48
  * We use this identity function to ensure the correct typings of the headless composables
49
+ * @example
50
+ * ```ts
51
+ * export const createTooltip = createBuilder(({ initialVisible }: CreateTooltipOptions) => {
52
+ * const tooltipId = createId("tooltip");
53
+ * const isVisible = ref(initialVisible);
54
+ *
55
+ * const hoverEvents = {
56
+ * onMouseover: () => (isVisible.value = true),
57
+ * onMouseout: () => (isVisible.value = false),
58
+ * onFocusin: () => (isVisible.value = true),
59
+ * onFocusout: () => (isVisible.value = false),
60
+ * };
61
+ *
62
+ * return {
63
+ * elements: {
64
+ * trigger: {
65
+ * "aria-describedby": tooltipId,
66
+ * ...hoverEvents,
67
+ * },
68
+ * tooltip: {
69
+ * role: "tooltip",
70
+ * id: tooltipId,
71
+ * tabindex: "-1",
72
+ * ...hoverEvents,
73
+ * },
74
+ * },
75
+ * state: {
76
+ * isVisible,
77
+ * },
78
+ * };
79
+ * });
80
+ *
81
+ * ```
31
82
  */
32
83
  export const createBuilder = <
33
84
  Args extends unknown[] = unknown[],
@@ -37,3 +88,47 @@ export const createBuilder = <
37
88
  >(
38
89
  builder: (...args: Args) => HeadlessComposable<Elements, State, Internals>,
39
90
  ) => builder;
91
+
92
+ type VueTemplateRefElement<E extends Element> = E | (ComponentPublicInstance & { $el: E }) | null;
93
+ type VueTemplateRef<E extends Element> = Ref<VueTemplateRefElement<E>>;
94
+
95
+ declare const HeadlessElRefSymbol: unique symbol;
96
+ type HeadlessElRef<E extends Element> = WritableComputedRef<E> & {
97
+ /**
98
+ * type differentiator
99
+ * ensures that only `createElRef` can be used for headless element ref bindings
100
+ */
101
+ [HeadlessElRefSymbol]: true;
102
+ };
103
+
104
+ /**
105
+ * Creates a special writeable computed that references a DOM Element.
106
+ * Vue Component references will be unwrapped.
107
+ * @example
108
+ * ```ts
109
+ * createBuilder() => {
110
+ * const buttonRef = createElRef<HtmlButtonElement>();
111
+ * return {
112
+ * elements: {
113
+ * button: {
114
+ * ref: buttonRef,
115
+ * },
116
+ * }
117
+ * };
118
+ * });
119
+ * ```
120
+ */
121
+ export function createElRef<E extends Element>(): HeadlessElRef<E>;
122
+ export function createElRef<
123
+ E extends Element,
124
+ V extends VueTemplateRefElement<E> = VueTemplateRefElement<E>,
125
+ >() {
126
+ const elementRef = shallowRef<E>();
127
+
128
+ return computed({
129
+ set: (element: V) => {
130
+ elementRef.value = element != null && "$el" in element ? element.$el : (element as E);
131
+ },
132
+ get: () => elementRef.value,
133
+ } as WritableComputedOptions<E>);
134
+ }