@aggc/ui 0.7.1 → 0.8.0

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 (33) hide show
  1. package/dist/chunks/UiToastProvider.vue_vue_type_script_setup_true_lang-D7OGRCU4.js +3958 -0
  2. package/dist/components/UiAvatar.styles.d.ts +53 -0
  3. package/dist/components/UiAvatar.vue.d.ts +13 -0
  4. package/dist/components/UiModal.styles.d.ts +21 -0
  5. package/dist/components/UiModal.vue.d.ts +30 -0
  6. package/dist/components/UiToast.styles.d.ts +41 -0
  7. package/dist/components/UiToast.vue.d.ts +13 -0
  8. package/dist/components/UiToastProvider.vue.d.ts +13 -0
  9. package/dist/components/index.d.ts +4 -0
  10. package/dist/components.js +14 -10
  11. package/dist/composables/useToast.d.ts +27 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.js +66 -61
  14. package/dist/ui.css +356 -58
  15. package/package.json +3 -2
  16. package/src/components/UiAvatar.styles.ts +81 -0
  17. package/src/components/UiAvatar.test.ts +43 -0
  18. package/src/components/UiAvatar.vue +41 -0
  19. package/src/components/UiModal.styles.ts +126 -0
  20. package/src/components/UiModal.test.ts +64 -0
  21. package/src/components/UiModal.vue +79 -0
  22. package/src/components/UiToast.styles.ts +143 -0
  23. package/src/components/UiToast.test.ts +47 -0
  24. package/src/components/UiToast.vue +65 -0
  25. package/src/components/UiToastProvider.vue +22 -0
  26. package/src/components/index.ts +4 -0
  27. package/src/composables/useToast.ts +43 -0
  28. package/src/css/base.css +50 -1
  29. package/src/index.ts +1 -0
  30. package/src/stories/feedback/UiToast.stories.ts +72 -0
  31. package/src/stories/layout/UiModal.stories.ts +89 -0
  32. package/src/stories/primitives/UiAvatar.stories.ts +83 -0
  33. package/dist/chunks/UiSkeleton.vue_vue_type_script_setup_true_lang-DUse1KRc.js +0 -1201
