sprintify-ui 0.12.11 → 0.12.13

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.
@@ -42,7 +42,7 @@ type __VLS_Slots = {} & {
42
42
  declare const __VLS_component: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
43
43
  modelValue: {
44
44
  default: undefined;
45
- type: PropType<string[] | string | number | null | undefined>;
45
+ type: PropType<string[] | string | number[] | number | null | undefined>;
46
46
  };
47
47
  url: {
48
48
  required: true;
@@ -99,7 +99,7 @@ declare const __VLS_component: import("vue").DefineComponent<import("vue").Extra
99
99
  }, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
100
100
  modelValue: {
101
101
  default: undefined;
102
- type: PropType<string[] | string | number | null | undefined>;
102
+ type: PropType<string[] | string | number[] | number | null | undefined>;
103
103
  };
104
104
  url: {
105
105
  required: true;
@@ -151,7 +151,7 @@ declare const __VLS_component: import("vue").DefineComponent<import("vue").Extra
151
151
  placeholder: string;
152
152
  required: boolean;
153
153
  disabled: boolean;
154
- modelValue: string | number | string[] | null | undefined;
154
+ modelValue: string | number | string[] | number[] | null | undefined;
155
155
  hasError: boolean;
156
156
  max: number;
157
157
  primaryKey: string;
@@ -1,4 +1,7 @@
1
1
  import { PropType } from 'vue';
2
+ import { Size } from '@/utils/sizes';
3
+ declare function focusAction(): void;
4
+ declare function clearAction(): void;
2
5
  declare const _default: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
3
6
  modelValue: {
4
7
  required: true;
@@ -12,7 +15,30 @@ declare const _default: import("vue").DefineComponent<import("vue").ExtractPropT
12
15
  default: string;
13
16
  type: PropType<"numeric" | "alphanumeric">;
14
17
  };
15
- }>, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
18
+ size: {
19
+ default: string;
20
+ type: PropType<Size>;
21
+ };
22
+ hasError: {
23
+ default: boolean;
24
+ type: BooleanConstructor;
25
+ };
26
+ disabled: {
27
+ default: boolean;
28
+ type: BooleanConstructor;
29
+ };
30
+ autofocus: {
31
+ default: boolean;
32
+ type: BooleanConstructor;
33
+ };
34
+ class: {
35
+ default: string;
36
+ type: PropType<string | string[]>;
37
+ };
38
+ }>, {
39
+ focus: typeof focusAction;
40
+ clear: typeof clearAction;
41
+ }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
16
42
  "update:modelValue": (...args: any[]) => void;
17
43
  }, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
18
44
  modelValue: {
@@ -27,9 +53,34 @@ declare const _default: import("vue").DefineComponent<import("vue").ExtractPropT
27
53
  default: string;
28
54
  type: PropType<"numeric" | "alphanumeric">;
29
55
  };
