From 00ef0e3d2f81e8b3246315df048b8ea139d37d86 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=9C=D0=B0?=
 =?UTF-8?q?=D0=BB=D1=8E=D0=B3=D0=B8=D0=BD?= <d.malygin@iqdev.digital>
Date: Sat, 15 Feb 2025 15:37:02 +0500
Subject: [PATCH] feat: finish 'InputDiv'

---
 src/Playground.vue                          |   3 +-
 src/common/interfaces/componentsProps.ts    |   3 +
 src/components/InputDiv/InputDiv.stories.ts |  28 ++++
 src/components/InputDiv/InputDiv.vue        | 141 ++++++++++++++------
 src/components/InputDiv/helpers.ts          |  78 +++++++++--
 5 files changed, 196 insertions(+), 57 deletions(-)

diff --git a/src/Playground.vue b/src/Playground.vue
index 29af7d2..31f397a 100644
--- a/src/Playground.vue
+++ b/src/Playground.vue
@@ -23,6 +23,7 @@ import HomeIcon from '@icons/Mono/HomeIcon.vue';
 import ProgressBar from '@components/ProgressBar/ProgressBar.vue';
 import Carousel from '@components/Carousel/Carousel.vue';
 import Toast from '@components/Toast/Toast.vue';
+import InputDiv from '@components/InputDiv/InputDiv.vue';
 
 const visibleDrawer = ref(false);
 const sliderOptions: ISliderOptions[] = [
@@ -184,7 +185,6 @@ const openDrawer = () => (visibleDrawer.value = true);
 
 <template>
   <h2 class="title gradient-text">Playground</h2>
-  <input type="text" style="border: 1px solid black" />
   <Button
     theme="black"
     label="Open all toasts"
@@ -197,6 +197,7 @@ const openDrawer = () => (visibleDrawer.value = true);
       }
     "
   />
+  <InputDiv :regex="/^[0-9-+=]+$/" />
   <Button label="Open toast" @click="toast2 = true" />
   <Toast v-model="toast" static :duration="60" type="success" position="topLeft" width="500px" />
   <Toast v-model="toast4" :duration="2" type="info" position="topLeft" width="500px" />
