vue-composable-ui 0.0.1

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.
Files changed (58) hide show
  1. package/.github/workflows/deploy.yml +21 -0
  2. package/.github/workflows/docs.yml +62 -0
  3. package/.vscode/extensions.json +7 -0
  4. package/LICENSE +21 -0
  5. package/README.md +3 -0
  6. package/docs/.vitepress/config.mts +40 -0
  7. package/docs/.vitepress/theme/index.js +4 -0
  8. package/docs/.vitepress/theme/style.scss +155 -0
  9. package/docs/components/combobox.md +174 -0
  10. package/docs/components/demos/combobox.vue +142 -0
  11. package/docs/components/demos/combobox_autocomplete.vue +75 -0
  12. package/docs/components/demos/combobox_tags.vue +127 -0
  13. package/docs/components/demos/input.vue +35 -0
  14. package/docs/components/demos/listbox.vue +64 -0
  15. package/docs/components/demos/menu.vue +93 -0
  16. package/docs/components/demos/popover.vue +16 -0
  17. package/docs/components/demos/tabs.vue +59 -0
  18. package/docs/components/demos/tooltip.vue +27 -0
  19. package/docs/components/listbox.md +9 -0
  20. package/docs/components/menu.md +121 -0
  21. package/docs/components/popover.md +82 -0
  22. package/docs/components/tabs.md +9 -0
  23. package/docs/components/tooltip.md +78 -0
  24. package/docs/composables/form-control.md +130 -0
  25. package/docs/composables/list-option.md +47 -0
  26. package/docs/composables/list.md +192 -0
  27. package/docs/composables/popover.md +90 -0
  28. package/docs/index.md +24 -0
  29. package/docs/intro.md +0 -0
  30. package/package.json +35 -0
  31. package/src/components/combobox/combobox.vue +229 -0
  32. package/src/components/combobox/comboboxFormInput.vue +33 -0
  33. package/src/components/combobox/comboboxOption.vue +52 -0
  34. package/src/components/combobox/comboboxOptions.vue +17 -0
  35. package/src/components/injectionKeys.ts +50 -0
  36. package/src/components/listbox/listbox.vue +91 -0
  37. package/src/components/listbox/listboxOption.vue +37 -0
  38. package/src/components/menu/menu.vue +100 -0
  39. package/src/components/menu/menuItem.vue +86 -0
  40. package/src/components/menu/menuItems.vue +51 -0
  41. package/src/components/popover/popover.vue +68 -0
  42. package/src/components/popover/popoverDialog.vue +35 -0
  43. package/src/components/tabs/tab.vue +35 -0
  44. package/src/components/tabs/tabContainer.vue +50 -0
  45. package/src/components/tabs/tabList.vue +52 -0
  46. package/src/components/tabs/tabPanel.vue +36 -0
  47. package/src/components/tooltip.vue +115 -0
  48. package/src/composables/formControl.ts +144 -0
  49. package/src/composables/list.ts +326 -0
  50. package/src/composables/listOption.ts +80 -0
  51. package/src/composables/popover.ts +306 -0
  52. package/src/main.ts +65 -0
  53. package/src/utils/element.ts +19 -0
  54. package/src/utils/id.ts +5 -0
  55. package/src/vite-env.d.ts +1 -0
  56. package/tsconfig.json +27 -0
  57. package/tsconfig.node.json +10 -0
  58. package/vite.config.ts +28 -0
