Bläddra i källkod

feat(组件): 新增input组件

gemercheung 2 år sedan
förälder
incheckning
165a958b4c

+ 4 - 0
docs/.vitepress/i18n/pages/component.json

@@ -16,6 +16,10 @@
           "text": "Audio音频"
         },
         {
+          "link": "/input",
+          "text": "Input 输入框"
+        },
+        {
           "link": "/dialog",
           "text": "Dialog对话框"
         }

+ 21 - 0
docs/examples/input/auto-sizing-textarea.vue

@@ -0,0 +1,21 @@
+<template>
+  <el-input
+    v-model="textarea1"
+    autosize
+    type="textarea"
+    placeholder="Please input"
+  />
+  <div style="margin: 20px 0" />
+  <el-input
+    v-model="textarea2"
+    :autosize="{ minRows: 2, maxRows: 4 }"
+    type="textarea"
+    placeholder="Please input"
+  />
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue'
+const textarea1 = ref('')
+const textarea2 = ref('')
+</script>

+ 8 - 0
docs/examples/input/basic.vue

@@ -0,0 +1,8 @@
+<template>
+  <el-input v-model="input" placeholder="Please input" />
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue'
+const input = ref('')
+</script>

+ 8 - 0
docs/examples/input/clearable.vue

@@ -0,0 +1,8 @@
+<template>
+  <el-input v-model="input" placeholder="Please input" clearable />
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue'
+const input = ref('')
+</script>

+ 8 - 0
docs/examples/input/disabled.vue

@@ -0,0 +1,8 @@
+<template>
+  <el-input v-model="input" disabled placeholder="Please input" />
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue'
+const input = ref('')
+</script>

+ 13 - 0
docs/examples/input/formatter.vue

@@ -0,0 +1,13 @@
+<template>
+  <el-input
+    v-model="input"
+    placeholder="Please input"
+    :formatter="(value) => `$ ${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')"
+    :parser="(value) => value.replace(/\$\s?|(,*)/g, '')"
+  />
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue'
+const input = ref('')
+</script>

+ 23 - 0
docs/examples/input/length-limiting.vue

@@ -0,0 +1,23 @@
+<template>
+  <el-input
+    v-model="text"
+    maxlength="10"
+    placeholder="Please input"
+    show-word-limit
+    type="text"
+  />
+  <div style="margin: 20px 0" />
+  <el-input
+    v-model="textarea"
+    maxlength="30"
+    placeholder="Please input"
+    show-word-limit
+    type="textarea"
+  />
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue'
+const text = ref('')
+const textarea = ref('')
+</script>

+ 63 - 0
docs/examples/input/mixed-input.vue

@@ -0,0 +1,63 @@
+<template>
+  <div>
+    <el-input v-model="input1" placeholder="Please input">
+      <template #prepend>Http://</template>
+    </el-input>
+  </div>
+  <div class="mt-4">
+    <el-input v-model="input2" placeholder="Please input">
+      <template #append>.com</template>
+    </el-input>
+  </div>
+  <div class="mt-4">
+    <el-input
+      v-model="input3"
+      placeholder="Please input"
+      class="input-with-select"
+    >
+      <template #prepend>
+        <el-select v-model="select" placeholder="Select" style="width: 115px">
+          <el-option label="Restaurant" value="1" />
+          <el-option label="Order No." value="2" />
+          <el-option label="Tel" value="3" />
+        </el-select>
+      </template>
+      <template #append>
+        <el-button :icon="Search" />
+      </template>
+    </el-input>
+  </div>
+  <div class="mt-4">
+    <el-input
+      v-model="input3"
+      placeholder="Please input"
+      class="input-with-select"
+    >
+      <template #prepend>
+        <el-button :icon="Search" />
+      </template>
+      <template #append>
+        <el-select v-model="select" placeholder="Select" style="width: 115px">
+          <el-option label="Restaurant" value="1" />
+          <el-option label="Order No." value="2" />
+          <el-option label="Tel" value="3" />
+        </el-select>
+      </template>
+    </el-input>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { Search } from '@element-plus/icons-vue'
+const input1 = ref('')
+const input2 = ref('')
+const input3 = ref('')
+const select = ref('')
+</script>
+
+<style>
+.input-with-select .el-input-group__prepend {
+  background-color: var(--el-fill-color-blank);
+}
+</style>

+ 13 - 0
docs/examples/input/password.vue

@@ -0,0 +1,13 @@
+<template>
+  <el-input
+    v-model="input"
+    type="password"
+    placeholder="Please input password"
+    show-password
+  />
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue'
+const input = ref('')
+</script>

+ 13 - 0
docs/examples/input/textarea.vue

@@ -0,0 +1,13 @@
+<template>
+  <el-input
+    v-model="textarea"
+    :rows="2"
+    type="textarea"
+    placeholder="Please input"
+  />
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue'
+const textarea = ref('')
+</script>

+ 69 - 0
docs/examples/input/various-size.vue

@@ -0,0 +1,69 @@
+<template>
+  <div class="demo-input-size">
+    <el-input
+      v-model="input1"
+      class="w-50 m-2"
+      size="large"
+      placeholder="Please Input"
+    />
+    <el-input v-model="input2" class="w-50 m-2" placeholder="Please Input" />
+    <el-input
+      v-model="input3"
+      class="w-50 m-2"
+      size="small"
+      placeholder="Please Input"
+    />
+  </div>
+  <div class="demo-input-size">
+    <el-input
+      v-model="input1"
+      class="w-50 m-2"
+      size="large"
+      placeholder="Please Input"
+      :suffix-icon="Search"
+    />
+    <el-input
+      v-model="input2"
+      class="w-50 m-2"
+      placeholder="Please Input"
+      :suffix-icon="Search"
+    />
+    <el-input
+      v-model="input3"
+      class="w-50 m-2"
+      size="small"
+      placeholder="Please Input"
+      :suffix-icon="Search"
+    />
+  </div>
+  <div class="demo-input-size">
+    <el-input
+      v-model="input1"
+      class="w-50 m-2"
+      size="large"
+      placeholder="Please Input"
+      :prefix-icon="Search"
+    />
+    <el-input
+      v-model="input2"
+      class="w-50 m-2"
+      placeholder="Please Input"
+      :prefix-icon="Search"
+    />
+    <el-input
+      v-model="input3"
+      class="w-50 m-2"
+      size="small"
+      placeholder="Please Input"
+      :prefix-icon="Search"
+    />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue'
+import { Search } from '@kankan-components/icons-vue'
+const input1 = ref('')
+const input2 = ref('')
+const input3 = ref('')
+</script>

