@iankibetsh/sh-tailwind 0.1.0 → 0.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iankibetsh/sh-tailwind",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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
- field.component ?? injectedComponents[field.type] ?? builtins[field.type] ?? builtins.text
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 dark:text-gray-200">
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 dark:text-gray-200',
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 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100',
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 dark:bg-gray-800 dark:text-gray-100',
11
- helper: 'text-xs text-gray-500 dark:text-gray-400',
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 dark:bg-red-950 dark:text-red-300',
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 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200',
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 dark:border-gray-600 dark:bg-gray-800',
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 dark:text-gray-300',
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 dark:bg-gray-700',
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 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100',
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 dark:hover:text-gray-300',
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 dark:bg-gray-700 dark:text-gray-200',
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 dark:border-gray-700 dark:bg-gray-800',
40
- option: 'cursor-pointer px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700',
41
- optionActive: 'cursor-pointer bg-blue-50 px-3 py-2 text-sm text-blue-700 dark:bg-gray-700 dark:text-blue-300',
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 dark:border-gray-600 dark:bg-gray-800',
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 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600',
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 dark:text-gray-300',
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 dark:text-gray-100',
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 dark:border-gray-700 dark:bg-gray-800',
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 dark:border-gray-700 dark:text-gray-100',
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 dark:text-gray-200 dark:hover:bg-gray-700',
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 dark:bg-gray-700 dark:text-blue-300',
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 dark:bg-gray-900',
65
- header: 'flex items-center justify-between border-b border-gray-100 px-5 py-3.5 dark:border-gray-800',
66
- title: 'text-base font-semibold text-gray-900 dark:text-gray-100',
67
- closeBtn: 'rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 focus:outline-none dark:hover:bg-gray-800',
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 dark:border-gray-800',
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 dark:bg-gray-900',
81
- header: 'flex items-center justify-between border-b border-gray-100 px-5 py-3.5 dark:border-gray-800',
82
- title: 'text-base font-semibold text-gray-900 dark:text-gray-100',
83
- closeBtn: 'rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 focus:outline-none dark:hover:bg-gray-800',
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 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100',
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 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200',
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 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-300',
108
- container: 'hidden overflow-x-auto rounded-lg border border-gray-200 md:block dark:border-gray-700',
109
- table: 'w-full min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700',
110
- thead: 'bg-gray-50 dark:bg-gray-800',
111
- th: 'px-4 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400',
112
- sortBtn: 'inline-flex cursor-pointer items-center gap-1 uppercase hover:text-gray-800 dark:hover:text-gray-200',
113
- tbody: 'divide-y divide-gray-100 bg-white dark:divide-gray-800 dark:bg-gray-900',
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 dark:hover:bg-gray-800',
116
- td: 'px-4 py-2.5 text-gray-700 dark:text-gray-200',
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 dark:bg-red-950 dark:text-red-300',
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 dark:border-gray-700 dark:bg-gray-900',
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 dark:text-gray-200',
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 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300',
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 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800',
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 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200'
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 dark:border-gray-700 dark:bg-gray-900',
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 dark:border-blue-800 dark:text-blue-300'
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 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200',
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: 'password',
6
+ pin: 'pin',
7
7
  password_confirmation: 'password',
8
8
  message: 'textarea',
9
9
  description: 'textarea',