@@ -0,0 +1,51 @@
1
+ <template>
2
+ <div
3
+ v-if="menu!.isOpen.value"
4
+ @keydown="onKeyDown"
5
+ :id="menu!.menuId"
6
+ :aria-activedescendant="menu!.activeItemId.value"
7
+ :aria-labelledby="menu!.activatorId"
8
+ role="menu"
9
+ tabindex="-1"
10
+ >
11
+ <slot />
12
+ </div>
13
+ </template>
14
+
15
+ <script setup lang="ts">
16
+ import { inject } from "vue";
17
+ import { injectMenu } from "../injectionKeys";
18
+
19
+ const menu = inject(injectMenu);
20
+ if (!menu) {
21
+ throw new Error("Menu injectable context is not provided");
22
+ }
23
+
24
+ function onKeyDown(evt: KeyboardEvent) {
25
+ switch (evt.code) {
26
+ case "ArrowUp":
27
+ menu!.activatePrevItem({ focus: true, loop: false });
28
+ break;
29
+ case "ArrowDown":
30
+ menu!.activateNextItem({ focus: true, loop: false });
31
+ break;
32
+ case "Home":
33
+ menu!.activateFirstItem({ focus: true });
34
+ break;
35
+ case "End":
36
+ menu!.activateLastItem({ focus: true });
37
+ break;
38
+ case "Escape":
39
+ menu!.close();
40
+ break;
41
+ case "Tab":
42
+ menu!.isOpen.value = false;
43
+ return;
44
+ default:
45
+ return;
46
+ }
47
+ evt.preventDefault();
48
+ }
49
+ </script>
50
+
51
+ <style scoped></style>
@@ -0,0 +1,68 @@
1
+ <template>
2
+ <slot
3
+ :attrs="activatorAttrs"
4
+ :isOpen="isOpen"
5
+ :toggle="toggle"
6
+ :open="open"
7
+ :close="close"
8
+ ></slot>
9
+ </template>
10
+
11
+ <script setup lang="ts">
12
+ import { provide, computed } from "vue";
13
+ import { useSequentialId } from "../../utils/id";
14
+ import { usePopover } from "../../composables/popover";
15
+ import { injectPopover } from "../injectionKeys";
16
+
17
+ const props = withDefaults(
18
+ defineProps<{
19
+ id?: string;
20
+ activatorId?: string;
21
+ direction?: "up" | "down" | "left" | "right";
22
+ align?: "top" | "bottom" | "left" | "right" | "center" | "stretch";
23
+ position?: "fixed" | "absolute";
24
+ closeOnOutsideClick?: boolean;
25
+ closeOnInsideClick?: boolean;
26
+ role?: "dialog" | "listbox" | "menu" | "tree" | "grid";
27
+ }>(),
28
+ {
29
+ id: useSequentialId("popover"),
30
+ activatorId: useSequentialId("popover-activator"),
31
+ direction: "down",
32
+ align: "left",
33
+ position: "fixed",
34
+ closeOnOutsideClick: true,
35
+ closeOnInsideClick: false,
36
+ role: "dialog",
37
+ }
38
+ );
39
+
40
+ const { isOpen, toggle, open, close } = usePopover(
41
+ `#${props.activatorId}`,
42
+ `#${props.id}`,
43
+ {
44
+ direction: props.direction,
45
+ align: props.align,
46
+ position: props.position,
47
+ closeOnOutsideClick: props.closeOnOutsideClick,
48
+ closeOnInsideClick: props.closeOnInsideClick,
49
+ }
50
+ );
51
+
52
+ const activatorAttrs = computed(() => ({
53
+ id: props.activatorId,
54
+ "aria-controls": isOpen.value ? props.id : undefined,
55
+ "aria-haspopup": props.role,
56
+ "aria-expanded": isOpen.value,
57
+ }));
58
+
59
+ provide(injectPopover, {
60
+ popoverId: props.id,
61
+ activatorId: props.activatorId,
62
+ role: props.role,
63
+ isOpen,
64
+ close,
65
+ });
66
+ </script>
67
+
68
+ <style scoped></style>
@@ -0,0 +1,35 @@
1
+ <template>
2
+ <div
3
+ v-if="popover!.isOpen.value"
4
+ @keydown="onKeyDown"
5
+ :id="popover!.popoverId"
6
+ :role="popover!.role"
7
+ :aria-labelledby="popover!.activatorId"
8
+ tabindex="-1"
9
+ >
10
+ <slot />
11
+ </div>
12
+ </template>
13
+
14
+ <script setup lang="ts">
15
+ import { inject } from "vue";
16
+ import { injectPopover } from "../injectionKeys";
17
+
18
+ const popover = inject(injectPopover);
19
+ if (!popover) {
20
+ throw new Error("Popover injectable context is not provided");
21
+ }
22
+
23
+ function onKeyDown(evt: KeyboardEvent) {
24
+ switch (evt.code) {
25
+ case "Escape":
26
+ popover!.close();
27
+ break;
28
+ default:
29
+ return;
30
+ }
31
+ evt.preventDefault();
32
+ }
33
+ </script>
34
+
35
+ <style scoped></style>
@@ -0,0 +1,35 @@
1
+ <template>
2
+ <button
3
+ ref="tab"
4
+ @click="select"
5
+ :v="value"
6
+ :id="tabId"
7
+ :aria-selected="isSelected"
8
+ :aria-controls="tabPanelId"
9
+ :tabindex="isSelected ? 0 : -1"
10
+ role="tab"
11
+ >
12
+ <slot />
13
+ </button>
14
+ </template>
15
+
16
+ <script setup lang="ts">
17
+ import { ref } from "vue";
18
+ import { useSequentialId } from "../../utils/id";
19
+ import { useListOption } from "../../composables/listOption";
20
+
21
+ const props = defineProps<{
22
+ id?: string;
23
+ ariaControls?: string;
24
+ value: any;
25
+ }>();
26
+
27
+ const tab = ref<HTMLButtonElement>();
28
+
29
+ const { isSelected, select } = useListOption(tab, props.value);
30
+
31
+ const tabId = props.id ?? useSequentialId("tab");
32
+ const tabPanelId = props.ariaControls ?? useSequentialId("tab-panel");
33
+ </script>
34
+
35
+ <style scoped></style>
@@ -0,0 +1,50 @@
1
+ <template>
2
+ <div>
3
+ <slot />
4
+ </div>
5
+ </template>
6
+
7
+ <script setup lang="ts">
8
+ import { provide, computed } from "vue";
9
+ import { useList } from "../../composables/list";
10
+ import { injectTabs } from "../injectionKeys";
11
+
12
+ const props = withDefaults(
13
+ defineProps<{
14
+ orientation?: "horizontal" | "vertical";
15
+ }>(),
16
+ {
17
+ orientation: "horizontal",
18
+ }
19
+ );
20
+
21
+ const model = defineModel();
22
+
23
+ const {
24
+ items,
25
+ selectActiveItem,
26
+ activateFirstItem,
27
+ activateLastItem,
28
+ activateNextItem,
29
+ activatePrevItem,
30
+ } = useList(model);
31
+
32
+ provide(injectTabs, {
33
+ getIds: (value: any) =>
34
+ computed(() => {
35
+ const item = items.value.find((item) => item.value === value);
36
+ return {
37
+ tabId: item?.element.value?.getAttribute("id") || undefined,
38
+ panelId: item?.element.value?.getAttribute("aria-controls") || undefined,
39
+ };
40
+ }),
41
+ selectActiveItem,
42
+ activateFirstItem,
43
+ activateLastItem,
44
+ activateNextItem,
45
+ activatePrevItem,
46
+ orientation: props.orientation,
47
+ });
48
+ </script>
49
+
50
+ <style scoped></style>
@@ -0,0 +1,52 @@
1
+ <template>
2
+ <div @keydown="onKeyDown" :aria-orientation="tabs!.orientation" role="tablist">
3
+ <slot />
4
+ </div>
5
+ </template>
6
+
7
+ <script setup lang="ts">
8
+ import { inject } from "vue";
9
+ import { injectTabs } from "../injectionKeys";
10
+
11
+ const tabs = inject(injectTabs);
12
+ if (!tabs) {
13
+ throw new Error("Tabs injectable context is not provided");
14
+ }
15
+
16
+ function onKeyDown(evt: KeyboardEvent) {
17
+ switch (evt.code) {
18
+ case "ArrowUp":
19
+ if (tabs!.orientation === "vertical") {
20
+ tabs!.activatePrevItem();
21
+ }
22
+ break;
23
+ case "ArrowDown":
24
+ if (tabs!.orientation === "vertical") {
25
+ tabs!.activateNextItem();
26
+ }
27
+ break;
28
+ case "ArrowLeft":
29
+ if (tabs!.orientation === "horizontal") {
30
+ tabs!.activatePrevItem();
31
+ }
32
+ break;
33
+ case "ArrowRight":
34
+ if (tabs!.orientation === "horizontal") {
35
+ tabs!.activateNextItem();
36
+ }
37
+ break;
38
+ case "Home":
39
+ tabs!.activateFirstItem();
40
+ break;
41
+ case "End":
42
+ tabs!.activateLastItem();
43
+ break;
44
+ default:
45
+ return;
46
+ }
47
+ tabs!.selectActiveItem();
48
+ evt.preventDefault();
49
+ }
50
+ </script>
51
+
52
+ <style scoped></style>
@@ -0,0 +1,36 @@
1
+ <template>
2
+ <div
3
+ v-if="isSelected"
4
+ :id="ids.panelId"
5
+ :aria-labelledby="ids.tabId"
6
+ tabindex="0"
7
+ role="tabpanel"
8
+ >
9
+ <slot />
10
+ </div>
11
+ </template>
12
+
13
+ <script setup lang="ts">
14
+ import { inject, computed } from "vue";
15
+ import { injectList } from "../../composables/list";
16
+ import { injectTabs } from "../injectionKeys";
17
+
18
+ const props = defineProps<{
19
+ value: string | number;
20
+ }>();
21
+
22
+ const list = inject(injectList);
23
+ if (!list) {
24
+ throw new Error("List injectable context is not provided");
25
+ }
26
+
27
+ const tabs = inject(injectTabs);
28
+ if (!tabs) {
29
+ throw new Error("Tabs injectable context is not provided");
30
+ }
31
+
32
+ const isSelected = computed(() => props.value === list.value.value);
33
+ const ids = tabs.getIds(props.value);
34
+ </script>
35
+
36
+ <style scoped></style>
@@ -0,0 +1,115 @@
1
+ <template>
2
+ <slot :attrs="activatorAttrs" />
3
+
4
+ <Teleport to="body">
5
+ <div
6
+ ref="popupEl"
7
+ v-if="isOpen"
8
+ :id="tooltipId"
9
+ v-bind="$attrs"
10
+ role="tooltip"
11
+ >
12
+ <slot name="content">{{ text }}</slot>
13
+ </div>
14
+ </Teleport>
15
+ </template>
16
+
17
+ <script setup lang="ts">
18
+ import { onBeforeUnmount, onMounted, ref, useSlots } from "vue";
19
+ import { useSequentialId } from "../utils/id";
20
+ import { usePopover } from "../composables/popover";
21
+
22
+ defineOptions({
23
+ inheritAttrs: false,
24
+ });
25
+
26
+ const props = withDefaults(
27
+ defineProps<{
28
+ id?: string;
29
+ text?: string;
30
+ delay?: string | number;
31
+ direction?: "up" | "down" | "left" | "right";
32
+ }>(),
33
+ {
34
+ direction: "up",
35
+ }
36
+ );
37
+ const slots = useSlots();
38
+
39
+ const popupEl = ref();
40
+ let timer: NodeJS.Timeout;
41
+
42
+ const tooltipId = props.id ?? useSequentialId("tooltip");
43
+
44
+ const { isOpen } = usePopover(`[aria-describedby=${tooltipId}]`, popupEl, {
45
+ direction: props.direction,
46
+ align: "center",
47
+ });
48
+
49
+ function open() {
50
+ timer = setTimeout(
51
+ () => {
52
+ isOpen.value = true;
53
+ },
54
+ typeof props.delay === "string" ? parseInt(props.delay) : props.delay
55
+ );
56
+ }
57
+
58
+ function close() {
59
+ isOpen.value = false;
60
+ clearTimeout(timer);
61
+ }
62
+
63
+ function onKeyDown(evt: KeyboardEvent) {
64
+ if (evt.key === "Escape") {
65
+ close();
66
+ }
67
+ }
68
+
69
+ const activatorAttrs = {
70
+ "aria-describedby": tooltipId,
71
+ onmouseenter: open,
72
+ onmouseleave: close,
73
+ onfocus: open,
74
+ onblur: close,
75
+ onkeydown: onKeyDown,
76
+ };
77
+
78
+ const controller = new AbortController();
79
+
80
+ onMounted(() => {
81
+ if (slots.default) return;
82
+ const activator = document.querySelector(
83
+ `[aria-describedby=${tooltipId}]`
84
+ ) as HTMLElement;
85
+ if (!activator) {
86
+ throw new Error(`Tooltip activator not found: ${tooltipId}`);
87
+ }
88
+ activator.addEventListener("mouseenter", open, {
89
+ signal: controller.signal,
90
+ passive: true,
91
+ });
92
+ activator.addEventListener("mouseleave", close, {
93
+ signal: controller.signal,
94
+ passive: true,
95
+ });
96
+ activator.addEventListener("focus", open, {
97
+ signal: controller.signal,
98
+ passive: true,
99
+ });
100
+ activator.addEventListener("blur", close, {
101
+ signal: controller.signal,
102
+ passive: true,
103
+ });
104
+ activator.addEventListener("keydown", onKeyDown, {
105
+ signal: controller.signal,
106
+ passive: true,
107
+ });
108
+ });
109
+
110
+ onBeforeUnmount(() => {
111
+ controller.abort();
112
+ });
113
+ </script>
114
+
115
+ <style scoped></style>
@@ -0,0 +1,144 @@
1
+ import type { MaybeRef, Ref } from "vue";
2
+ import { ref, unref, onBeforeUnmount, onMounted, readonly } from "vue";
3
+
4
+ import type { TemplateRefOrSelector } from "../utils/element";
5
+ import { useHTMLElement } from "../utils/element";
6
+
7
+ type FormElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
8
+
9
+ /**
10
+ * A validating function must return `true` or a string describing the error
11
+ */
12
+ export type FormValidator = (value: string) => boolean | string;
13
+
14
+ export interface useFormControlReturn {
15
+ /**
16
+ * Focus the form control
17
+ */
18
+ focus: () => void;
19
+ /**
20
+ * Validate the form control
21
+ *
22
+ * @returns Validation result
23
+ */
24
+ validate: () => boolean;
25
+ /**
26
+ * Whether the form control is valid. A shorthand for `validity.valid`, but can be manually overriden.
27
+ */
28
+ isValid: Ref<boolean>;
29
+ /**
30
+ * The validity state of the form control
31
+ *
32
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/ValidityState}
33
+ */
34
+ validity: Readonly<Ref<ValidityState | undefined>>;
35
+ /**
36
+ * A string representing a localized message that describes the validation constraints that the control does not satisfy (if any)
37
+ */
38
+ validationMessage: Readonly<Ref<string>>;
39
+ /**
40
+ * Whether the value has been changed since the form control was mounted
41
+ */
42
+ isChanged: Ref<boolean>;
43
+ }
44
+
45
+ /**
46
+ * Utilizes Constraint Validation API to enable custom validation constrains for a form control
47
+ *
48
+ * @param el A template ref or a CSS selector
49
+ * @param validators An array of validating functions
50
+ * @param showValidationError Whether to show the default browser validation error (true by default)
51
+ *
52
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation}
53
+ */
54
+ export function useFormControl(
55
+ el: TemplateRefOrSelector<FormElement>,
56
+ validators: MaybeRef<FormValidator[]> = [],
57
+ showValidationError: MaybeRef<boolean> = true
58
+ ): useFormControlReturn {
59
+ const isChanged = ref(false);
60
+ const isValid = ref(true);
61
+ const validity = ref<ValidityState>();
62
+ const validationMessage = ref("");
63
+
64
+ let formElement: FormElement | undefined;
65
+ let initValue: string;
66
+
67
+ function focus() {
68
+ formElement?.focus();
69
+ }
70
+
71
+ function updateValidationState() {
72
+ isValid.value = formElement!.validity.valid;
73
+ validity.value = formElement!.validity;
74
+ validationMessage.value = formElement!.validationMessage;
75
+ }
76
+
77
+ function validate() {
78
+ if (!formElement) throw new Error("Form element is undefined");
79
+ formElement.setCustomValidity("");
80
+
81
+ if (formElement.validity.valid) {
82
+ const value = formElement.value;
83
+ for (const validator of unref(validators)) {
84
+ const check = validator(value);
85
+ if (check !== true) {
86
+ formElement.setCustomValidity(check as string);
87
+ break;
88
+ }
89
+ }
90
+ }
91
+
92
+ updateValidationState();
93
+ return isValid.value;
94
+ }
95
+
96
+ function onInvalid(event: Event) {
97
+ updateValidationState();
98
+ if (!unref(showValidationError)) {
99
+ event.preventDefault();
100
+ }
101
+ }
102
+
103
+ function onChange() {
104
+ validate();
105
+ isChanged.value = initValue !== formElement!.value;
106
+ }
107
+
108
+ let abortController = new AbortController();
109
+
110
+ onMounted(() => {
111
+ formElement = useHTMLElement(el).value;
112
+ if (!formElement) throw new Error("Form element is undefined");
113
+
114
+ initValue = formElement.value;
115
+
116
+ formElement.addEventListener("change", onChange, {
117
+ signal: abortController.signal,
118
+ passive: true,
119
+ });
120
+ formElement.addEventListener("invalid", onInvalid, {
121
+ signal: abortController.signal,
122
+ });
123
+
124
+ // Fix autofocus?
125
+ if (formElement?.autofocus) {
126
+ focus();
127
+ }
128
+ });
129
+
130
+ onBeforeUnmount(() => {
131
+ abortController.abort();
132
+ });
133
+
134
+ return {
135
+ focus,
136
+ validate,
137
+
138
+ isValid,
139
+ validity: readonly(validity),
140
+ validationMessage: readonly(validationMessage),
141
+
142
+ isChanged,
143
+ };
144
+ }