+ 47 - 0
docs/examples/input/with-icon.vue

@@ -0,0 +1,47 @@
+<template>
+  <div class="demo-input-suffix">
+    <el-row :gutter="20">
+      <span class="ml-3 w-35 text-gray-600 inline-flex items-center"
+        >Using attributes</span
+      >
+      <el-input
+        v-model="input1"
+        class="w-50 m-2"
+        placeholder="Pick a date"
+        :suffix-icon="Calendar"
+      />
+      <el-input
+        v-model="input2"
+        class="w-50 m-2"
+        placeholder="Type something"
+        :prefix-icon="Search"
+      />
+    </el-row>
+  </div>
+  <div class="demo-input-suffix">
+    <el-row :gutter="20">
+      <span class="ml-3 w-35 text-gray-600 inline-flex items-center"
+        >Using slots</span
+      >
+      <el-input v-model="input3" class="w-50 m-2" placeholder="Pick a date">
+        <template #suffix>
+          <el-icon class="el-input__icon"><calendar /></el-icon>
+        </template>
+      </el-input>
+      <el-input v-model="input4" class="w-50 m-2" placeholder="Type something">
+        <template #prefix>
+          <el-icon class="el-input__icon"><search /></el-icon>
+        </template>
+      </el-input>
+    </el-row>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { Calendar, Search } from '@kankan-components/icons-vue'
+const input1 = ref('')
+const input2 = ref('')
+const input3 = ref('')
+const input4 = ref('')
+</script>

+ 1 - 1
docs/package.json

