├── .github ├── vue-search-input.gif └── workflows │ └── release.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── .nvmrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── commitlint.config.js ├── coverage ├── badge.svg └── coverage-summary.json ├── dist ├── styles.css ├── tests │ ├── searchInput.spec.d.ts │ └── utils.d.ts ├── types │ ├── SearchInput.types.d.ts │ ├── SearchInput.vue.d.ts │ ├── filterObject.d.ts │ └── filterObject.spec.d.ts ├── vue-search-input.es.js └── vue-search-input.umd.js ├── eslint.config.js ├── index.html ├── lint-staged.config.js ├── package.json ├── playground ├── App.vue ├── assets │ └── logo.png ├── css │ └── app.css └── main.ts ├── pnpm-lock.yaml ├── prettier.config.js ├── public └── favicon.ico ├── release.config.js ├── src ├── SearchInput.types.ts ├── SearchInput.vue ├── filterObject.spec.ts ├── filterObject.ts └── styles.scss ├── tests ├── searchInput.spec.ts └── utils.ts ├── tsconfig.json └── vite.config.ts /.github/vue-search-input.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kouts/vue-search-input/ee5ef2f71d93861071574dae8070b4bb7432d8e2/.github/vue-search-input.gif -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release-main 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | ci: 9 | runs-on: ${{ matrix.os }} 10 | 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest] 14 | 15 | steps: 16 | - name: Checkout 🛎 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Setup node env 🏗 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version-file: '.nvmrc' 25 | check-latest: true 26 | 27 | - name: Setup pnpm 📦 28 | uses: pnpm/action-setup@v4 29 | with: 30 | package_json_file: package.json 31 | 32 | - name: Install dependencies 📦 33 | run: pnpm install 34 | 35 | - name: Install semantic-release extra plugins 📦 36 | run: pnpm install --save-dev @semantic-release/changelog @semantic-release/git 37 | 38 | - name: Run linter 👀 39 | run: pnpm run lint-fix 40 | 41 | - name: Typecheck 👀 42 | run: pnpm run typecheck 43 | 44 | - name: Run tests 🧪 45 | run: pnpm run test:unit-coverage --if-present 46 | 47 | - name: Build 48 | run: pnpm run build 49 | 50 | - name: Release 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 54 | run: npx semantic-release 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist-ssr 4 | dist-playground 5 | *.local 6 | .vscode 7 | .idea 8 | *.iml -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit $1 -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v23 -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.1.17](https://github.com/kouts/vue-search-input/compare/v1.1.16...v1.1.17) (2025-03-28) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * added vitest, updated node and npm packages ([ad35ce4](https://github.com/kouts/vue-search-input/commit/ad35ce448b05daa0c64bc710522a20ce8a6d29bb)) 7 | * fixed playground styling ([ff2e6d6](https://github.com/kouts/vue-search-input/commit/ff2e6d69feab68a6de6a02ea350289a3f324307a)) 8 | * removed bootstrap ([218e29a](https://github.com/kouts/vue-search-input/commit/218e29a2c6f9f67ae41ddabf29536af1092817ec)) 9 | 10 | ## [1.1.16](https://github.com/kouts/vue-search-input/compare/v1.1.15...v1.1.16) (2024-06-21) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * added types to package.json ([c09295d](https://github.com/kouts/vue-search-input/commit/c09295ddf35b8f49ec4170e019db4429fb3a0548)) 16 | 17 | ## [1.1.15](https://github.com/kouts/vue-search-input/compare/v1.1.14...v1.1.15) (2024-06-15) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | * updated vite and npm packages ([5cb5c8b](https://github.com/kouts/vue-search-input/commit/5cb5c8b9d2d099ad8849181a3113efc6b822b1f1)) 23 | 24 | ## [1.1.14](https://github.com/kouts/vue-search-input/compare/v1.1.13...v1.1.14) (2023-10-17) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * fixed input types based on type prop ([a5d5256](https://github.com/kouts/vue-search-input/commit/a5d52569dc9d7e5d91464feb7d0d12984979a965)) 30 | 31 | ## [1.1.13](https://github.com/kouts/vue-search-input/compare/v1.1.12...v1.1.13) (2023-10-16) 32 | 33 | 34 | ### Bug Fixes 35 | 36 | * added password as a field type ([975f540](https://github.com/kouts/vue-search-input/commit/975f540130025db13aa74caeba546b9f9b750bc7)) 37 | 38 | ## [1.1.12](https://github.com/kouts/vue-search-input/compare/v1.1.11...v1.1.12) (2023-08-14) 39 | 40 | 41 | ### Bug Fixes 42 | 43 | * updated node to v18 ([380fa2f](https://github.com/kouts/vue-search-input/commit/380fa2f6c5bf5dc10024440525401aa38aab78f7)) 44 | * updated npm packages ([797658e](https://github.com/kouts/vue-search-input/commit/797658e7823ec30d956651485a4b12057077d0d2)) 45 | 46 | ## [1.1.11](https://github.com/kouts/vue-search-input/compare/v1.1.10...v1.1.11) (2022-11-10) 47 | 48 | 49 | ### Bug Fixes 50 | 51 | * updated eslint config ([2ac45dc](https://github.com/kouts/vue-search-input/commit/2ac45dcfdff9b0a07b9abb436e8352d53d4d9e35)) 52 | * updated npm packages ([4480cc2](https://github.com/kouts/vue-search-input/commit/4480cc2236657f54a01952b4299d3d0a70f706c3)) 53 | 54 | ## [1.1.10](https://github.com/kouts/vue-search-input/compare/v1.1.9...v1.1.10) (2022-09-05) 55 | 56 | 57 | ### Bug Fixes 58 | 59 | * reverted vue3-jest version ([f285a28](https://github.com/kouts/vue-search-input/commit/f285a287b86c28f5cef40583dbdb29caa9a10106)) 60 | * updated npm packages ([904910e](https://github.com/kouts/vue-search-input/commit/904910e5a1b611649b15bd8669b972f58597821f)) 61 | 62 | ## [1.1.9](https://github.com/kouts/vue-search-input/compare/v1.1.8...v1.1.9) (2022-08-14) 63 | 64 | 65 | ### Bug Fixes 66 | 67 | * fixed bootstrap sass ([89e1254](https://github.com/kouts/vue-search-input/commit/89e125494250af185ea1d2f4d8e11d0e7ec1cf9a)) 68 | 69 | ## [1.1.8](https://github.com/kouts/vue-search-input/compare/v1.1.7...v1.1.8) (2022-07-23) 70 | 71 | 72 | ### Bug Fixes 73 | 74 | * updated node and npm packages ([9178d40](https://github.com/kouts/vue-search-input/commit/9178d406e27ee88c8860952f3991ddec20284eea)) 75 | 76 | ## [1.1.7](https://github.com/kouts/vue-search-input/compare/v1.1.6...v1.1.7) (2022-07-03) 77 | 78 | 79 | ### Bug Fixes 80 | 81 | * updated jest to v28 ([ba1b876](https://github.com/kouts/vue-search-input/commit/ba1b8763a35263c988d61f28093e45ab854eb6bb)) 82 | * updated npm packages and eslint config ([d4f14c6](https://github.com/kouts/vue-search-input/commit/d4f14c6c622bd5c0a33e4fe8ba4d541536ade660)) 83 | 84 | ## [1.1.6](https://github.com/kouts/vue-search-input/compare/v1.1.5...v1.1.6) (2022-06-19) 85 | 86 | 87 | ### Bug Fixes 88 | 89 | * fixed props table head ([517e893](https://github.com/kouts/vue-search-input/commit/517e8935c5f2ea197b1b68549fa41c313077a318)) 90 | 91 | ## [1.1.5](https://github.com/kouts/vue-search-input/compare/v1.1.4...v1.1.5) (2022-06-19) 92 | 93 | 94 | ### Bug Fixes 95 | 96 | * updated npm packages ([e834978](https://github.com/kouts/vue-search-input/commit/e834978dccaf96d0880c088fdd9c5e06bfa238ab)) 97 | 98 | ## [1.1.4](https://github.com/kouts/vue-search-input/compare/v1.1.3...v1.1.4) (2022-05-02) 99 | 100 | 101 | ### Bug Fixes 102 | 103 | * fixed eslint config package location ([076ccfa](https://github.com/kouts/vue-search-input/commit/076ccfa07df639bb5c9d4b858ef5e6cece335224)) 104 | * removed extraneus height prop ([cd94519](https://github.com/kouts/vue-search-input/commit/cd945197e9f677f725bc93bc5960003f7d39e688)) 105 | * updated eslint config and npm packages ([4349370](https://github.com/kouts/vue-search-input/commit/434937086168700d64d9203882b449235a3758a0)) 106 | 107 | ## [1.1.3](https://github.com/kouts/vue-search-input/compare/v1.1.2...v1.1.3) (2022-05-02) 108 | 109 | 110 | ### Bug Fixes 111 | 112 | * added css to exports, removed duplicate style ([236c39f](https://github.com/kouts/vue-search-input/commit/236c39ff0b9a15194b5b1de9aee666bfc5529f5c)) 113 | 114 | ## [1.1.2](https://github.com/kouts/vue-search-input/compare/v1.1.1...v1.1.2) (2022-04-21) 115 | 116 | 117 | ### Bug Fixes 118 | 119 | * updated npm packages ([6b3c536](https://github.com/kouts/vue-search-input/commit/6b3c536bc6f6bec38852ed28e6a9fb1c90fd844b)) 120 | 121 | ## [1.1.1](https://github.com/kouts/vue-search-input/compare/v1.1.0...v1.1.1) (2022-01-30) 122 | 123 | 124 | ### Bug Fixes 125 | 126 | * fixed focus in case of multiple occurrences ([86a7532](https://github.com/kouts/vue-search-input/commit/86a753201eb7e35fe7f50455aae0a73a18da82ce)) 127 | 128 | # [1.1.0](https://github.com/kouts/vue-search-input/compare/v1.0.4...v1.1.0) (2022-01-23) 129 | 130 | 131 | ### Bug Fixes 132 | 133 | * fixed select on ficus, added hideShortcutIconOnBlur prop ([2398b6f](https://github.com/kouts/vue-search-input/commit/2398b6fc042db59f8ea05d089689904c448f0953)) 134 | 135 | 136 | ### Features 137 | 138 | * added slots, shortcut prop, clear icon button ([86ec366](https://github.com/kouts/vue-search-input/commit/86ec366cf9359ffede749d2c888b730ee47c9072)) 139 | 140 | ## [1.0.4](https://github.com/kouts/vue-search-input/compare/v1.0.3...v1.0.4) (2022-01-21) 141 | 142 | 143 | ### Bug Fixes 144 | 145 | * moved icons to css ([b549fd3](https://github.com/kouts/vue-search-input/commit/b549fd3be223493ac66da0ddb8621337e5d72a4c)) 146 | 147 | ## [1.0.3](https://github.com/kouts/vue-search-input/compare/v1.0.2...v1.0.3) (2022-01-21) 148 | 149 | 150 | ### Bug Fixes 151 | 152 | * added extra options ([1e56d66](https://github.com/kouts/vue-search-input/commit/1e56d6625ad649d18d0806a352c746f10523d485)) 153 | * added shortcut key prop ([beb57f3](https://github.com/kouts/vue-search-input/commit/beb57f3defbfca354db5934f79359d509b1ccf31)) 154 | * fixed icons spacing ([7bd0b8d](https://github.com/kouts/vue-search-input/commit/7bd0b8dfefcf5d0d9ea0acd039a4b01070fb8fc8)) 155 | * removed search-input css class ([dca0810](https://github.com/kouts/vue-search-input/commit/dca0810b952a7993bf0ea1d20fbd45726ac45c67)) 156 | * removed unneeded prop ([4c84f90](https://github.com/kouts/vue-search-input/commit/4c84f90e892da2b68aa93e44747e96c3c624510a)) 157 | 158 | ## [1.0.2](https://github.com/kouts/vue-search-input/compare/v1.0.1...v1.0.2) (2022-01-18) 159 | 160 | 161 | ### Bug Fixes 162 | 163 | * added type definitions ([e0635ea](https://github.com/kouts/vue-search-input/commit/e0635ea8f003ecca45522888f02c8228564963f3)) 164 | 165 | ## [1.0.1](https://github.com/kouts/vue-search-input/compare/v1.0.0...v1.0.1) (2022-01-17) 166 | 167 | 168 | ### Bug Fixes 169 | 170 | * added css in dist ([cacd47a](https://github.com/kouts/vue-search-input/commit/cacd47ae4ab6dd90155ef13a5279a90dbc0b3f92)) 171 | 172 | # 1.0.0 (2022-01-16) 173 | 174 | 175 | ### Features 176 | 177 | * added files ([1c2dd93](https://github.com/kouts/vue-search-input/commit/1c2dd93b57c7e46e675a7af93351ea201ec461fc)) 178 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Giannis Koutsaftakis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-search-input ![](https://img.badgesize.io/kouts/vue-search-input/main/dist/vue-search-input.umd.js.svg) ![](https://img.badgesize.io/kouts/vue-search-input/main/dist/vue-search-input.umd.js.svg?compression=gzip) ![](coverage/badge.svg) 2 | 3 | A Vue.js 3 search input component, inspired by the global search input of Storybook and GitHub. 4 | 5 | ![](.github/vue-search-input.gif) 6 | 7 | The `SearchInput` component displays a search input with some additional features built-in. 8 | 9 | **Features:** 10 | 11 | - **Focus** on the search input at any time by pressing the `/` key on the keyboard. 12 | - Includes a default CSS styling but it's also easy to bring your own styles too. 13 | - Completely customizable icons via `slots` 14 | - Displays an `x` icon on the right side of the search input, used for **clearing** the text when there's a value typed inside. 15 | - The search text gets cleared by pressing the `esc` key when the search input has focus (configurable). 16 | 17 | **_Important:_** It is advisable that you include the `SearchInput` component **only once** on each page. 18 | In case multiple `SearchInput` components are present, the first one being displayed will take focus precedence upon the `/` keypress. 19 | 20 | **Demo with examples** 21 | 22 | https://vue-search-input.vercel.app 23 | 24 | ## Installation 25 | 26 | ```bash 27 | npm i vue-search-input 28 | ``` 29 | 30 | ## Usage 31 | 32 | ```html 33 | 36 | 37 | 56 | ``` 57 | 58 | ## Styling 59 | `vue-search-input` includes default styling (`dist/styles.css`) with that you can use as a base to create your own CSS. 60 | All the component's elements are inside a `div` which acts a wrapper for the icons and the input. 61 | The default class for the wrapper `div` is `search-input-wrapper` you can override it by providing class(es) to the `SearchInput` component. 62 | > Class and styles bound to the `SearchInput` component will be added to the wrapper `div`. 63 | 64 | ## Events 65 | > Events bound to the `SearchInput` component will be passed to the `input` element. 66 | 67 | | Name | Description | Returned value 68 | | :--- | :--- | :--- | 69 | | update:modelValue | The updated bound model | `string` 70 | 71 | ## Props 72 | | Name | Type | Description | Default 73 | | :--- | :--- | :--- | :--- | 74 | | type | string | The type of the input field. Allowed types are `search` and `text` | `search` | 75 | | modelValue (v-model) | string | The input's value | `''` | 76 | | wrapperClass | string | The default CSS class of the wrapper div | `search-input-wrapper` | 77 | | searchIcon | boolean | Displays the "search" icon | true | 78 | | shortcutIcon | boolean | Displays the "shortcut" icon | true | 79 | | clearIcon | boolean | Displays the "clear text" icon | true | 80 | | hideShortcutIconOnBlur | boolean | Whether to hide the shortcut icon when the input loses focus | true | 81 | | clearOnEsc | boolean | Whether to clear the input field when the `esc` key is pressed | true | 82 | | blurOnEsc | boolean | Whether to takes the focus out of the input field when the `esc` key is pressed | true | 83 | | selectOnFocus | boolean | Selects the input's text upon `/` keypress | true | 84 | | shortcutListenerEnabled | boolean | Enables the functionality for the `/` keypress | true | 85 | | shortcutKey | string | The `key` for the shortcut functionality | `/` | 86 | 87 | ## Slots 88 | 89 | `vue-search-input` includes some default icons but you can also customize them to suit your needs using the available `slots`. 90 | 91 | | Name | Description | Default content 92 | | :--- | :--- | :--- | 93 | | search-icon | Slot for the search icon | `` | 94 | | shortcut-icon | Slot for the shortcut icon | `` | 95 | | clear-icon | Slot for the clear icon
`{ clear: () => void }` the function that clears the input | ``| 96 | | append | Adds an item inside the input wrapper, before the search icon | - | 97 | | append-inner | Adds an item inside the input wrapper, after the search icon | - | 98 | | prepend | Adds an item inside the input wrapper directly after the input element | - | 99 | | prepend-outer | Adds an item inside the input wrapper directly after the clear icon | - | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'body-max-line-length': [1, 'always', 200], 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /coverage/badge.svg: -------------------------------------------------------------------------------- 1 | Coverage: 100%Coverage100% -------------------------------------------------------------------------------- /coverage/coverage-summary.json: -------------------------------------------------------------------------------- 1 | {"total": {"lines":{"total":154,"covered":154,"skipped":0,"pct":100},"statements":{"total":154,"covered":154,"skipped":0,"pct":100},"functions":{"total":15,"covered":14,"skipped":0,"pct":93.33},"branches":{"total":52,"covered":51,"skipped":0,"pct":98.07},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":"Unknown"}} 2 | ,"/home/runner/work/vue-search-input/vue-search-input/src/SearchInput.types.ts": {"lines":{"total":1,"covered":1,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":1,"covered":1,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} 3 | ,"/home/runner/work/vue-search-input/vue-search-input/src/SearchInput.vue": {"lines":{"total":143,"covered":143,"skipped":0,"pct":100},"functions":{"total":14,"covered":13,"skipped":0,"pct":92.85},"statements":{"total":143,"covered":143,"skipped":0,"pct":100},"branches":{"total":47,"covered":46,"skipped":0,"pct":97.87}} 4 | ,"/home/runner/work/vue-search-input/vue-search-input/src/filterObject.ts": {"lines":{"total":10,"covered":10,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":10,"covered":10,"skipped":0,"pct":100},"branches":{"total":5,"covered":5,"skipped":0,"pct":100}} 5 | } 6 | -------------------------------------------------------------------------------- /dist/styles.css: -------------------------------------------------------------------------------- 1 | .search-input-wrapper { 2 | position: relative; 3 | } 4 | .search-input-wrapper input[data-search-input=true] { 5 | display: block; 6 | font-family: "Inter", system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 7 | width: 100%; 8 | padding: 6px 30px 6px 32px; 9 | font-size: 16px; 10 | font-weight: normal; 11 | height: 38px; 12 | color: #333; 13 | background-color: #f6f9fc; 14 | border: 1px solid #f6f9fc; 15 | border-radius: 0.35rem; 16 | transition: border-color 0.15s ease-in-out; 17 | } 18 | .search-input-wrapper input[data-search-input=true]:focus { 19 | background-color: hsl(210, 50%, 122.6470588235%); 20 | border-color: #1ea7fd; 21 | outline: 0; 22 | box-shadow: none; 23 | } 24 | .search-input-wrapper .search-icon { 25 | color: rgb(131.25, 172.5, 213.75); 26 | position: absolute; 27 | } 28 | .search-input-wrapper .search-icon.search { 29 | left: 12px; 30 | bottom: 12px; 31 | box-sizing: border-box; 32 | display: block; 33 | width: 16px; 34 | height: 16px; 35 | border: 2px solid; 36 | border-radius: 100%; 37 | margin-left: -4px; 38 | margin-top: -4px; 39 | } 40 | .search-input-wrapper .search-icon.search::after { 41 | content: ""; 42 | display: block; 43 | box-sizing: border-box; 44 | position: absolute; 45 | border-radius: 3px; 46 | width: 2px; 47 | height: 7px; 48 | background: rgb(131.25, 172.5, 213.75); 49 | transform: rotate(-45deg); 50 | top: 11px; 51 | left: 12px; 52 | } 53 | .search-input-wrapper .search-icon.shortcut { 54 | width: 22px; 55 | height: 24px; 56 | cursor: text; 57 | right: 8px; 58 | bottom: 7px; 59 | background-color: rgb(230.7, 238.8, 246.9); 60 | border-radius: 3px; 61 | z-index: 50; 62 | } 63 | .search-input-wrapper .search-icon.shortcut::after { 64 | content: ""; 65 | display: block; 66 | box-sizing: border-box; 67 | position: absolute; 68 | border-radius: 2px; 69 | transform: rotate(25deg); 70 | width: 2px; 71 | height: 16px; 72 | top: 4px; 73 | left: 10px; 74 | z-index: 51; 75 | background-color: rgb(150.375, 185.25, 220.125); 76 | } 77 | .search-input-wrapper .search-icon.clear { 78 | right: 5px; 79 | bottom: 7px; 80 | cursor: pointer; 81 | z-index: 10; 82 | box-sizing: border-box; 83 | display: block; 84 | width: 24px; 85 | height: 24px; 86 | border: 2px solid transparent; 87 | border-radius: 40px; 88 | background: none; 89 | padding: 0px; 90 | outline: none; 91 | } 92 | .search-input-wrapper .search-icon.clear:focus { 93 | background: rgb(230.7, 238.8, 246.9); 94 | } 95 | .search-input-wrapper .search-icon.clear::after, .search-input-wrapper .search-icon.clear::before { 96 | content: ""; 97 | display: block; 98 | box-sizing: border-box; 99 | position: absolute; 100 | width: 16px; 101 | height: 2px; 102 | background: rgb(131.25, 172.5, 213.75); 103 | transform: rotate(45deg); 104 | border-radius: 5px; 105 | top: 9px; 106 | left: 2px; 107 | } 108 | .search-input-wrapper .search-icon.clear::after { 109 | transform: rotate(-45deg); 110 | } 111 | 112 | /* Fix the X appearing in search field on Chrome and IE */ 113 | input[type=search]::-ms-clear { 114 | display: none; 115 | width: 0; 116 | height: 0; 117 | } 118 | 119 | input[type=search]::-ms-reveal { 120 | display: none; 121 | width: 0; 122 | height: 0; 123 | } 124 | 125 | input[type=search]::-webkit-search-decoration, 126 | input[type=search]::-webkit-search-cancel-button, 127 | input[type=search]::-webkit-search-results-button, 128 | input[type=search]::-webkit-search-results-decoration { 129 | display: none; 130 | } 131 | -------------------------------------------------------------------------------- /dist/tests/searchInput.spec.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /dist/tests/utils.d.ts: -------------------------------------------------------------------------------- 1 | export declare const sleep: (ms: number) => Promise; 2 | -------------------------------------------------------------------------------- /dist/types/SearchInput.types.d.ts: -------------------------------------------------------------------------------- 1 | export declare const fieldType: readonly ["search", "text", "password"]; 2 | export type FieldType = (typeof fieldType)[number]; 3 | -------------------------------------------------------------------------------- /dist/types/SearchInput.vue.d.ts: -------------------------------------------------------------------------------- 1 | import { PropType } from 'vue'; 2 | import { FieldType } from './SearchInput.types'; 3 | declare const _default: import('vue').DefineComponent; 6 | default: string; 7 | validator: (prop: FieldType) => boolean; 8 | }; 9 | modelValue: { 10 | type: StringConstructor; 11 | default: string; 12 | }; 13 | wrapperClass: { 14 | type: StringConstructor; 15 | default: string; 16 | }; 17 | searchIcon: { 18 | type: BooleanConstructor; 19 | default: boolean; 20 | }; 21 | shortcutIcon: { 22 | type: BooleanConstructor; 23 | default: boolean; 24 | }; 25 | clearIcon: { 26 | type: BooleanConstructor; 27 | default: boolean; 28 | }; 29 | hideShortcutIconOnBlur: { 30 | type: BooleanConstructor; 31 | default: boolean; 32 | }; 33 | clearOnEsc: { 34 | type: BooleanConstructor; 35 | default: boolean; 36 | }; 37 | blurOnEsc: { 38 | type: BooleanConstructor; 39 | default: boolean; 40 | }; 41 | selectOnFocus: { 42 | type: BooleanConstructor; 43 | default: boolean; 44 | }; 45 | shortcutListenerEnabled: { 46 | type: BooleanConstructor; 47 | default: boolean; 48 | }; 49 | shortcutKey: { 50 | type: PropType; 51 | default: string; 52 | }; 53 | }>, { 54 | inputRef: import('vue').Ref; 55 | hasFocus: import('vue').Ref; 56 | clear: () => void; 57 | onInput: (e: Event) => void; 58 | onKeydown: (e: KeyboardEvent) => void; 59 | attrsStyles: import('vue').ComputedRef<{ 60 | [key: string]: unknown; 61 | }>; 62 | attrsWithoutStyles: import('vue').ComputedRef<{ 63 | [key: string]: unknown; 64 | }>; 65 | showClearIcon: import('vue').ComputedRef; 66 | showShortcutIcon: import('vue').ComputedRef; 67 | }, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, "update:modelValue"[], "update:modelValue", import('vue').PublicProps, Readonly; 70 | default: string; 71 | validator: (prop: FieldType) => boolean; 72 | }; 73 | modelValue: { 74 | type: StringConstructor; 75 | default: string; 76 | }; 77 | wrapperClass: { 78 | type: StringConstructor; 79 | default: string; 80 | }; 81 | searchIcon: { 82 | type: BooleanConstructor; 83 | default: boolean; 84 | }; 85 | shortcutIcon: { 86 | type: BooleanConstructor; 87 | default: boolean; 88 | }; 89 | clearIcon: { 90 | type: BooleanConstructor; 91 | default: boolean; 92 | }; 93 | hideShortcutIconOnBlur: { 94 | type: BooleanConstructor; 95 | default: boolean; 96 | }; 97 | clearOnEsc: { 98 | type: BooleanConstructor; 99 | default: boolean; 100 | }; 101 | blurOnEsc: { 102 | type: BooleanConstructor; 103 | default: boolean; 104 | }; 105 | selectOnFocus: { 106 | type: BooleanConstructor; 107 | default: boolean; 108 | }; 109 | shortcutListenerEnabled: { 110 | type: BooleanConstructor; 111 | default: boolean; 112 | }; 113 | shortcutKey: { 114 | type: PropType; 115 | default: string; 116 | }; 117 | }>> & Readonly<{ 118 | "onUpdate:modelValue"?: ((...args: any[]) => any) | undefined; 119 | }>, { 120 | type: "search" | "text" | "password"; 121 | modelValue: string; 122 | wrapperClass: string; 123 | searchIcon: boolean; 124 | shortcutIcon: boolean; 125 | clearIcon: boolean; 126 | hideShortcutIconOnBlur: boolean; 127 | clearOnEsc: boolean; 128 | blurOnEsc: boolean; 129 | selectOnFocus: boolean; 130 | shortcutListenerEnabled: boolean; 131 | shortcutKey: string; 132 | }, {}, {}, {}, string, import('vue').ComponentProvideOptions, true, {}, any>; 133 | export default _default; 134 | -------------------------------------------------------------------------------- /dist/types/filterObject.d.ts: -------------------------------------------------------------------------------- 1 | export declare const filterObject: (obj: { 2 | [key: string]: unknown; 3 | }, properties: (string | number)[], remove?: boolean) => { 4 | [key: string]: unknown; 5 | }; 6 | -------------------------------------------------------------------------------- /dist/types/filterObject.spec.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /dist/vue-search-input.es.js: -------------------------------------------------------------------------------- 1 | import { defineComponent as k, ref as I, computed as i, watch as b, onBeforeUnmount as K, createElementBlock as V, openBlock as B, normalizeProps as C, guardReactiveProps as L, renderSlot as u, createCommentVNode as y, createElementVNode as d, mergeProps as H, withKeys as M } from "vue"; 2 | const g = (e, t, l = !0) => { 3 | const r = {}; 4 | return Object.keys(e).forEach((o) => { 5 | (l ? t.indexOf(o) === -1 : t.indexOf(o) >= 0) && (r[o] = e[o]); 6 | }), r; 7 | }, P = ["search", "text", "password"], a = (e = !0) => ({ type: Boolean, default: e }), R = k({ 8 | name: "SearchInput", 9 | inheritAttrs: !1, 10 | props: { 11 | type: { 12 | type: String, 13 | default: "search", 14 | validator: (e) => P.includes(e) 15 | }, 16 | modelValue: { 17 | type: String, 18 | default: "" 19 | }, 20 | wrapperClass: { 21 | type: String, 22 | default: "search-input-wrapper" 23 | }, 24 | searchIcon: a(), 25 | shortcutIcon: a(), 26 | clearIcon: a(), 27 | hideShortcutIconOnBlur: a(), 28 | clearOnEsc: a(), 29 | blurOnEsc: a(), 30 | selectOnFocus: a(), 31 | shortcutListenerEnabled: a(), 32 | shortcutKey: { 33 | type: String, 34 | default: "/" 35 | } 36 | }, 37 | emits: ["update:modelValue"], 38 | setup(e, { emit: t, attrs: l }) { 39 | const r = I(!1), o = I(null), f = i(() => g(l, ["class", "style"])), s = i(() => { 40 | const n = g(l, ["class", "style"], !1); 41 | return n.class || (n.class = e.wrapperClass), n; 42 | }), S = i(() => !!(e.clearIcon && e.modelValue.length > 0)), E = i(() => !!(e.shortcutIcon && !r.value && !e.hideShortcutIconOnBlur || e.shortcutIcon && !r.value && e.modelValue.length === 0)), m = () => { 43 | t("update:modelValue", ""); 44 | }, O = (n) => { 45 | t("update:modelValue", n.target.value); 46 | }, $ = (n) => { 47 | n.key === "Escape" && (e.clearOnEsc && m(), e.blurOnEsc && o.value.blur()); 48 | }, w = (n) => { 49 | if (n.key === e.shortcutKey && n.target !== o.value && window.document.activeElement !== o.value && !(n.target instanceof HTMLInputElement) && !(n.target instanceof HTMLSelectElement) && !(n.target instanceof HTMLTextAreaElement)) { 50 | n.preventDefault(); 51 | const h = [].slice.call(document.querySelectorAll('[data-search-input="true"]:not([data-shortcut-enabled="false"])')).filter((p) => !!(p.offsetWidth || p.offsetHeight || p.getClientRects().length)), c = h.length > 1 ? h[0] : o.value; 52 | c == null || c.focus(), e.selectOnFocus && (c == null || c.select()); 53 | } 54 | }, v = () => window.document.removeEventListener("keydown", w); 55 | return b( 56 | () => e.shortcutListenerEnabled, 57 | (n) => { 58 | n ? window.document.addEventListener("keydown", w) : v(); 59 | }, 60 | { immediate: !0 } 61 | ), K(() => { 62 | v(); 63 | }), { 64 | inputRef: o, 65 | hasFocus: r, 66 | clear: m, 67 | onInput: O, 68 | onKeydown: $, 69 | attrsStyles: s, 70 | attrsWithoutStyles: f, 71 | showClearIcon: S, 72 | showShortcutIcon: E 73 | }; 74 | } 75 | }), D = (e, t) => { 76 | const l = e.__vccOpts || e; 77 | for (const [r, o] of t) 78 | l[r] = o; 79 | return l; 80 | }, F = ["type", "data-shortcut-enabled", "value"]; 81 | function W(e, t, l, r, o, f) { 82 | return B(), V("div", C(L(e.attrsStyles)), [ 83 | u(e.$slots, "prepend"), 84 | e.searchIcon ? u(e.$slots, "search-icon", { key: 0 }, () => [ 85 | t[6] || (t[6] = d("i", { class: "search-icon search" }, null, -1)) 86 | ]) : y("", !0), 87 | u(e.$slots, "prepend-inner"), 88 | d("input", H({ 89 | ref: "inputRef", 90 | type: e.type, 91 | "data-search-input": "true", 92 | "data-shortcut-enabled": e.shortcutListenerEnabled, 93 | value: e.modelValue 94 | }, e.attrsWithoutStyles, { 95 | onInput: t[0] || (t[0] = (...s) => e.onInput && e.onInput(...s)), 96 | onFocus: t[1] || (t[1] = (s) => e.hasFocus = !0), 97 | onBlur: t[2] || (t[2] = (s) => e.hasFocus = !1), 98 | onKeydown: t[3] || (t[3] = (...s) => e.onKeydown && e.onKeydown(...s)) 99 | }), null, 16, F), 100 | u(e.$slots, "append"), 101 | e.showShortcutIcon ? u(e.$slots, "shortcut-icon", { key: 1 }, () => [ 102 | t[7] || (t[7] = d("i", { 103 | class: "search-icon shortcut", 104 | title: 'Press "/" to search' 105 | }, null, -1)) 106 | ]) : y("", !0), 107 | e.showClearIcon ? u(e.$slots, "clear-icon", { 108 | key: 2, 109 | clear: e.clear 110 | }, () => [ 111 | d("button", { 112 | class: "search-icon clear", 113 | "aria-label": "Clear", 114 | onMousedown: t[4] || (t[4] = (...s) => e.clear && e.clear(...s)), 115 | onKeydown: t[5] || (t[5] = M((...s) => e.clear && e.clear(...s), ["space", "enter"])) 116 | }, null, 32) 117 | ]) : y("", !0), 118 | u(e.$slots, "append-outer") 119 | ], 16); 120 | } 121 | const T = /* @__PURE__ */ D(R, [["render", W]]); 122 | export { 123 | T as default 124 | }; 125 | -------------------------------------------------------------------------------- /dist/vue-search-input.umd.js: -------------------------------------------------------------------------------- 1 | (function(t,u){typeof exports=="object"&&typeof module<"u"?module.exports=u(require("vue")):typeof define=="function"&&define.amd?define(["vue"],u):(t=typeof globalThis<"u"?globalThis:t||self,t.VueSearchInput=u(t.Vue))})(this,function(t){"use strict";const u=(e,n,c=!0)=>{const l={};return Object.keys(e).forEach(r=>{(c?n.indexOf(r)===-1:n.indexOf(r)>=0)&&(l[r]=e[r])}),l},w=["search","text","password"],a=(e=!0)=>({type:Boolean,default:e}),S=t.defineComponent({name:"SearchInput",inheritAttrs:!1,props:{type:{type:String,default:"search",validator:e=>w.includes(e)},modelValue:{type:String,default:""},wrapperClass:{type:String,default:"search-input-wrapper"},searchIcon:a(),shortcutIcon:a(),clearIcon:a(),hideShortcutIconOnBlur:a(),clearOnEsc:a(),blurOnEsc:a(),selectOnFocus:a(),shortcutListenerEnabled:a(),shortcutKey:{type:String,default:"/"}},emits:["update:modelValue"],setup(e,{emit:n,attrs:c}){const l=t.ref(!1),r=t.ref(null),i=t.computed(()=>u(c,["class","style"])),s=t.computed(()=>{const o=u(c,["class","style"],!1);return o.class||(o.class=e.wrapperClass),o}),V=t.computed(()=>!!(e.clearIcon&&e.modelValue.length>0)),O=t.computed(()=>!!(e.shortcutIcon&&!l.value&&!e.hideShortcutIconOnBlur||e.shortcutIcon&&!l.value&&e.modelValue.length===0)),h=()=>{n("update:modelValue","")},$=o=>{n("update:modelValue",o.target.value)},k=o=>{o.key==="Escape"&&(e.clearOnEsc&&h(),e.blurOnEsc&&r.value.blur())},m=o=>{if(o.key===e.shortcutKey&&o.target!==r.value&&window.document.activeElement!==r.value&&!(o.target instanceof HTMLInputElement)&&!(o.target instanceof HTMLSelectElement)&&!(o.target instanceof HTMLTextAreaElement)){o.preventDefault();const f=[].slice.call(document.querySelectorAll('[data-search-input="true"]:not([data-shortcut-enabled="false"])')).filter(p=>!!(p.offsetWidth||p.offsetHeight||p.getClientRects().length)),d=f.length>1?f[0]:r.value;d==null||d.focus(),e.selectOnFocus&&(d==null||d.select())}},y=()=>window.document.removeEventListener("keydown",m);return t.watch(()=>e.shortcutListenerEnabled,o=>{o?window.document.addEventListener("keydown",m):y()},{immediate:!0}),t.onBeforeUnmount(()=>{y()}),{inputRef:r,hasFocus:l,clear:h,onInput:$,onKeydown:k,attrsStyles:s,attrsWithoutStyles:i,showClearIcon:V,showShortcutIcon:O}}}),I=(e,n)=>{const c=e.__vccOpts||e;for(const[l,r]of n)c[l]=r;return c},E=["type","data-shortcut-enabled","value"];function g(e,n,c,l,r,i){return t.openBlock(),t.createElementBlock("div",t.normalizeProps(t.guardReactiveProps(e.attrsStyles)),[t.renderSlot(e.$slots,"prepend"),e.searchIcon?t.renderSlot(e.$slots,"search-icon",{key:0},()=>[n[6]||(n[6]=t.createElementVNode("i",{class:"search-icon search"},null,-1))]):t.createCommentVNode("",!0),t.renderSlot(e.$slots,"prepend-inner"),t.createElementVNode("input",t.mergeProps({ref:"inputRef",type:e.type,"data-search-input":"true","data-shortcut-enabled":e.shortcutListenerEnabled,value:e.modelValue},e.attrsWithoutStyles,{onInput:n[0]||(n[0]=(...s)=>e.onInput&&e.onInput(...s)),onFocus:n[1]||(n[1]=s=>e.hasFocus=!0),onBlur:n[2]||(n[2]=s=>e.hasFocus=!1),onKeydown:n[3]||(n[3]=(...s)=>e.onKeydown&&e.onKeydown(...s))}),null,16,E),t.renderSlot(e.$slots,"append"),e.showShortcutIcon?t.renderSlot(e.$slots,"shortcut-icon",{key:1},()=>[n[7]||(n[7]=t.createElementVNode("i",{class:"search-icon shortcut",title:'Press "/" to search'},null,-1))]):t.createCommentVNode("",!0),e.showClearIcon?t.renderSlot(e.$slots,"clear-icon",{key:2,clear:e.clear},()=>[t.createElementVNode("button",{class:"search-icon clear","aria-label":"Clear",onMousedown:n[4]||(n[4]=(...s)=>e.clear&&e.clear(...s)),onKeydown:n[5]||(n[5]=t.withKeys((...s)=>e.clear&&e.clear(...s),["space","enter"]))},null,32)]):t.createCommentVNode("",!0),t.renderSlot(e.$slots,"append-outer")],16)}return I(S,[["render",g]])}); 2 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { config } from '@kouts/eslint-config' 2 | 3 | export default [ 4 | ...config({ 5 | env: ['browser'], 6 | }), 7 | ] 8 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Playground for vue-search-input 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '*.{vue,ts,js}': ['npm run lint-fix'], 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-search-input", 3 | "description": "A Vue.js 3 search input component, inspired by the global search input of Storybook and GitHub.", 4 | "version": "0.0.0-semantic-release", 5 | "license": "MIT", 6 | "repository": "https://github.com/kouts/vue-search-input", 7 | "packageManager": "pnpm@10.6.3", 8 | "type": "module", 9 | "author": "Giannis Koutsaftakis", 10 | "keywords": [ 11 | "search", 12 | "input", 13 | "storybook", 14 | "find", 15 | "search-input", 16 | "input-search", 17 | "form-search", 18 | "vue" 19 | ], 20 | "main": "dist/vue-search-input.umd.js", 21 | "module": "dist/vue-search-input.es.js", 22 | "types": "dist/types/SearchInput.vue.d.ts", 23 | "exports": { 24 | ".": { 25 | "types": "./dist/types/SearchInput.vue.d.ts", 26 | "import": "./dist/vue-search-input.es.js", 27 | "require": "./dist/vue-search-input.umd.js" 28 | }, 29 | "./dist/styles.css": { 30 | "import": "./dist/styles.css", 31 | "require": "./dist/styles.css" 32 | } 33 | }, 34 | "unpkg": "dist/vue-search-input.umd.js", 35 | "sideEffects": false, 36 | "scripts": { 37 | "dev": "vite", 38 | "build:vite": "vue-tsc --noEmit && vite build", 39 | "build:sass": "sass --no-source-map src/styles.scss dist/styles.css", 40 | "build": "npm run build:vite && npm run build:sass", 41 | "build-playground": "BUILD_MODE=playground npm run build:vite", 42 | "preview": "vite preview", 43 | "lint": "eslint \"**/*.{vue,ts,js}\"", 44 | "lint-fix": "eslint --fix \"**/*.{vue,ts,js}\"", 45 | "test:unit": "vitest", 46 | "test:unit-coverage": "vitest run --coverage && make-coverage-badge", 47 | "typecheck": "vue-tsc --noEmit", 48 | "prepare": "husky" 49 | }, 50 | "devDependencies": { 51 | "@commitlint/cli": "^19.8.0", 52 | "@commitlint/config-conventional": "^19.8.0", 53 | "@kouts/eslint-config": "^3.0.1", 54 | "@tailwindcss/vite": "^4.0.17", 55 | "@types/jsdom": "^21.1.7", 56 | "@types/node": "^22.13.14", 57 | "@vitejs/plugin-vue": "^5.2.3", 58 | "@vitest/coverage-v8": "^3.0.9", 59 | "@vue/compiler-sfc": "^3.5.13", 60 | "@vue/test-utils": "^2.4.6", 61 | "eslint": "^9.23.0", 62 | "husky": "^9.1.7", 63 | "jsdom": "^26.0.0", 64 | "lint-staged": "^15.5.0", 65 | "make-coverage-badge": "^1.2.0", 66 | "prettier": "^3.5.3", 67 | "prettier-plugin-tailwindcss": "^0.6.11", 68 | "rollup-plugin-delete": "^3.0.1", 69 | "sass": "^1.86.0", 70 | "tailwindcss": "^4.0.17", 71 | "typescript": "^5.8.2", 72 | "vite": "^6.2.3", 73 | "vite-plugin-dts": "^4.5.3", 74 | "vitest": "^3.0.9", 75 | "vue": "^3.5.13", 76 | "vue-tsc": "^2.2.8" 77 | } 78 | } -------------------------------------------------------------------------------- /playground/App.vue: -------------------------------------------------------------------------------- 1 | 143 | 144 | 187 | 188 | 191 | -------------------------------------------------------------------------------- /playground/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kouts/vue-search-input/ee5ef2f71d93861071574dae8070b4bb7432d8e2/playground/assets/logo.png -------------------------------------------------------------------------------- /playground/css/app.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | html { 4 | font-family: Avenir, Helvetica, Arial, sans-serif; 5 | } 6 | 7 | :root { 8 | --white: #ffffff; 9 | --primary-color: #1ea7fd; 10 | --input-icon-color: #83add6; 11 | } 12 | 13 | a { 14 | color: var(--primary-color); 15 | } 16 | 17 | .font-monospace { 18 | font-family: SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; 19 | } 20 | 21 | .btn { 22 | display: inline-block; 23 | padding: 0rem 0.75rem; 24 | border-radius: 0.25rem; 25 | cursor: pointer; 26 | font-size: 1rem; 27 | background-color: var(--primary-color); 28 | color: var(--white); 29 | } 30 | 31 | .btn:hover { 32 | background-color: #40b4fd; 33 | } 34 | 35 | .btn-primary { 36 | color: var(--white); 37 | &:hover { 38 | color: var(--white); 39 | } 40 | &:focus { 41 | color: var(--white); 42 | } 43 | } 44 | 45 | .w270 { 46 | width: 270px; 47 | transition: width 0.35s; 48 | } 49 | 50 | .w350 { 51 | width: 350px; 52 | transition: width 0.35s; 53 | } 54 | 55 | .search-input-wrapper { 56 | &.no-search-icon { 57 | [data-search-input='true'] { 58 | padding-left: 12px; 59 | } 60 | } 61 | 62 | &.gmail { 63 | input[data-search-input='true'] { 64 | padding-right: 56px; 65 | } 66 | .search-icon { 67 | &.clear { 68 | right: 32px; 69 | } 70 | } 71 | .settings { 72 | position: absolute; 73 | bottom: 7px; 74 | right: 6px; 75 | background: none; 76 | border: none; 77 | cursor: pointer; 78 | outline: none; 79 | padding: 0px; 80 | line-height: 0; 81 | color: var(--input-icon-color); 82 | fill: var(--input-icon-color); 83 | } 84 | } 85 | 86 | &.youtube { 87 | display: flex; 88 | flex-wrap: wrap; 89 | input[data-search-input='true'] { 90 | flex: 1 1 auto; 91 | width: 1%; 92 | } 93 | .search-icon { 94 | &.clear { 95 | right: 50px; 96 | } 97 | } 98 | .btn-search { 99 | fill: var(--white); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /playground/main.ts: -------------------------------------------------------------------------------- 1 | import App from '@playground/App.vue' 2 | import { createApp } from 'vue' 3 | 4 | createApp(App).mount('#app') 5 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | import prettierConfig from '@kouts/eslint-config/prettier' 2 | 3 | export default prettierConfig 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kouts/vue-search-input/ee5ef2f71d93861071574dae8070b4bb7432d8e2/public/favicon.ico -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | branches: [ 3 | 'main', 4 | { 5 | name: 'beta', 6 | prerelease: true, 7 | }, 8 | { 9 | name: 'next', 10 | channel: 'next', 11 | }, 12 | ], 13 | plugins: [ 14 | '@semantic-release/commit-analyzer', 15 | '@semantic-release/release-notes-generator', 16 | [ 17 | '@semantic-release/changelog', 18 | { 19 | changelogFile: 'CHANGELOG.md', 20 | }, 21 | ], 22 | '@semantic-release/npm', 23 | '@semantic-release/github', 24 | [ 25 | '@semantic-release/git', 26 | { 27 | assets: ['CHANGELOG.md', 'dist', 'coverage'], 28 | // eslint-disable-next-line no-template-curly-in-string 29 | message: 'chore(release): set `package.json` to ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}', 30 | }, 31 | ], 32 | ], 33 | } 34 | -------------------------------------------------------------------------------- /src/SearchInput.types.ts: -------------------------------------------------------------------------------- 1 | export const fieldType = ['search', 'text', 'password'] as const 2 | 3 | export type FieldType = (typeof fieldType)[number] 4 | -------------------------------------------------------------------------------- /src/SearchInput.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 167 | -------------------------------------------------------------------------------- /src/filterObject.spec.ts: -------------------------------------------------------------------------------- 1 | import { filterObject } from './filterObject' 2 | 3 | describe('filterObject utility function', () => { 4 | it('should include only specified properties when remove is false', () => { 5 | const obj = { a: 1, b: 2, c: 3 } 6 | const properties = ['a', 'c'] 7 | const result = filterObject(obj, properties, false) 8 | 9 | expect(result).toEqual({ a: 1, c: 3 }) 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /src/filterObject.ts: -------------------------------------------------------------------------------- 1 | export const filterObject = (obj: { [key: string]: unknown }, properties: (string | number)[], remove = true) => { 2 | const res: { [key: string]: unknown } = {} 3 | 4 | Object.keys(obj).forEach((objAttr) => { 5 | const condition = remove ? properties.indexOf(objAttr) === -1 : properties.indexOf(objAttr) >= 0 6 | 7 | if (condition) { 8 | res[objAttr] = obj[objAttr] 9 | } 10 | }) 11 | 12 | return res 13 | } 14 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:color'; 2 | 3 | $input-color: #333; 4 | $input-background: #f6f9fc; 5 | $icon-color: color.adjust($input-background, $lightness: -30%); 6 | $active-color: #1ea7fd; 7 | 8 | .search-input-wrapper { 9 | position: relative; 10 | 11 | input[data-search-input='true'] { 12 | display: block; 13 | font-family: 'Inter', system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; 14 | width: 100%; 15 | padding: 6px 30px 6px 32px; 16 | font-size: 16px; 17 | font-weight: normal; 18 | height: 38px; 19 | color: $input-color; 20 | background-color: $input-background; 21 | border: 1px solid $input-background; 22 | border-radius: 0.35rem; 23 | transition: border-color 0.15s ease-in-out; 24 | &:focus { 25 | background-color: color.adjust($input-background, $lightness: 25%); 26 | border-color: $active-color; 27 | outline: 0; 28 | box-shadow: none; 29 | } 30 | } 31 | 32 | .search-icon { 33 | color: $icon-color; 34 | position: absolute; 35 | &.search { 36 | left: 12px; 37 | bottom: 12px; 38 | box-sizing: border-box; 39 | display: block; 40 | width: 16px; 41 | height: 16px; 42 | border: 2px solid; 43 | border-radius: 100%; 44 | margin-left: -4px; 45 | margin-top: -4px; 46 | } 47 | &.search::after { 48 | content: ''; 49 | display: block; 50 | box-sizing: border-box; 51 | position: absolute; 52 | border-radius: 3px; 53 | width: 2px; 54 | height: 7px; 55 | background: $icon-color; 56 | transform: rotate(-45deg); 57 | top: 11px; 58 | left: 12px; 59 | } 60 | &.shortcut { 61 | width: 22px; 62 | height: 24px; 63 | cursor: text; 64 | right: 8px; 65 | bottom: 7px; 66 | background-color: color.adjust($input-background, $lightness: -4%); 67 | border-radius: 3px; 68 | z-index: 50; 69 | } 70 | &.shortcut::after { 71 | content: ''; 72 | display: block; 73 | box-sizing: border-box; 74 | position: absolute; 75 | border-radius: 2px; 76 | transform: rotate(25deg); 77 | width: 2px; 78 | height: 16px; 79 | top: 4px; 80 | left: 10px; 81 | z-index: 51; 82 | background-color: color.adjust($icon-color, $lightness: 5%); 83 | } 84 | &.clear { 85 | right: 5px; 86 | bottom: 7px; 87 | cursor: pointer; 88 | z-index: 10; 89 | box-sizing: border-box; 90 | display: block; 91 | width: 24px; 92 | height: 24px; 93 | border: 2px solid transparent; 94 | border-radius: 40px; 95 | background: none; 96 | padding: 0px; 97 | outline: none; 98 | &:focus { 99 | background: color.adjust($input-background, $lightness: -4%); 100 | } 101 | } 102 | &.clear::after, 103 | &.clear::before { 104 | content: ''; 105 | display: block; 106 | box-sizing: border-box; 107 | position: absolute; 108 | width: 16px; 109 | height: 2px; 110 | background: $icon-color; 111 | transform: rotate(45deg); 112 | border-radius: 5px; 113 | top: 9px; 114 | left: 2px; 115 | } 116 | &.clear::after { 117 | transform: rotate(-45deg); 118 | } 119 | } 120 | } 121 | 122 | /* Fix the X appearing in search field on Chrome and IE */ 123 | input[type='search']::-ms-clear { 124 | display: none; 125 | width: 0; 126 | height: 0; 127 | } 128 | input[type='search']::-ms-reveal { 129 | display: none; 130 | width: 0; 131 | height: 0; 132 | } 133 | 134 | input[type='search']::-webkit-search-decoration, 135 | input[type='search']::-webkit-search-cancel-button, 136 | input[type='search']::-webkit-search-results-button, 137 | input[type='search']::-webkit-search-results-decoration { 138 | display: none; 139 | } 140 | -------------------------------------------------------------------------------- /tests/searchInput.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import { fieldType } from '@/SearchInput.types' 3 | import SearchInput from '@/SearchInput.vue' 4 | 5 | const INPUT_SELECTOR = 'input[data-search-input="true"]' 6 | 7 | const createWrapper = (opts?: Record) => { 8 | return mount(SearchInput, opts) 9 | } 10 | 11 | const createWrapperContainer = () => { 12 | const wrapperContainer = { 13 | components: { 14 | SearchInput, 15 | }, 16 | data() { 17 | return { 18 | searchText1: '', 19 | searchText2: '', 20 | } 21 | }, 22 | template: ` 23 |
24 | 25 | 26 |
27 | `, 28 | } 29 | 30 | return mount(wrapperContainer, { 31 | attachTo: document.body, 32 | }) 33 | } 34 | 35 | describe('SearchInput.vue', () => { 36 | beforeEach(() => { 37 | document.body.innerHTML = '' 38 | }) 39 | 40 | it.each(fieldType)('should render an input with type %s', (typeProp) => { 41 | const wrapper = createWrapper({ 42 | props: { 43 | type: typeProp, 44 | }, 45 | }) 46 | 47 | const input = wrapper.find(`input[type="${typeProp}"]`) 48 | 49 | expect(input).toBeTruthy() 50 | }) 51 | 52 | it('should render a search icon', async () => { 53 | const wrapper = createWrapper() 54 | 55 | const i = await wrapper.find('i.search-icon.search') 56 | 57 | expect(i).toBeTruthy() 58 | }) 59 | 60 | it('should pass class to the input wrapper', async () => { 61 | const wrapper = createWrapper({ 62 | attrs: { 63 | class: 'test-class', 64 | }, 65 | }) 66 | 67 | const div = await wrapper.find('div') 68 | 69 | expect(div.classes()).toContain('test-class') 70 | }) 71 | 72 | it('should pass event listeners to the input', async () => { 73 | const onClick = vi.fn() 74 | const wrapper = createWrapper({ attrs: { onClick } }) 75 | 76 | wrapper.find('input').trigger('click') 77 | 78 | expect(onClick).toHaveBeenCalled() 79 | }) 80 | 81 | it('sets the value', async () => { 82 | const wrapper = createWrapper() 83 | const input = wrapper.find('input') 84 | 85 | await input.setValue('test_value') 86 | 87 | expect(input.element.value).toBe('test_value') 88 | }) 89 | 90 | it('emits the updated value', async () => { 91 | const wrapper = createWrapper() 92 | const input = wrapper.find('input') 93 | 94 | await input.setValue('test_value') 95 | 96 | expect(wrapper.emitted()['update:modelValue'][0]).toEqual(['test_value']) 97 | }) 98 | 99 | it('clears the value when the clear icon is clicked', async () => { 100 | const wrapper = createWrapper({ 101 | props: { 102 | modelValue: 'test', 103 | }, 104 | }) 105 | 106 | const button = await wrapper.find('button.search-icon.clear') 107 | 108 | await button.trigger('mousedown') 109 | 110 | expect(wrapper.emitted()['update:modelValue'][0]).toEqual(['']) 111 | }) 112 | 113 | it('clears the value with the "esc" key', async () => { 114 | const wrapper = createWrapper({ 115 | props: { 116 | modelValue: 'test', 117 | }, 118 | }) 119 | 120 | const input = wrapper.find('input') 121 | 122 | await input.trigger('keydown', { 123 | key: 'Escape', 124 | }) 125 | 126 | expect(wrapper.emitted()['update:modelValue'][0]).toEqual(['']) 127 | }) 128 | 129 | it('focuses the input when the "/" key is pressed', async () => { 130 | const wrapper = createWrapper({ 131 | attachTo: document.body, 132 | }) 133 | 134 | const event = new KeyboardEvent('keydown', { key: '/' }) 135 | 136 | document.dispatchEvent(event) 137 | 138 | const input = wrapper.find(INPUT_SELECTOR) 139 | 140 | expect(input.element).toBe(document.activeElement) 141 | }) 142 | 143 | it('removes the keydown event listener when unmounted', async () => { 144 | const removeSpy = vi.spyOn(document, 'removeEventListener').mockImplementation(() => {}) 145 | const wrapper = createWrapper() 146 | 147 | wrapper.unmount() 148 | expect(removeSpy).toHaveBeenCalled() 149 | }) 150 | 151 | it('removes the keydown event listener when shortcutListenerEnabled prop turns false', async () => { 152 | const removeSpy = vi.spyOn(document, 'removeEventListener').mockImplementation(() => {}) 153 | const wrapper = createWrapper() 154 | 155 | wrapper.setProps({ 156 | shortcutListenerEnabled: false, 157 | }) 158 | 159 | await wrapper.vm.$nextTick() 160 | 161 | expect(removeSpy).toHaveBeenCalled() 162 | }) 163 | 164 | it('selects the input text when focused with the "/" key', async () => { 165 | const wrapper = createWrapper({ 166 | props: { 167 | modelValue: 'test', 168 | }, 169 | }) 170 | 171 | const event = new KeyboardEvent('keydown', { key: '/' }) 172 | 173 | document.dispatchEvent(event) 174 | 175 | const input = await wrapper.find(INPUT_SELECTOR) 176 | 177 | const inputEl = input.element as HTMLInputElement 178 | 179 | expect(inputEl.selectionStart).toBe(0) 180 | expect(inputEl.selectionEnd).toBe(4) 181 | }) 182 | 183 | it('focuses the input text when the "/" key is pressed', async () => { 184 | const wrapper = createWrapperContainer() 185 | 186 | const inputs = await wrapper.findAll(INPUT_SELECTOR) 187 | 188 | Object.defineProperty(inputs[0].element as HTMLInputElement, 'offsetWidth', { value: 10, writable: true }) 189 | Object.defineProperty(inputs[1].element as HTMLInputElement, 'offsetWidth', { value: 10, writable: true }) 190 | 191 | const event = new KeyboardEvent('keydown', { key: '/' }) 192 | 193 | document.dispatchEvent(event) 194 | 195 | expect(inputs[0].element).toBe(document.activeElement) 196 | }) 197 | 198 | it('should render a shortcut icon when the hideShortcutIconOnBlur prop is false', async () => { 199 | const wrapper = createWrapper({ 200 | props: { 201 | hideShortcutIconOnBlur: false, 202 | }, 203 | }) 204 | 205 | const i = await wrapper.find('i.search-icon.shortcut') 206 | 207 | expect(i).toBeTruthy() 208 | }) 209 | 210 | it('renders the prepend slot', async () => { 211 | const wrapper = createWrapper({ 212 | slots: { 213 | prepend: '
prepend content
', 214 | }, 215 | }) 216 | 217 | const prepend = wrapper.find('.prepend') 218 | const i = wrapper.find('i.search-icon.search') 219 | 220 | expect(prepend.element.nextElementSibling).toEqual(i.element) 221 | }) 222 | 223 | it('renders the prepend-inner slot', async () => { 224 | const wrapper = createWrapper({ 225 | slots: { 226 | 'prepend-inner': '
prepend-inner content
', 227 | }, 228 | }) 229 | 230 | const prependInner = wrapper.find('.prepend-inner') 231 | const i = wrapper.find('i.search-icon.search') 232 | 233 | expect(i.element.nextElementSibling).toEqual(prependInner.element) 234 | }) 235 | 236 | it('renders the append slot', async () => { 237 | const wrapper = createWrapper({ 238 | slots: { 239 | append: '
append content
', 240 | }, 241 | }) 242 | 243 | const append = wrapper.find('.append') 244 | const i = wrapper.find('i.search-icon.shortcut') 245 | 246 | expect(append.element.nextElementSibling).toEqual(i.element) 247 | }) 248 | 249 | it('renders the append-outer slot', async () => { 250 | const wrapper = createWrapper({ 251 | slots: { 252 | 'append-outer': '
append-outer content
', 253 | }, 254 | }) 255 | 256 | const appendOuter = wrapper.find('.append-outer') 257 | const i = wrapper.find('i.search-icon.shortcut') 258 | 259 | expect(i.element.nextElementSibling).toEqual(appendOuter.element) 260 | }) 261 | }) 262 | -------------------------------------------------------------------------------- /tests/utils.ts: -------------------------------------------------------------------------------- 1 | export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "skipLibCheck": true, 4 | "baseUrl": ".", 5 | "target": "es2016", 6 | "useDefineForClassFields": true, 7 | "module": "esnext", 8 | "moduleResolution": "node", 9 | "strict": true, 10 | "jsx": "preserve", 11 | "sourceMap": true, 12 | "resolveJsonModule": true, 13 | "esModuleInterop": true, 14 | "lib": [ 15 | "esnext", 16 | "dom" 17 | ], 18 | "paths": { 19 | "@/*": [ 20 | "./src/*" 21 | ], 22 | "@playground/*": [ 23 | "./playground/*" 24 | ], 25 | "@root/*": [ 26 | "./*" 27 | ] 28 | }, 29 | "types": [ 30 | "vite/client", 31 | "vitest/globals", 32 | "jsdom", 33 | "node", 34 | ], 35 | }, 36 | "include": [ 37 | "src/**/*.ts", 38 | "src/**/*.d.ts", 39 | "src/**/*.tsx", 40 | "src/**/*.vue", 41 | "playground/**/*.ts", 42 | "playground/**/*.d.ts", 43 | "playground/**/*.tsx", 44 | "playground/**/*.vue", 45 | "tests/**/*.ts", 46 | "tests/**/*.d.ts", 47 | "tests/**/*.tsx", 48 | "tests/**/*.vue" 49 | ], 50 | "exclude": [ 51 | "node_modules" 52 | ] 53 | } -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import tailwindcss from '@tailwindcss/vite' 4 | import vue from '@vitejs/plugin-vue' 5 | import { resolve } from 'path' 6 | import del from 'rollup-plugin-delete' 7 | import { defineConfig } from 'vite' 8 | import dts from 'vite-plugin-dts' 9 | 10 | const alias = { 11 | '@': resolve(__dirname, './src'), 12 | '@playground': resolve(__dirname, './playground'), 13 | '@root': resolve(__dirname, './'), 14 | } 15 | 16 | const playgroundConfig = { 17 | plugins: [vue(), tailwindcss()], 18 | resolve: { alias }, 19 | build: { outDir: 'dist-playground' }, 20 | test: { 21 | globals: true, 22 | environment: 'jsdom', 23 | reporters: ['default'], 24 | coverage: { 25 | reporter: ['text', 'json-summary'], 26 | include: ['src/**'], 27 | }, 28 | }, 29 | } 30 | 31 | const libConfig = { 32 | plugins: [ 33 | del({ targets: 'dist/favicon.ico', hook: 'writeBundle' }), 34 | vue(), 35 | // https://github.com/qmhc/vite-plugin-dts#options 36 | dts({ 37 | exclude: ['playground/**'], 38 | beforeWriteFile: function (filePath, content) { 39 | // Write definition files in /dist/types/ instead of /dist/src/ 40 | const finalFilePath = filePath.replace('/dist/src/', '/dist/types/') 41 | 42 | return { filePath: finalFilePath, content } 43 | }, 44 | }), 45 | ], 46 | resolve: { alias }, 47 | build: { 48 | lib: { 49 | entry: resolve(__dirname, 'src/SearchInput.vue'), 50 | name: 'VueSearchInput', 51 | fileName: (format) => `vue-search-input.${format}.js`, 52 | }, 53 | rollupOptions: { 54 | external: ['vue'], 55 | output: { 56 | globals: { 57 | vue: 'Vue', 58 | }, 59 | }, 60 | }, 61 | }, 62 | } 63 | 64 | const config = process.env.BUILD_MODE && process.env.BUILD_MODE === 'playground' ? playgroundConfig : libConfig 65 | 66 | export default defineConfig(({ command }) => { 67 | if (command === 'serve') { 68 | return playgroundConfig 69 | } else { 70 | return config 71 | } 72 | }) 73 | --------------------------------------------------------------------------------