@saasmakers/ui 0.1.59 → 0.1.61

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.
@@ -0,0 +1,109 @@
1
+ <script lang="ts" setup>
2
+ import type { FieldDays } from '../../types/fields'
3
+
4
+ const props = withDefaults(defineProps<FieldDays>(), { modelValue: () => [] })
5
+
6
+ const emit = defineEmits<{
7
+ 'change': [event: MouseEvent, value: number[]]
8
+ 'update:modelValue': [value: number[]]
9
+ }>()
10
+
11
+ const { t } = useI18n()
12
+
13
+ const daysToDisplay = computed(() => {
14
+ return [
15
+ {
16
+ label: t('days.monday'),
17
+ value: 1,
18
+ },
19
+ {
20
+ label: t('days.tuesday'),
21
+ value: 2,
22
+ },
23
+ {
24
+ label: t('days.wednesday'),
25
+ value: 3,
26
+ },
27
+ {
28
+ label: t('days.thursday'),
29
+ value: 4,
30
+ },
31
+ {
32
+ label: t('days.friday'),
33
+ value: 5,
34
+ },
35
+ {
36
+ label: t('days.saturday'),
37
+ value: 6,
38
+ },
39
+ {
40
+ label: t('days.sunday'),
41
+ value: 0,
42
+ },
43
+ ]
44
+ })
45
+
46
+ function onUpdateDays(event: MouseEvent, day: number) {
47
+ const days = [...props.modelValue]
48
+
49
+ days.includes(day) ? days.splice(days.indexOf(day), 1) : days.push(day)
50
+
51
+ emit('change', event, days)
52
+ emit('update:modelValue', days)
53
+ }
54
+ </script>
55
+
56
+ <template>
57
+ <div class="flex items-center">
58
+ <BaseText
59
+ v-for="day in daysToDisplay"
60
+ :key="day.value"
61
+ class="mr-2 h-7 w-7 flex cursor-pointer items-center justify-center border rounded-full shadow-inner last:mr-0"
62
+ :class="{
63
+ 'border-indigo-700 dark:border-indigo-300 bg-gray-100 dark:bg-gray-900 text-indigo-700 dark:text-indigo-300': modelValue.includes(day.value),
64
+ 'border-red-700 dark:border-red-300 bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300': !modelValue.includes(day.value),
65
+ }"
66
+ size="xs"
67
+ :text="day.label.charAt(0)"
68
+ @click="onUpdateDays($event, day.value)"
69
+ />
70
+ </div>
71
+ </template>
72
+
73
+ <i18n lang="json">
74
+ {
75
+ "en": {
76
+ "days": {
77
+ "monday": "Monday",
78
+ "tuesday": "Tuesday",
79
+ "wednesday": "Wednesday",
80
+ "thursday": "Thursday",
81
+ "friday": "Friday",
82
+ "saturday": "Saturday",
83
+ "sunday": "Sunday"
84
+ }
85
+ },
86
+ "fr": {
87
+ "days": {
88
+ "monday": "Lundi",
89
+ "tuesday": "Mardi",
90
+ "wednesday": "Mercredi",
91
+ "thursday": "Jeudi",
92
+ "friday": "Vendredi",
93
+ "saturday": "Samedi",
94
+ "sunday": "Dimanche"
95
+ }
96
+ },
97
+ "ja": {
98
+ "days": {
99
+ "monday": "月曜日",
100
+ "tuesday": "火曜日",
101
+ "wednesday": "水曜日",
102
+ "thursday": "木曜日",
103
+ "friday": "金曜日",
104
+ "saturday": "土曜日",
105
+ "sunday": "日曜日"
106
+ }
107
+ }
108
+ }
109
+ </i18n>
@@ -0,0 +1,104 @@
1
+ <script lang="ts" setup>
2
+ import type { FieldEmojis } from '../../types/fields'
3
+ import { emojis } from '@saasmakers/shared'
4
+ import { refDebounced } from '@vueuse/core'
5
+
6
+ withDefaults(defineProps<FieldEmojis>(), { modelValue: undefined })
7
+
8
+ const emit = defineEmits<{
9
+ 'click': [event: MouseEvent, emoji: string]
10
+ 'update:modelValue': [value: string]
11
+ }>()
12
+
13
+ const { locale, t } = useI18n()
14
+
15
+ const searchRaw = ref('')
16
+ const searchQuery = refDebounced(searchRaw, 250)
17
+
18
+ const emojisFiltered = computed(() => {
19
+ const searchQueryCleaned = normalizeText(searchQuery.value.trim())
20
+
21
+ if (!searchQueryCleaned) {
22
+ return emojis
23
+ }
24
+
25
+ return emojis
26
+ .map((category) => {
27
+ const filtered = category.emojis.filter((emoji) => {
28
+ const localizedKeywords = (emoji.keywords[locale.value] ?? [])
29
+ const englishKeywords = (emoji.keywords.en ?? [])
30
+ const allKeywords = [...localizedKeywords, ...englishKeywords]
31
+
32
+ return allKeywords.some(keyword => normalizeText(keyword).includes(searchQueryCleaned))
33
+ })
34
+
35
+ return {
36
+ ...category,
37
+ emojis: filtered,
38
+ }
39
+ })
40
+ .filter(category => category.emojis.length > 0)
41
+ })
42
+
43
+ function onEmojiClick(event: MouseEvent, emoji?: string) {
44
+ emit('click', event, emoji || '')
45
+ emit('update:modelValue', emoji || '')
46
+ }
47
+ </script>
48
+
49
+ <template>
50
+ <div class="border border-gray-200 rounded-lg p-4 shadow-sm dark:border-gray-800">
51
+ <FieldInput
52
+ v-model="searchRaw"
53
+ autofocus
54
+ :placeholder="t('searchEmoji')"
55
+ size="sm"
56
+ />
57
+
58
+ <div class="h-48 overflow-y-auto">
59
+ <template
60
+ v-for="category in emojisFiltered"
61
+ :key="category.category"
62
+ >
63
+ <div
64
+ v-if="category.emojis.length"
65
+ class="mt-4"
66
+ >
67
+ <div class="mb-4">
68
+ <BaseText
69
+ class="mb-2 text-xs text-gray-700 dark:text-gray-300"
70
+ size="xs"
71
+ :text="category.category"
72
+ />
73
+
74
+ <div class="flex flex-wrap">
75
+ <BaseEmoji
76
+ v-for="emoji in category.emojis"
77
+ :key="emoji.id"
78
+ class="rounded-lg p-1.5 hover:bg-gray-200 dark:hover:bg-gray-800"
79
+ clickable
80
+ :emoji="emoji.id"
81
+ size="xl"
82
+ @click="onEmojiClick"
83
+ />
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </template>
88
+ </div>
89
+ </div>
90
+ </template>
91
+
92
+ <i18n lang="json">
93
+ {
94
+ "en": {
95
+ "searchEmoji": "Search an emoji"
96
+ },
97
+ "fr": {
98
+ "searchEmoji": "Rechercher un emoji (en anglais)"
99
+ },
100
+ "ja": {
101
+ "searchEmoji": "絵文字を検索 (英語)"
102
+ }
103
+ }
104
+ </i18n>
@@ -0,0 +1,209 @@
1
+ <script lang="ts" setup>
2
+ import type { FieldInput } from '../../types/fields'
3
+
4
+ const props = withDefaults(defineProps<FieldInput>(), {
5
+ alignment: 'left',
6
+ autocomplete: true,
7
+ autofocus: false,
8
+ background: 'gray',
9
+ border: 'full',
10
+ description: '',
11
+ disabled: false,
12
+ fullWidth: false,
13
+ hideError: false,
14
+ label: '',
15
+ labelIcon: undefined,
16
+ lineThrough: false,
17
+ loading: false,
18
+ lowercaseOnly: false,
19
+ max: undefined,
20
+ min: undefined,
21
+ modelValue: '',
22
+ placeholder: '',
23
+ required: false,
24
+ size: 'base',
25
+ type: 'text',
26
+ uppercase: false,
27
+ validation: undefined,
28
+ })
29
+
30
+ const emit = defineEmits<{
31
+ 'blur': [event: FocusEvent]
32
+ 'focus': [event: FocusEvent]
33
+ 'keyup': [event: KeyboardEvent, value: number | string ]
34
+ 'submit': [event: KeyboardEvent, value: number | string ]
35
+ 'update:modelValue': [value: number | string ]
36
+ }>()
37
+
38
+ const input = ref<HTMLInputElement>()
39
+ const uuid = ref(`${Math.floor((1 + Math.random()) * 0x100000)}`)
40
+
41
+ const { isDesktopBrowser } = useDevice()
42
+
43
+ onMounted(() => {
44
+ if (props.autofocus && input.value && isDesktopBrowser.value) {
45
+ input.value.focus()
46
+ }
47
+ })
48
+
49
+ const value = computed({
50
+ get() {
51
+ return props.modelValue
52
+ },
53
+ set(value) {
54
+ emit('update:modelValue', value || '')
55
+ },
56
+ })
57
+
58
+ function blur() {
59
+ if (input.value) {
60
+ input.value.blur()
61
+ }
62
+ }
63
+
64
+ function focus() {
65
+ if (input.value) {
66
+ input.value.focus()
67
+ }
68
+ }
69
+
70
+ function onFieldBlur(event: FocusEvent) {
71
+ emit('blur', event)
72
+ }
73
+
74
+ function onFieldFocus(event: FocusEvent) {
75
+ emit('focus', event)
76
+ }
77
+
78
+ function onFieldInput(event: Event) {
79
+ let value: number | string = (event.target as HTMLInputElement).value
80
+
81
+ if (props.type === 'number' && props.max && Number.parseInt(value) > props.max) {
82
+ value = props.max
83
+ }
84
+
85
+ if (props.lowercaseOnly) {
86
+ value = value.toString().toLowerCase()
87
+ }
88
+
89
+ emit('update:modelValue', value)
90
+ }
91
+
92
+ function onFieldKeyDown(event: KeyboardEvent) {
93
+ const value = (event.target as HTMLInputElement).value
94
+
95
+ if (['Control', 'Escape'].includes(event.key)) {
96
+ blur()
97
+ }
98
+
99
+ else if (event.key === 'Enter' && props.modelValue) {
100
+ emit('submit', event, value)
101
+ }
102
+
103
+ else if (props.type === 'number' && [
104
+ '+',
105
+ '-',
106
+ ':',
107
+ '^',
108
+ 'e',
109
+ 'E',
110
+ ].includes(event.key)) {
111
+ return event.preventDefault()
112
+ }
113
+
114
+ emit('keyup', event, value)
115
+ }
116
+
117
+ // Expose
118
+ defineExpose({ focus })
119
+ </script>
120
+
121
+ <template>
122
+ <div
123
+ class="relative flex flex-col"
124
+ :class="{ 'w-full': fullWidth }"
125
+ >
126
+ <div
127
+ v-if="label"
128
+ class="flex justify-between"
129
+ >
130
+ <FieldLabel
131
+ :disabled="disabled"
132
+ :for-field="uuid"
133
+ has-margin-bottom
134
+ :icon="labelIcon"
135
+ :label="label"
136
+ :required="required"
137
+ :size="size"
138
+ />
139
+
140
+ <slot name="label" />
141
+ </div>
142
+
143
+ <div
144
+ class="flex items-center"
145
+ :class="{
146
+ 'h-8': size === 'xs',
147
+ 'h-10': size === 'sm',
148
+ 'h-12': size === 'base',
149
+ 'h-14': size === 'lg',
150
+ }"
151
+ >
152
+ <slot name="inputLeft" />
153
+
154
+ <input
155
+ :id="uuid"
156
+ ref="input"
157
+ v-model="value"
158
+ :autocomplete="autocomplete ? 'on' : 'off'"
159
+ class="h-full w-full flex-1 appearance-none items-center rounded-lg outline-none placeholder-gray-600 dark:placeholder-gray-400 focus:placeholder-gray-900 hover:placeholder-gray-900 dark:focus:placeholder-gray-100 dark:hover:placeholder-gray-100"
160
+ :class="{
161
+ 'text-gray-900 dark:text-gray-100': !lineThrough,
162
+ 'text-gray-500 dark:text-gray-500 line-through': lineThrough,
163
+ 'font-medium tracking-tight uppercase': uppercase,
164
+ 'font-normal': !uppercase,
165
+
166
+ 'text-center': alignment === 'center',
167
+ 'text-left': alignment === 'left',
168
+ 'text-right': alignment === 'right',
169
+
170
+ 'bg-gray-100 dark:bg-gray-900': background === 'gray',
171
+ 'bg-white dark:bg-gray-900': background === 'white',
172
+
173
+ 'border shadow-sm border-gray-200 dark:border-gray-800 px-4 hover:border-gray-300 focus:border-gray-400 dark:hover:border-gray-700 dark:focus:border-gray-600': border === 'full',
174
+ 'border-b border-gray-200 dark:border-gray-800 px-10': border === 'bottom',
175
+ 'border-0 p-0': border === 'none',
176
+
177
+ 'text-xs': size === 'xs',
178
+ 'text-sm': size === 'sm',
179
+ 'text-base': size === 'base',
180
+ 'text-lg': size === 'lg',
181
+ }"
182
+ :max="max"
183
+ :min="min"
184
+ :placeholder="placeholder"
185
+ spellcheck="false"
186
+ :type="type"
187
+ @blur="onFieldBlur"
188
+ @focus="onFieldFocus"
189
+ @input="onFieldInput"
190
+ @keydown="onFieldKeyDown"
191
+ >
192
+
193
+ <BaseSpinner
194
+ v-if="loading"
195
+ class="ml-4 flex-initial"
196
+ size="sm"
197
+ />
198
+
199
+ <slot name="inputRight" />
200
+ </div>
201
+
202
+ <FieldMessage
203
+ :description="description"
204
+ :hide-error="hideError"
205
+ :size="size"
206
+ :validation="validation"
207
+ />
208
+ </div>
209
+ </template>
@@ -0,0 +1,64 @@
1
+ <script lang="ts" setup>
2
+ import type { FieldLabel } from '../../types/fields'
3
+
4
+ withDefaults(defineProps<FieldLabel>(), {
5
+ disabled: false,
6
+ forField: '',
7
+ hasMarginBottom: false,
8
+ hasMarginLeft: false,
9
+ icon: undefined,
10
+ label: '',
11
+ lineThrough: false,
12
+ loading: false,
13
+ required: false,
14
+ size: 'base',
15
+ truncate: false,
16
+ })
17
+
18
+ const emit = defineEmits<{
19
+ click: [event: MouseEvent]
20
+ }>()
21
+
22
+ function onClick(event: MouseEvent) {
23
+ emit('click', event)
24
+ }
25
+ </script>
26
+
27
+ <template>
28
+ <label
29
+ class="flex select-none items-center font-semibold tracking-tight"
30
+ :class="{
31
+ 'cursor-pointer': !loading && !disabled,
32
+ 'cursor-not-allowed': disabled,
33
+ 'cursor-wait': loading,
34
+ 'text-gray-900 dark:text-gray-100': !lineThrough,
35
+ 'text-gray-500 dark:text-gray-500 line-through': lineThrough,
36
+ 'hover:text-black dark:hover:text-white': !loading && !disabled && !lineThrough,
37
+ 'w-0 truncate': truncate,
38
+
39
+ 'mb-1.5': size === 'xs' && hasMarginBottom,
40
+ 'mb-2': size === 'sm' && hasMarginBottom,
41
+ 'mb-2.5': size === 'base' && hasMarginBottom,
42
+ 'mb-3': size === 'lg' && hasMarginBottom,
43
+ 'ml-1': size === 'xs' && hasMarginLeft,
44
+ 'ml-1.5': size === 'sm' && hasMarginLeft,
45
+ 'ml-2': size === 'base' && hasMarginLeft,
46
+ 'ml-2.5': size === 'lg' && hasMarginLeft,
47
+ }"
48
+ :for="forField"
49
+ @click="onClick"
50
+ >
51
+ <BaseIcon
52
+ :icon="icon"
53
+ :size="size"
54
+ :text="label"
55
+ />
56
+
57
+ <BaseText
58
+ v-if="required"
59
+ class="ml-1 text-red-700 dark:text-red-300"
60
+ :size="size"
61
+ text="*"
62
+ />
63
+ </label>
64
+ </template>
@@ -0,0 +1,209 @@
1
+ <script lang="ts" setup>
2
+ import type { FieldMessage } from '../../types/fields'
3
+
4
+ const props = withDefaults(defineProps<FieldMessage>(), {
5
+ description: '',
6
+ hideError: true,
7
+ size: 'base',
8
+ validation: undefined,
9
+ })
10
+
11
+ // Composable
12
+ const { t } = useI18n()
13
+
14
+ const validationMessage = computed(() => {
15
+ // Available rules
16
+ // https://vuelidate.js.org/#sub-builtin-validators
17
+ let message = ''
18
+
19
+ if (props.validation && props.validation.$dirty) {
20
+ // Required, Required If, Required Unless
21
+ if (props.validation.required === false || props.validation.requiredIf === false || props.validation.requiredUnless === false) {
22
+ message = t('required')
23
+ }
24
+ // Min Length
25
+ else if (props.validation.minLength === false) {
26
+ const min = props.validation.$params.minLength.min
27
+
28
+ message = t('minLength', { min })
29
+ }
30
+ // Max Length
31
+ else if (props.validation.maxLength === false) {
32
+ const max = props.validation.$params.maxLength.max
33
+
34
+ message = t('maxLength', { max })
35
+ }
36
+ // Min Value
37
+ else if (props.validation.minValue === false) {
38
+ const min = props.validation.$params.minValue.min
39
+
40
+ message = t('minValue', { min })
41
+ }
42
+ // Max Value
43
+ else if (props.validation.maxValue === false) {
44
+ const max = props.validation.$params.maxValue.max
45
+
46
+ message = t('maxValue', { max })
47
+ }
48
+ // Between
49
+ else if (props.validation.between === false) {
50
+ const min = props.validation.$params.between.min
51
+ const max = props.validation.$params.between.max
52
+
53
+ message = t('between', {
54
+ max,
55
+ min,
56
+ })
57
+ }
58
+ // Alpha
59
+ else if (props.validation.alpha === false) {
60
+ message = t('alpha')
61
+ }
62
+ // Alpha Num
63
+ else if (props.validation.alphaNum === false) {
64
+ message = t('alphaNum')
65
+ }
66
+ // Numeric
67
+ else if (props.validation.numeric === false) {
68
+ message = t('numeric')
69
+ }
70
+ // Integer
71
+ else if (props.validation.integer === false) {
72
+ message = t('integer')
73
+ }
74
+ // Integer
75
+ else if (props.validation.decimal === false) {
76
+ message = t('decimal')
77
+ }
78
+ // Email
79
+ else if (props.validation.email === false) {
80
+ message = t('email')
81
+ }
82
+ // IP Address
83
+ else if (props.validation.ipAddress === false) {
84
+ message = t('ipAddress')
85
+ }
86
+ // Mac Address
87
+ else if (props.validation.macAddress === false) {
88
+ message = t('maxAddress')
89
+ }
90
+ // Same As
91
+ else if (props.validation.sameAs === false) {
92
+ const field = props.validation.$params.sameAs.eq
93
+
94
+ message = t('sameAs', { field })
95
+ }
96
+ // Url
97
+ else if (props.validation.url === false) {
98
+ message = t('url')
99
+ }
100
+ // Other rules
101
+ else if (props.validation.$invalid === true) {
102
+ message = t('invalid')
103
+ }
104
+ }
105
+
106
+ return message
107
+ })
108
+
109
+ const status = computed(() => {
110
+ if (validationMessage.value && !props.hideError) {
111
+ return 'error'
112
+ }
113
+ else if (props.description) {
114
+ return 'default'
115
+ }
116
+ })
117
+
118
+ const text = computed<BaseTextText>(() => {
119
+ if (validationMessage.value && !props.hideError) {
120
+ return validationMessage.value
121
+ }
122
+ else if (props.description) {
123
+ return props.description
124
+ }
125
+ else {
126
+ return ''
127
+ }
128
+ })
129
+ </script>
130
+
131
+ <template>
132
+ <BaseIcon
133
+ v-if="text"
134
+ class="text-left tracking-tighter"
135
+ :class="{
136
+ 'text-red-700 dark:text-red-300': status === 'error',
137
+ 'text-gray-700 dark:text-gray-300': status === 'default',
138
+
139
+ 'mt-2': size === 'xs',
140
+ 'mt-2.5': size === 'sm',
141
+ 'mt-3': size === 'base',
142
+ 'mt-3.5': size === 'lg',
143
+ }"
144
+ size="sm"
145
+ :text="text"
146
+ />
147
+ </template>
148
+
149
+ <i18n lang="json">
150
+ {
151
+ "en": {
152
+ "alpha": "The value accepts only alphabet characters",
153
+ "alphaNum": "The value accepts only alphanumerics",
154
+ "between": "Value should be between {min} and {max}",
155
+ "decimal": "The value accepts only positive and negative decimal numbers",
156
+ "email": "The value is not a valid email",
157
+ "integer": "The value accepts only positive and negative integers",
158
+ "invalid": "The value is invalid",
159
+ "ipAddress": "The value accepts only a valid IPv4 address",
160
+ "macAddress": "The value accepts only a valid MAC address",
161
+ "maxLength": "This value is too long (max: {max})",
162
+ "maxValue": "Maximum value allowed: {max}",
163
+ "minLength": "The value is too short (min: {min})",
164
+ "minValue": "Minimum value allowed: {min}",
165
+ "numeric": "The value accepts only numerics",
166
+ "required": "A value is required",
167
+ "sameAs": "The value does not match: {field}",
168
+ "url": "The value is not a valid url"
169
+ },
170
+ "fr": {
171
+ "alpha": "La valeur n'accepte que les caractères alphabétiques",
172
+ "alphaNum": "La valeur n'accepte que les alphanumériques",
173
+ "between": "La valeur doit être entre {min} et {max}",
174
+ "decimal": "La valeur naccepte que les nombres décimaux positifs et négatifs",
175
+ "email": "La valeur n'est pas un email valide",
176
+ "integer": "La valeur n'accepte que des entiers positifs et négatifs",
177
+ "ipAddress": "La valeur n'accepte qu'une adresse IPv4 valide",
178
+ "invalid": "La valeur n'est pas valide",
179
+ "macAddress": "La valeur n'accepte qu'une adresse MAC valide",
180
+ "maxLength": "La valeur est trop longue (max: {max})",
181
+ "maxValue": "La valeur maximum acceptée: {max}",
182
+ "minLength": "La valeur est trop petite (min: {min})",
183
+ "minValue": "La valeur minimum acceptée: {min}",
184
+ "numeric": "La valeur n'accepte que des chiffres",
185
+ "required": "La valeur est requise",
186
+ "sameAs": "La valeur ne correspond pas à: {field}",
187
+ "url": "La valeur n'est pas une URL valide"
188
+ },
189
+ "ja": {
190
+ "alpha": "値はアルファベット文字のみを受け入れます",
191
+ "alphaNum": "値はアルファベットと数字のみを受け入れます",
192
+ "between": "値は {min} から {max} の間である必要があります",
193
+ "decimal": "値は正の小数と負の小数のみを受け入れます",
194
+ "email": "値は有効なメールアドレスではありません",
195
+ "integer": "値は正の整数と負の整数のみを受け入れます",
196
+ "invalid": "値は無効です",
197
+ "ipAddress": "値は有効なIPv4アドレスのみを受け入れます",
198
+ "macAddress": "値は有効なMACアドレスのみを受け入れます",
199
+ "maxLength": "値は長すぎます (最大: {max})",
200
+ "maxValue": "最大値: {max}",
201
+ "minLength": "値は短すぎます (最小: {min})",
202
+ "minValue": "最小値: {max}",
203
+ "numeric": "値は数字のみを受け入れます",
204
+ "required": "値は必須です",
205
+ "sameAs": "値は一致しません: {field}",
206
+ "url": "値は有効なURLではありません"
207
+ }
208
+ }
209
+ </i18n>