@iankibetsh/sh-tailwind 0.1.0 → 0.1.2
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.
- package/README.md +253 -55
- package/dist/sh-tailwind.cjs.js +1 -1
- package/dist/sh-tailwind.es.js +371 -171
- package/package.json +3 -2
- package/src/components/form/ShForm.vue +13 -2
- package/src/components/form/inputs/MaskedInput.vue +61 -0
- package/src/components/form/inputs/PinInput.vue +125 -0
- package/src/components/table/ShTable.vue +1 -1
- package/src/index.js +3 -0
- package/src/theme/defaultTheme.js +58 -52
- package/src/utils/mask.js +103 -0
- package/src/utils/normalizeField.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iankibetsh/sh-tailwind",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Vue 3 + Tailwind CSS v4 component library for Laravel backends, built on @iankibetsh/sh-core. Forms, dialogs, drawers and action components.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/sh-tailwind.cjs.js",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"scripts": {
|
|
20
20
|
"dev": "vite",
|
|
21
21
|
"build": "vite build",
|
|
22
|
-
"preview": "vite preview"
|
|
22
|
+
"preview": "vite preview",
|
|
23
|
+
"prepublishOnly": "vite build"
|
|
23
24
|
},
|
|
24
25
|
"keywords": [
|
|
25
26
|
"vue",
|
|
@@ -10,6 +10,8 @@ import TextInput from './inputs/TextInput.vue'
|
|
|
10
10
|
import TextAreaInput from './inputs/TextAreaInput.vue'
|
|
11
11
|
import EmailInput from './inputs/EmailInput.vue'
|
|
12
12
|
import PasswordInput from './inputs/PasswordInput.vue'
|
|
13
|
+
import PinInput from './inputs/PinInput.vue'
|
|
14
|
+
import MaskedInput from './inputs/MaskedInput.vue'
|
|
13
15
|
import NumberInput from './inputs/NumberInput.vue'
|
|
14
16
|
import DateInput from './inputs/DateInput.vue'
|
|
15
17
|
import SelectInput from './inputs/SelectInput.vue'
|
|
@@ -48,6 +50,7 @@ const builtins = {
|
|
|
48
50
|
textarea: TextAreaInput,
|
|
49
51
|
email: EmailInput,
|
|
50
52
|
password: PasswordInput,
|
|
53
|
+
pin: PinInput,
|
|
51
54
|
number: NumberInput,
|
|
52
55
|
date: DateInput,
|
|
53
56
|
select: SelectInput,
|
|
@@ -93,8 +96,14 @@ const formSteps = computed(() => {
|
|
|
93
96
|
|
|
94
97
|
const isLastStep = computed(() => currentStep.value >= formSteps.value.length - 1)
|
|
95
98
|
|
|
96
|
-
const resolveComponent = (field) =>
|
|
97
|
-
|
|
99
|
+
const resolveComponent = (field) => {
|
|
100
|
+
// a `mask` (string/object/function) auto-formats via MaskedInput,
|
|
101
|
+
// except for pins (their `secret` flag is unrelated to formatting)
|
|
102
|
+
if (field.mask && field.type !== 'pin') {
|
|
103
|
+
return MaskedInput
|
|
104
|
+
}
|
|
105
|
+
return field.component ?? injectedComponents[field.type] ?? builtins[field.type] ?? builtins.text
|
|
106
|
+
}
|
|
98
107
|
|
|
99
108
|
const inputClass = (field) => {
|
|
100
109
|
if (errors[field.name]) {
|
|
@@ -119,6 +128,8 @@ const inputProps = (field) => ({
|
|
|
119
128
|
...(field.type === 'textarea' ? { rows: field.rows } : {}),
|
|
120
129
|
...(field.type === 'date' ? { withTime: field.withTime, min: field.min, max: field.max } : {}),
|
|
121
130
|
...(field.type === 'phone' ? { countryCode: field.countryCode, detectCountry: field.detectCountry } : {}),
|
|
131
|
+
...(field.type === 'pin' ? { length: field.length ?? field.digits, secret: field.secret } : {}),
|
|
132
|
+
...(field.mask && field.type !== 'pin' ? { mask: field.mask } : {}),
|
|
122
133
|
...(field.props ?? {})
|
|
123
134
|
})
|
|
124
135
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { nextTick, ref, watch } from 'vue'
|
|
3
|
+
import { applyMask, maskInputMode } from '../../../utils/mask.js'
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
modelValue: [String, Number],
|
|
7
|
+
// mask spec: named ('money'|'integer'|'decimal'), pattern string,
|
|
8
|
+
// options object, or a (value) => string function
|
|
9
|
+
mask: { type: [String, Object, Function], required: true },
|
|
10
|
+
placeholder: String,
|
|
11
|
+
isInvalid: Boolean,
|
|
12
|
+
disabled: Boolean
|
|
13
|
+
})
|
|
14
|
+
const emit = defineEmits(['update:modelValue', 'clearValidationErrors'])
|
|
15
|
+
|
|
16
|
+
const display = ref('')
|
|
17
|
+
|
|
18
|
+
const sync = (value) => {
|
|
19
|
+
const { display: formatted } = applyMask(value, props.mask)
|
|
20
|
+
if (formatted !== display.value) {
|
|
21
|
+
display.value = formatted
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
watch(() => props.modelValue, sync, { immediate: true })
|
|
25
|
+
watch(() => props.mask, () => sync(props.modelValue))
|
|
26
|
+
|
|
27
|
+
const onInput = (event) => {
|
|
28
|
+
emit('clearValidationErrors')
|
|
29
|
+
const el = event.target
|
|
30
|
+
const prevCaret = el.selectionStart ?? el.value.length
|
|
31
|
+
const prevLen = el.value.length
|
|
32
|
+
const { display: formatted, model } = applyMask(el.value, props.mask)
|
|
33
|
+
display.value = formatted
|
|
34
|
+
emit('update:modelValue', model)
|
|
35
|
+
// keep the caret stable relative to the end as separators are inserted
|
|
36
|
+
nextTick(() => {
|
|
37
|
+
if (el.type !== 'text' && el.type !== 'tel') {
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
let pos = prevCaret + (formatted.length - prevLen)
|
|
41
|
+
pos = Math.max(0, Math.min(pos, formatted.length))
|
|
42
|
+
try {
|
|
43
|
+
el.setSelectionRange(pos, pos)
|
|
44
|
+
} catch (err) {
|
|
45
|
+
// some input types don't support selection range; ignore
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<template>
|
|
52
|
+
<input
|
|
53
|
+
:value="display"
|
|
54
|
+
type="text"
|
|
55
|
+
:inputmode="maskInputMode(mask)"
|
|
56
|
+
:placeholder="placeholder"
|
|
57
|
+
:disabled="disabled"
|
|
58
|
+
@input="onInput"
|
|
59
|
+
@focus="emit('clearValidationErrors')"
|
|
60
|
+
>
|
|
61
|
+
</template>
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, nextTick, ref, watch } from 'vue'
|
|
3
|
+
import { useTheme } from '../../../theme/useTheme.js'
|
|
4
|
+
|
|
5
|
+
defineOptions({ inheritAttrs: false })
|
|
6
|
+
|
|
7
|
+
const props = defineProps({
|
|
8
|
+
modelValue: [String, Number],
|
|
9
|
+
// number of digit boxes
|
|
10
|
+
length: { type: [Number, String], default: 4 },
|
|
11
|
+
// render entries as dots (secret PIN) vs. visible (OTP code)
|
|
12
|
+
secret: Boolean,
|
|
13
|
+
isInvalid: Boolean,
|
|
14
|
+
disabled: Boolean
|
|
15
|
+
})
|
|
16
|
+
const emit = defineEmits(['update:modelValue', 'clearValidationErrors', 'complete'])
|
|
17
|
+
|
|
18
|
+
const t = useTheme('inputs')
|
|
19
|
+
const count = computed(() => Math.max(1, parseInt(props.length) || 4))
|
|
20
|
+
const boxes = ref([]) // input element refs
|
|
21
|
+
const digits = ref([])
|
|
22
|
+
|
|
23
|
+
const seed = (value) => {
|
|
24
|
+
const chars = String(value ?? '').replace(/\D/g, '').slice(0, count.value).split('')
|
|
25
|
+
digits.value = Array.from({ length: count.value }, (_, i) => chars[i] ?? '')
|
|
26
|
+
}
|
|
27
|
+
seed(props.modelValue)
|
|
28
|
+
|
|
29
|
+
// re-seed when the count changes or the model is set externally
|
|
30
|
+
watch(count, () => seed(props.modelValue))
|
|
31
|
+
watch(() => props.modelValue, (value) => {
|
|
32
|
+
if (value !== digits.value.join('')) {
|
|
33
|
+
seed(value)
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const emitValue = () => {
|
|
38
|
+
const value = digits.value.join('')
|
|
39
|
+
emit('update:modelValue', value)
|
|
40
|
+
if (value.length === count.value) {
|
|
41
|
+
emit('complete', value)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const focusBox = (index) => {
|
|
46
|
+
nextTick(() => {
|
|
47
|
+
const el = boxes.value[index]
|
|
48
|
+
el?.focus()
|
|
49
|
+
el?.select()
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const onInput = (index, event) => {
|
|
54
|
+
emit('clearValidationErrors')
|
|
55
|
+
const raw = event.target.value.replace(/\D/g, '')
|
|
56
|
+
if (!raw) {
|
|
57
|
+
digits.value[index] = ''
|
|
58
|
+
emitValue()
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
// take the last typed digit; advance through remaining boxes if pasted
|
|
62
|
+
const chars = raw.split('')
|
|
63
|
+
let i = index
|
|
64
|
+
for (const char of chars) {
|
|
65
|
+
if (i >= count.value) break
|
|
66
|
+
digits.value[i] = char
|
|
67
|
+
i++
|
|
68
|
+
}
|
|
69
|
+
emitValue()
|
|
70
|
+
focusBox(Math.min(i, count.value - 1))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const onKeydown = (index, event) => {
|
|
74
|
+
if (event.key === 'Backspace') {
|
|
75
|
+
event.preventDefault()
|
|
76
|
+
if (digits.value[index]) {
|
|
77
|
+
digits.value[index] = ''
|
|
78
|
+
} else if (index > 0) {
|
|
79
|
+
digits.value[index - 1] = ''
|
|
80
|
+
focusBox(index - 1)
|
|
81
|
+
}
|
|
82
|
+
emitValue()
|
|
83
|
+
} else if (event.key === 'ArrowLeft' && index > 0) {
|
|
84
|
+
focusBox(index - 1)
|
|
85
|
+
} else if (event.key === 'ArrowRight' && index < count.value - 1) {
|
|
86
|
+
focusBox(index + 1)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const onPaste = (event) => {
|
|
91
|
+
event.preventDefault()
|
|
92
|
+
const text = (event.clipboardData?.getData('text') ?? '').replace(/\D/g, '').slice(0, count.value)
|
|
93
|
+
seed(text)
|
|
94
|
+
emitValue()
|
|
95
|
+
focusBox(Math.min(text.length, count.value - 1))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const boxClass = (index) => {
|
|
99
|
+
if (props.isInvalid) {
|
|
100
|
+
return t.value.pin.boxInvalid
|
|
101
|
+
}
|
|
102
|
+
return digits.value[index] ? t.value.pin.boxFilled : t.value.pin.box
|
|
103
|
+
}
|
|
104
|
+
</script>
|
|
105
|
+
|
|
106
|
+
<template>
|
|
107
|
+
<div :class="t.pin.wrapper">
|
|
108
|
+
<input
|
|
109
|
+
v-for="(digit, index) in digits"
|
|
110
|
+
:key="index"
|
|
111
|
+
:ref="el => (boxes[index] = el)"
|
|
112
|
+
:value="digit"
|
|
113
|
+
:type="secret ? 'password' : 'text'"
|
|
114
|
+
inputmode="numeric"
|
|
115
|
+
autocomplete="one-time-code"
|
|
116
|
+
maxlength="1"
|
|
117
|
+
:disabled="disabled"
|
|
118
|
+
:class="boxClass(index)"
|
|
119
|
+
@input="onInput(index, $event)"
|
|
120
|
+
@keydown="onKeydown(index, $event)"
|
|
121
|
+
@paste="onPaste"
|
|
122
|
+
@focus="$event.target.select(); emit('clearValidationErrors')"
|
|
123
|
+
>
|
|
124
|
+
</div>
|
|
125
|
+
</template>
|
|
@@ -451,7 +451,7 @@ defineExpose({ reload: () => reloadData(), records })
|
|
|
451
451
|
</template>
|
|
452
452
|
|
|
453
453
|
<div v-if="selected.length && activeMultiActions.length" :class="t.multiBar">
|
|
454
|
-
<div class="text-sm text-gray-700
|
|
454
|
+
<div class="text-sm text-gray-700">
|
|
455
455
|
<span :class="t.multiCount">{{ selected.length }}</span>
|
|
456
456
|
selected
|
|
457
457
|
</div>
|
package/src/index.js
CHANGED
|
@@ -35,6 +35,9 @@ export { default as TextInput } from './components/form/inputs/TextInput.vue'
|
|
|
35
35
|
export { default as TextAreaInput } from './components/form/inputs/TextAreaInput.vue'
|
|
36
36
|
export { default as EmailInput } from './components/form/inputs/EmailInput.vue'
|
|
37
37
|
export { default as PasswordInput } from './components/form/inputs/PasswordInput.vue'
|
|
38
|
+
export { default as PinInput } from './components/form/inputs/PinInput.vue'
|
|
39
|
+
export { default as MaskedInput } from './components/form/inputs/MaskedInput.vue'
|
|
40
|
+
export { applyMask, maskMoney, maskPattern } from './utils/mask.js'
|
|
38
41
|
export { default as NumberInput } from './components/form/inputs/NumberInput.vue'
|
|
39
42
|
export { default as DateInput } from './components/form/inputs/DateInput.vue'
|
|
40
43
|
export { default as SelectInput } from './components/form/inputs/SelectInput.vue'
|
|
@@ -4,55 +4,61 @@ export const defaultTheme = {
|
|
|
4
4
|
form: {
|
|
5
5
|
form: 'space-y-4',
|
|
6
6
|
group: 'space-y-1',
|
|
7
|
-
label: 'block text-sm font-medium text-gray-700
|
|
7
|
+
label: 'block text-sm font-medium text-gray-700',
|
|
8
8
|
required: 'text-red-500',
|
|
9
|
-
input: 'block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/30 disabled:cursor-not-allowed disabled:opacity-50
|
|
10
|
-
inputInvalid: 'block w-full rounded-md border border-red-500 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/30
|
|
11
|
-
helper: 'text-xs text-gray-500
|
|
9
|
+
input: 'block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/30 disabled:cursor-not-allowed disabled:opacity-50',
|
|
10
|
+
inputInvalid: 'block w-full rounded-md border border-red-500 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/30',
|
|
11
|
+
helper: 'text-xs text-gray-500',
|
|
12
12
|
error: 'text-xs text-red-600',
|
|
13
|
-
errorTitle: 'rounded-md bg-red-50 px-4 py-3 text-sm text-red-700
|
|
13
|
+
errorTitle: 'rounded-md bg-red-50 px-4 py-3 text-sm text-red-700',
|
|
14
14
|
nav: 'flex items-center justify-end gap-3 pt-2',
|
|
15
15
|
submitBtn: 'inline-flex items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500/40 disabled:cursor-not-allowed disabled:opacity-60',
|
|
16
|
-
prevBtn: 'inline-flex items-center justify-center gap-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-300
|
|
16
|
+
prevBtn: 'inline-flex items-center justify-center gap-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-300',
|
|
17
17
|
nextBtn: 'inline-flex items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500/40',
|
|
18
18
|
steps: {
|
|
19
19
|
wrapper: 'mb-6 flex items-start',
|
|
20
20
|
step: 'relative flex flex-1 flex-col items-center gap-1',
|
|
21
|
-
circle: 'z-10 flex size-9 items-center justify-center rounded-full border-2 border-gray-300 bg-white text-sm font-semibold text-gray-500
|
|
21
|
+
circle: 'z-10 flex size-9 items-center justify-center rounded-full border-2 border-gray-300 bg-white text-sm font-semibold text-gray-500',
|
|
22
22
|
circleActive: 'z-10 flex size-9 items-center justify-center rounded-full border-2 border-blue-600 bg-blue-600 text-sm font-semibold text-white',
|
|
23
23
|
circleDone: 'z-10 flex size-9 items-center justify-center rounded-full border-2 border-emerald-500 bg-emerald-500 text-sm font-semibold text-white',
|
|
24
|
-
title: 'text-xs text-gray-600
|
|
24
|
+
title: 'text-xs text-gray-600',
|
|
25
25
|
titleActive: 'text-xs font-semibold text-blue-600',
|
|
26
|
-
connector: 'absolute top-4 right-1/2 -z-0 h-0.5 w-full bg-gray-200
|
|
26
|
+
connector: 'absolute top-4 right-1/2 -z-0 h-0.5 w-full bg-gray-200',
|
|
27
27
|
connectorDone: 'absolute top-4 right-1/2 -z-0 h-0.5 w-full bg-emerald-500'
|
|
28
28
|
}
|
|
29
29
|
},
|
|
30
30
|
inputs: {
|
|
31
|
-
select: 'block w-full appearance-none rounded-md border border-gray-300 bg-white px-3 py-2 pr-8 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/30 disabled:opacity-50
|
|
31
|
+
select: 'block w-full appearance-none rounded-md border border-gray-300 bg-white px-3 py-2 pr-8 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/30 disabled:opacity-50',
|
|
32
32
|
passwordWrapper: 'relative',
|
|
33
|
-
passwordToggle: 'absolute inset-y-0 right-2 flex items-center text-gray-400 hover:text-gray-600
|
|
33
|
+
passwordToggle: 'absolute inset-y-0 right-2 flex items-center text-gray-400 hover:text-gray-600',
|
|
34
|
+
pin: {
|
|
35
|
+
wrapper: 'flex items-center gap-2',
|
|
36
|
+
box: 'size-11 rounded-md border border-gray-300 bg-white text-center text-lg font-semibold text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/30 disabled:cursor-not-allowed disabled:opacity-50',
|
|
37
|
+
boxFilled: 'size-11 rounded-md border border-blue-400 bg-blue-50 text-center text-lg font-semibold text-gray-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/30',
|
|
38
|
+
boxInvalid: 'size-11 rounded-md border border-red-500 bg-white text-center text-lg font-semibold text-gray-900 shadow-sm focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/30'
|
|
39
|
+
},
|
|
34
40
|
suggest: {
|
|
35
41
|
wrapper: 'relative',
|
|
36
42
|
badges: 'mb-1 flex flex-wrap gap-1',
|
|
37
|
-
badge: 'inline-flex items-center gap-1 rounded-full bg-gray-200 px-2 py-0.5 text-xs text-gray-700
|
|
43
|
+
badge: 'inline-flex items-center gap-1 rounded-full bg-gray-200 px-2 py-0.5 text-xs text-gray-700',
|
|
38
44
|
badgeRemove: 'cursor-pointer text-gray-500 hover:text-red-600',
|
|
39
|
-
dropdown: 'absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg
|
|
40
|
-
option: 'cursor-pointer px-3 py-2 text-sm text-gray-700 hover:bg-gray-100
|
|
41
|
-
optionActive: 'cursor-pointer bg-blue-50 px-3 py-2 text-sm text-blue-700
|
|
45
|
+
dropdown: 'absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg',
|
|
46
|
+
option: 'cursor-pointer px-3 py-2 text-sm text-gray-700 hover:bg-gray-100',
|
|
47
|
+
optionActive: 'cursor-pointer bg-blue-50 px-3 py-2 text-sm text-blue-700',
|
|
42
48
|
empty: 'px-3 py-2 text-sm text-gray-400'
|
|
43
49
|
},
|
|
44
50
|
phone: {
|
|
45
|
-
wrapper: 'relative flex items-stretch rounded-md border border-gray-300 bg-white shadow-sm focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-500/30
|
|
46
|
-
trigger: 'flex shrink-0 cursor-pointer items-center gap-1.5 rounded-l-md border-r border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 focus:outline-none
|
|
51
|
+
wrapper: 'relative flex items-stretch rounded-md border border-gray-300 bg-white shadow-sm focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-500/30',
|
|
52
|
+
trigger: 'flex shrink-0 cursor-pointer items-center gap-1.5 rounded-l-md border-r border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 focus:outline-none',
|
|
47
53
|
flag: 'text-base leading-none',
|
|
48
|
-
dial: 'text-sm font-medium text-gray-600
|
|
54
|
+
dial: 'text-sm font-medium text-gray-600',
|
|
49
55
|
chevron: 'size-3.5 text-gray-400',
|
|
50
|
-
input: 'block w-full rounded-r-md border-0 bg-transparent px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-0
|
|
51
|
-
dropdown: 'absolute left-0 top-full z-20 mt-1 w-72 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg
|
|
52
|
-
search: 'block w-full border-0 border-b border-gray-100 bg-transparent px-3 py-2.5 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-0
|
|
56
|
+
input: 'block w-full rounded-r-md border-0 bg-transparent px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-0',
|
|
57
|
+
dropdown: 'absolute left-0 top-full z-20 mt-1 w-72 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg',
|
|
58
|
+
search: 'block w-full border-0 border-b border-gray-100 bg-transparent px-3 py-2.5 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-0',
|
|
53
59
|
list: 'max-h-60 overflow-y-auto py-1',
|
|
54
|
-
option: 'flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100
|
|
55
|
-
optionActive: 'flex w-full cursor-pointer items-center gap-2.5 bg-blue-50 px-3 py-2 text-left text-sm text-blue-700
|
|
60
|
+
option: 'flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100',
|
|
61
|
+
optionActive: 'flex w-full cursor-pointer items-center gap-2.5 bg-blue-50 px-3 py-2 text-left text-sm text-blue-700',
|
|
56
62
|
optionName: 'flex-1 truncate',
|
|
57
63
|
optionDial: 'text-xs text-gray-400',
|
|
58
64
|
empty: 'px-3 py-3 text-center text-sm text-gray-400'
|
|
@@ -61,12 +67,12 @@ export const defaultTheme = {
|
|
|
61
67
|
dialog: {
|
|
62
68
|
backdrop: 'fixed inset-0 bg-black/50',
|
|
63
69
|
wrapper: 'fixed inset-0 flex items-center justify-center overflow-y-auto p-4',
|
|
64
|
-
panel: 'relative flex max-h-[90vh] w-full flex-col rounded-xl bg-white shadow-xl outline-none
|
|
65
|
-
header: 'flex items-center justify-between border-b border-gray-100 px-5 py-3.5
|
|
66
|
-
title: 'text-base font-semibold text-gray-900
|
|
67
|
-
closeBtn: 'rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 focus:outline-none
|
|
70
|
+
panel: 'relative flex max-h-[90vh] w-full flex-col rounded-xl bg-white shadow-xl outline-none',
|
|
71
|
+
header: 'flex items-center justify-between border-b border-gray-100 px-5 py-3.5',
|
|
72
|
+
title: 'text-base font-semibold text-gray-900',
|
|
73
|
+
closeBtn: 'rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 focus:outline-none',
|
|
68
74
|
body: 'overflow-y-auto px-5 py-4',
|
|
69
|
-
footer: 'flex justify-end gap-2 border-t border-gray-100 px-5 py-3
|
|
75
|
+
footer: 'flex justify-end gap-2 border-t border-gray-100 px-5 py-3',
|
|
70
76
|
sizes: {
|
|
71
77
|
sm: 'max-w-sm',
|
|
72
78
|
md: 'max-w-lg',
|
|
@@ -77,10 +83,10 @@ export const defaultTheme = {
|
|
|
77
83
|
},
|
|
78
84
|
drawer: {
|
|
79
85
|
backdrop: 'fixed inset-0 bg-black/50',
|
|
80
|
-
panel: 'fixed flex flex-col bg-white shadow-xl outline-none
|
|
81
|
-
header: 'flex items-center justify-between border-b border-gray-100 px-5 py-3.5
|
|
82
|
-
title: 'text-base font-semibold text-gray-900
|
|
83
|
-
closeBtn: 'rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 focus:outline-none
|
|
86
|
+
panel: 'fixed flex flex-col bg-white shadow-xl outline-none',
|
|
87
|
+
header: 'flex items-center justify-between border-b border-gray-100 px-5 py-3.5',
|
|
88
|
+
title: 'text-base font-semibold text-gray-900',
|
|
89
|
+
closeBtn: 'rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 focus:outline-none',
|
|
84
90
|
body: 'flex-1 overflow-y-auto px-5 py-4',
|
|
85
91
|
sizes: {
|
|
86
92
|
sm: 'max-w-xs',
|
|
@@ -100,48 +106,48 @@ export const defaultTheme = {
|
|
|
100
106
|
table: {
|
|
101
107
|
wrapper: 'space-y-3',
|
|
102
108
|
toolbar: 'flex flex-col gap-3 md:flex-row md:items-center md:justify-between',
|
|
103
|
-
search: 'block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/30 md:max-w-xs
|
|
109
|
+
search: 'block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/30 md:max-w-xs',
|
|
104
110
|
exactLabel: 'inline-flex items-center gap-1.5 text-xs text-gray-500',
|
|
105
111
|
rangeWrapper: 'flex items-center gap-2',
|
|
106
|
-
rangeInput: 'rounded-md border border-gray-300 px-2 py-1.5 text-sm text-gray-700
|
|
107
|
-
offline: 'flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700
|
|
108
|
-
container: 'hidden overflow-x-auto rounded-lg border border-gray-200 md:block
|
|
109
|
-
table: 'w-full min-w-full divide-y divide-gray-200 text-sm
|
|
110
|
-
thead: 'bg-gray-50
|
|
111
|
-
th: 'px-4 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-500
|
|
112
|
-
sortBtn: 'inline-flex cursor-pointer items-center gap-1 uppercase hover:text-gray-800
|
|
113
|
-
tbody: 'divide-y divide-gray-100 bg-white
|
|
112
|
+
rangeInput: 'rounded-md border border-gray-300 px-2 py-1.5 text-sm text-gray-700',
|
|
113
|
+
offline: 'flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700',
|
|
114
|
+
container: 'hidden overflow-x-auto rounded-lg border border-gray-200 md:block',
|
|
115
|
+
table: 'w-full min-w-full divide-y divide-gray-200 text-sm',
|
|
116
|
+
thead: 'bg-gray-50',
|
|
117
|
+
th: 'px-4 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-500',
|
|
118
|
+
sortBtn: 'inline-flex cursor-pointer items-center gap-1 uppercase hover:text-gray-800',
|
|
119
|
+
tbody: 'divide-y divide-gray-100 bg-white',
|
|
114
120
|
tr: '',
|
|
115
|
-
trClickable: 'cursor-pointer hover:bg-gray-50
|
|
116
|
-
td: 'px-4 py-2.5 text-gray-700
|
|
121
|
+
trClickable: 'cursor-pointer hover:bg-gray-50',
|
|
122
|
+
td: 'px-4 py-2.5 text-gray-700',
|
|
117
123
|
money: 'font-semibold text-emerald-600',
|
|
118
124
|
empty: 'px-4 py-10 text-center text-sm text-gray-400',
|
|
119
|
-
error: 'rounded-md bg-red-50 px-4 py-3 text-sm text-red-700
|
|
125
|
+
error: 'rounded-md bg-red-50 px-4 py-3 text-sm text-red-700',
|
|
120
126
|
loading: 'flex justify-center px-4 py-10 text-gray-400',
|
|
121
127
|
actionsCell: 'whitespace-nowrap px-4 py-2.5 text-right',
|
|
122
128
|
actionBtn: 'ml-3 inline-flex cursor-pointer items-center gap-1 text-sm text-blue-600 hover:underline first:ml-0',
|
|
123
129
|
checkbox: 'size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500',
|
|
124
130
|
cards: 'space-y-3 md:hidden',
|
|
125
|
-
card: 'rounded-lg border border-gray-200 bg-white p-4
|
|
131
|
+
card: 'rounded-lg border border-gray-200 bg-white p-4',
|
|
126
132
|
cardLabel: 'text-xs font-semibold uppercase tracking-wide text-gray-400',
|
|
127
|
-
cardValue: 'mb-2 text-sm text-gray-700
|
|
133
|
+
cardValue: 'mb-2 text-sm text-gray-700',
|
|
128
134
|
pagination: {
|
|
129
135
|
wrapper: 'flex flex-col items-center justify-between gap-3 md:flex-row',
|
|
130
136
|
info: 'text-xs text-gray-500',
|
|
131
|
-
perPage: 'rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-600
|
|
137
|
+
perPage: 'rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-600',
|
|
132
138
|
pages: 'flex items-center gap-1',
|
|
133
|
-
pageBtn: 'inline-flex size-8 cursor-pointer items-center justify-center rounded-md border border-gray-300 text-xs text-gray-600 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40
|
|
139
|
+
pageBtn: 'inline-flex size-8 cursor-pointer items-center justify-center rounded-md border border-gray-300 text-xs text-gray-600 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40',
|
|
134
140
|
pageBtnActive: 'inline-flex size-8 items-center justify-center rounded-md border border-blue-600 bg-blue-600 text-xs font-semibold text-white',
|
|
135
141
|
ellipsis: 'px-1 text-xs text-gray-400',
|
|
136
|
-
loadMore: 'inline-flex items-center justify-center gap-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-60
|
|
142
|
+
loadMore: 'inline-flex items-center justify-center gap-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-60'
|
|
137
143
|
},
|
|
138
|
-
multiBar: 'fixed bottom-5 left-1/2 z-40 flex min-w-80 -translate-x-1/2 items-center justify-between gap-4 rounded-xl border border-gray-200 bg-white p-3 shadow-lg
|
|
144
|
+
multiBar: 'fixed bottom-5 left-1/2 z-40 flex min-w-80 -translate-x-1/2 items-center justify-between gap-4 rounded-xl border border-gray-200 bg-white p-3 shadow-lg',
|
|
139
145
|
multiCount: 'inline-flex items-center justify-center rounded-full bg-blue-600 px-2 py-0.5 text-xs font-semibold text-white',
|
|
140
|
-
multiBtn: 'inline-flex items-center justify-center gap-1 rounded-md border border-blue-200 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-50
|
|
146
|
+
multiBtn: 'inline-flex items-center justify-center gap-1 rounded-md border border-blue-200 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-50'
|
|
141
147
|
},
|
|
142
148
|
buttons: {
|
|
143
149
|
primary: 'inline-flex items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500/40 disabled:opacity-60',
|
|
144
|
-
secondary: 'inline-flex items-center justify-center gap-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none
|
|
150
|
+
secondary: 'inline-flex items-center justify-center gap-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none',
|
|
145
151
|
danger: 'inline-flex items-center justify-center gap-2 rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500/40',
|
|
146
152
|
link: 'inline-flex cursor-pointer items-center gap-1 text-sm text-blue-600 hover:underline'
|
|
147
153
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Lightweight, dependency-free input masking.
|
|
2
|
+
//
|
|
3
|
+
// A field's `mask` can be:
|
|
4
|
+
// - a named numeric mask: 'money' | 'integer' | 'decimal'
|
|
5
|
+
// - a pattern string: '#' = digit, 'A' = letter, 'N'/'*' = alphanumeric,
|
|
6
|
+
// any other char is a literal e.g. '#### #### #### ####', '(###) ###-####'
|
|
7
|
+
// - an options object: { pattern, unmask } or { type:'money', decimals, prefix, suffix }
|
|
8
|
+
// - a function: (rawValue) => formattedString
|
|
9
|
+
//
|
|
10
|
+
// applyMask returns { display, model }:
|
|
11
|
+
// display = what the user sees, model = what v-model emits (raw number for
|
|
12
|
+
// money, stripped tokens when unmask:true, otherwise the formatted string).
|
|
13
|
+
|
|
14
|
+
const TOKENS = {
|
|
15
|
+
'#': /[0-9]/,
|
|
16
|
+
A: /[a-zA-Z]/,
|
|
17
|
+
N: /[a-zA-Z0-9]/,
|
|
18
|
+
'*': /[a-zA-Z0-9]/
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const NAMED = ['money', 'integer', 'number', 'decimal']
|
|
22
|
+
|
|
23
|
+
export function isNamedNumericMask (mask) {
|
|
24
|
+
return typeof mask === 'string' && NAMED.includes(mask)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function maskPattern (raw, pattern, { unmask = false } = {}) {
|
|
28
|
+
const chars = String(raw ?? '').split('')
|
|
29
|
+
let display = ''
|
|
30
|
+
let model = ''
|
|
31
|
+
let ci = 0
|
|
32
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
33
|
+
const token = TOKENS[pattern[i]]
|
|
34
|
+
if (token) {
|
|
35
|
+
while (ci < chars.length && !token.test(chars[ci])) ci++
|
|
36
|
+
if (ci >= chars.length) break
|
|
37
|
+
display += chars[ci]
|
|
38
|
+
model += chars[ci]
|
|
39
|
+
ci++
|
|
40
|
+
} else if (ci < chars.length) {
|
|
41
|
+
// insert literal only while there is more input to place after it
|
|
42
|
+
display += pattern[i]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { display, model: unmask ? model : display }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function maskMoney (raw, { decimals = 2, prefix = '', suffix = '', integer = false } = {}) {
|
|
49
|
+
let s = String(raw ?? '').replace(/[^0-9.]/g, '')
|
|
50
|
+
const dot = s.indexOf('.')
|
|
51
|
+
if (dot !== -1) {
|
|
52
|
+
s = s.slice(0, dot + 1) + s.slice(dot + 1).replace(/\./g, '')
|
|
53
|
+
}
|
|
54
|
+
if (integer) {
|
|
55
|
+
// drop the decimal part rather than merging the digits
|
|
56
|
+
s = s.split('.')[0]
|
|
57
|
+
}
|
|
58
|
+
let [int = '', dec] = s.split('.')
|
|
59
|
+
int = int.replace(/^0+(?=\d)/, '')
|
|
60
|
+
const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
|
61
|
+
const hasDot = s.includes('.') && !integer
|
|
62
|
+
|
|
63
|
+
let display = grouped
|
|
64
|
+
let model = int
|
|
65
|
+
if (hasDot) {
|
|
66
|
+
dec = (dec ?? '').slice(0, decimals)
|
|
67
|
+
display = (grouped || '0') + '.' + dec
|
|
68
|
+
model = (int || '0') + '.' + dec
|
|
69
|
+
}
|
|
70
|
+
if (display) {
|
|
71
|
+
display = prefix + display + suffix
|
|
72
|
+
}
|
|
73
|
+
return { display, model }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function applyMask (value, mask) {
|
|
77
|
+
if (typeof mask === 'function') {
|
|
78
|
+
const display = mask(value) ?? ''
|
|
79
|
+
return { display: String(display), model: String(display) }
|
|
80
|
+
}
|
|
81
|
+
if (mask && typeof mask === 'object') {
|
|
82
|
+
if (mask.pattern) {
|
|
83
|
+
return maskPattern(value, mask.pattern, mask)
|
|
84
|
+
}
|
|
85
|
+
return maskMoney(value, mask)
|
|
86
|
+
}
|
|
87
|
+
if (isNamedNumericMask(mask)) {
|
|
88
|
+
return maskMoney(value, { integer: mask === 'integer' || mask === 'number', decimals: mask === 'integer' || mask === 'number' ? 0 : 2 })
|
|
89
|
+
}
|
|
90
|
+
return maskPattern(value, String(mask))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// inputmode hint for the on-screen keyboard
|
|
94
|
+
export function maskInputMode (mask) {
|
|
95
|
+
if (isNamedNumericMask(mask) || (mask && typeof mask === 'object' && !mask.pattern)) {
|
|
96
|
+
return 'decimal'
|
|
97
|
+
}
|
|
98
|
+
const pattern = typeof mask === 'string' ? mask : (mask && mask.pattern)
|
|
99
|
+
if (typeof pattern === 'string' && /^[#\s().+-]*$/.test(pattern)) {
|
|
100
|
+
return 'numeric'
|
|
101
|
+
}
|
|
102
|
+
return 'text'
|
|
103
|
+
}
|
|
@@ -3,7 +3,7 @@ import { startCase } from './strings.js'
|
|
|
3
3
|
// Exact-name inference carried over from shframework's ShAutoForm
|
|
4
4
|
const NAME_TYPE_MAP = {
|
|
5
5
|
password: 'password',
|
|
6
|
-
pin: '
|
|
6
|
+
pin: 'pin',
|
|
7
7
|
password_confirmation: 'password',
|
|
8
8
|
message: 'textarea',
|
|
9
9
|
description: 'textarea',
|