@@ -10,7 +10,7 @@
   },
   "dependencies": {
     "@docsearch/js": "^3.1.0",
-    "@element-plus/icons-vue": "^2.0.9",
+    "@kankan-components/icons-vue": "^0.0.1",
     "@kankan-components/metadata": "workspace:*",
     "@kankan-components/utils": "workspace:*",
     "@vue/repl": "^1.3.2",

+ 185 - 0
docs/zh-CN/component/input.md

@@ -0,0 +1,185 @@
+---
+title: Input
+lang: en-US
+---
+
+# Input
+
+Input data using mouse or keyboard.
+
+:::warning
+
+Input is a controlled component, it **always shows Vue binding value**.
+
+Under normal circumstances, `input` event should be handled. Its handler should update component's binding value (or use `v-model`). Otherwise, input box's value will not change.
+
+Do not support `v-model` modifiers.
+
+:::
+
+## Basic usage
+
+:::demo
+
+input/basic
+
+:::
+
+## Disabled
+
+:::demo Disable the Input with the `disabled` attribute.
+
+input/disabled
+
+:::
+
+## Clearable
+
+:::demo Make the Input clearable with the `clearable` attribute.
+
+input/clearable
+
+:::
+
+## Formatter
+
+Display value within it's situation with `formatter`, and we usually use `parser` at the same time.
+
+:::demo
+
+input/formatter
+
+:::
+
+## Password box
+
+:::demo Make a toggle-able password Input with the `show-password` attribute.
+
+input/password
+
+:::
+
+## Input with icon
+
+Add an icon to indicate input type.
+
+:::demo To add icons in Input, you can simply use `prefix-icon` and `suffix-icon` attributes. Also, the `prefix` and `suffix` named slots works as well.
+
+input/with-icon
+
+:::
+
+## Textarea
+
+Resizable for entering multiple lines of text information. Add attribute `type="textarea"` to change `input` into native `textarea`.
+
+:::demo Control the height by setting the `rows` prop.
+
+input/textarea
+
+:::
+
+## Autosize Textarea
+
+Setting the `autosize` prop for a textarea type of Input makes the height to automatically adjust based on the content. An options object can be provided to `autosize` to specify the minimum and maximum number of lines the textarea can automatically adjust.
+
+:::demo
+
+input/auto-sizing-textarea
+
+:::
+
+## Mixed input
+
+Prepend or append an element, generally a label or a button.
+
+:::demo Use `slot` to distribute elements that prepend or append to Input.
+
+input/mixed-input
+
+:::
+
+## Sizes
+
+:::demo Add `size` attribute to change the size of Input. In addition to the default size, there are two other options: `large`, `small`.
+
+input/various-size
+
+:::
+
+## Limit length
+
+:::demo `maxlength` and `minlength` attributes of input, they declare a limit on the number of characters a user can input. The "number of characters" is measured using JavaScript string length.Setting the `maxlength` prop for a text or textarea type of Input can limit the length of input value, allows you to show word count by setting `show-word-limit` to `true` at the same time.
+
+input/length-limiting
+
+:::
+
+## API
+
+### Attributes
+
+| Name                  | Description                                                                                                                            | Type                                                                                                                                                | Default |
+| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
+| type                  | type of input                                                                                                                          | ^[string]`'text' \| 'textarea' \| ...` [native input types](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#Form_%3Cinput%3E_types) | text    |
+| model-value / v-model | binding value                                                                                                                          | ^[string] / ^[number]                                                                                                                               | —       |
+| maxlength             | the max length                                                                                                                         | ^[string] / ^[number]                                                                                                                               | —       |
+| minlength             | same as `minlength` in native input                                                                                                    | ^[number]                                                                                                                                           | —       |
+| show-word-limit       | whether show word count, only works when `type` is 'text' or 'textarea'                                                                | ^[boolean]                                                                                                                                          | false   |
+| placeholder           | placeholder of Input                                                                                                                   | ^[string]                                                                                                                                           | —       |
+| clearable             | whether to show clear button, only works when `type` is not 'textarea'                                                                 | ^[boolean]                                                                                                                                          | false   |
+| formatter             | specifies the format of the value presented input.(only works when `type` is 'text')                                                   | ^[Function]`(value: string \| number) => string`                                                                                                    | —       |
+| parser                | specifies the value extracted from formatter input.(only works when `type` is 'text')                                                  | ^[Function]`(value: string) => string`                                                                                                              | —       |
+| show-password         | whether to show toggleable password input                                                                                              | ^[boolean]                                                                                                                                          | false   |
+| disabled              | whether Input is disabled                                                                                                              | ^[boolean]                                                                                                                                          | false   |
+| size                  | size of Input, works when `type` is not 'textarea'                                                                                     | ^[enum]`'large' \| 'default' \| 'small'`                                                                                                            | —       |
+| prefix-icon           | prefix icon component                                                                                                                  | ^[string] / ^[Component]                                                                                                                            | —       |
+| suffix-icon           | suffix icon component                                                                                                                  | ^[string] / ^[Component]                                                                                                                            | —       |
+| rows                  | number of rows of textarea, only works when `type` is 'textarea'                                                                       | ^[number]                                                                                                                                           | 2       |
+| autosize              | whether textarea has an adaptive height, only works when `type` is 'textarea'. Can accept an object, e.g. `{ minRows: 2, maxRows: 6 }` | ^[boolean] / ^[object]`{ minRows?: number, maxRows?: number }`                                                                                      | false   |
+| autocomplete          | same as `autocomplete` in native input                                                                                                 | ^[string]                                                                                                                                           | off     |
+| name                  | same as `name` in native input                                                                                                         | ^[string]                                                                                                                                           | —       |
+| readonly              | same as `readonly` in native input                                                                                                     | ^[boolean]                                                                                                                                          | false   |
+| max                   | same as `max` in native input                                                                                                          | —                                                                                                                                                   | —       |
+| min                   | same as `min` in native input                                                                                                          | —                                                                                                                                                   | —       |
+| step                  | same as `step` in native input                                                                                                         | —                                                                                                                                                   | —       |
+| resize                | control the resizability                                                                                                               | ^[enum]`'none' \| 'both' \| 'horizontal' \| 'vertical'`                                                                                             | —       |
+| autofocus             | same as `autofocus` in native input                                                                                                    | ^[boolean]                                                                                                                                          | false   |
+| form                  | same as `form` in native input                                                                                                         | `string`                                                                                                                                            | —       |
+| label<A11yTag />      | same as `aria-label` in native input                                                                                                   | ^[string]                                                                                                                                           | —       |
+| tabindex              | input tabindex                                                                                                                         | ^[string] / ^[number]                                                                                                                               | —       |
+| validate-event        | whether to trigger form validation                                                                                                     | ^[boolean]                                                                                                                                          | true    |
+| input-style           | the style of the input element or textarea element                                                                                     | ^[string] / ^[object]`CSSProperties \| CSSProperties[] \| string[]`                                                                                 | {}      |
+
+### Events
+
+| Name   | Description                                                                                           | Type                                           |
+| ------ | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
+| blur   | triggers when Input blurs                                                                             | ^[Function]`(event: FocusEvent) => void`       |
+| focus  | triggers when Input focuses                                                                           | ^[Function]`(event: FocusEvent) => void`       |
+| change | triggers when the input box loses focus or the user presses Enter, only if the modelValue has changed | ^[Function]`(value: string \| number) => void` |
+| input  | triggers when the Input value change                                                                  | ^[Function]`(value: string \| number) => void` |
+| clear  | triggers when the Input is cleared by clicking the clear button                                       | ^[Function]`() => void`                        |
+
+### Slots
+
+| Name    | Description                                                               |
+| ------- | ------------------------------------------------------------------------- |
+| prefix  | content as Input prefix, only works when `type` is not 'textarea'         |
+| suffix  | content as Input suffix, only works when `type` is not 'textarea'         |
+| prepend | content to prepend before Input, only works when `type` is not 'textarea' |
+| append  | content to append after Input, only works when `type` is not 'textarea'   |
+
+### Exposes
+
+| Name           | Description                      | Type                                                    |
+| -------------- | -------------------------------- | ------------------------------------------------------- |
+| blur           | blur the input element           | ^[Function]`() => void`                                 |
+| clear          | clear input value                | ^[Function]`() => void`                                 |
+| focus          | focus the input element          | ^[Function]`() => void`                                 |
+| input          | HTML input element               | ^[object]`Ref<HTMLInputElement>`                        |
+| ref            | HTML element, input or textarea  | ^[object]`Ref<HTMLInputElement \| HTMLTextAreaElement>` |
+| resizeTextarea | resize textarea                  | ^[Function]`() => void`                                 |
+| select         | select the text in input element | ^[Function]`() => void`                                 |
+| textarea       | HTML textarea element            | ^[object]`Ref<HTMLTextAreaElement>`                     |
+| textareaStyle  | style of textarea                | ^[object]`Ref<StyleValue>`                              |

+ 5 - 4
package.json

@@ -36,6 +36,7 @@
     "@kankan-components/components": "workspace:*",
     "@kankan-components/constants": "workspace:*",
     "@kankan-components/hooks": "workspace:*",
+    "@kankan-components/icons-vue": "^0.0.1",
     "@kankan-components/theme-chalk": "workspace:*",
     "@kankan-components/tokens": "workspace:*",
     "@kankan-components/utils": "workspace:*",
@@ -57,9 +58,9 @@
     "@types/fs-extra": "^9.0.13",
     "@types/gulp": "^4.0.10",
     "@types/jsdom": "^16.2.15",
-    "@types/node": "^18.11.18",
+    "@types/node": "^18.13.0",
     "@types/sass": "^1.43.1",
-    "@typescript-eslint/eslint-plugin": "^5.50.0",
+    "@typescript-eslint/eslint-plugin": "^5.51.0",
     "@vitejs/plugin-vue": "^3.2.0",
     "@vitejs/plugin-vue-jsx": "^2.1.1",
     "@vue/test-utils": "^2.2.10",
@@ -75,9 +76,9 @@
     "eslint-plugin-prettier": "^4.2.1",
     "eslint-plugin-vue": "^8.7.1",
     "husky": "^8.0.3",
-    "jest": "^29.4.1",
+    "jest": "^29.4.2",
     "jsdom": "16.4.0",
-    "lint-staged": "^13.1.0",
+    "lint-staged": "^13.1.1",
     "resize-observer-polyfill": "^1.5.1",
     "sass": "^1.58.0",
     "typescript": "~4.7.4",

+ 1 - 0
packages/components/basic/index.ts

@@ -2,6 +2,7 @@ export * from './icon'
 export * from './button'
 export * from './audio'
 export * from './overlay'
+export * from './input'
 export * from './dialog'
 export * from './config-provider'
 export * from './focus-trap'

+ 10 - 0
packages/components/basic/input/index.ts

@@ -0,0 +1,10 @@
+import { withInstall } from '@kankan-components/utils'
+
+import Input from './src/input.vue'
+
+export const KkInput = withInstall(Input)
+export default KkInput
+
+export * from './src/input'
+
+export type InputInstance = InstanceType<typeof Input>

+ 190 - 0
packages/components/basic/input/src/input.ts

@@ -0,0 +1,190 @@
+import { isString } from '@vue/shared'
+import {
+  buildProps,
+  definePropType,
+  iconPropType,
+  mutable,
+} from '@kankan-components/utils'
+import { UPDATE_MODEL_EVENT } from '@kankan-components/constants'
+import { useSizeProp } from '@kankan-components/hooks'
+import type Input from './input.vue'
+import type { ExtractPropTypes, StyleValue } from 'vue'
+
+export type InputAutoSize = { minRows?: number; maxRows?: number } | boolean
+
+export const inputProps = buildProps({
+  /**
+   * @description native input id
+   */
+  id: {
+    type: String,
+    default: undefined,
+  },
+  /**
+   * @description input box size
+   */
+  size: useSizeProp,
+  /**
+   * @description whether to disable
+   */
+  disabled: Boolean,
+  /**
+   * @description binding value
+   */
+  modelValue: {
+    type: definePropType<string | number | null | undefined>([
+      String,
+      Number,
+      Object,
+    ]),
+    default: '',
+  },
+  /**
+   * @description type of input
+   */
+  type: {
+    type: String,
+    default: 'text',
+  },
+  /**
+   * @description control the resizability
+   */
+  resize: {
+    type: String,
+    values: ['none', 'both', 'horizontal', 'vertical'],
+  },
+  /**
+   * @description whether textarea has an adaptive height
+   */
+  autosize: {
+    type: definePropType<InputAutoSize>([Boolean, Object]),
+    default: false,
+  },
+  /**
+   * @description native input autocomplete
+   */
+  autocomplete: {
+    type: String,
+    default: 'off',
+  },
+  /**
+   * @description format content
+   */
+  formatter: {
+    type: Function,
+  },
+  /**
+   * @description parse content
+   */
+  parser: {
+    type: Function,
+  },
+  /**
+   * @description placeholder
+   */
+  placeholder: {
+    type: String,
+  },
+  /**
+   * @description native input form
+   */
+  form: {
+    type: String,
+  },
+  /**
+   * @description native input readonly
+   */
+  readonly: {
+    type: Boolean,
+    default: false,
+  },
+  /**
+   * @description native input readonly
+   */
+  clearable: {
+    type: Boolean,
+    default: false,
+  },
+  /**
+   * @description toggleable password input
+   */
+  showPassword: {
+    type: Boolean,
+    default: false,
+  },
+  /**
+   * @description word count
+   */
+  showWordLimit: {
+    type: Boolean,
+    default: false,
+  },
+  /**
+   * @description suffix icon
+   */
+  suffixIcon: {
+    type: iconPropType,
+  },
+  /**
+   * @description prefix icon
+   */
+  prefixIcon: {
+    type: iconPropType,
+  },
+  /**
+   * @description container role, internal properties provided for use by the picker component
+   */
+  containerRole: {
+    type: String,
+    default: undefined,
+  },
+  /**
+   * @description native input aria-label
+   */
+  label: {
+    type: String,
+    default: undefined,
+  },
+  /**
+   * @description input tabindex
+   */
+  tabindex: {
+    type: [String, Number],
+    default: 0,
+  },
+  /**
+   * @description whether to trigger form validation
+   */
+  validateEvent: {
+    type: Boolean,
+    default: true,
+  },
+  /**
+   * @description input or texearea element style
+   */
+  inputStyle: {
+    type: definePropType<StyleValue>([Object, Array, String]),
+    default: () => mutable({} as const),
+  },
+} as const)
+export type InputProps = ExtractPropTypes<typeof inputProps>
+
+export const inputEmits = {
+  [UPDATE_MODEL_EVENT]: (value: string) => isString(value),
+  input: (value: string) => isString(value),
+  change: (value: string) => isString(value),
+  focus: (evt: FocusEvent) => evt instanceof FocusEvent,
+  blur: (evt: FocusEvent) => evt instanceof FocusEvent,
+  clear: () => true,
+  mouseleave: (evt: MouseEvent) => evt instanceof MouseEvent,
+  mouseenter: (evt: MouseEvent) => evt instanceof MouseEvent,
+  // NOTE: when autofill by browser, the keydown event is instanceof Event, not KeyboardEvent
+  // relative bug report https://github.com/kankan-components/kankan-components/issues/6665
+  keydown: (evt: KeyboardEvent | Event) => evt instanceof Event,
+  compositionstart: (evt: CompositionEvent) => evt instanceof CompositionEvent,
+  compositionupdate: (evt: CompositionEvent) => evt instanceof CompositionEvent,
+  compositionend: (evt: CompositionEvent) => evt instanceof CompositionEvent,
+}
+export type InputEmits = typeof inputEmits
+
+export type InputInstance = InstanceType<typeof Input>

+ 519 - 0
packages/components/basic/input/src/input.vue

@@ -0,0 +1,519 @@
+<template>
+  <div
+    v-show="type !== 'hidden'"
+    v-bind="containerAttrs"
+    :class="containerKls"
+    :style="containerStyle"
+    :role="containerRole"
+    @mouseenter="handleMouseEnter"
+    @mouseleave="handleMouseLeave"
+  >
+    <!-- input -->
+    <template v-if="type !== 'textarea'">
+      <!-- prepend slot -->
+      <div v-if="$slots.prepend" :class="nsInput.be('group', 'prepend')">
+        <slot name="prepend" />
+      </div>
+
+      <div :class="wrapperKls">
+        <!-- prefix slot -->
+        <span v-if="$slots.prefix || prefixIcon" :class="nsInput.e('prefix')">
+          <span :class="nsInput.e('prefix-inner')" @click="focus">
+            <slot name="prefix" />
+            <kk-icon v-if="prefixIcon" :class="nsInput.e('icon')">
+              <component :is="prefixIcon" />
+            </kk-icon>
+          </span>
+        </span>
+
+        <input
+          :id="inputId"
+          ref="input"
+          :class="nsInput.e('inner')"
+          v-bind="attrs"
+          :type="showPassword ? (passwordVisible ? 'text' : 'password') : type"
+          :disabled="inputDisabled"
+          :formatter="formatter"
+          :parser="parser"
+          :readonly="readonly"
+          :autocomplete="autocomplete"
+          :tabindex="tabindex"
+          :aria-label="label"
+          :placeholder="placeholder"
+          :style="inputStyle"
+          :form="props.form"
+          @compositionstart="handleCompositionStart"
+          @compositionupdate="handleCompositionUpdate"
+          @compositionend="handleCompositionEnd"
+          @input="handleInput"
+          @focus="handleFocus"
+          @blur="handleBlur"
+          @change="handleChange"
+          @keydown="handleKeydown"
+        />
+
+        <!-- suffix slot -->
+        <span v-if="suffixVisible" :class="nsInput.e('suffix')">
+          <span :class="nsInput.e('suffix-inner')" @click="focus">
+            <template
+              v-if="!showClear || !showPwdVisible || !isWordLimitVisible"
+            >
+              <slot name="suffix" />
+              <kk-icon v-if="suffixIcon" :class="nsInput.e('icon')">
+                <component :is="suffixIcon" />
+              </kk-icon>
+            </template>
+            <kk-icon
+              v-if="showClear"
+              :class="[nsInput.e('icon'), nsInput.e('clear')]"
+              @mousedown.prevent="NOOP"
+              @click="clear"
+            >
+              <circle-close />
+            </kk-icon>
+            <kk-icon
+              v-if="showPwdVisible"
+              :class="[nsInput.e('icon'), nsInput.e('password')]"
+              @click="handlePasswordVisible"
+            >
+              <component :is="passwordIcon" />
+            </kk-icon>
+            <span v-if="isWordLimitVisible" :class="nsInput.e('count')">
+              <span :class="nsInput.e('count-inner')">
+                {{ textLength }} / {{ attrs.maxlength }}
+              </span>
+            </span>
+            <kk-icon
+              v-if="validateState && validateIcon && needStatusIcon"
+              :class="[
+                nsInput.e('icon'),
+                nsInput.e('validateIcon'),
+                nsInput.is('loading', validateState === 'validating'),
+              ]"
+            >
+              <component :is="validateIcon" />
+            </kk-icon>
+          </span>
+        </span>
+      </div>
+
+      <!-- append slot -->
+      <div v-if="$slots.append" :class="nsInput.be('group', 'append')">
+        <slot name="append" />
+      </div>
+    </template>
+
+    <!-- textarea -->
+    <template v-else>
+      <textarea
+        :id="inputId"
+        ref="textarea"
+        :class="nsTextarea.e('inner')"
+        v-bind="attrs"
+        :tabindex="tabindex"
+        :disabled="inputDisabled"
+        :readonly="readonly"
+        :autocomplete="autocomplete"
+        :style="textareaStyle"
+        :aria-label="label"
+        :placeholder="placeholder"
+        :form="props.form"
+        @compositionstart="handleCompositionStart"
+        @compositionupdate="handleCompositionUpdate"
+        @compositionend="handleCompositionEnd"
+        @input="handleInput"
+        @focus="handleFocus"
+        @blur="handleBlur"
+        @change="handleChange"
+        @keydown="handleKeydown"
+      />
+      <span
+        v-if="isWordLimitVisible"
+        :style="countStyle"
+        :class="nsInput.e('count')"
+      >
+        {{ textLength }} / {{ attrs.maxlength }}
+      </span>
+    </template>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import {
+  computed,
+  nextTick,
+  onMounted,
+  ref,
+  shallowRef,
+  toRef,
+  useAttrs as useRawAttrs,
+  useSlots,
+  watch,
+} from 'vue'
+import { isClient, useResizeObserver } from '@vueuse/core'
+import { isNil } from 'lodash-unified'
+import { KkIcon } from '@kankan-components/components/basic/icon'
+import {
+  CircleClose,
+  Hide as IconHide,
+  View as IconView,
+} from '@kankan-components/icons-vue'
+import {
+  NOOP,
+  ValidateComponentsMap,
+  debugWarn,
+  isKorean,
+  isObject,
+} from '@kankan-components/utils'
+import {
+  useAttrs,
+  useCursor,
+  useDisabled,
+  useFormItem,
+  useFormItemInputId,
+  useNamespace,
+  useSize,
+} from '@kankan-components/hooks'
+import { UPDATE_MODEL_EVENT } from '@kankan-components/constants'
+import { calcTextareaHeight } from './utils'
+import { inputEmits, inputProps } from './input'
+import type { StyleValue } from 'vue'
+
+type TargetElement = HTMLInputElement | HTMLTextAreaElement
+
+defineOptions({
+  name: 'KkInput',
+  inheritAttrs: false,
+})
+const props = defineProps(inputProps)
+const emit = defineEmits(inputEmits)
+
+const rawAttrs = useRawAttrs()
+const slots = useSlots()
+
+const containerAttrs = computed(() => {
+  const comboBoxAttrs: Record<string, unknown> = {}
+  if (props.containerRole === 'combobox') {
+    comboBoxAttrs['aria-haspopup'] = rawAttrs['aria-haspopup']
+    comboBoxAttrs['aria-owns'] = rawAttrs['aria-owns']
+    comboBoxAttrs['aria-expanded'] = rawAttrs['aria-expanded']
+  }
+  return comboBoxAttrs
+})
+
+const containerKls = computed(() => [
+  props.type === 'textarea' ? nsTextarea.b() : nsInput.b(),
+  nsInput.m(inputSize.value),
+  nsInput.is('disabled', inputDisabled.value),
+  nsInput.is('exceed', inputExceed.value),
+  {
+    [nsInput.b('group')]: slots.prepend || slots.append,
+    [nsInput.bm('group', 'append')]: slots.append,
+    [nsInput.bm('group', 'prepend')]: slots.prepend,
+    [nsInput.m('prefix')]: slots.prefix || props.prefixIcon,
+    [nsInput.m('suffix')]:
+      slots.suffix || props.suffixIcon || props.clearable || props.showPassword,
+    [nsInput.bm('suffix', 'password-clear')]:
+      showClear.value && showPwdVisible.value,
+  },
+  rawAttrs.class,
+])
+
+const wrapperKls = computed(() => [
+  nsInput.e('wrapper'),
+  nsInput.is('focus', focused.value),
+])
+
+const attrs = useAttrs({
+  excludeKeys: computed<string[]>(() => {
+    return Object.keys(containerAttrs.value)
+  }),
+})
+const { form, formItem } = useFormItem()
+const { inputId } = useFormItemInputId(props, {
+  formItemContext: formItem,
+})
+const inputSize = useSize()
+const inputDisabled = useDisabled()
+const nsInput = useNamespace('input')
+const nsTextarea = useNamespace('textarea')
+
+const input = shallowRef<HTMLInputElement>()
+const textarea = shallowRef<HTMLTextAreaElement>()
+
+const focused = ref(false)
+const hovering = ref(false)
+const isComposing = ref(false)
+const passwordVisible = ref(false)
+const countStyle = ref<StyleValue>()
+const textareaCalcStyle = shallowRef(props.inputStyle)
+
+const _ref = computed(() => input.value || textarea.value)
+
+const needStatusIcon = computed(() => form?.statusIcon ?? false)
+const validateState = computed(() => formItem?.validateState || '')
+const validateIcon = computed(
+  () => validateState.value && ValidateComponentsMap[validateState.value]
+)
+const passwordIcon = computed(() =>
+  passwordVisible.value ? IconView : IconHide
+)
+const containerStyle = computed<StyleValue>(() => [
+  rawAttrs.style as StyleValue,
+  props.inputStyle,
+])
+const textareaStyle = computed<StyleValue>(() => [
+  props.inputStyle,
+  textareaCalcStyle.value,
+  { resize: props.resize },
+])
+const nativeInputValue = computed(() =>
+  isNil(props.modelValue) ? '' : String(props.modelValue)
+)
+const showClear = computed(
+  () =>
+    props.clearable &&
+    !inputDisabled.value &&
+    !props.readonly &&
+    !!nativeInputValue.value &&
+    (focused.value || hovering.value)
+)
+const showPwdVisible = computed(
+  () =>
+    props.showPassword &&
+    !inputDisabled.value &&
+    !props.readonly &&
+    !!nativeInputValue.value &&
+    (!!nativeInputValue.value || focused.value)
+)
+const isWordLimitVisible = computed(
+  () =>
+    props.showWordLimit &&
+    !!attrs.value.maxlength &&
+    (props.type === 'text' || props.type === 'textarea') &&
+    !inputDisabled.value &&
+    !props.readonly &&
+    !props.showPassword
+)
+const textLength = computed(() => Array.from(nativeInputValue.value).length)
+const inputExceed = computed(
+  () =>
+    // show exceed style if length of initial value greater then maxlength
+    !!isWordLimitVisible.value &&
+    textLength.value > Number(attrs.value.maxlength)
+)
+const suffixVisible = computed(
+  () =>
+    !!slots.suffix ||
+    !!props.suffixIcon ||
+    showClear.value ||
+    props.showPassword ||
+    isWordLimitVisible.value ||
+    (!!validateState.value && needStatusIcon.value)
+)
+
+const [recordCursor, setCursor] = useCursor(input)
+
+useResizeObserver(textarea, (entries) => {
+  if (!isWordLimitVisible.value || props.resize !== 'both') return
+  const entry = entries[0]
+  const { width } = entry.contentRect
+  countStyle.value = {
+    /** right: 100% - width + padding(15) + right(6) */
+    right: `calc(100% - ${width + 15 + 6}px)`,
+  }
+})
+
+const resizeTextarea = () => {
+  const { type, autosize } = props
+
+  if (!isClient || type !== 'textarea') return
+
+  if (autosize) {
+    const minRows = isObject(autosize) ? autosize.minRows : undefined
+    const maxRows = isObject(autosize) ? autosize.maxRows : undefined
+    textareaCalcStyle.value = {
+      ...calcTextareaHeight(textarea.value!, minRows, maxRows),
+    }
+  } else {
+    textareaCalcStyle.value = {
+      minHeight: calcTextareaHeight(textarea.value!).minHeight,
+    }
+  }
+}
+
+const setNativeInputValue = () => {
+  const input = _ref.value
+  if (!input || input.value === nativeInputValue.value) return
+  input.value = nativeInputValue.value
+}
+
+const handleInput = async (event: Event) => {
+  recordCursor()
+
+  let { value } = event.target as TargetElement
+
+  if (props.formatter) {
+    value = props.parser ? props.parser(value) : value
+    value = props.formatter(value)
+  }
+
+  // should not emit input during composition
+  // see: https://github.com/ElemeFE/element/issues/10516
+  if (isComposing.value) return
+
+  // hack for https://github.com/ElemeFE/element/issues/8548
+  // should remove the following line when we don't support IE
+  if (value === nativeInputValue.value) {
+    setNativeInputValue()
+    return
+  }
+
+  emit(UPDATE_MODEL_EVENT, value)
+  emit('input', value)
+
+  // ensure native input value is controlled
+  // see: https://github.com/ElemeFE/element/issues/12850
+  await nextTick()
+  setNativeInputValue()
+  setCursor()
+}
+
+const handleChange = (event: Event) => {
+  emit('change', (event.target as TargetElement).value)
+}
+
+const handleCompositionStart = (event: CompositionEvent) => {
+  emit('compositionstart', event)
+  isComposing.value = true
+}
+
+const handleCompositionUpdate = (event: CompositionEvent) => {
+  emit('compositionupdate', event)
+  const text = (event.target as HTMLInputElement)?.value
+  const lastCharacter = text[text.length - 1] || ''
+  isComposing.value = !isKorean(lastCharacter)
+}
+
+const handleCompositionEnd = (event: CompositionEvent) => {
+  emit('compositionend', event)
+  if (isComposing.value) {
+    isComposing.value = false
+    handleInput(event)
+  }
+}
+
+const handlePasswordVisible = () => {
+  passwordVisible.value = !passwordVisible.value
+  focus()
+}
+
+const focus = async () => {
+  // see: https://github.com/ElemeFE/element/issues/18573
+  await nextTick()
+  _ref.value?.focus()
+}
+
+const blur = () => _ref.value?.blur()
+
+const handleFocus = (event: FocusEvent) => {
+  focused.value = true
+  emit('focus', event)
+}
+
+const handleBlur = (event: FocusEvent) => {
+  focused.value = false
+  emit('blur', event)
+  if (props.validateEvent) {
+    formItem?.validate?.('blur').catch((err) => debugWarn(err))
+  }
+}
+
+const handleMouseLeave = (evt: MouseEvent) => {
+  hovering.value = false
+  emit('mouseleave', evt)
+}
+
+const handleMouseEnter = (evt: MouseEvent) => {
+  hovering.value = true
+  emit('mouseenter', evt)
+}
+
+const handleKeydown = (evt: KeyboardEvent) => {
+  emit('keydown', evt)
+}
+
+const select = () => {
+  _ref.value?.select()
+}
+
+const clear = () => {
+  emit(UPDATE_MODEL_EVENT, '')
+  emit('change', '')
+  emit('clear')
+  emit('input', '')
+}
+
+watch(
+  () => props.modelValue,
+  () => {
+    nextTick(() => resizeTextarea())
+    if (props.validateEvent) {
+      formItem?.validate?.('change').catch((err) => debugWarn(err))
+    }
+  }
+)
+
+// native input value is set explicitly
+// do not use v-model / :value in template
+// see: https://github.com/ElemeFE/element/issues/14521
+watch(nativeInputValue, () => setNativeInputValue())
+
+// when change between <input> and <textarea>,
+// update DOM dependent value and styles
+// https://github.com/ElemeFE/element/issues/14857
+watch(
+  () => props.type,
+  async () => {
+    await nextTick()
+    setNativeInputValue()
+    resizeTextarea()
+  }
+)
+
+onMounted(() => {
+  if (!props.formatter && props.parser) {
+    debugWarn(
+      'ElInput',
+      'If you set the parser, you also need to set the formatter.'
+    )
+  }
+  setNativeInputValue()
+  nextTick(resizeTextarea)
+})
+
+defineExpose({
+  /** @description HTML input element */
+  input,
+  /** @description HTML textarea element */
+  textarea,
+  /** @description HTML element, input or textarea */
+  ref: _ref,
+  /** @description style of textarea. */
+  textareaStyle,
+
+  /** @description from props (used on unit test) */
+  autosize: toRef(props, 'autosize'),
+
+  /** @description HTML input element native method */
+  focus,
+  /** @description HTML input element native method */
+  blur,
+  /** @description HTML input element native method */
+  select,
+  /** @description clear input value */
+  clear,
+  /** @description resize textarea. */
+  resizeTextarea,
+})
+</script>

+ 113 - 0
packages/components/basic/input/src/utils.ts

@@ -0,0 +1,113 @@
+import { isNumber } from '@kankan-components/utils'
+
+let hiddenTextarea: HTMLTextAreaElement | undefined = undefined
+
+const HIDDEN_STYLE = `
+  height:0 !important;
+  visibility:hidden !important;
+  overflow:hidden !important;
+  position:absolute !important;
+  z-index:-1000 !important;
+  top:0 !important;
+  right:0 !important;
+`
+
+const CONTEXT_STYLE = [
+  'letter-spacing',
+  'line-height',
+  'padding-top',
+  'padding-bottom',
+  'font-family',
+  'font-weight',
+  'font-size',
+  'text-rendering',
+  'text-transform',
+  'width',
+  'text-indent',
+  'padding-left',
+  'padding-right',
+  'border-width',
+  'box-sizing',
+]
+
+type NodeStyle = {
+  contextStyle: string
+  boxSizing: string
+  paddingSize: number
+  borderSize: number
+}
+
+type TextAreaHeight = {
+  height: string
+  minHeight?: string
+}
+
+function calculateNodeStyling(targetElement: Element): NodeStyle {
+  const style = window.getComputedStyle(targetElement)
+
+  const boxSizing = style.getPropertyValue('box-sizing')
+
+  const paddingSize =
+    Number.parseFloat(style.getPropertyValue('padding-bottom')) +
+    Number.parseFloat(style.getPropertyValue('padding-top'))
+
+  const borderSize =
+    Number.parseFloat(style.getPropertyValue('border-bottom-width')) +
+    Number.parseFloat(style.getPropertyValue('border-top-width'))
+
+  const contextStyle = CONTEXT_STYLE.map(
+    (name) => `${name}:${style.getPropertyValue(name)}`
+  ).join(';')
+
+  return { contextStyle, paddingSize, borderSize, boxSizing }
+}
+
+export function calcTextareaHeight(
+  targetElement: HTMLTextAreaElement,
+  minRows = 1,
+  maxRows?: number
+): TextAreaHeight {
+  if (!hiddenTextarea) {
+    hiddenTextarea = document.createElement('textarea')
+    document.body.appendChild(hiddenTextarea)
+  }
+
+  const { paddingSize, borderSize, boxSizing, contextStyle } =
+    calculateNodeStyling(targetElement)
+
+  hiddenTextarea.setAttribute('style', `${contextStyle};${HIDDEN_STYLE}`)
+  hiddenTextarea.value = targetElement.value || targetElement.placeholder || ''
+
+  let height = hiddenTextarea.scrollHeight
+  const result = {} as TextAreaHeight
+
+  if (boxSizing === 'border-box') {
+    height = height + borderSize
+  } else if (boxSizing === 'content-box') {
+    height = height - paddingSize
+  }
+
+  hiddenTextarea.value = ''
+  const singleRowHeight = hiddenTextarea.scrollHeight - paddingSize
+
+  if (isNumber(minRows)) {
+    let minHeight = singleRowHeight * minRows
+    if (boxSizing === 'border-box') {
+      minHeight = minHeight + paddingSize + borderSize
+    }
+    height = Math.max(minHeight, height)
+    result.minHeight = `${minHeight}px`
+  }
+  if (isNumber(maxRows)) {
+    let maxHeight = singleRowHeight * maxRows
+    if (boxSizing === 'border-box') {
+      maxHeight = maxHeight + paddingSize + borderSize
+    }
+    height = Math.min(maxHeight, height)
+  }
+  result.height = `${height}px`
+  hiddenTextarea.parentNode?.removeChild(hiddenTextarea)
+  hiddenTextarea = undefined
+
+  return result
+}

+ 2 - 0
packages/components/basic/input/style/css.ts

@@ -0,0 +1,2 @@
+import '@kankan-components/components/base/style/css'
+import '@kankan-components/theme-chalk/kk-input.css'

+ 2 - 0
packages/components/basic/input/style/index.ts

@@ -0,0 +1,2 @@
+import '@kankan-components/components/base/style'
+import '@kankan-components/theme-chalk/src/input.scss'

+ 2 - 0
packages/hooks/index.ts

@@ -8,3 +8,5 @@ export * from './use-escape-keydown'
 export * from './use-lockscreen'
 export * from './use-escape-keydown'
 export * from './use-common-props'
+export * from './use-attrs'
+export * from './use-cursor'

+ 41 - 0
packages/hooks/use-attrs/index.ts

@@ -0,0 +1,41 @@
+import { computed, getCurrentInstance } from 'vue'
+import { fromPairs } from 'lodash-unified'
+import { debugWarn } from '@kankan-components/utils'
+
+import type { ComputedRef } from 'vue'
+
+interface Params {
+  excludeListeners?: boolean
+  excludeKeys?: ComputedRef<string[]>
+}
+
+const DEFAULT_EXCLUDE_KEYS = ['class', 'style']
+const LISTENER_PREFIX = /^on[A-Z]/
+
+export const useAttrs = (
+  params: Params = {}
+): ComputedRef<Record<string, unknown>> => {
+  const { excludeListeners = false, excludeKeys } = params
+  const allExcludeKeys = computed<string[]>(() => {
+    return (excludeKeys?.value || []).concat(DEFAULT_EXCLUDE_KEYS)
+  })
+
+  const instance = getCurrentInstance()
+  if (!instance) {
+    debugWarn(
+      'use-attrs',
+      'getCurrentInstance() returned null. useAttrs() must be called at the top of a setup function'
+    )
+    return computed(() => ({}))
+  }
+
+  return computed(() =>
+    fromPairs(
+      Object.entries(instance.proxy?.$attrs!).filter(
+        ([key]) =>
+          !allExcludeKeys.value.includes(key) &&
+          !(excludeListeners && LISTENER_PREFIX.test(key))
+      )
+    )
+  )
+}

+ 66 - 0
packages/hooks/use-cursor/index.ts

@@ -0,0 +1,66 @@
+import { ref } from 'vue'
+
+import type { ShallowRef } from 'vue'
+
+// Keep input cursor in the correct position when we use formatter.
+export function useCursor(
+  input: ShallowRef<HTMLInputElement | undefined>
+): [() => void, () => void] {
+  const selectionRef = ref<{
+    selectionStart?: number
+    selectionEnd?: number
+    value?: string
+    beforeTxt?: string
+    afterTxt?: string
+  }>()
+
+  function recordCursor() {
+    if (input.value == undefined) return
+
+    const { selectionStart, selectionEnd, value } = input.value
+
+    if (selectionStart == null || selectionEnd == null) return
+
+    const beforeTxt = value.slice(0, Math.max(0, selectionStart))
+    const afterTxt = value.slice(Math.max(0, selectionEnd))
+
+    selectionRef.value = {
+      selectionStart,
+      selectionEnd,
+      value,
+      beforeTxt,
+      afterTxt,
+    }
+  }
+  function setCursor() {
+    if (input.value == undefined || selectionRef.value == undefined) return
+
+    const { value } = input.value
+    const { beforeTxt, afterTxt, selectionStart } = selectionRef.value
+
+    if (
+      beforeTxt == undefined ||
+      afterTxt == undefined ||
+      selectionStart == undefined
+    )
+      return
+
+    let startPos = value.length
+
+    if (value.endsWith(afterTxt)) {
+      startPos = value.length - afterTxt.length
+    } else if (value.startsWith(beforeTxt)) {
+      startPos = beforeTxt.length
+    } else {
+      const beforeLastChar = beforeTxt[selectionStart - 1]
+      const newIndex = value.indexOf(beforeLastChar, selectionStart - 1)
+      if (newIndex !== -1) {
+        startPos = newIndex + 1
+      }
+    }
+
+    input.value.setSelectionRange(startPos, startPos)
+  }
+
+  return [recordCursor, setCursor]
+}

+ 1 - 1
packages/kankan-components/package.json

@@ -38,8 +38,8 @@
   },
   "dependencies": {
     "@ctrl/tinycolor": "^3.4.1",
-    "@element-plus/icons-vue": "^2.0.6",
     "@floating-ui/dom": "^1.0.1",
+    "@kankan-components/icons-vue": "^0.0.1",
     "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7",
     "@types/lodash": "^4.14.182",
     "@types/lodash-es": "^4.17.6",

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 760 - 702
pnpm-lock.yaml