diff --git a/src/common/interfaces/componentsProps.ts b/src/common/interfaces/componentsProps.ts
index f6b1b0d..f948015 100644
--- a/src/common/interfaces/componentsProps.ts
+++ b/src/common/interfaces/componentsProps.ts
@@ -227,9 +227,12 @@ export interface ISelectProps {
 export interface IInputDivProps {
   scheme?: TInputDivScheme;
   size?: TSize;
+  gap?: string;
+  inputsGap?: string;
   secret?: boolean;
   dashed?: boolean;
   numbersOnly?: boolean;
+  regex?: RegExp;
   bottomOnly?: boolean;
   theme?: TThemeColor;
   darknessTheme?: TDarkness;
diff --git a/src/components/InputDiv/InputDiv.stories.ts b/src/components/InputDiv/InputDiv.stories.ts
index 2b13d87..3c37e8e 100644
--- a/src/components/InputDiv/InputDiv.stories.ts
+++ b/src/components/InputDiv/InputDiv.stories.ts
@@ -20,6 +20,9 @@ const meta: Meta = {
     numbersOnly: { control: 'boolean' },
     bottomOnly: { control: 'boolean' },
     scheme: { control: 'text' },
+    regex: { control: 'text' },
+    gap: { control: 'text' },
+    inputsGap: { control: 'text' },
     darknessTheme: { control: 'select', options: ['100', '200', '300', '400', '500', '600', '700', '800', '900'] },
     darknessTextColor: {
       control: 'select',
@@ -76,3 +79,28 @@ type Story = StoryObj<typeof meta>;
 export const Simple: Story = {
   args: {},
 };
+
+export const Full: Story = {
+  args: {
+    secret: true,
+    dashed: true,
+    numbersOnly: true,
+    bottomOnly: false,
+    scheme: '2-4-3-1',
+    size: 'large',
+    theme: 'sky',
+  },
+};
+
+export const Full2: Story = {
+  args: {
+    secret: false,
+    dashed: true,
+    numbersOnly: false,
+    bottomOnly: true,
+    scheme: '4by3',
+    size: 'large',
+    theme: 'white',
+    textColor: 'blue',
+  },
+};
diff --git a/src/components/InputDiv/InputDiv.vue b/src/components/InputDiv/InputDiv.vue
index 61aaafd..cce4d05 100644
--- a/src/components/InputDiv/InputDiv.vue
+++ b/src/components/InputDiv/InputDiv.vue
@@ -2,7 +2,13 @@
 import type { IInputDivProps } from '@interfaces/componentsProps';
 import { computed, ref, type Ref, watch } from 'vue';
 import { convertThemeToColor, convertThemeToTextColor, getValueFromSize } from '@helpers/common';
-import { calcPartsBy, calcPartsDash, changeInputHandler, moveFocus } from '@components/InputDiv/helpers';
+import {
+  calcIndexesToValueindex,
+  calcPartsBy,
+  calcPartsDash,
+  changeInputHandler,
+  moveFocus,
+} from '@components/InputDiv/helpers';
 
 const props = withDefaults(defineProps<IInputDivProps>(), {
   scheme: '4by1',
@@ -15,39 +21,20 @@ const props = withDefaults(defineProps<IInputDivProps>(), {
 const value = defineModel() as Ref<string>;
 const valueParts = ref<string[]>([]);
 
-watch(valueParts, () => {
-  value.value = valueParts.value.join('');
-});
+watch(
+  valueParts,
+  () => {
+    value.value = valueParts.value.join('');
+  },
+  { deep: true },
+);
 let container: HTMLElement | null;
 setTimeout(() => (container = document.querySelector('#inputDiv-container')), 0);
 
 const inputPartsBy = computed(() => calcPartsBy(props.scheme));
+const isInputPartsBy = computed(() => !!inputPartsBy.value);
 const inputPartsDash = computed(() => calcPartsDash(props.scheme));
-const indexesToValueIndex = computed(() => {
-  const result = {};
-  let index = 0;
-  if (inputPartsBy.value) {
-    const splat = props.scheme.split('by');
-    for (const itemIndex of [...Array(+splat[0]).keys()]) {
-      for (const inputIndex of [...Array(+splat[1]).keys()]) {
-        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-        //@ts-expect-error
-        result[itemIndex + '-' + inputIndex] = index++;
-      }
-    }
-  } else {
-    const splat = props.scheme.split('-').map((i) => +i);
-    for (const item of splat) {
-      for (const inputIndex of [...Array(item).keys()]) {
-        const itemIndex = splat.indexOf(item);
-        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-        //@ts-expect-error
-        result[itemIndex + '-' + inputIndex] = index++;
-      }
-    }
-  }
-  return result;
-});
+const indexesToValueIndex = computed(() => calcIndexesToValueindex(isInputPartsBy.value, props.scheme));
 
 const themeColor = computed(() => convertThemeToColor(props.theme, props.darknessTheme));
 const color = computed(() =>
@@ -58,49 +45,85 @@ const color = computed(() =>
 const inputWidth = computed(() => getValueFromSize(props.size, ['20px', '24px', '30px', '45px']));
 const inputHeight = computed(() => getValueFromSize(props.size, ['30px', '36px', '45px', '67px']));
 const fontSize = computed(() => getValueFromSize(props.size, ['12px', '16px', '24px', '32px']));
+const gap = computed(() => props.gap ?? fontSize.value);
+const dashRight = computed(() => +gap.value.slice(0, -2) * -0.5 - 5 + 'px');
 const borderWidth = computed(() => (props.size === 'small' || props.size === 'normal' ? '1px' : '2px'));
 
 const toggleInput = (target: any, itemIndex: number, inputIndex: number, backspace?: boolean) =>
   (valueParts.value = changeInputHandler(
     target,
     container!,
-    !!inputPartsBy.value,
+    isInputPartsBy.value,
     valueParts.value,
     indexesToValueIndex.value,
     itemIndex,
     inputIndex,
     backspace ?? false,
+    props.numbersOnly,
+    props.regex ?? null,
   ));
 </script>
 
 <template>
   <section id="inputDiv-container">
     <div v-show="inputPartsBy" class="list">
-      <div v-for="(item, itemIndex) of inputPartsBy" :key="itemIndex" :class="`item ${itemIndex}`">
+      <div
+        v-for="(item, itemIndex) of inputPartsBy"
+        :key="itemIndex"
+        :class="[
+          `item ${itemIndex}`,
+          {
+            dashed: dashed && (inputPartsBy?.length ?? -1) - 1 !== itemIndex,
+          },
+        ]"
+      >
         <input
           v-for="(_, inputIndex) of item"
           :key="inputIndex"
           @input="toggleInput($event.target, itemIndex, +inputIndex)"
           @keydown.delete="toggleInput($event.target, itemIndex, +inputIndex, true)"
-          @keydown.left="moveFocus('left', container!, !!inputPartsBy, itemIndex, inputIndex)"
-          @keydown.right="moveFocus('right', container!, !!inputPartsBy, itemIndex, inputIndex)"
-          type="text"
-          :class="`input ${inputIndex}`"
+          @keydown.left="moveFocus('left', container!, isInputPartsBy, itemIndex, inputIndex)"
+          @keydown.right="moveFocus('right', container!, isInputPartsBy, itemIndex, inputIndex)"
+          :type="secret ? 'password' : 'text'"
+          :class="[
+            `input ${inputIndex}`,
+            {
+              firstInput: !bottomOnly && inputIndex === 0,
+              lastInput: !bottomOnly && inputPartsBy && inputIndex === inputPartsBy[itemIndex].length - 1,
+              bottomOnly,
+            },
+          ]"
           maxlength="2"
         />
       </div>
     </div>
     <div v-show="inputPartsDash" class="list">
-      <div v-for="(item, itemIndex) of inputPartsDash" :key="itemIndex" :class="`item ${itemIndex}`">
+      <div
+        v-for="(item, itemIndex) of inputPartsDash"
+        :key="itemIndex"
+        :class="[
+          `item ${itemIndex}`,
+          {
+            dashed: dashed && (inputPartsDash?.length ?? -1) - 1 !== itemIndex,
+          },
+        ]"
+      >
         <input
           v-for="(_, inputIndex) of item"
           :key="inputIndex"
           @input="toggleInput($event.target, itemIndex, +inputIndex)"
           @keydown.delete="toggleInput($event.target, itemIndex, +inputIndex, true)"
-          @keydown.left="moveFocus('left', container!, !!inputPartsBy, itemIndex, inputIndex)"
-          @keydown.right="moveFocus('right', container!, !!inputPartsBy, itemIndex, inputIndex)"
-          type="text"
-          :class="`input ${inputIndex}`"
+          @keydown.left="moveFocus('left', container!, isInputPartsBy, itemIndex, inputIndex)"
+          @keydown.right="moveFocus('right', container!, isInputPartsBy, itemIndex, inputIndex)"
+          :type="secret ? 'password' : 'text'"
+          :class="[
+            `input ${inputIndex}`,
+            {
+              firstInput: !bottomOnly && inputIndex === 0,
+              lastInput: !bottomOnly && inputPartsDash && inputIndex === inputPartsDash[itemIndex].length - 1,
+              bottomOnly,
+            },
+          ]"
           maxlength="2"
         />
       </div>
@@ -111,7 +134,7 @@ const toggleInput = (target: any, itemIndex: number, inputIndex: number, backspa
 <style scoped>
 .list {
   display: flex;
-  gap: v-bind(fontSize);
+  gap: v-bind(gap);
 }
 .input {
   all: unset;
@@ -121,7 +144,39 @@ const toggleInput = (target: any, itemIndex: number, inputIndex: number, backspa
   text-align: center;
   background-color: v-bind(themeColor);
   color: v-bind(color);
-  border: v-bind(borderWidth) solid black;
-  border-radius: 5px;
+  border-top: v-bind(borderWidth) solid v-bind(color);
+  border-bottom: v-bind(borderWidth) solid v-bind(color);
+  border-right: v-bind(borderWidth) solid v-bind(color);
+}
+.input.bottomOnly {
+  border: none;
+  border-bottom: v-bind(borderWidth) solid v-bind(color);
+}
+.item {
+  position: relative;
+  display: flex;
+  gap: v-bind(inputsGap);
+}
+.item.dashed::after {
+  position: absolute;
+  color: v-bind(color);
+  z-index: 2;
+  top: calc(50% - 2px);
+  right: v-bind(dashRight);
+  content: '-';
+  width: 10px;
+  height: 4px;
+  text-align: center;
+  line-height: 0;
+  font-size: v-bind(fontSize);
+}
+.firstInput {
+  border-left: v-bind(borderWidth) solid v-bind(color);
+  border-top-left-radius: 5px;
+  border-bottom-left-radius: 5px;
+}
+.lastInput {
+  border-top-right-radius: 5px;
+  border-bottom-right-radius: 5px;
 }
 </style>
diff --git a/src/components/InputDiv/helpers.ts b/src/components/InputDiv/helpers.ts
index ab51752..4ece187 100644
--- a/src/components/InputDiv/helpers.ts
+++ b/src/components/InputDiv/helpers.ts
@@ -9,6 +9,8 @@ export const changeInputHandler = (
   itemIndex: number,
   inputIndex: number,
   backspace: boolean,
+  numbersOnly: boolean,
+  regex: RegExp | null,
 ) => {
   let currentInput: HTMLInputElement | null = null;
   let currentItem: HTMLElement | null = null;
@@ -27,15 +29,26 @@ export const changeInputHandler = (
       }
     }
   }
+
+  const valueIndex = indexesToValueIndex[(itemIndex + '-' + inputIndex) as keyof typeof indexesToValueIndex] as number;
   // если значение ввели
-  if (currentInput?.value && currentItem) {
-    const valueIndex = indexesToValueIndex[
-      (itemIndex + '-' + inputIndex) as keyof typeof indexesToValueIndex
-    ] as number;
+  if (currentInput?.value && currentItem && !backspace) {
+    const prevIndexValue = valueParts[valueIndex];
     if (target.value.length === 2) {
-      currentInput!.value = target.value[0] === valueParts[valueIndex] ? target.value[1] : target.value[0];
+      currentInput!.value = target.value[0] === prevIndexValue ? target.value[1] : target.value[0];
+    }
+    if (numbersOnly && !currentInput!.value.match(/[0-9-]/)) {
+      if (!valueParts[valueIndex]) {
+        currentInput!.value = '';
+      }
+      return valueParts;
     }
     valueParts[valueIndex] = currentInput!.value;
+    if (regex && !regex.test(valueParts.join(''))) {
+      currentInput!.value = '';
+      valueParts[valueIndex] = prevIndexValue ?? '';
+      return valueParts;
+    }
     // поиск следующего инпута в той же части (если есть)
     let nextInputInSameItem: HTMLInputElement | null = null;
     for (const child of currentItem.children) {
@@ -54,7 +67,7 @@ export const changeInputHandler = (
         targetInput.focus();
       }
     }
-  } else if (backspace && currentItem) {
+  } else if (backspace && currentItem && currentInput) {
     // если значение удалили
     let prevInputInSameItem: HTMLInputElement | null = null;
     for (const child of currentItem.children) {
@@ -63,15 +76,28 @@ export const changeInputHandler = (
         break;
       }
     }
-    if (prevInputInSameItem) {
-      prevInputInSameItem.focus();
-    } else {
+
+    let deletedCurrentValue = false;
+    if (currentInput.value) {
+      valueParts[valueIndex] = '';
+      currentInput.value = '';
+      deletedCurrentValue = true;
+    } else if (prevInputInSameItem) {
+      valueParts[valueIndex - 1] = '';
+      setTimeout(() => prevInputInSameItem.focus(), 0);
+      prevInputInSameItem.value = '';
+    }
+    if (!prevInputInSameItem) {
       // обработка предыдущей части, если она есть, иначе ничего не делать
-      currentItem = list?.[currentItemIndex! - 1] as HTMLElement | null;
-      if (currentItem) {
-        const children = Array.from(currentItem.children);
+      const prevItem = list?.[currentItemIndex! - 1] as HTMLElement | null;
+      if (prevItem) {
+        const children = Array.from(prevItem.children);
         const targetInput = children[children.length - 1] as HTMLInputElement;
-        targetInput.focus();
+        setTimeout(() => targetInput.focus(), 0);
+        if (!deletedCurrentValue) {
+          targetInput.value = '';
+          valueParts[valueIndex - 1] = '';
+        }
       }
     }
   }
@@ -131,6 +157,32 @@ export const moveFocus = (
   }
 };
 
+export const calcIndexesToValueindex = (inputPartsBy: boolean, scheme: TInputDivScheme) => {
+  const result = {};
+  let index = 0;
+  if (inputPartsBy) {
+    const splat = scheme.split('by');
+    for (const itemIndex of [...Array(+splat[0]).keys()]) {
+      for (const inputIndex of [...Array(+splat[1]).keys()]) {
+        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+        //@ts-expect-error
+        result[itemIndex + '-' + inputIndex] = index++;
+      }
+    }
+  } else {
+    const splat = scheme.split('-').map((i: string) => +i);
+    for (const item of splat) {
+      for (const inputIndex of [...Array(item).keys()]) {
+        const itemIndex = splat.indexOf(item);
+        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+        //@ts-expect-error
+        result[itemIndex + '-' + inputIndex] = index++;
+      }
+    }
+  }
+  return result;
+};
+
 export const calcPartsBy = (scheme: TInputDivScheme) => {
   if (!scheme.includes('by')) return null;
   const splat = scheme.split('by');
-- 
GitLab