@@ -0,0 +1,43 @@
1
+ import { mount } from "@vue/test-utils";
2
+ import { describe, expect, it } from "vitest";
3
+ import UiAvatar from "./UiAvatar.vue";
4
+
5
+ describe("UiAvatar", () => {
6
+ it("has accessible label with full name", () => {
7
+ const wrapper = mount(UiAvatar, {
8
+ props: { name: "Alice Johnson" },
9
+ });
10
+
11
+ expect(wrapper.attributes("aria-label")).toBe("Alice Johnson");
12
+ expect(wrapper.attributes("role")).toBe("img");
13
+ });
14
+
15
+ it("renders image when src provided", () => {
16
+ const wrapper = mount(UiAvatar, {
17
+ props: {
18
+ name: "Alice",
19
+ src: "https://example.com/avatar.jpg",
20
+ },
21
+ });
22
+
23
+ const img = wrapper.find("img");
24
+ expect(img.exists()).toBe(true);
25
+ expect(img.attributes("src")).toBe("https://example.com/avatar.jpg");
26
+ });
27
+
28
+ it("does not render image when src is not provided", () => {
29
+ const wrapper = mount(UiAvatar, {
30
+ props: { name: "Alice" },
31
+ });
32
+
33
+ expect(wrapper.find("img").exists()).toBe(false);
34
+ });
35
+
36
+ it("renders avatar root element", () => {
37
+ const wrapper = mount(UiAvatar, {
38
+ props: { name: "Alice Johnson" },
39
+ });
40
+
41
+ expect(wrapper.find("[role='img']").exists()).toBe(true);
42
+ });
43
+ });
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+ import { User } from "lucide-vue-next";
4
+ import { AvatarFallback, AvatarImage, AvatarRoot } from "reka-ui";
5
+ import {
6
+ uiAvatarFallbackClass,
7
+ uiAvatarImageClass,
8
+ uiAvatarRootClass,
9
+ } from "./UiAvatar.styles";
10
+
11
+ const props = withDefaults(
12
+ defineProps<{
13
+ name: string;
14
+ src?: string;
15
+ size?: "sm" | "md" | "lg" | "xl";
16
+ shape?: "circle" | "square";
17
+ }>(),
18
+ {
19
+ src: undefined,
20
+ size: "md",
21
+ shape: "circle",
22
+ }
23
+ );
24
+
25
+ const initials = computed(() => {
26
+ const parts = props.name.trim().split(/\s+/);
27
+ if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
28
+ return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
29
+ });
30
+
31
+ const iconSizeMap = { sm: 16, md: 20, lg: 24, xl: 28 };
32
+ </script>
33
+
34
+ <template>
35
+ <AvatarRoot :class="uiAvatarRootClass({ size, shape })" :aria-label="name" role="img">
36
+ <AvatarImage v-if="src" :src="src" :alt="name" :class="uiAvatarImageClass" />
37
+ <AvatarFallback :delay-ms="300" :class="uiAvatarFallbackClass({ size })">
38
+ {{ initials }}
39
+ </AvatarFallback>
40
+ </AvatarRoot>
41
+ </template>
@@ -0,0 +1,126 @@
1
+ import { css, cva } from "@styled/css";
2
+
3
+ export const uiModalOverlayClass = css({
4
+ position: "fixed",
5
+ inset: "0",
6
+ bg: "rgba(0, 0, 0, 0.3)",
7
+ backdropFilter: "blur(12px)",
8
+ _dark: {
9
+ bg: "rgba(0, 0, 0, 0.5)",
10
+ },
11
+ display: "flex",
12
+ alignItems: "center",
13
+ justifyContent: "center",
14
+ zIndex: "50",
15
+ px: "4",
16
+ overscrollBehavior: "contain",
17
+ animation: "fadeIn 160ms cubic-bezier(0.16, 1, 0.3, 1)",
18
+ });
19
+
20
+ export const uiModalContentClass = cva({
21
+ base: {
22
+ bg: "bg.menu",
23
+ borderRadius: "xl",
24
+ borderWidth: "1px",
25
+ borderColor: "border.subtle",
26
+ p: "0",
27
+ display: "flex",
28
+ flexDirection: "column",
29
+ maxH: "calc(100dvh - 2rem)",
30
+ boxShadow:
31
+ "0 16px 48px -8px rgba(0, 0, 0, 0.12), 0 4px 16px -4px rgba(0, 0, 0, 0.06)",
32
+ animation: "modalIn 240ms cubic-bezier(0.16, 1, 0.3, 1)",
33
+ position: "fixed",
34
+ top: "50%",
35
+ left: "50%",
36
+ transform: "translate(-50%, -50%)",
37
+ zIndex: "51",
38
+ _dark: {
39
+ boxShadow:
40
+ "0 16px 48px -8px rgba(0, 0, 0, 0.56), 0 4px 16px -4px rgba(0, 0, 0, 0.32)",
41
+ borderColor: "border.default",
42
+ },
43
+ _focusVisible: {
44
+ outline: "none",
45
+ },
46
+ },
47
+ variants: {
48
+ size: {
49
+ sm: { w: "380px" },
50
+ md: { w: "480px" },
51
+ lg: { w: "640px" },
52
+ },
53
+ },
54
+ defaultVariants: {
55
+ size: "md",
56
+ },
57
+ });
58
+
59
+ export const uiModalHeaderClass = css({
60
+ display: "flex",
61
+ justifyContent: "space-between",
62
+ alignItems: "center",
63
+ p: "6",
64
+ pb: "4",
65
+ });
66
+
67
+ export const uiModalHeaderContentClass = css({
68
+ display: "flex",
69
+ flexDirection: "column",
70
+ gap: "1",
71
+ });
72
+
73
+ export const uiModalTitleClass = css({
74
+ fontFamily: "heading",
75
+ fontWeight: "600",
76
+ fontSize: "lg",
77
+ color: "text.primary",
78
+ });
79
+
80
+ export const uiModalDescriptionClass = css({
81
+ fontSize: "sm",
82
+ color: "text.secondary",
83
+ lineHeight: "1.5",
84
+ });
85
+
86
+ export const uiModalCloseClass = css({
87
+ display: "flex",
88
+ alignItems: "center",
89
+ justifyContent: "center",
90
+ w: "8",
91
+ h: "8",
92
+ borderRadius: "lg",
93
+ border: "none",
94
+ bg: "transparent",
95
+ color: "text.muted",
96
+ cursor: "pointer",
97
+ flexShrink: "0",
98
+ transition:
99
+ "color 160ms cubic-bezier(0.25, 0.1, 0.25, 1), background 160ms cubic-bezier(0.25, 0.1, 0.25, 1)",
100
+ _hover: {
101
+ bg: "bg.hover",
102
+ color: "text.primary",
103
+ },
104
+ _focusVisible: {
105
+ outline: "2px solid",
106
+ outlineColor: "border.accent",
107
+ outlineOffset: "2px",
108
+ },
109
+ });
110
+
111
+ export const uiModalBodyClass = css({
112
+ px: "6",
113
+ pb: "6",
114
+ overflowY: "auto",
115
+ overscrollBehavior: "contain",
116
+ });
117
+
118
+ export const uiModalActionsClass = css({
119
+ display: "flex",
120
+ justifyContent: "flex-end",
121
+ gap: "3",
122
+ p: "6",
123
+ pt: "3",
124
+ borderTopWidth: "1px",
125
+ borderTopColor: "border.soft",
126
+ });
@@ -0,0 +1,64 @@
1
+ import { mount } from "@vue/test-utils";
2
+ import { describe, expect, it } from "vitest";
3
+ import { DialogRoot } from "reka-ui";
4
+ import UiModal from "./UiModal.vue";
5
+
6
+ describe("UiModal", () => {
7
+ it("mounts without errors when open", () => {
8
+ const wrapper = mount(UiModal, {
9
+ props: {
10
+ open: true,
11
+ title: "Test Modal",
12
+ },
13
+ });
14
+
15
+ expect(wrapper.findComponent(DialogRoot).exists()).toBe(true);
16
+ });
17
+
18
+ it("passes title prop to DialogTitle", () => {
19
+ const wrapper = mount(UiModal, {
20
+ props: {
21
+ open: true,
22
+ title: "Create Workspace",
23
+ },
24
+ });
25
+
26
+ expect(wrapper.props("title")).toBe("Create Workspace");
27
+ });
28
+
29
+ it("passes size prop correctly", () => {
30
+ const wrapper = mount(UiModal, {
31
+ props: {
32
+ open: true,
33
+ title: "Test",
34
+ size: "lg",
35
+ },
36
+ });
37
+
38
+ expect(wrapper.props("size")).toBe("lg");
39
+ });
40
+
41
+ it("renders with default size md", () => {
42
+ const wrapper = mount(UiModal, {
43
+ props: {
44
+ open: true,
45
+ title: "Test",
46
+ },
47
+ });
48
+
49
+ expect(wrapper.props("size")).toBe("md");
50
+ });
51
+
52
+ it("emits update:open when DialogRoot state changes", async () => {
53
+ const wrapper = mount(UiModal, {
54
+ props: {
55
+ open: true,
56
+ title: "Test",
57
+ },
58
+ });
59
+
60
+ const dialogRoot = wrapper.findComponent(DialogRoot);
61
+ await dialogRoot.vm.$emit("update:open", false);
62
+ expect(wrapper.emitted("update:open")?.[0]).toEqual([false]);
63
+ });
64
+ });
@@ -0,0 +1,79 @@
1
+ <script setup lang="ts">
2
+ import { X } from "lucide-vue-next";
3
+ import {
4
+ DialogClose,
5
+ DialogContent,
6
+ DialogDescription,
7
+ DialogOverlay,
8
+ DialogPortal,
9
+ DialogRoot,
10
+ DialogTitle,
11
+ VisuallyHidden,
12
+ } from "reka-ui";
13
+ import {
14
+ uiModalActionsClass,
15
+ uiModalBodyClass,
16
+ uiModalCloseClass,
17
+ uiModalContentClass,
18
+ uiModalDescriptionClass,
19
+ uiModalHeaderClass,
20
+ uiModalHeaderContentClass,
21
+ uiModalOverlayClass,
22
+ uiModalTitleClass,
23
+ } from "./UiModal.styles";
24
+
25
+ withDefaults(
26
+ defineProps<{
27
+ open: boolean;
28
+ size?: "sm" | "md" | "lg";
29
+ title: string;
30
+ description?: string;
31
+ }>(),
32
+ {
33
+ size: "md",
34
+ description: undefined,
35
+ }
36
+ );
37
+
38
+ const emit = defineEmits<{
39
+ "update:open": [value: boolean];
40
+ close: [];
41
+ }>();
42
+
43
+ function handleOpenChange(open: boolean) {
44
+ emit("update:open", open);
45
+ if (!open) {
46
+ emit("close");
47
+ }
48
+ }
49
+ </script>
50
+
51
+ <template>
52
+ <DialogRoot :open="open" @update:open="handleOpenChange">
53
+ <DialogPortal>
54
+ <DialogOverlay :class="uiModalOverlayClass" />
55
+ <DialogContent :class="uiModalContentClass({ size })">
56
+ <div :class="uiModalHeaderClass">
57
+ <div :class="uiModalHeaderContentClass">
58
+ <DialogTitle :class="uiModalTitleClass">{{ title }}</DialogTitle>
59
+ <DialogDescription v-if="description" :class="uiModalDescriptionClass">
60
+ {{ description }}
61
+ </DialogDescription>
62
+ <VisuallyHidden v-else>
63
+ <DialogDescription>{{ title }}</DialogDescription>
64
+ </VisuallyHidden>
65
+ </div>
66
+ <DialogClose :class="uiModalCloseClass" aria-label="Close dialog">
67
+ <X :size="20" aria-hidden="true" />
68
+ </DialogClose>
69
+ </div>
70
+ <div :class="uiModalBodyClass">
71
+ <slot />
72
+ </div>
73
+ <div v-if="$slots.actions" :class="uiModalActionsClass">
74
+ <slot name="actions" />
75
+ </div>
76
+ </DialogContent>
77
+ </DialogPortal>
78
+ </DialogRoot>
79
+ </template>
@@ -0,0 +1,143 @@
1
+ import { css, cva } from "@styled/css";
2
+
3
+ export const uiToastViewportClass = css({
4
+ position: "fixed",
5
+ bottom: "4",
6
+ right: "4",
7
+ zIndex: "50",
8
+ display: "flex",
9
+ flexDirection: "column",
10
+ gap: "2",
11
+ w: "360px",
12
+ maxW: "calc(100vw - 2rem)",
13
+ maxHeight: "calc(100dvh - 2rem)",
14
+ pointerEvents: "none",
15
+ outline: "none",
16
+ });
17
+
18
+ export const uiToastRootClass = cva({
19
+ base: {
20
+ display: "flex",
21
+ alignItems: "flex-start",
22
+ gap: "3",
23
+ p: "4",
24
+ borderRadius: "lg",
25
+ borderWidth: "1px",
26
+ bg: "bg.cardStrong",
27
+ borderColor: "border.default",
28
+ boxShadow:
29
+ "0 4px 12px -4px rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)",
30
+ pointerEvents: "auto",
31
+ position: "relative",
32
+ overflow: "hidden",
33
+ transition: "all 240ms cubic-bezier(0.16, 1, 0.3, 1)",
34
+ _dark: {
35
+ boxShadow:
36
+ "0 4px 12px -4px rgba(0, 0, 0, 0.4), 0 1px 2px 0 rgba(0, 0, 0, 0.24)",
37
+ },
38
+ '&[data-state="open"]': {
39
+ animation: "toastSlideIn 240ms cubic-bezier(0.16, 1, 0.3, 1)",
40
+ },
41
+ '&[data-state="closed"]': {
42
+ animation: "toastFadeOut 160ms cubic-bezier(0.25, 0.1, 0.25, 1)",
43
+ },
44
+ '&[data-swipe="move"]': {
45
+ transform: "translateX(var(--reka-toast-swipe-move-x))",
46
+ },
47
+ '&[data-swipe="cancel"]': {
48
+ transform: "translateX(0)",
49
+ transition: "transform 200ms ease-out",
50
+ },
51
+ '&[data-swipe="end"]': {
52
+ animation: "toastSwipeOut 100ms ease-out",
53
+ },
54
+ },
55
+ variants: {
56
+ tone: {
57
+ success: {
58
+ borderColor: "badge.successBorder",
59
+ bg: "badge.successBg",
60
+ },
61
+ error: {
62
+ borderColor: "badge.dangerBorder",
63
+ bg: "badge.dangerBg",
64
+ },
65
+ info: {
66
+ borderColor: "badge.infoBorder",
67
+ bg: "badge.infoBg",
68
+ },
69
+ warning: {
70
+ borderColor: "badge.warningBorder",
71
+ bg: "badge.warningBg",
72
+ },
73
+ },
74
+ },
75
+ defaultVariants: {
76
+ tone: "info",
77
+ },
78
+ });
79
+
80
+ export const uiToastIconClass = cva({
81
+ base: {
82
+ flexShrink: "0",
83
+ w: "5",
84
+ h: "5",
85
+ },
86
+ variants: {
87
+ tone: {
88
+ success: { color: "badge.successText" },
89
+ error: { color: "badge.dangerText" },
90
+ info: { color: "badge.infoText" },
91
+ warning: { color: "badge.warningText" },
92
+ },
93
+ },
94
+ defaultVariants: {
95
+ tone: "info",
96
+ },
97
+ });
98
+
99
+ export const uiToastContentClass = css({
100
+ flex: "1",
101
+ minW: "0",
102
+ });
103
+
104
+ export const uiToastTitleClass = css({
105
+ fontWeight: "600",
106
+ fontSize: "sm",
107
+ color: "text.primary",
108
+ lineHeight: "1.4",
109
+ });
110
+
111
+ export const uiToastDescriptionClass = css({
112
+ fontSize: "sm",
113
+ color: "text.secondary",
114
+ lineHeight: "1.5",
115
+ });
116
+
117
+ export const uiToastCloseClass = css({
118
+ position: "absolute",
119
+ top: "3",
120
+ right: "3",
121
+ display: "flex",
122
+ alignItems: "center",
123
+ justifyContent: "center",
124
+ w: "6",
125
+ h: "6",
126
+ borderRadius: "md",
127
+ border: "none",
128
+ bg: "transparent",
129
+ color: "text.muted",
130
+ cursor: "pointer",
131
+ flexShrink: "0",
132
+ transition:
133
+ "color 160ms cubic-bezier(0.25, 0.1, 0.25, 1), background 160ms cubic-bezier(0.25, 0.1, 0.25, 1)",
134
+ _hover: {
135
+ bg: "bg.hover",
136
+ color: "text.primary",
137
+ },
138
+ _focusVisible: {
139
+ outline: "2px solid",
140
+ outlineColor: "border.accent",
141
+ outlineOffset: "2px",
142
+ },
143
+ });
@@ -0,0 +1,47 @@
1
+ import { mount } from "@vue/test-utils";
2
+ import { describe, expect, it } from "vitest";
3
+ import { ToastProvider } from "reka-ui";
4
+ import UiToast from "./UiToast.vue";
5
+
6
+ function mountToast(props: Record<string, unknown> = {}) {
7
+ return mount(
8
+ {
9
+ components: { ToastProvider, UiToast },
10
+ template: `<ToastProvider><UiToast v-bind="props" /></ToastProvider>`,
11
+ setup() {
12
+ return { props };
13
+ },
14
+ },
15
+ {
16
+ props: {
17
+ tone: "success",
18
+ message: "Workspace created",
19
+ ...props,
20
+ },
21
+ }
22
+ );
23
+ }
24
+
25
+ describe("UiToast", () => {
26
+ it("renders without errors", () => {
27
+ const wrapper = mountToast();
28
+ expect(wrapper.findComponent(UiToast).exists()).toBe(true);
29
+ });
30
+
31
+ it("passes tone prop correctly", () => {
32
+ const wrapper = mountToast({ tone: "error", message: "Failed" });
33
+ expect(wrapper.findComponent(UiToast).props("tone")).toBe("error");
34
+ });
35
+
36
+ it("passes message prop correctly", () => {
37
+ const wrapper = mountToast({ message: "Custom message" });
38
+ expect(wrapper.findComponent(UiToast).props("message")).toBe(
39
+ "Custom message"
40
+ );
41
+ });
42
+
43
+ it("passes duration prop", () => {
44
+ const wrapper = mountToast({ duration: 8000 });
45
+ expect(wrapper.findComponent(UiToast).props("duration")).toBe(8000);
46
+ });
47
+ });
@@ -0,0 +1,65 @@
1
+ <script setup lang="ts">
2
+ import { X } from "lucide-vue-next";
3
+ import {
4
+ ToastClose,
5
+ ToastDescription,
6
+ ToastRoot,
7
+ ToastTitle,
8
+ } from "reka-ui";
9
+ import {
10
+ uiToastCloseClass,
11
+ uiToastContentClass,
12
+ uiToastDescriptionClass,
13
+ uiToastIconClass,
14
+ uiToastRootClass,
15
+ uiToastTitleClass,
16
+ } from "./UiToast.styles";
17
+ import type { ToastTone } from "../composables/useToast";
18
+ import {
19
+ CheckCircle,
20
+ AlertCircle,
21
+ Info,
22
+ AlertTriangle,
23
+ } from "lucide-vue-next";
24
+
25
+ const props = withDefaults(
26
+ defineProps<{
27
+ tone: ToastTone;
28
+ message: string;
29
+ title?: string;
30
+ duration?: number;
31
+ }>(),
32
+ {
33
+ title: undefined,
34
+ duration: 4000,
35
+ }
36
+ );
37
+
38
+ const iconMap = {
39
+ success: CheckCircle,
40
+ error: AlertCircle,
41
+ info: Info,
42
+ warning: AlertTriangle,
43
+ };
44
+ </script>
45
+
46
+ <template>
47
+ <ToastRoot
48
+ :class="uiToastRootClass({ tone })"
49
+ :duration="duration"
50
+ :type="'foreground'"
51
+ >
52
+ <component
53
+ :is="iconMap[props.tone]"
54
+ :class="uiToastIconClass({ tone })"
55
+ aria-hidden="true"
56
+ />
57
+ <div :class="uiToastContentClass">
58
+ <ToastTitle v-if="title" :class="uiToastTitleClass">{{ title }}</ToastTitle>
59
+ <ToastDescription :class="uiToastDescriptionClass">{{ message }}</ToastDescription>
60
+ </div>
61
+ <ToastClose :class="uiToastCloseClass" aria-label="Dismiss">
62
+ <X :size="14" aria-hidden="true" />
63
+ </ToastClose>
64
+ </ToastRoot>
65
+ </template>
@@ -0,0 +1,22 @@
1
+ <script setup lang="ts">
2
+ import { ToastProvider, ToastViewport } from "reka-ui";
3
+ import { useToast } from "../composables/useToast";
4
+ import UiToast from "./UiToast.vue";
5
+ import { uiToastViewportClass } from "./UiToast.styles";
6
+
7
+ const { toasts } = useToast();
8
+ </script>
9
+
10
+ <template>
11
+ <ToastProvider swipe-direction="right">
12
+ <slot />
13
+ <template v-for="toast in toasts" :key="toast.id">
14
+ <UiToast
15
+ :tone="toast.tone"
16
+ :message="toast.message"
17
+ :duration="toast.duration"
18
+ />
19
+ </template>
20
+ <ToastViewport :class="uiToastViewportClass" />
21
+ </ToastProvider>
22
+ </template>
@@ -2,10 +2,14 @@ export { default as PageSurface } from "./PageSurface.vue";
2
2
  export { default as ResultPanel } from "./ResultPanel.vue";
