@saasmakers/ui 1.3.9 → 1.4.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.
@@ -27,3 +27,16 @@ export const Default: StoryObj<typeof BaseToast> = {
27
27
  text: 'Toast message',
28
28
  } satisfies Partial<BaseToast>,
29
29
  }
30
+
31
+ export const WithAction: StoryObj<typeof BaseToast> = {
32
+ args: {
33
+ action: {
34
+ label: 'Undo',
35
+ // eslint-disable-next-line no-console
36
+ onClick: () => console.log('action clicked'),
37
+ },
38
+ id: 'toast-2',
39
+ status: 'success',
40
+ text: 'Toast message',
41
+ } satisfies Partial<BaseToast>,
42
+ }
@@ -9,11 +9,73 @@ const props = withDefaults(defineProps<BaseToast>(), {
9
9
  })
10
10
 
11
11
  const emit = defineEmits<{
12
+ action: [event: MouseEvent, toast: BaseToast]
12
13
  close: [event: MouseEvent, toast: BaseToast]
13
14
  }>()
14
15
 
15
16
  const { getIcon } = useLayerIcons()
16
17
 
18
+ const actionPillClasses = computed(() => {
19
+ switch (props.status) {
20
+ case 'error':
21
+ return 'bg-red-600/20 text-red-400 hover:bg-red-600/30 focus-visible:ring-red-500 dark:bg-red-400/15 dark:text-red-400 dark:hover:bg-red-400/25'
22
+ case 'info':
23
+ return 'bg-indigo-600/20 text-indigo-400 hover:bg-indigo-600/30 focus-visible:ring-indigo-500 dark:bg-indigo-400/15 dark:text-indigo-400 dark:hover:bg-indigo-400/25'
24
+ case 'success':
25
+ return 'bg-green-600/20 text-green-400 hover:bg-green-600/30 focus-visible:ring-green-500 dark:bg-green-400/15 dark:text-green-400 dark:hover:bg-green-400/25'
26
+ case 'warning':
27
+ return 'bg-orange-600/20 text-orange-400 hover:bg-orange-600/30 focus-visible:ring-orange-500 dark:bg-orange-400/15 dark:text-orange-400 dark:hover:bg-orange-400/25'
28
+ default:
29
+ return ''
30
+ }
31
+ })
32
+
33
+ const closeHoverClasses = computed(() => {
34
+ switch (props.status) {
35
+ case 'error':
36
+ return 'hover:text-red-600 dark:hover:text-red-400'
37
+ case 'info':
38
+ return 'hover:text-indigo-600 dark:hover:text-indigo-400'
39
+ case 'success':
40
+ return 'hover:text-green-600 dark:hover:text-green-400'
41
+ case 'warning':
42
+ return 'hover:text-orange-600 dark:hover:text-orange-400'
43
+ default:
44
+ return ''
45
+ }
46
+ })
47
+
48
+ const isDismissibleByClick = computed(() => {
49
+ return props.hasClose && !props.action
50
+ })
51
+
52
+ const rootInteractiveBindings = computed(() => {
53
+ if (!isDismissibleByClick.value) {
54
+ return {}
55
+ }
56
+
57
+ return {
58
+ onClick: onClose,
59
+ onKeydown: (event: KeyboardEvent) => {
60
+ if (event.key === 'Enter' || event.key === ' ') {
61
+ if (event.key === ' ') {
62
+ event.preventDefault()
63
+ }
64
+
65
+ onClose(event)
66
+ }
67
+ },
68
+ role: 'button' as const,
69
+ tabindex: 0,
70
+ }
71
+ })
72
+
73
+ function onAction(event: KeyboardEvent | MouseEvent) {
74
+ if (props.action) {
75
+ emit('action', event as MouseEvent, props)
76
+ }
77
+ }
78
+
17
79
  function onClose(event: KeyboardEvent | MouseEvent) {
18
80
  if (props.hasClose) {
19
81
  emit('close', event as MouseEvent, props)
@@ -23,13 +85,9 @@ function onClose(event: KeyboardEvent | MouseEvent) {
23
85
 
24
86
  <template>
25
87
  <div
26
- class="group flex select-none items-center rounded-full bg-gray-900 px-3 py-2 text-base text-white font-normal dark:bg-gray-100 dark:text-black"
27
- :class="{ 'cursor-pointer': hasClose }"
28
- role="button"
29
- tabindex="0"
30
- @click="onClose"
31
- @keydown.enter="onClose"
32
- @keydown.space.prevent="onClose"
88
+ v-bind="rootInteractiveBindings"
89
+ class="group flex select-none items-center rounded-full bg-gray-900 px-3 py-2 text-base text-white font-normal dark:bg-gray-800 dark:text-gray-100 dark:ring-1 dark:ring-gray-700"
90
+ :class="{ 'cursor-pointer': isDismissibleByClick }"
33
91
  >
34
92
  <BaseIcon
35
93
  class="pointer-events-none flex-initial"
@@ -37,16 +95,46 @@ function onClose(event: KeyboardEvent | MouseEvent) {
37
95
  :text="text"
38
96
  />
39
97
 
98
+ <button
99
+ v-if="action"
100
+ class="ml-2 rounded-full px-2.5 py-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-gray-900 dark:focus-visible:ring-offset-gray-800"
101
+ :class="actionPillClasses"
102
+ type="button"
103
+ @click.stop="onAction"
104
+ >
105
+ <BaseText
106
+ bold
107
+ class="pointer-events-none"
108
+ no-wrap
109
+ :text="action.label"
110
+ uppercase
111
+ />
112
+ </button>
113
+
40
114
  <template v-if="hasClose">
41
- <div class="ml-2 mr-1.5 h-5 border-l border-gray-700 flex-initial dark:border-gray-300" />
115
+ <div class="ml-2 mr-1.5 h-5 border-l border-gray-700 flex-initial dark:border-gray-600" />
116
+
117
+ <button
118
+ v-if="action"
119
+ class="cursor-pointer bg-transparent text-gray-500 flex-initial dark:text-gray-500"
120
+ :class="closeHoverClasses"
121
+ type="button"
122
+ @click.stop="onClose"
123
+ >
124
+ <BaseIcon
125
+ class="pointer-events-none"
126
+ :icon="getIcon('close')"
127
+ />
128
+ </button>
42
129
 
43
130
  <BaseIcon
131
+ v-else
44
132
  class="text-gray-500 flex-initial dark:text-gray-500"
45
133
  :class="{
46
134
  'group-hover:text-red-600 dark:group-hover:text-red-400': status === 'error',
47
135
  'group-hover:text-indigo-600 dark:group-hover:text-indigo-400': status === 'info',
136
+ 'group-hover:text-green-600 dark:group-hover:text-green-400': status === 'success',
48
137
  'group-hover:text-orange-600 dark:group-hover:text-orange-400': status === 'warning',
49
- 'group-hover:text-teal-600 dark:group-hover:text-teal-400': status === 'success',
50
138
  }"
51
139
  :icon="getIcon('close')"
52
140
  />
@@ -67,14 +67,11 @@ function focus() {
67
67
  }
68
68
  }
69
69
 
70
- function trimModelValue() {
70
+ function onFieldBlur(event: FocusEvent) {
71
71
  if (props.trim) {
72
72
  modelValue.value = modelValue.value.toString().trim()
73
73
  }
74
- }
75
74
 
76
- function onFieldBlur(event: FocusEvent) {
77
- trimModelValue()
78
75
  emit('blur', event)
79
76
  }
80
77
 
@@ -107,8 +104,11 @@ function onFieldKeyDown(event: KeyboardEvent) {
107
104
  }
108
105
 
109
106
  else if (event.key === 'Enter' && modelValue.value) {
110
- trimModelValue()
111
- emit('submit', event, (event.target as HTMLInputElement).value)
107
+ if (props.trim) {
108
+ modelValue.value = modelValue.value.toString().trim()
109
+ }
110
+
111
+ emit('submit', event, modelValue.value.toString())
112
112
  }
113
113
 
114
114
  else if (props.type === 'number' && [
@@ -2,6 +2,12 @@
2
2
  const { fadeInUp } = useMotion()
3
3
  const toasts = useToasts()
4
4
 
5
+ async function onToastAction(_event: MouseEvent, toast: BaseToast) {
6
+ toasts.closeToast(toast.id)
7
+
8
+ await toast.action?.onClick()
9
+ }
10
+
5
11
  function onToastClose(_event: MouseEvent, toast: BaseToast) {
6
12
  toasts.closeToast(toast.id)
7
13
  }
@@ -20,8 +26,10 @@ function onToastClose(_event: MouseEvent, toast: BaseToast) {
20
26
  <BaseToast
21
27
  :id="toast.id"
22
28
  :key="toast.id"
29
+ :action="toast.action"
23
30
  :status="toast.status"
24
31
  :text="toast.text"
32
+ @action="onToastAction"
25
33
  @close="onToastClose"
26
34
  />
27
35
  </Motion>
@@ -17,9 +17,10 @@ export default function useToast() {
17
17
  }
18
18
  }
19
19
 
20
- const createToast = (message: string, status: BaseStatus = 'success', i18nParams?: Record<string, number | string>) => {
20
+ const createToast = (message: string, status: BaseStatus = 'success', i18nParams?: Record<string, number | string>, action?: BaseToastAction) => {
21
21
  if (import.meta.client) {
22
22
  const toast: BaseToast = {
23
+ action,
23
24
  id: crypto.randomUUID(),
24
25
  status: status || 'success',
25
26
  text: message.includes(' ') ? message : nuxtApp.$i18n.t(`toasts.${message}`, i18nParams || {}),
@@ -27,8 +28,8 @@ export default function useToast() {
27
28
 
28
29
  toasts.value.push(toast)
29
30
 
30
- // Remove toast after 5 seconds
31
- setTimeout(() => closeToast(toast.id), 5 * 1000)
31
+ // Give more time to interact when the toast offers an action
32
+ setTimeout(() => closeToast(toast.id), (action ? 8 : 5) * 1000)
32
33
  }
33
34
  }
34
35
 
@@ -358,8 +358,14 @@ export type BaseTextText = string | {
358
358
  }
359
359
 
360
360
  export interface BaseToast {
361
+ action?: BaseToastAction
361
362
  hasClose?: boolean
362
363
  id: string
363
364
  status?: BaseStatus
364
365
  text: BaseTextText
365
366
  }
367
+
368
+ export interface BaseToastAction {
369
+ label: BaseTextText
370
+ onClick: () => Promise<void> | void
371
+ }
@@ -57,6 +57,8 @@ declare global {
57
57
  type BaseText = Bases.BaseText
58
58
  type BaseTextText = Bases.BaseTextText
59
59
  type BaseToast = Bases.BaseToast
60
+ type BaseToastAction = Bases.BaseToastAction
61
+
60
62
  // Libraries
61
63
  type ChartistBarChartData = import('chartist').BarChartData
62
64
  type ChartistLineChartData = import('chartist').LineChartData
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasmakers/ui",
3
- "version": "1.3.9",
3
+ "version": "1.4.1",
4
4
  "private": false,
5
5
  "description": "Reusable Nuxt UI components for SaaS Makers projects",
6
6
  "license": "MIT",