@iankibetsh/sh-tailwind 0.1.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 (40) hide show
  1. package/README.md +177 -0
  2. package/dist/sh-tailwind.cjs.js +1 -0
  3. package/dist/sh-tailwind.es.js +3695 -0
  4. package/package.json +56 -0
  5. package/src/components/actions/ShConfirmAction.vue +78 -0
  6. package/src/components/actions/ShSilentAction.vue +66 -0
  7. package/src/components/actions/ShSpinner.vue +6 -0
  8. package/src/components/form/ShForm.vue +272 -0
  9. package/src/components/form/ShFormSteps.vue +30 -0
  10. package/src/components/form/inputs/DateInput.vue +29 -0
  11. package/src/components/form/inputs/EmailInput.vue +27 -0
  12. package/src/components/form/inputs/NumberInput.vue +32 -0
  13. package/src/components/form/inputs/PasswordInput.vue +47 -0
  14. package/src/components/form/inputs/PhoneInput.vue +190 -0
  15. package/src/components/form/inputs/SelectInput.vue +50 -0
  16. package/src/components/form/inputs/ShSuggest.vue +198 -0
  17. package/src/components/form/inputs/TextAreaInput.vue +27 -0
  18. package/src/components/form/inputs/TextInput.vue +26 -0
  19. package/src/components/overlay/ShDialog.vue +143 -0
  20. package/src/components/overlay/ShDialogBtn.vue +41 -0
  21. package/src/components/overlay/ShDialogForm.vue +80 -0
  22. package/src/components/overlay/ShDrawer.vue +129 -0
  23. package/src/components/overlay/ShDrawerBtn.vue +40 -0
  24. package/src/components/table/ShTable.vue +472 -0
  25. package/src/components/table/ShTablePagination.vue +96 -0
  26. package/src/composables/useDialog.js +68 -0
  27. package/src/composables/useScrollLock.js +19 -0
  28. package/src/data/countries.js +1474 -0
  29. package/src/index.js +45 -0
  30. package/src/plugin/ShTailwind.js +36 -0
  31. package/src/table/localQuery.js +60 -0
  32. package/src/table/tableCache.js +116 -0
  33. package/src/table/useTableData.js +125 -0
  34. package/src/theme/defaultTheme.js +148 -0
  35. package/src/theme/keys.js +3 -0
  36. package/src/theme/useTheme.js +11 -0
  37. package/src/utils/deepMerge.js +19 -0
  38. package/src/utils/normalizeField.js +61 -0
  39. package/src/utils/normalizeOptions.js +18 -0
  40. package/src/utils/strings.js +11 -0
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@iankibetsh/sh-tailwind",
3
+ "version": "0.1.0",
4
+ "description": "Vue 3 + Tailwind CSS v4 component library for Laravel backends, built on @iankibetsh/sh-core. Forms, dialogs, drawers and action components.",
5
+ "type": "module",
6
+ "main": "./dist/sh-tailwind.cjs.js",
7
+ "module": "./dist/sh-tailwind.es.js",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/sh-tailwind.es.js",
11
+ "require": "./dist/sh-tailwind.cjs.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "src",
17
+ "!src/playground"
18
+ ],
19
+ "scripts": {
20
+ "dev": "vite",
21
+ "build": "vite build",
22
+ "preview": "vite preview"
23
+ },
24
+ "keywords": [
25
+ "vue",
26
+ "tailwind",
27
+ "laravel",
28
+ "forms",
29
+ "dialog"
30
+ ],
31
+ "author": "Iankibet",
32
+ "license": "ISC",
33
+ "peerDependencies": {
34
+ "@iankibetsh/sh-core": "^1",
35
+ "pinia": "^3",
36
+ "vue": "^3.5",
37
+ "vue-router": "^4 || ^5"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "vue-router": {
41
+ "optional": true
42
+ }
43
+ },
44
+ "devDependencies": {
45
+ "@iankibetsh/sh-core": "file:../sh-core",
46
+ "@tailwindcss/vite": "^4",
47
+ "@vitejs/plugin-vue": "^6",
48
+ "pinia": "^3",
49
+ "tailwindcss": "^4",
50
+ "vite": "^8",
51
+ "vue": "^3.5"
52
+ },
53
+ "publishConfig": {
54
+ "access": "public"
55
+ }
56
+ }
@@ -0,0 +1,78 @@
1
+ <script setup>
2
+ import { ref } from 'vue'
3
+ import { shRepo } from '@iankibetsh/sh-core'
4
+ import { useTheme } from '../../theme/useTheme.js'
5
+ import ShSpinner from './ShSpinner.vue'
6
+
7
+ const props = defineProps({
8
+ url: { type: String, required: true },
9
+ data: Object,
10
+ title: String,
11
+ message: String,
12
+ loadingMessage: { type: String, default: 'Processing...' },
13
+ successMessage: { type: String, default: 'Action Successful' },
14
+ failMessage: { type: String, default: 'Action failed' },
15
+ tag: { type: String, default: 'button' },
16
+ btnClass: String
17
+ })
18
+
19
+ const emit = defineEmits([
20
+ 'success', 'failed', 'canceled',
21
+ // legacy-compatible aliases
22
+ 'actionSuccessful', 'actionFailed', 'actionCanceled'
23
+ ])
24
+
25
+ const buttons = useTheme('buttons')
26
+ const processing = ref(false)
27
+
28
+ const actionSuccessful = (res) => {
29
+ processing.value = false
30
+ emit('actionSuccessful', res)
31
+ emit('success', res)
32
+ if (props.successMessage || res?.message) {
33
+ shRepo.showToast(res?.message ?? props.successMessage)
34
+ }
35
+ }
36
+
37
+ const actionFailed = (reason) => {
38
+ processing.value = false
39
+ emit('actionFailed', reason)
40
+ emit('failed', reason)
41
+ if (props.failMessage || reason?.value?.message) {
42
+ shRepo.showToast(reason?.value?.message ?? props.failMessage, 'error')
43
+ }
44
+ }
45
+
46
+ function runAction () {
47
+ processing.value = true
48
+ shRepo.runPlainRequest(props.url, props.message, props.title, props.data).then(res => {
49
+ if (res.isConfirmed) {
50
+ if (res.value?.success) {
51
+ actionSuccessful(res.value.response)
52
+ } else {
53
+ actionFailed(res)
54
+ }
55
+ } else {
56
+ processing.value = false
57
+ emit('actionCanceled')
58
+ emit('canceled')
59
+ }
60
+ }).catch(ex => {
61
+ actionFailed(ex)
62
+ })
63
+ }
64
+ </script>
65
+
66
+ <template>
67
+ <component
68
+ :is="tag"
69
+ :class="[btnClass ?? buttons.link, processing ? 'pointer-events-none opacity-60' : '']"
70
+ @click="runAction"
71
+ >
72
+ <template v-if="processing">
73
+ <ShSpinner class="size-4" />
74
+ <span>{{ loadingMessage }}</span>
75
+ </template>
76
+ <slot v-else />
77
+ </component>
78
+ </template>
@@ -0,0 +1,66 @@
1
+ <script setup>
2
+ import { ref } from 'vue'
3
+ import { shApis, shRepo } from '@iankibetsh/sh-core'
4
+ import { useTheme } from '../../theme/useTheme.js'
5
+ import ShSpinner from './ShSpinner.vue'
6
+
7
+ const props = defineProps({
8
+ url: { type: String, required: true },
9
+ data: Object,
10
+ method: { type: String, default: 'POST' }, // GET | POST | PUT | DELETE
11
+ loadingMessage: { type: String, default: 'Processing' },
12
+ successMessage: { type: String, default: 'Action Successful' },
13
+ failMessage: { type: String, default: 'Action failed' },
14
+ disableSuccessMessage: Boolean,
15
+ tag: { type: String, default: 'button' },
16
+ btnClass: String
17
+ })
18
+
19
+ const emit = defineEmits([
20
+ 'success', 'failed',
21
+ // legacy-compatible aliases
22
+ 'actionSuccessful', 'actionFailed'
23
+ ])
24
+
25
+ const buttons = useTheme('buttons')
26
+ const processing = ref(false)
27
+
28
+ const methods = {
29
+ GET: shApis.doGet,
30
+ POST: shApis.doPost,
31
+ PUT: shApis.doPut,
32
+ DELETE: shApis.doDelete
33
+ }
34
+
35
+ function runAction () {
36
+ processing.value = true
37
+ const send = methods[props.method.toUpperCase()] ?? shApis.doPost
38
+ send(props.url, props.data).then(res => {
39
+ processing.value = false
40
+ emit('actionSuccessful', res)
41
+ emit('success', res)
42
+ if (!props.disableSuccessMessage) {
43
+ shRepo.showToast(res.data?.message ?? props.successMessage)
44
+ }
45
+ }).catch(reason => {
46
+ processing.value = false
47
+ emit('actionFailed', reason)
48
+ emit('failed', reason)
49
+ shRepo.showToast(reason?.message ?? props.failMessage, 'error')
50
+ })
51
+ }
52
+ </script>
53
+
54
+ <template>
55
+ <component
56
+ :is="tag"
57
+ :class="[btnClass ?? buttons.link, processing ? 'pointer-events-none opacity-60' : '']"
58
+ @click="runAction"
59
+ >
60
+ <template v-if="processing">
61
+ <ShSpinner class="size-4" />
62
+ <span>{{ loadingMessage }}</span>
63
+ </template>
64
+ <slot v-else />
65
+ </component>
66
+ </template>
@@ -0,0 +1,6 @@
1
+ <template>
2
+ <svg class="animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
3
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
4
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
5
+ </svg>
6
+ </template>
@@ -0,0 +1,272 @@
1
+ <script setup>
2
+ import { computed, inject, reactive, ref, watch } from 'vue'
3
+ import { shApis, shRepo } from '@iankibetsh/sh-core'
4
+ import { useTheme } from '../../theme/useTheme.js'
5
+ import { SH_TW_COMPONENTS, SH_DIALOG_CONTEXT } from '../../theme/keys.js'
6
+ import { normalizeFields } from '../../utils/normalizeField.js'
7
+ import ShFormSteps from './ShFormSteps.vue'
8
+ import ShSpinner from '../actions/ShSpinner.vue'
9
+ import TextInput from './inputs/TextInput.vue'
10
+ import TextAreaInput from './inputs/TextAreaInput.vue'
11
+ import EmailInput from './inputs/EmailInput.vue'
12
+ import PasswordInput from './inputs/PasswordInput.vue'
13
+ import NumberInput from './inputs/NumberInput.vue'
14
+ import DateInput from './inputs/DateInput.vue'
15
+ import SelectInput from './inputs/SelectInput.vue'
16
+ import PhoneInput from './inputs/PhoneInput.vue'
17
+ import ShSuggest from './inputs/ShSuggest.vue'
18
+
19
+ const props = defineProps({
20
+ action: { type: String, required: true },
21
+ method: { type: String, default: 'post' }, // post | put | patch | delete
22
+ fields: { type: Array, required: true }, // strings or field objects
23
+ currentData: Object, // prefill for edit flows
24
+ steps: Array, // [{ title, fields: ['name', ...] }]
25
+ submitLabel: { type: String, default: 'Submit' },
26
+ successMessage: String,
27
+ retainData: Boolean,
28
+ // (data) => false aborts, object replaces payload, anything else proceeds
29
+ preSubmit: Function,
30
+ hiddenId: { type: Boolean, default: true },
31
+ classes: Object,
32
+ disabled: Boolean
33
+ })
34
+
35
+ const emit = defineEmits([
36
+ 'success', 'error', 'fieldChanged', 'preSubmit',
37
+ // legacy-compatible aliases
38
+ 'formSubmitted', 'formError'
39
+ ])
40
+
41
+ const t = useTheme('form', computed(() => props.classes))
42
+ const inputsTheme = useTheme('inputs')
43
+ const injectedComponents = inject(SH_TW_COMPONENTS, {})
44
+ const dialogContext = inject(SH_DIALOG_CONTEXT, null)
45
+
46
+ const builtins = {
47
+ text: TextInput,
48
+ textarea: TextAreaInput,
49
+ email: EmailInput,
50
+ password: PasswordInput,
51
+ number: NumberInput,
52
+ date: DateInput,
53
+ select: SelectInput,
54
+ phone: PhoneInput,
55
+ suggest: ShSuggest
56
+ }
57
+
58
+ const formFields = ref([])
59
+ const errors = reactive({})
60
+ const submitting = ref(false)
61
+ const currentStep = ref(0)
62
+ const isDirty = ref(false)
63
+
64
+ const buildFields = () => {
65
+ const normalized = normalizeFields(props.fields, props.currentData)
66
+ if (props.hiddenId && props.currentData?.id && !normalized.some(f => f.name === 'id')) {
67
+ normalized.push({ name: 'id', type: 'hidden', value: props.currentData.id })
68
+ }
69
+ formFields.value = normalized
70
+ }
71
+ buildFields()
72
+
73
+ watch(() => props.currentData, () => {
74
+ // don't clobber values the user already edited
75
+ if (!isDirty.value) {
76
+ buildFields()
77
+ }
78
+ }, { deep: true })
79
+
80
+ watch(() => props.fields, buildFields)
81
+
82
+ const formSteps = computed(() => {
83
+ if (!props.steps?.length) {
84
+ return [{ title: '', fields: formFields.value }]
85
+ }
86
+ return props.steps.map(step => ({
87
+ title: step.title ?? '',
88
+ fields: formFields.value.filter(f =>
89
+ (step.fields ?? []).includes(f.name) || f.type === 'hidden'
90
+ )
91
+ }))
92
+ })
93
+
94
+ const isLastStep = computed(() => currentStep.value >= formSteps.value.length - 1)
95
+
96
+ const resolveComponent = (field) =>
97
+ field.component ?? injectedComponents[field.type] ?? builtins[field.type] ?? builtins.text
98
+
99
+ const inputClass = (field) => {
100
+ if (errors[field.name]) {
101
+ return t.value.inputInvalid
102
+ }
103
+ return field.type === 'select' ? inputsTheme.value.select : t.value.input
104
+ }
105
+
106
+ const inputProps = (field) => ({
107
+ placeholder: field.placeholder || undefined,
108
+ disabled: props.disabled || field.disabled,
109
+ ...(field.type === 'select' || field.type === 'suggest'
110
+ ? {
111
+ options: Array.isArray(field.options) ? field.options : undefined,
112
+ url: Array.isArray(field.options) ? undefined : field.options?.url,
113
+ multiple: field.multiple,
114
+ allowCustom: field.allowCustom,
115
+ optionTemplate: field.optionTemplate
116
+ }
117
+ : {}),
118
+ ...(field.type === 'number' ? { min: field.min, max: field.max, step: field.step } : {}),
119
+ ...(field.type === 'textarea' ? { rows: field.rows } : {}),
120
+ ...(field.type === 'date' ? { withTime: field.withTime, min: field.min, max: field.max } : {}),
121
+ ...(field.type === 'phone' ? { countryCode: field.countryCode, detectCountry: field.detectCountry } : {}),
122
+ ...(field.props ?? {})
123
+ })
124
+
125
+ const fieldChanged = (field, value) => {
126
+ isDirty.value = true
127
+ delete errors[field.name]
128
+ const data = collectData()
129
+ emit('fieldChanged', field.name, value, data)
130
+ }
131
+
132
+ const collectData = () => {
133
+ const data = {}
134
+ formFields.value.forEach(field => {
135
+ if (field.value !== null && typeof field.value !== 'undefined') {
136
+ data[field.name] = field.value
137
+ }
138
+ })
139
+ return data
140
+ }
141
+
142
+ const clearValues = () => {
143
+ formFields.value.forEach(field => {
144
+ if (field.type !== 'hidden') {
145
+ field.value = null
146
+ }
147
+ })
148
+ isDirty.value = false
149
+ }
150
+
151
+ const goToErrorStep = () => {
152
+ const errored = Object.keys(errors)
153
+ if (!errored.length || !props.steps?.length) {
154
+ return
155
+ }
156
+ const stepIndex = formSteps.value.findIndex(step =>
157
+ step.fields.some(f => errored.includes(f.name))
158
+ )
159
+ if (stepIndex >= 0) {
160
+ currentStep.value = stepIndex
161
+ }
162
+ }
163
+
164
+ const submitForm = async () => {
165
+ if (!isLastStep.value) {
166
+ currentStep.value++
167
+ return
168
+ }
169
+ Object.keys(errors).forEach(key => delete errors[key])
170
+ let data = collectData()
171
+
172
+ if (props.preSubmit) {
173
+ const result = await props.preSubmit(data)
174
+ if (result === false) {
175
+ return
176
+ }
177
+ if (result && typeof result === 'object') {
178
+ data = result
179
+ }
180
+ }
181
+ emit('preSubmit', data)
182
+
183
+ const methods = {
184
+ post: shApis.doPost,
185
+ put: shApis.doPut,
186
+ patch: shApis.doPatch,
187
+ delete: shApis.doDelete
188
+ }
189
+ const send = methods[props.method.toLowerCase()] ?? shApis.doPost
190
+
191
+ submitting.value = true
192
+ try {
193
+ const res = await send(props.action, data)
194
+ if (props.successMessage) {
195
+ shRepo.showToast(props.successMessage)
196
+ }
197
+ emit('success', res.data)
198
+ emit('formSubmitted', res.data)
199
+ if (!props.retainData) {
200
+ clearValues()
201
+ currentStep.value = 0
202
+ }
203
+ dialogContext?.requestClose?.('success')
204
+ } catch (reason) {
205
+ if (reason.response?.status === 422) {
206
+ const serverErrors = reason.response.data?.errors ?? {}
207
+ Object.keys(serverErrors).forEach(key => {
208
+ const value = serverErrors[key]
209
+ errors[key] = Array.isArray(value) ? value[0] : value
210
+ })
211
+ goToErrorStep()
212
+ } else {
213
+ shRepo.showToast(reason.response?.data?.message ?? reason.message ?? 'Request failed', 'error')
214
+ }
215
+ emit('error', reason)
216
+ emit('formError', reason)
217
+ } finally {
218
+ submitting.value = false
219
+ }
220
+ }
221
+ </script>
222
+
223
+ <template>
224
+ <form :class="t.form" @submit.prevent="submitForm">
225
+ <ShFormSteps v-if="formSteps.length > 1" :steps="formSteps" :current="currentStep" />
226
+
227
+ <template v-for="(step, stepIndex) in formSteps" :key="stepIndex">
228
+ <div v-show="currentStep === stepIndex" class="space-y-4">
229
+ <div v-for="field in step.fields" :key="field.name" :class="field.type === 'hidden' ? '' : t.group">
230
+ <input v-if="field.type === 'hidden'" v-model="field.value" type="hidden">
231
+ <template v-else>
232
+ <label v-if="field.label" :class="t.label">
233
+ {{ field.label }} <span v-if="field.required" :class="t.required">*</span>
234
+ </label>
235
+ <component
236
+ :is="resolveComponent(field)"
237
+ v-model="field.value"
238
+ v-bind="inputProps(field)"
239
+ :is-invalid="!!errors[field.name]"
240
+ :class="[inputClass(field), field.class]"
241
+ @update:model-value="value => fieldChanged(field, value)"
242
+ @clear-validation-errors="delete errors[field.name]"
243
+ />
244
+ <p v-if="field.helper" :class="t.helper" v-html="field.helper" />
245
+ <p v-if="errors[field.name]" :class="t.error">{{ errors[field.name] }}</p>
246
+ </template>
247
+ </div>
248
+ </div>
249
+ </template>
250
+
251
+ <slot />
252
+
253
+ <div :class="t.nav">
254
+ <button
255
+ v-if="currentStep > 0"
256
+ type="button"
257
+ :class="t.prevBtn"
258
+ @click="currentStep--"
259
+ >
260
+ Back
261
+ </button>
262
+ <button
263
+ type="submit"
264
+ :class="isLastStep ? t.submitBtn : t.nextBtn"
265
+ :disabled="submitting || disabled"
266
+ >
267
+ <ShSpinner v-if="submitting" class="size-4" />
268
+ {{ isLastStep ? submitLabel : 'Next' }}
269
+ </button>
270
+ </div>
271
+ </form>
272
+ </template>
@@ -0,0 +1,30 @@
1
+ <script setup>
2
+ import { useTheme } from '../../theme/useTheme.js'
3
+
4
+ defineProps({
5
+ steps: { type: Array, required: true }, // [{ title }]
6
+ current: { type: Number, default: 0 }
7
+ })
8
+
9
+ const theme = useTheme('form')
10
+ </script>
11
+
12
+ <template>
13
+ <div :class="theme.steps.wrapper">
14
+ <div v-for="(step, index) in steps" :key="index" :class="theme.steps.step">
15
+ <div
16
+ v-if="index > 0"
17
+ :class="index <= current ? theme.steps.connectorDone : theme.steps.connector"
18
+ />
19
+ <div
20
+ :class="index < current ? theme.steps.circleDone : (index === current ? theme.steps.circleActive : theme.steps.circle)"
21
+ >
22
+ <svg v-if="index < current" xmlns="http://www.w3.org/2000/svg" class="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
23
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
24
+ </svg>
25
+ <template v-else>{{ index + 1 }}</template>
26
+ </div>
27
+ <span :class="index === current ? theme.steps.titleActive : theme.steps.title">{{ step.title }}</span>
28
+ </div>
29
+ </div>
30
+ </template>
@@ -0,0 +1,29 @@
1
+ <script setup>
2
+ import { computed } from 'vue'
3
+
4
+ const props = defineProps({
5
+ modelValue: String,
6
+ isInvalid: Boolean,
7
+ disabled: Boolean,
8
+ withTime: Boolean,
9
+ min: String,
10
+ max: String
11
+ })
12
+ const emit = defineEmits(['update:modelValue', 'clearValidationErrors'])
13
+
14
+ const model = computed({
15
+ get: () => props.modelValue,
16
+ set: (value) => emit('update:modelValue', value)
17
+ })
18
+ </script>
19
+
20
+ <template>
21
+ <input
22
+ v-model="model"
23
+ :type="withTime ? 'datetime-local' : 'date'"
24
+ :min="min"
25
+ :max="max"
26
+ :disabled="disabled"
27
+ @focus="emit('clearValidationErrors')"
28
+ >
29
+ </template>
@@ -0,0 +1,27 @@
1
+ <script setup>
2
+ import { computed } from 'vue'
3
+
4
+ const props = defineProps({
5
+ modelValue: String,
6
+ placeholder: String,
7
+ isInvalid: Boolean,
8
+ disabled: Boolean
9
+ })
10
+ const emit = defineEmits(['update:modelValue', 'clearValidationErrors'])
11
+
12
+ const model = computed({
13
+ get: () => props.modelValue,
14
+ set: (value) => emit('update:modelValue', value)
15
+ })
16
+ </script>
17
+
18
+ <template>
19
+ <input
20
+ v-model="model"
21
+ type="email"
22
+ autocomplete="email"
23
+ :placeholder="placeholder"
24
+ :disabled="disabled"
25
+ @focus="emit('clearValidationErrors')"
26
+ >
27
+ </template>
@@ -0,0 +1,32 @@
1
+ <script setup>
2
+ import { computed } from 'vue'
3
+
4
+ const props = defineProps({
5
+ modelValue: [String, Number],
6
+ placeholder: String,
7
+ isInvalid: Boolean,
8
+ disabled: Boolean,
9
+ min: [Number, String],
10
+ max: [Number, String],
11
+ step: [Number, String]
12
+ })
13
+ const emit = defineEmits(['update:modelValue', 'clearValidationErrors'])
14
+
15
+ const model = computed({
16
+ get: () => props.modelValue,
17
+ set: (value) => emit('update:modelValue', value)
18
+ })
19
+ </script>
20
+
21
+ <template>
22
+ <input
23
+ v-model="model"
24
+ type="number"
25
+ :min="min"
26
+ :max="max"
27
+ :step="step"
28
+ :placeholder="placeholder"
29
+ :disabled="disabled"
30
+ @focus="emit('clearValidationErrors')"
31
+ >
32
+ </template>
@@ -0,0 +1,47 @@
1
+ <script setup>
2
+ import { computed, ref } from 'vue'
3
+ import { useTheme } from '../../../theme/useTheme.js'
4
+
5
+ defineOptions({ inheritAttrs: false })
6
+
7
+ const props = defineProps({
8
+ modelValue: String,
9
+ placeholder: String,
10
+ isInvalid: Boolean,
11
+ disabled: Boolean,
12
+ autocomplete: { type: String, default: 'current-password' }
13
+ })
14
+ const emit = defineEmits(['update:modelValue', 'clearValidationErrors'])
15
+
16
+ const t = useTheme('inputs')
17
+ const visible = ref(false)
18
+
19
+ const model = computed({
20
+ get: () => props.modelValue,
21
+ set: (value) => emit('update:modelValue', value)
22
+ })
23
+ </script>
24
+
25
+ <template>
26
+ <div :class="t.passwordWrapper">
27
+ <input
28
+ v-bind="$attrs"
29
+ v-model="model"
30
+ :type="visible ? 'text' : 'password'"
31
+ :autocomplete="autocomplete"
32
+ :placeholder="placeholder"
33
+ :disabled="disabled"
34
+ class="pr-9"
35
+ @focus="emit('clearValidationErrors')"
36
+ >
37
+ <button type="button" :class="t.passwordToggle" tabindex="-1" @click="visible = !visible">
38
+ <svg v-if="!visible" xmlns="http://www.w3.org/2000/svg" class="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
39
+ <path stroke-linecap="round" stroke-linejoin="round" d="M2.04 12.32a1.01 1.01 0 010-.64C3.42 7.51 7.36 4.5 12 4.5c4.64 0 8.57 3.01 9.96 7.18.07.21.07.43 0 .64C20.58 16.49 16.64 19.5 12 19.5c-4.64 0-8.57-3.01-9.96-7.18z"/>
40
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
41
+ </svg>
42
+ <svg v-else xmlns="http://www.w3.org/2000/svg" class="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
43
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.22A10.48 10.48 0 002.04 11.68a1.01 1.01 0 000 .64C3.42 16.49 7.36 19.5 12 19.5c.99 0 1.95-.14 2.86-.39M6.23 6.23A10.45 10.45 0 0112 4.5c4.64 0 8.57 3.01 9.96 7.18.07.21.07.43 0 .64a10.5 10.5 0 01-4.19 5.45M6.23 6.23L3 3m3.23 3.23l3.65 3.65m7.89 7.89L21 21m-3.23-3.23l-3.65-3.65m0 0a3 3 0 10-4.24-4.24m4.24 4.24L9.88 9.88"/>
44
+ </svg>
45
+ </button>
46
+ </div>
47
+ </template>