3
3
  export { default as SectionCard } from "./SectionCard.vue";
4
4
  export { default as StatusBadge } from "./StatusBadge.vue";
5
+ export { default as UiAvatar } from "./UiAvatar.vue";
5
6
  export { default as UiButton } from "./UiButton.vue";
6
7
  export { default as UiCheckbox } from "./UiCheckbox.vue";
7
8
  export { default as UiField } from "./UiField.vue";
8
9
  export { default as UiLoadingState } from "./UiLoadingState.vue";
10
+ export { default as UiModal } from "./UiModal.vue";
9
11
  export { default as UiSegmentedControl } from "./UiSegmentedControl.vue";
10
12
  export { default as UiSelect } from "./UiSelect.vue";
11
13
  export { default as UiSkeleton } from "./UiSkeleton.vue";
14
+ export { default as UiToast } from "./UiToast.vue";
15
+ export { default as UiToastProvider } from "./UiToastProvider.vue";
@@ -0,0 +1,43 @@
1
+ import { ref } from "vue";
2
+
3
+ export type ToastTone = "success" | "error" | "info" | "warning";
4
+
5
+ export interface ToastItem {
6
+ id: number;
7
+ tone: ToastTone;
8
+ message: string;
9
+ duration: number;
10
+ }
11
+
12
+ const toasts = ref<ToastItem[]>([]);
13
+ let nextId = 0;
14
+
15
+ function addToast(tone: ToastTone, message: string, duration = 4000) {
16
+ const id = nextId++;
17
+ toasts.value = [...toasts.value, { id, tone, message, duration }];
18
+
19
+ if (duration > 0) {
20
+ setTimeout(() => removeToast(id), duration);
21
+ }
22
+
23
+ return id;
24
+ }
25
+
26
+ function removeToast(id: number) {
27
+ toasts.value = toasts.value.filter((t) => t.id !== id);
28
+ }
29
+
30
+ export function useToast() {
31
+ return {
32
+ toasts,
33
+ success: (message: string, duration?: number) =>
34
+ addToast("success", message, duration),
35
+ error: (message: string, duration?: number) =>
36
+ addToast("error", message, duration),
37
+ info: (message: string, duration?: number) =>
38
+ addToast("info", message, duration),
39
+ warning: (message: string, duration?: number) =>
40
+ addToast("warning", message, duration),
41
+ dismiss: removeToast,
42
+ };
43
+ }