56
+ size: {
57
+ default: string;
58
+ type: PropType<Size>;
59
+ };
60
+ hasError: {
61
+ default: boolean;
62
+ type: BooleanConstructor;
63
+ };
64
+ disabled: {
65
+ default: boolean;
66
+ type: BooleanConstructor;
67
+ };
68
+ autofocus: {
69
+ default: boolean;
70
+ type: BooleanConstructor;
71
+ };
72
+ class: {
73
+ default: string;
74
+ type: PropType<string | string[]>;
75
+ };
30
76
  }>> & Readonly<{
31
77
  "onUpdate:modelValue"?: ((...args: any[]) => any) | undefined;
32
78
  }>, {
79
+ class: string | string[];
33
80
  type: "numeric" | "alphanumeric";
81
+ size: "xs" | "sm" | "md" | "lg" | "xl";
82
+ disabled: boolean;
83
+ hasError: boolean;
84
+ autofocus: boolean;
34
85
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
35
86
  export default _default;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprintify-ui",
3
- "version": "0.12.11",
3
+ "version": "0.12.13",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "generate-llm-txt": "node scripts/generate-llm-txt.js",
@@ -1,5 +1,6 @@
1
1
  <template>
2
2
  <BaseTooltip
3
+ as="span"
3
4
  :visible="tooltip"
4
5
  :text="tooltipContent"
5
6
  >
@@ -55,7 +55,7 @@ import { getItems } from '@/utils/getApiData';
55
55
  const props = defineProps({
56
56
  modelValue: {
57
57
  default: undefined,
58
- type: [Array, String, Number, null, undefined] as PropType<string[] | string | number | null | undefined>,
58
+ type: [Array, String, Number, null, undefined] as PropType<string[] | string | number[] | number | null | undefined>,
59
59
  },
60
60
  url: {
61
61
  required: true,
@@ -1,14 +1,23 @@
1
1
  import { BaseUniqueCode } from "."
2
2
  import ShowValue from "@/../.storybook/components/ShowValue.vue";
3
+ import { sizes } from "../../.storybook/utils";
3
4
 
4
5
  export default {
5
6
  title: "Form/BaseUniqueCode",
6
7
  component: BaseUniqueCode,
8
+ args: {
9
+ numberOfCharacters: 4,
10
+ autofocus: false,
11
+ },
7
12
  argTypes: {
8
13
  type: {
9
14
  control: { type: "select" },
10
15
  options: ["numeric", "alphanumeric"],
11
16
  },
17
+ size: {
18
+ control: { type: "select" },
19
+ options: sizes,
20
+ },
12
21
  },
13
22
  };
14
23
 
@@ -19,18 +28,76 @@ const Template = (args) => ({
19
28
  return { args, value };
20
29
  },
21
30
  template: `
22
- <BaseUniqueCode v-model="value" v-bind="args" class="w-full"></BaseUniqueCode>
31
+ <BaseUniqueCode v-model="value" v-bind="args"></BaseUniqueCode>
23
32
  <ShowValue :value="value" />
24
33
  `,
25
34
  });
26
35
 
27
36
  export const Numeric = Template.bind({});
28
- Numeric.args = {
29
- numberOfCharacters: 4,
30
- };
31
37
 
32
38
  export const Alphanumeric = Template.bind({});
33
39
  Alphanumeric.args = {
34
- numberOfCharacters: 4,
35
- type: "text",
40
+ type: "alphanumeric",
36
41
  };
42
+
43
+ export const SixCharacters = Template.bind({});
44
+ SixCharacters.args = {
45
+ numberOfCharacters: 6,
46
+ };
47
+
48
+ export const Prefilled = Template.bind({});
49
+ Prefilled.args = {
50
+ modelValue: "1234",
51
+ };
52
+
53
+ export const Error = Template.bind({});
54
+ Error.args = {
55
+ hasError: true,
56
+ };
57
+
58
+ export const Disabled = Template.bind({});
59
+ Disabled.args = {
60
+ modelValue: "12",
61
+ disabled: true,
62
+ };
63
+
64
+ export const Autofocus = Template.bind({});
65
+ Autofocus.args = {
66
+ autofocus: true,
67
+ };
68
+
69
+ const TemplateSizes = (args) => ({
70
+ components: { BaseUniqueCode },
71
+ setup() {
72
+ const value = ref("");
73
+ return { args, value, sizes };
74
+ },
75
+ template: `
76
+ <div v-for="size in sizes" :key="size" class="mb-4">
77
+ <p class="text-xs text-slate-600 leading-tight mb-1">{{ size }}</p>
78
+ <BaseUniqueCode v-model="value" v-bind="args" :size="size"></BaseUniqueCode>
79
+ </div>
80
+ `,
81
+ });
82
+
83
+ export const Sizes = TemplateSizes.bind({});
84
+
85
+ const FocusAndClearTemplate = (args) => ({
86
+ components: { BaseUniqueCode, ShowValue },
87
+ setup() {
88
+ const value = ref("");
89
+ const input = ref(null);
90
+ return { args, value, input };
91
+ },
92
+ template: `
93
+ <BaseUniqueCode ref="input" v-model="value" v-bind="args"></BaseUniqueCode>
94
+ <ShowValue :value="value" />
95
+
96
+ <div class="mt-4 flex gap-2">
97
+ <button @click="input.focus()" class="btn btn-sm">Focus</button>
98
+ <button @click="input.clear()" class="btn btn-sm">Clear</button>
99
+ </div>
100
+ `,
101
+ });
102
+
103
+ export const FocusAndClear = FocusAndClearTemplate.bind({});
@@ -1,25 +1,29 @@
1
1
  <template>
2
- <div>
2
+ <div :class="classes">
3
3
  <input
4
- v-for="index in props.numberOfCharacters "
4
+ v-for="index in numberOfCharacters"
5
5
  :key="index"
6
- ref="codeInputs"
7
- class="border-t-0 border-x-0 border-b-2 border-gray-300 w-[70px] h-[90px]
8
- text-4xl font-normal text-gray-700 text-center p-0 m-0 mx-2 outline-none
9
- duration-200 focus:outline-none focus:shadow-none focus:border-b-primary-500 focus:ring-0"
6
+ :ref="(el) => setInputRef(el, index - 1)"
7
+ :value="characters[index - 1] || ''"
10
8
  type="text"
11
9
  maxlength="1"
12
10
  :pattern="pattern"
13
- :value="uniqueCode[index] || ''"
14
- @input="handleCodeInput(index, $event)"
15
- @paste="handleCodePaste(index, $event)"
16
- @keydown="handleCodeKeyDown(index, $event)"
11
+ :inputmode="type === 'numeric' ? 'numeric' : 'text'"
12
+ :autocomplete="index === 1 ? 'one-time-code' : 'off'"
13
+ :disabled="disabled"
14
+ :class="cellClasses"
15
+ @input="onInput(index - 1, $event)"
16
+ @paste="onPaste(index - 1, $event)"
17
+ @keydown="onKeydown(index - 1, $event)"
18
+ @focus="onFocus($event)"
17
19
  >
18
20
  </div>
19
21
  </template>
20
22
 
21
23
  <script setup lang="ts">
22
- import { ref, onMounted, nextTick, PropType } from 'vue';
24
+ import { computed, nextTick, onMounted, PropType, ref, watch } from 'vue';
25
+ import { Size } from '@/utils/sizes';
26
+ import { twMerge } from 'tailwind-merge';
23
27
 
24
28
  const props = defineProps({
25
29
  modelValue: {
@@ -31,153 +35,224 @@ const props = defineProps({
31
35
  type: Number,
32
36
  },
33
37
  type: {
34
- default: "numeric",
38
+ default: 'numeric',
35
39
  type: String as PropType<'numeric' | 'alphanumeric'>,
36
- }
40
+ },
41
+ size: {
42
+ default: 'md',
43
+ type: String as PropType<Size>,
44
+ },
45
+ hasError: {
46
+ default: false,
47
+ type: Boolean,
48
+ },
49
+ disabled: {
50
+ default: false,
51
+ type: Boolean,
52
+ },
53
+ autofocus: {
54
+ default: true,
55
+ type: Boolean,
56
+ },
57
+ class: {
58
+ default: '',
59
+ type: [String, Array] as PropType<string | string[]>,
60
+ },
37
61
  });
38
62
 
39
63
  const emit = defineEmits(['update:modelValue']);
40
64
 
41
- const pattern = computed(() => {
42
- if (props.type === 'alphanumeric') {
43
- return "[A-Za-z0-9]+";
44
- }
65
+ const codeInputs = ref<(HTMLInputElement | null)[]>([]);
66
+ const characters = ref<string[]>(Array(props.numberOfCharacters).fill(''));
45
67
 
46
- return "[0-9]*";
47
- })
68
+ const code = computed(() => characters.value.join(''));
48
69
 
49
- const form = reactive({
50
- code: '',
51
- });
70
+ const characterPattern = computed(() =>
71
+ props.type === 'alphanumeric' ? /^[A-Za-z0-9]$/ : /^[0-9]$/
72
+ );
52
73
 
53
- const codeInputs = ref<(HTMLInputElement | null)[]>([]);
54
- const uniqueCode = ref<string[]>(Array(props.numberOfCharacters).fill(''));
55
- const prvIndex = ref(0);
74
+ const pattern = computed(() =>
75
+ props.type === 'alphanumeric' ? '[A-Za-z0-9]+' : '[0-9]*'
76
+ );
77
+
78
+ const sizeClasses: Record<Size, string> = {
79
+ xs: 'h-8 w-7 rounded-md text-sm',
80
+ sm: 'h-10 w-8 rounded-md text-base',
81
+ md: 'h-12 w-10 rounded-lg text-xl',
82
+ lg: 'h-14 w-12 rounded-xl text-2xl',
83
+ xl: 'h-16 w-14 rounded-xl text-3xl',
84
+ };
85
+
86
+ const classes = computed(() =>
87
+ twMerge(['flex items-center gap-2', props.class])
88
+ );
89
+
90
+ const cellClasses = computed(() =>
91
+ twMerge([
92
+ 'border bg-slate-50 p-0 text-center font-semibold text-slate-900 shadow-sm outline-none transition-all',
93
+ 'focus:bg-white focus:outline-none focus:ring-4',
94
+ sizeClasses[props.size],
95
+ props.hasError
96
+ ? 'border-red-300 focus:border-red-500 focus:ring-red-100'
97
+ : 'border-slate-200 focus:border-primary-500 focus:ring-primary-100',
98
+ props.disabled ? 'cursor-not-allowed opacity-50' : '',
99
+ ])
100
+ );
101
+
102
+ watch(code, (value) => {
103
+ if (value !== normalizedModelValue()) {
104
+ emit('update:modelValue', value);
105
+ }
106
+ });
56
107
 
57
108
  watch(
58
- () => form.code,
109
+ () => props.modelValue,
59
110
  () => {
60
- emit('update:modelValue', form.code);
111
+ const value = normalizedModelValue();
112
+
113
+ if (value !== code.value) {
114
+ setCharacters(value);
115
+ }
61
116
  },
62
117
  { immediate: true }
63
118
  );
64
119
 
65
- // Max length check for each input
66
- const maxLengthCheck = (input: HTMLInputElement) => {
67
- if (input.value.length > input.maxLength) {
68
- input.value = input.value.slice(0, input.maxLength);
69
- }
70
- };
120
+ function normalizedModelValue(): string {
121
+ return String(props.modelValue ?? '');
122
+ }
123
+
124
+ function setCharacters(value: string) {
125
+ const valid = filterValidCharacters(value);
71
126
 
72
- // Handle code input
73
- const handleCodeInput = (index: number, event: Event) => {
127
+ characters.value = Array.from(
128
+ { length: props.numberOfCharacters },
129
+ (_, index) => valid[index] || ''
130
+ );
131
+ }
132
+
133
+ function filterValidCharacters(value: string): string[] {
134
+ return value.split('').filter((char) => characterPattern.value.test(char));
135
+ }
136
+
137
+ function setInputRef(el: unknown, index: number) {
138
+ codeInputs.value[index] = el as HTMLInputElement | null;
139
+ }
140
+
141
+ function onInput(index: number, event: Event) {
74
142
  const input = event.target as HTMLInputElement;
75
- maxLengthCheck(input);
143
+ const value = input.value;
76
144
 
77
- const nextIndex = index < props.numberOfCharacters ? index + 1 : index;
145
+ // Autofill (e.g. iOS one-time-code) can inject the full code into one input
146
+ if (value.length > 1) {
147
+ fillFrom(index, value);
148
+ input.value = characters.value[index] || '';
78
149
 
79
- if (index >= 0 && index <= props.numberOfCharacters) {
80
- if (props.type === 'numeric') {
81
- const inputValue = parseInt(input.value);
82
- if (isNaN(inputValue) || inputValue < 0 || inputValue > 9) {
83
- input.value = '';
84
- uniqueCode.value[index] = '';
150
+ return;
151
+ }
85
152
 
86
- return;
87
- }
88
- }
153
+ if (value && !characterPattern.value.test(value)) {
154
+ input.value = characters.value[index] || '';
89
155
 
90
- uniqueCode.value[index] = input.value;
156
+ return;
157
+ }
91
158
 
92
- if (!uniqueCode.value[nextIndex] || uniqueCode.value[nextIndex] === '') {
93
- codeInputs.value[nextIndex - 1]?.focus();
94
- }
159
+ characters.value[index] = value;
95
160
 
96
- updateFormCode();
161
+ if (value) {
162
+ focusIndex(index + 1);
97
163
  }
98
- };
164
+ }
99
165
 
100
- // Handle code keydown events
101
- function handleCodeKeyDown(index: number, event: KeyboardEvent) {
166
+ function onKeydown(index: number, event: KeyboardEvent) {
102
167
  const input = event.target as HTMLInputElement;
103
- const allowedKeys = props.type === 'alphanumeric' ? /^[A-Za-z0-9]$/ : /^[0-9]$/;
104
168
 
105
- if (index === 0 && event.key === 'Backspace') {
169
+ if (event.key === 'Backspace') {
170
+ event.preventDefault();
171
+
172
+ if (characters.value[index]) {
173
+ characters.value[index] = '';
174
+ } else if (index > 0) {
175
+ characters.value[index - 1] = '';
176
+ focusIndex(index - 1);
177
+ }
178
+
106
179
  return;
107
180
  }
108
181
 
109
- if (index === props.numberOfCharacters + 1) {
182
+ if (event.key === 'ArrowLeft') {
183
+ event.preventDefault();
184
+ focusIndex(index - 1);
185
+
110
186
  return;
111
187
  }
112
188
 
113
- prvIndex.value = index - 1;
189
+ if (event.key === 'ArrowRight') {
190
+ event.preventDefault();
191
+ focusIndex(index + 1);
114
192
 
115
- if (event.key === 'Backspace') {
116
- if (index > 0 && index <= props.numberOfCharacters) {
117
-
118
- if (input.value) {
119
- input.value = '';
120
- uniqueCode.value[index] = '';
121
- updateFormCode();
122
- } else {
123
- setTimeout(() => {
124
- codeInputs.value[prvIndex.value - 1]?.focus();
125
- uniqueCode.value[prvIndex.value] = '';
126
- updateFormCode();
127
- }, 10);
128
- }
129
- }
130
- } else if (event.key === 'ArrowRight') {
131
- codeInputs.value[index]?.focus();
132
- } else if (event.key === 'ArrowLeft') {
133
- codeInputs.value[prvIndex.value - 1]?.focus();
134
- } else if (allowedKeys.test(event.key)) {
135
- uniqueCode.value[index] = '';
193
+ return;
136
194
  }
137
- }
138
195
 
139
- // Function to update form.code with the concatenated values of uniqueCode
140
- function updateFormCode() {
141
- form.code = uniqueCode.value.join('');
196
+ // Typing over a filled cell replaces its character
197
+ if (
198
+ characterPattern.value.test(event.key) &&
199
+ !event.metaKey &&
200
+ !event.ctrlKey &&
201
+ !event.altKey
202
+ ) {
203
+ input.value = '';
204
+ }
142
205
  }
143
206
 
144
- // Handle code paste
145
- const handleCodePaste = (index: number, event: ClipboardEvent) => {
146
- const pastedData = event.clipboardData?.getData('text/plain') || '';
207
+ function onPaste(index: number, event: ClipboardEvent) {
208
+ event.preventDefault();
209
+
210
+ const pasted = event.clipboardData?.getData('text/plain') || '';
211
+ const startIndex = filterValidCharacters(pasted).length >= props.numberOfCharacters ? 0 : index;
212
+
213
+ fillFrom(startIndex, pasted);
214
+ }
147
215
 
148
- let data = [] as string[];
216
+ function fillFrom(startIndex: number, value: string) {
217
+ const valid = filterValidCharacters(value);
149
218
 
150
- if (props.type === 'alphanumeric') {
151
- data = pastedData.split('').filter((char) => /^[A-Za-z0-9]+$/.test(char));
152
- } else {
153
- data = pastedData.split('').filter((char) => !isNaN(Number(char)));
219
+ for (let i = 0; i < valid.length && startIndex + i < props.numberOfCharacters; i++) {
220
+ characters.value[startIndex + i] = valid[i];
154
221
  }
155
222
 
156
- // Reset code
157
- form.code = '';
223
+ focusIndex(startIndex + valid.length);
224
+ }
158
225
 
159
- for (let i = 0; i < data.length && i < props.numberOfCharacters; i++) {
160
- uniqueCode.value[index + i] = data[i];
161
- form.code += data[i];
162
- }
226
+ function onFocus(event: FocusEvent) {
227
+ (event.target as HTMLInputElement).select();
228
+ }
163
229
 
164
- // Focus the next input after paste
165
- codeInputs.value[index + data.length]?.focus();
230
+ function focusIndex(index: number) {
231
+ const clamped = Math.min(Math.max(index, 0), props.numberOfCharacters - 1);
166
232
 
167
- event.preventDefault();
168
- };
233
+ codeInputs.value[clamped]?.focus();
234
+ }
235
+
236
+ function focusAction() {
237
+ const firstEmpty = characters.value.findIndex((char) => !char);
238
+
239
+ focusIndex(firstEmpty === -1 ? props.numberOfCharacters - 1 : firstEmpty);
240
+ }
241
+
242
+ function clearAction() {
243
+ characters.value = Array(props.numberOfCharacters).fill('');
244
+ }
169
245
 
170
- // Focus the first input on component mount
171
246
  onMounted(async () => {
172
247
  await nextTick();
173
- codeInputs.value = Array.from(
174
- { length: props.numberOfCharacters },
175
- (_, index) => codeInputs.value[index]
176
- );
177
- const firstInput = codeInputs.value[0];
178
- if (firstInput !== null) {
179
- firstInput.focus();
248
+
249
+ if (props.autofocus && !props.disabled) {
250
+ focusAction();
180
251
  }
181
252
  });
182
253
 
254
+ defineExpose({
255
+ focus: focusAction,
256
+ clear: clearAction,
257
+ });
183
258
  </script>