├── .circleci └── config.yml ├── .editorconfig ├── .eslintrc.cjs ├── .gitignore ├── .vscode └── extensions.json ├── CHANGELOG-CN.md ├── CHANGELOG.md ├── LICENSE ├── README.md ├── examples ├── App.vue ├── ExamplesCoreList.vue ├── ExamplesCoreTable.vue ├── ExamplesDropdownList.vue ├── ExamplesDropdownTable.vue ├── ExamplesIndex.vue ├── LayoutAside.vue ├── LayoutHeader.vue ├── example-data.js ├── handles.js ├── main.js ├── router.js └── store.js ├── index.html ├── jsconfig.json ├── package.json ├── pnpm-lock.yaml ├── postcss.config.cjs ├── src ├── SelectPageList.js ├── SelectPageListCore.js ├── SelectPageTable.js ├── SelectPageTableCore.js ├── __tests__ │ ├── core-list.spec.js │ ├── core-table.spec.js │ ├── dropdown-list.spec.js │ └── dropdown-table.spec.js ├── components │ └── CircleButton.js ├── core │ ├── constants.js │ ├── data.js │ ├── helper.js │ ├── list.js │ ├── pagination.js │ ├── render.js │ └── utilities.js ├── icons │ ├── IconChevronDown.vue │ ├── IconClose.vue │ ├── IconFirst.vue │ ├── IconLast.vue │ ├── IconLoading.vue │ ├── IconMessage.vue │ ├── IconNext.vue │ ├── IconPrevious.vue │ ├── IconSearch.vue │ └── IconTrash.vue ├── index.js ├── language.js ├── modules │ ├── Control.js │ ├── FormElementChips.js │ ├── FormElementSelect.js │ ├── List.js │ ├── ListItem.js │ ├── Pagination.js │ ├── Search.js │ ├── Table.js │ ├── TableRow.js │ └── Trigger.js └── styles │ ├── common.sass │ ├── list-view.sass │ ├── pagination.sass │ ├── search.sass │ ├── table-view.sass │ └── trigger.sass ├── tsconfig.json ├── types ├── common.d.ts ├── index.d.ts ├── list.d.ts └── table.d.ts └── vite.config.js /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | branches: 9 | only: master 10 | 11 | docker: 12 | # specify the version you desire here 13 | # - image: circleci/node:12.22-browsers 14 | - image: cimg/node:16.17.0-browsers 15 | 16 | # Specify service dependencies here if necessary 17 | # CircleCI maintains a library of pre-built images 18 | # documented at https://circleci.com/docs/2.0/circleci-images/ 19 | # - image: circleci/mongo:3.4.4 20 | 21 | working_directory: ~/repo 22 | 23 | steps: 24 | - checkout 25 | 26 | # Download and cache dependencies 27 | - restore_cache: 28 | keys: 29 | - v1-dependencies-{{ checksum "package.json" }} 30 | # fallback to using the latest cache if no exact match is found 31 | - v1-dependencies- 32 | 33 | - run: npm install 34 | # - run: npm rebuild node-sass 35 | - run: sudo npm install -g codecov 36 | 37 | - save_cache: 38 | paths: 39 | - node_modules 40 | key: v1-dependencies-{{ checksum "package.json" }} 41 | 42 | # run tests! 43 | - run: npm run coverage 44 | - run: codecov 45 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require('@rushstack/eslint-patch/modern-module-resolution') 3 | 4 | const path = require('node:path') 5 | const createAliasSetting = require('@vue/eslint-config-standard/createAliasSetting') 6 | 7 | module.exports = { 8 | root: true, 9 | extends: [ 10 | 'plugin:vue/vue3-strongly-recommended', 11 | '@vue/eslint-config-standard' 12 | ], 13 | parserOptions: { 14 | ecmaVersion: 'latest' 15 | }, 16 | settings: { 17 | ...createAliasSetting({ 18 | '@': `${path.resolve(__dirname, './src')}` 19 | }) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | .nyc_output/ 16 | coverage/ 17 | 18 | # Editor directories and files 19 | .vscode/* 20 | !.vscode/extensions.json 21 | .idea 22 | .DS_Store 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG-CN.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 英文 changelog 内容请访问 [CHANGELOG](CHANGELOG.md) 4 | 5 | ## [3.0.1](https://github.com/TerryZ/v-selectpage/compare/v3.0.0...v3.0.1) (2023-10-27) 6 | 7 | ### 问题修复 8 | 9 | - v-model 设置为空数组时不会清除已选中项目 [#68](https://github.com/TerryZ/v-selectpage/issues/68) 10 | 11 | ### 新特性 12 | 13 | - `SelectPageList` 与 `SelectPageTable` 添加 `removeItem` 与 `removeAll` 函数 14 | 15 | ## [3.0.0](https://github.com/TerryZ/v-selectpage/compare/v3.0.0-beta.2...v3.0.0) (2023-10-09) 16 | 17 | ### 问题修复 18 | 19 | - 更新 `.d.ts` 文档 20 | 21 | ## [3.0.0-beta.2](https://github.com/TerryZ/v-selectpage/compare/v3.0.0-beta.1...v3.0.0-beta.2) (2023-10-05) 22 | 23 | ### 新特性 24 | 25 | - 更新 `v-dropdown` 至 `v3.0.0` 26 | - 新增 `customTriggerClass` prop,用于为触发对象添加自定义样式 27 | - 新增 `customContainerClass` prop,用于为下拉容器添加自定义样式 28 | 29 | ### 问题修复 30 | 31 | - 更新 `.d.ts` 文档 32 | 33 | ## [3.0.0-beta.1](https://github.com/TerryZ/v-selectpage) (2023-09-01) 34 | 35 | ### 新特性 36 | 37 | - 使用 vue3 **composition api** 重构 `v-selectpage` 38 | - 工具链从 `webpack` 更换为 `vite` 39 | - 单元测试库从 `mocha` 更换为 `vitest` 40 | - 新增 `SelectPageListCore` 与 `SelectPageTableCore` 核心模块可独立使用 41 | - 下拉列表形态模块 `SelectPageList` 与 `SelectPageTable` 模块增加 `visible-change` 事件,响应下拉层打开/关闭状态 42 | - 数据加载方式修改为通过 `fetch-data` 与 `fetch-selected-data` 事件响应的渠道,增加数据处理灵活性 43 | - 多语言新增 `繁体中文`、`俄罗斯文`、`土耳其文` 与 `荷兰文` 44 | - 选中项目变更事件从 `values` 修改为 `selection-change` 45 | - 新增 `remove` 事件响应项目移除操作 46 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | Please refer to [CHANGELOG-CN](CHANGELOG-CN.md) for Chinese changelog 4 | 5 | ## [3.0.1](https://github.com/TerryZ/v-selectpage/compare/v3.0.0...v3.0.1) (2023-10-27) 6 | 7 | ### Bug Fixes 8 | 9 | - Selected items will not be cleared when `v-model` set value to an empty array [#68](https://github.com/TerryZ/v-selectpage/issues/68) 10 | 11 | ### Features 12 | 13 | - Added `removeItem` and `removeAll` methods to `SelectPageList` , `SelectPageTable` components 14 | 15 | ## [3.0.0](https://github.com/TerryZ/v-selectpage/compare/v3.0.0-beta.2...v3.0.0) (2023-10-09) 16 | 17 | ### Bug Fixes 18 | 19 | - Update `.d.ts` document 20 | 21 | ## [3.0.0-beta.2](https://github.com/TerryZ/v-selectpage/compare/v3.0.0-beta.1...v3.0.0-beta.2) (2023-10-05) 22 | 23 | ### Features 24 | 25 | - Upgrade `v-dropdown` to `v3.0.0` 26 | - Added `customTriggerClass` prop, used to add custom class to trigger objects 27 | - Added `customContainerClass` prop for adding custom class to dropdown container 28 | 29 | ### Bug Fixes 30 | 31 | - Update `.d.ts` document 32 | 33 | ## [3.0.0-beta.1](https://github.com/TerryZ/v-selectpage) (2023-09-01) 34 | 35 | ### Features 36 | 37 | - The `v-selectpage` component has been refactored using Vue 3 **composition API** 38 | - The build tool has been switched from `Webpack` to `Vite` 39 | - The unit testing library has been switched from `Mocha` to `Vitest` 40 | - Two new core modules, `SelectPageListCore` and `SelectPageTableCore`, have been added. These modules can be used independently 41 | - The `visible-change` event has been added to the dropdown list modules `SelectPageList` and `SelectPageTable`. This event is fired when the dropdown layer is opened or closed 42 | - Data loading has been changed to use the `fetch-data` and `fetch-selected-data` events to improve flexibility in data processing 43 | - Four new languages have been added: `Traditional Chinese`, `Russian`, `Turkish`, and `Dutch` 44 | - The event for changing the selected items has been changed from `values` to `selection-change` 45 | - A new `remove` event has been added to respond to item removal operations 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Terry Zeng 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 | # [v-selectpage](https://terryz.github.io/vue/#/selectpage) 2 | 3 | 4 | SelectPage 5 | 6 | 7 | [![CircleCI](https://dl.circleci.com/status-badge/img/gh/TerryZ/v-selectpage/tree/master.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/TerryZ/v-selectpage/tree/master) [![code coverage](https://codecov.io/gh/TerryZ/v-selectpage/branch/master/graph/badge.svg)](https://codecov.io/gh/TerryZ/v-selectpage) [![npm version](https://img.shields.io/npm/v/v-selectpage.svg)](https://www.npmjs.com/package/v-selectpage) 8 | 9 | SelectPage for Vue3, a select items components provides the list of items with pagination 10 | 11 | [![Financial Contributors on Open Collective](https://opencollective.com/v-selectpage/all/badge.svg?label=financial+contributors)](https://opencollective.com/v-selectpage) 12 | [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 13 | [![npm download](https://img.shields.io/npm/dy/v-selectpage.svg)](https://www.npmjs.com/package/v-selectpage) 14 | 15 | If you are using vue `2.x` version, please use [v-selectpage 2.x](https://github.com/TerryZ/v-selectpage/tree/dev-vue-2) version instead 16 | 17 |







18 | 19 | ## Examples and Documentation 20 | 21 | Documentation and examples please visit below sites 22 | 23 | - [github-pages](https://terryz.github.io/docs-vue3/select-page/) 24 | 25 | The jQuery version: [SelectPage](https://github.com/TerryZ/SelectPage) 26 | 27 | ## Features 28 | 29 | - Display contents with pagination 30 | - I18n support 31 | - Select single / multiple options 32 | - Tags form for multiple selection 33 | - Keyboard navigation 34 | - Searchable 35 | - Provide display forms such as list view and table view 36 | - Customization of row / cell content rendering 37 | - Core module that can be used independently 38 | 39 | I18n support languages 40 | 41 | 42 | 43 | - Chinese Simplified 44 | - English 45 | - Japanese 46 | - Arabic 47 | - Spanish 48 | - German 49 | - Romanian 50 | - French 51 | - Portuguese-Brazil 52 | - Polish 53 | - Dutch 54 | - Chinese Traditional 55 | - Russian 56 | - Turkish 57 | 58 | ## Installation 59 | 60 | [![https://nodei.co/npm/v-selectpage.png?downloads=true&downloadRank=true&stars=true](https://nodei.co/npm/v-selectpage.png?downloads=true&downloadRank=true&stars=true)](https://www.npmjs.com/package/v-selectpage) 61 | 62 | Install `v-selectpage` to your project 63 | 64 | ``` bash 65 | # npm 66 | npm i v-selectpage 67 | # yarn 68 | yarn add v-selectpage 69 | # pnpm 70 | pnpm add v-selectpage 71 | ``` 72 | 73 | ## Usage 74 | 75 | Quick start example 76 | 77 | ```vue 78 | 85 | 86 | 120 | ``` 121 | 122 | Set default selected items 123 | 124 | ```vue 125 | 135 | 136 | 163 | ``` 164 | 165 | ## Plugin preview 166 | 167 | List view for Single selection 168 | 169 | ![single](https://terryz.github.io/image/v-selectpage/v3/selectpage-list-single.png) 170 | 171 | List view for multiple selection with tags form 172 | 173 | ![multiple](https://terryz.github.io/image/v-selectpage/v3/selectpage-list-multiple.png) 174 | 175 | Table view for single selection 176 | 177 | ![table](https://terryz.github.io/image/v-selectpage/v3/selectpage-table-single.png) 178 | 179 | ## Dependencies 180 | 181 | - [v-dropdown](https://github.com/TerryZ/v-dropdown) - The dropdown container 182 | 183 | ## License 184 | 185 | [![license](https://img.shields.io/badge/license-MIT-brightgreen.svg)](https://mit-license.org/) 186 | -------------------------------------------------------------------------------- /examples/App.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /examples/ExamplesCoreList.vue: -------------------------------------------------------------------------------- 1 | 107 | 108 | 133 | ./example-data 134 | -------------------------------------------------------------------------------- /examples/ExamplesCoreTable.vue: -------------------------------------------------------------------------------- 1 | 91 | 92 | 119 | -------------------------------------------------------------------------------- /examples/ExamplesDropdownList.vue: -------------------------------------------------------------------------------- 1 | 114 | 115 | 146 | -------------------------------------------------------------------------------- /examples/ExamplesDropdownTable.vue: -------------------------------------------------------------------------------- 1 | 117 | 118 | 159 | -------------------------------------------------------------------------------- /examples/ExamplesIndex.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 26 | 27 | 39 | -------------------------------------------------------------------------------- /examples/LayoutAside.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 27 | -------------------------------------------------------------------------------- /examples/LayoutHeader.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 66 | -------------------------------------------------------------------------------- /examples/example-data.js: -------------------------------------------------------------------------------- 1 | export const list1 = Array 2 | .from({ length: 101 }) 3 | .map((val, index) => ({ 4 | id: index + 1, 5 | name: `列表项目-item-${index + 1}`, 6 | code: `编码-code-${index + 1}`, 7 | price: (Math.random() * 1000000).toFixed(2) 8 | })) 9 | 10 | export const nbaTeams = [ 11 | { id: 1, name: 'Chicago Bulls', desc: '芝加哥公牛' }, 12 | { id: 2, name: 'Cleveland Cavaliers', desc: '克里夫兰骑士' }, 13 | { id: 3, name: 'Detroit Pistons', desc: '底特律活塞' }, 14 | { id: 4, name: 'Indiana Pacers', desc: '印第安纳步行者' }, 15 | { id: 5, name: 'Milwaukee Bucks', desc: '密尔沃基雄鹿' }, 16 | { id: 6, name: 'Brooklyn Nets', desc: '布鲁克林篮网' }, 17 | { id: 7, name: 'Boston Celtics', desc: '波士顿凯尔特人' }, 18 | { id: 8, name: 'New York Knicks', desc: '纽约尼克斯' }, 19 | { id: 9, name: 'Philadelphia 76ers', desc: '费城76人' }, 20 | { id: 10, name: 'Toronto Raptors', desc: '多伦多猛龙' }, 21 | { id: 11, name: 'Atlanta Hawks', desc: '亚特兰大老鹰' }, 22 | { id: 12, name: 'Charlotte Hornets', desc: '夏洛特黄蜂' }, 23 | { id: 13, name: 'Miami Heat', desc: '迈阿密热火' }, 24 | { id: 14, name: 'Orlando Magic', desc: '奥兰多魔术' }, 25 | { id: 15, name: 'Washington Wizards', desc: '华盛顿奇才' }, 26 | { id: 16, name: 'Denver Nuggets', desc: '丹佛掘金' }, 27 | { id: 17, name: 'Minnesota Timberwolves', desc: '明尼苏达森林狼' }, 28 | { id: 18, name: 'Oklahoma City Thunder', desc: '俄克拉荷马雷霆' }, 29 | { id: 19, name: 'Portland Trail Blazers', desc: '波特兰开拓者' }, 30 | { id: 20, name: 'Utah Jazz', desc: '犹他爵士' }, 31 | { id: 21, name: 'Golden State Warriors', desc: '金州勇士' }, 32 | { id: 22, name: 'Los Angeles Clippers', desc: '洛杉矶快船' }, 33 | { id: 23, name: 'Los Angeles Lakers', desc: '洛杉矶湖人' }, 34 | { id: 24, name: 'Phoenix Suns', desc: '菲尼克斯太阳' }, 35 | { id: 25, name: 'Sacramento Kings', desc: '萨克拉门托国王' }, 36 | { id: 26, name: 'Dallas Mavericks', desc: '达拉斯小牛' }, 37 | { id: 27, name: 'Houston Rockets', desc: '休斯顿火箭' }, 38 | { id: 28, name: 'Memphis Grizzlies', desc: '孟菲斯灰熊' }, 39 | { id: 29, name: 'New Orleans Pelicans', desc: '新奥尔良鹈鹕' }, 40 | { id: 30, name: 'San Antonio Spurs', desc: '圣安东尼奥马刺' } 41 | ] 42 | -------------------------------------------------------------------------------- /examples/handles.js: -------------------------------------------------------------------------------- 1 | import { list1 } from './example-data' 2 | 3 | export function useSelectPageHandle (dataList = list1) { 4 | function dataListHandle (data) { 5 | const { search, pageNumber, pageSize } = data 6 | 7 | const start = (pageNumber - 1) * pageSize 8 | const end = start + pageSize - 1 9 | 10 | const list = search 11 | ? dataList.filter(val => val.name.includes(search)) 12 | : dataList 13 | 14 | const result = pageSize === 0 15 | ? list 16 | : list.filter((val, index) => index >= start && index <= end) 17 | return { 18 | list: result, 19 | count: list.length 20 | } 21 | } 22 | function selectedItemsHandle (data) { 23 | return dataList.filter(val => data.includes(val.id)) 24 | } 25 | // local data list pagination 26 | function fetchData (data, callback) { 27 | const result = dataListHandle(data) 28 | 29 | setTimeout(() => callback(result.list, result.count), 500) 30 | } 31 | /** 32 | * Fetch selected item models 33 | * @param {Array} data selected item keys 34 | * @param {function} callback a function to send data models back 35 | */ 36 | function fetchSelectedData (data, callback) { 37 | callback(selectedItemsHandle(data)) 38 | } 39 | function selectionChange (data) { 40 | console.log(data) 41 | } 42 | function remove (data) { 43 | console.log(data) 44 | } 45 | function labelFormatter (row) { 46 | return `${row.name} (id: ${row.id})` 47 | } 48 | 49 | return { 50 | dataListHandle, 51 | selectedItemsHandle, 52 | fetchData, 53 | fetchSelectedData, 54 | selectionChange, 55 | remove, 56 | labelFormatter 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /examples/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import { router } from './router' 3 | import App from './App.vue' 4 | 5 | import 'bootstrap/dist/css/bootstrap.min.css' 6 | 7 | const app = createApp(App) 8 | app.use(router) 9 | app.mount('#app') 10 | -------------------------------------------------------------------------------- /examples/router.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHashHistory } from 'vue-router' 2 | 3 | import { routers } from './store' 4 | 5 | const routes = [ 6 | { 7 | path: '/', 8 | component: () => import('./ExamplesIndex.vue'), 9 | redirect: '/core/list', 10 | children: routers 11 | }, { 12 | path: '/:pathMatch(.*)*', 13 | redirect: '/core/list' 14 | } 15 | ] 16 | 17 | const router = createRouter({ 18 | history: createWebHashHistory(), 19 | routes 20 | }) 21 | 22 | export { router } 23 | -------------------------------------------------------------------------------- /examples/store.js: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | 3 | export const routers = [ 4 | { 5 | name: 'core-list', 6 | path: '/core/list', 7 | component: () => import('./ExamplesCoreList.vue') 8 | }, { 9 | name: 'core-table', 10 | path: '/core/table', 11 | component: () => import('./ExamplesCoreTable.vue') 12 | }, { 13 | name: 'dropdown-list', 14 | path: '/dropdown/list', 15 | component: () => import('./ExamplesDropdownList.vue') 16 | }, { 17 | name: 'dropdown-table', 18 | path: '/dropdown/table', 19 | component: () => import('./ExamplesDropdownTable.vue') 20 | } 21 | ] 22 | 23 | export const types = [ 24 | { name: 'Core', code: 'core' }, 25 | { name: 'Dropdown', code: 'dropdown' } 26 | ] 27 | 28 | export const forms = [ 29 | { name: 'List', code: 'list' }, 30 | { name: 'Table', code: 'table' } 31 | ] 32 | 33 | const DEFAULT_FORM = 'list' 34 | 35 | export const type = ref('core') 36 | export const form = ref(DEFAULT_FORM) 37 | 38 | export function switchType (data, router) { 39 | type.value = data.code 40 | form.value = DEFAULT_FORM 41 | router.push({ name: `${data.code}-${DEFAULT_FORM}` }).catch(() => {}) 42 | } 43 | export function switchForm (data, router) { 44 | form.value = data.code 45 | router.push({ name: `${type.value}-${data.code}` }).catch(() => {}) 46 | } 47 | export function detectActive (route) { 48 | const [typeCode, formCode] = route.name.split('-') 49 | type.value = typeCode 50 | form.value = formCode 51 | } 52 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | v-selectpage examples 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": { 5 | "@/*": ["src/*"] 6 | }, 7 | "target": "ES6", 8 | "module": "commonjs", 9 | "allowSyntheticDefaultImports": true, 10 | "jsx": "preserve" 11 | }, 12 | "include": ["src/**/*"], 13 | "exclude": ["node_modules"] 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "v-selectpage", 3 | "description": "SelectPage for Vue, a select items components provides the list of items with pagination", 4 | "version": "3.0.1", 5 | "author": "TerryZ ", 6 | "type": "module", 7 | "files": [ 8 | "/dist", 9 | "/types" 10 | ], 11 | "main": "./dist/v-selectpage.umd.cjs", 12 | "module": "./dist/v-selectpage.js", 13 | "exports": { 14 | ".": { 15 | "import": { 16 | "types": "./types/index.d.ts", 17 | "default": "./dist/v-selectpage.js" 18 | }, 19 | "require": "./dist/v-selectpage.umd.cjs" 20 | } 21 | }, 22 | "typings": "types/index.d.ts", 23 | "license": "MIT", 24 | "scripts": { 25 | "dev": "vite", 26 | "build": "vite build", 27 | "preview": "vite preview --port 4173", 28 | "test:unit": "vitest", 29 | "coverage": "vitest run --coverage", 30 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore", 31 | "dts": "npx -p typescript tsc src/index.js --declaration --allowJs --emitDeclarationOnly --outDir types", 32 | "dts:vue": "vue-tsc --declaration --emitDeclarationOnly" 33 | }, 34 | "repository": { 35 | "type": "git", 36 | "url": "https://github.com/TerryZ/v-selectpage.git" 37 | }, 38 | "keywords": [ 39 | "front-end", 40 | "javascript", 41 | "vue", 42 | "selector", 43 | "table-view", 44 | "i18n", 45 | "pagination", 46 | "tags", 47 | "multiple" 48 | ], 49 | "peerDependencies": { 50 | "vue": "^3.2.0" 51 | }, 52 | "dependencies": { 53 | "v-dropdown": "3.0.0", 54 | "vue": "^3.3.7" 55 | }, 56 | "devDependencies": { 57 | "@rushstack/eslint-patch": "^1.5.1", 58 | "@vitejs/plugin-vue": "^4.4.0", 59 | "@vitejs/plugin-vue-jsx": "^3.0.2", 60 | "@vitest/coverage-v8": "^0.34.6", 61 | "@vue/eslint-config-standard": "^8.0.1", 62 | "@vue/test-utils": "^2.4.1", 63 | "autoprefixer": "^10.4.16", 64 | "bootstrap": "^5.3.2", 65 | "eslint": "^8.52.0", 66 | "eslint-plugin-vue": "^9.18.0", 67 | "jsdom": "^22.1.0", 68 | "postcss": "^8.4.31", 69 | "sass": "^1.69.5", 70 | "typescript": "^5.2.2", 71 | "vite": "^4.5.0", 72 | "vite-plugin-css-injected-by-js": "^3.3.0", 73 | "vitest": "^0.34.6", 74 | "vue-router": "^4.2.5", 75 | "vue-tsc": "^1.8.22" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/SelectPageList.js: -------------------------------------------------------------------------------- 1 | import { ref, h, defineComponent, mergeProps, nextTick } from 'vue' 2 | 3 | import { dropdownProps } from './core/data' 4 | import { useDropdown } from './core/render' 5 | import { isMultiple } from './core/helper' 6 | 7 | import SelectPageListCore from './SelectPageListCore' 8 | import Trigger from './modules/Trigger' 9 | import FormElementSelect from './modules/FormElementSelect' 10 | import FormElementChips from './modules/FormElementChips' 11 | 12 | export default defineComponent({ 13 | name: 'SelectPageList', 14 | inheritAttrs: false, 15 | props: { 16 | ...dropdownProps() 17 | }, 18 | emits: ['visible-change'], 19 | setup (props, { emit, attrs, expose }) { 20 | const { 21 | visible, 22 | adjustDropdown, 23 | closeDropdown, 24 | renderDropdown 25 | } = useDropdown(props) 26 | 27 | const selectedItems = ref([]) 28 | const core = ref() 29 | 30 | expose({ 31 | removeItem: row => core.value?.removeItem(row), 32 | removeAll: () => core.value?.removeAll() 33 | }) 34 | 35 | return () => { 36 | const elementOption = { 37 | selected: selectedItems, 38 | disabled: props.disabled, 39 | lang: core?.value?.lang, 40 | renderCell: core?.value?.renderCell, 41 | onRemove (row) { 42 | if (isMultiple(attrs)) { 43 | core.value.removeItem(row) 44 | } else { 45 | core.value.removeAll() 46 | } 47 | } 48 | } 49 | const selectedContents = selectedItems.value.length 50 | ? () => h(isMultiple(attrs) ? FormElementChips : FormElementSelect, elementOption) 51 | : undefined 52 | 53 | const triggerOption = { 54 | dropdownVisible: visible.value, 55 | disabled: props.disabled, 56 | placeholder: attrs.placeholder, 57 | lang: core?.value?.lang 58 | } 59 | const dropdownTrigger = h(Trigger, triggerOption, selectedContents) 60 | 61 | const coreOption = { 62 | ref: core, 63 | onAdjustDropdown: adjustDropdown, 64 | onCloseDropdown: closeDropdown, 65 | onSelectionChange (data) { 66 | selectedItems.value = data 67 | // close dropdown when item selected in single selection mode 68 | if (!isMultiple(attrs) && data.length) { 69 | closeDropdown() 70 | } 71 | } 72 | } 73 | 74 | const dropdownOption = { 75 | onVisibleChange: val => { 76 | emit('visible-change', val) 77 | if (!val) return 78 | 79 | nextTick(() => { 80 | core.value.setSearchFocus() 81 | }) 82 | } 83 | } 84 | return renderDropdown( 85 | dropdownOption, 86 | dropdownTrigger, 87 | h(SelectPageListCore, mergeProps(coreOption, attrs)) 88 | ) 89 | } 90 | } 91 | }) 92 | -------------------------------------------------------------------------------- /src/SelectPageListCore.js: -------------------------------------------------------------------------------- 1 | import { defineComponent } from 'vue' 2 | 3 | import { selectPageProps, selectPageEmits } from './core/data' 4 | import { useRender } from './core/render' 5 | 6 | export default defineComponent({ 7 | name: 'SelectPageListCore', 8 | props: { 9 | ...selectPageProps() 10 | }, 11 | emits: selectPageEmits(), 12 | setup (props, { emit, expose }) { 13 | const { 14 | selected, 15 | lang, 16 | renderCell, 17 | removeAll, 18 | removeItem, 19 | setSearchFocus, 20 | renderSearch, 21 | renderMessage, 22 | renderList, 23 | renderPagination, 24 | renderContainer 25 | } = useRender(props, emit) 26 | 27 | expose({ 28 | selected, 29 | lang, 30 | renderCell, 31 | removeAll, 32 | removeItem, 33 | setSearchFocus 34 | }) 35 | 36 | return () => renderContainer([ 37 | renderSearch(), 38 | renderMessage(), 39 | renderList(), 40 | renderPagination() 41 | ]) 42 | } 43 | }) 44 | -------------------------------------------------------------------------------- /src/SelectPageTable.js: -------------------------------------------------------------------------------- 1 | import { ref, h, defineComponent, mergeProps, nextTick } from 'vue' 2 | 3 | import { dropdownProps } from './core/data' 4 | import { useDropdown } from './core/render' 5 | import { isMultiple } from './core/helper' 6 | 7 | import SelectPageTableCore from './SelectPageTableCore' 8 | import Trigger from './modules/Trigger' 9 | import FormElementSelect from './modules/FormElementSelect' 10 | import FormElementChips from './modules/FormElementChips' 11 | 12 | export default defineComponent({ 13 | name: 'SelectPageTable', 14 | inheritAttrs: false, 15 | props: { 16 | ...dropdownProps() 17 | }, 18 | emits: ['visible-change'], 19 | setup (props, { emit, attrs, expose }) { 20 | const { 21 | visible, 22 | adjustDropdown, 23 | closeDropdown, 24 | renderDropdown 25 | } = useDropdown(props) 26 | 27 | const selectedItems = ref([]) 28 | const core = ref() 29 | 30 | expose({ 31 | removeItem: row => core.value?.removeItem(row), 32 | removeAll: () => core.value?.removeAll() 33 | }) 34 | 35 | return () => { 36 | const elementOption = { 37 | selected: selectedItems, 38 | disabled: props.disabled, 39 | lang: core?.value?.lang, 40 | renderCell: core?.value?.renderCell, 41 | onRemove (row) { 42 | if (isMultiple(attrs)) { 43 | core.value.removeItem(row) 44 | } else { 45 | core.value.removeAll() 46 | } 47 | } 48 | } 49 | const selectedContents = selectedItems.value.length 50 | ? () => h(isMultiple(attrs) ? FormElementChips : FormElementSelect, elementOption) 51 | : undefined 52 | 53 | const triggerOption = { 54 | dropdownVisible: visible.value, 55 | disabled: props.disabled, 56 | placeholder: attrs.placeholder, 57 | lang: core?.value?.lang 58 | } 59 | const dropdownTrigger = h(Trigger, triggerOption, selectedContents) 60 | 61 | const coreOption = { 62 | ref: core, 63 | onAdjustDropdown: adjustDropdown, 64 | onCloseDropdown: closeDropdown, 65 | onSelectionChange (data) { 66 | selectedItems.value = data 67 | // close dropdown when item selected in single selection mode 68 | if (!isMultiple(attrs) && data.length) { 69 | closeDropdown() 70 | } 71 | } 72 | } 73 | 74 | const dropdownOption = { 75 | onVisibleChange: val => { 76 | emit('visible-change', val) 77 | if (!val) return 78 | 79 | nextTick(() => { 80 | core.value.setSearchFocus() 81 | }) 82 | } 83 | } 84 | return renderDropdown( 85 | dropdownOption, 86 | dropdownTrigger, 87 | h(SelectPageTableCore, mergeProps(coreOption, attrs)) 88 | ) 89 | } 90 | } 91 | }) 92 | -------------------------------------------------------------------------------- /src/SelectPageTableCore.js: -------------------------------------------------------------------------------- 1 | import { defineComponent } from 'vue' 2 | import { selectPageProps, selectPageEmits } from './core/data' 3 | import { useRender } from './core/render' 4 | 5 | export default defineComponent({ 6 | name: 'SelectPageTableCore', 7 | props: { 8 | ...selectPageProps(), 9 | /** 10 | * table column settings 11 | */ 12 | columns: { type: Array, default: undefined } 13 | }, 14 | emits: selectPageEmits(), 15 | setup (props, { emit, expose }) { 16 | const { 17 | selected, 18 | lang, 19 | removeAll, 20 | removeItem, 21 | setSearchFocus, 22 | renderCell, 23 | renderSearch, 24 | renderMessage, 25 | renderTable, 26 | renderPagination, 27 | renderContainer 28 | } = useRender(props, emit) 29 | 30 | expose({ 31 | selected, 32 | lang, 33 | renderCell, 34 | removeAll, 35 | removeItem, 36 | setSearchFocus 37 | }) 38 | 39 | return () => renderContainer([ 40 | renderSearch(), 41 | renderMessage(), 42 | renderTable(), 43 | renderPagination() 44 | ]) 45 | } 46 | }) 47 | -------------------------------------------------------------------------------- /src/__tests__/core-list.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest' 2 | import { mount } from '@vue/test-utils' 3 | import { nextTick } from 'vue' 4 | // import list from './data/nba-teams' 5 | import { SelectPageListCore } from '@/index' 6 | import { useSelectPageHandle } from '../../examples/handles' 7 | 8 | describe('v-selectpage SelectPageListCore 列表模式核心模块', () => { 9 | describe('默认参数场景', () => { 10 | const wrapper = mount(SelectPageListCore) 11 | const { dataListHandle } = useSelectPageHandle() 12 | 13 | it('初始加载第一页列表数据', () => { 14 | // { search: "", pageNumber: 1, pageSize: 10 } 15 | const [data, callback] = wrapper.emitted()['fetch-data'].at(-1) 16 | 17 | const result = dataListHandle(data) 18 | callback(result.list, result.count) 19 | 20 | expect(data.search).toBe('') 21 | expect(data.pageNumber).toBe(1) 22 | expect(data.pageSize).toBe(10) 23 | }) 24 | it('列表中应有 10 个项目', () => { 25 | expect(wrapper.findAll('.sp-list-item')).toHaveLength(10) 26 | }) 27 | it('搜索栏中显示的提示文字应为 `Search`', () => { 28 | expect(wrapper.find('.sp-search-input').attributes('placeholder')).toBe('Search') 29 | }) 30 | it('搜索栏清空搜索内容图标应不存在', () => { 31 | expect(wrapper.find('.sp-search-container .bi-x-lg').exists()).toBe(false) 32 | }) 33 | it('无选中项目时,清空选择按钮应为禁用状态', () => { 34 | expect( 35 | wrapper.find('.sp-search-control .sp-circle-btn').classes('sp-circle-btn--disabled') 36 | ).toBe(true) 37 | }) 38 | it('分页栏中显示信息应为 `Page 1 of 11 (101 records)`', () => { 39 | expect(wrapper.find('.sp-page-info').text()).toBe('Page 1 of 11 (101 records)') 40 | }) 41 | it('分页栏中的首页、上一页按钮应为禁用状态', () => { 42 | const buttons = wrapper.findAll('.sp-page-button') 43 | 44 | expect(buttons.at(0).classes('sp-page-disabled')).toBe(true) 45 | expect(buttons.at(1).classes('sp-page-disabled')).toBe(true) 46 | }) 47 | it('跳转至下一页,应更新分页栏页码,且首页、上一页按钮状态可用', async () => { 48 | const buttons = wrapper.findAll('.sp-page-button') 49 | // click `next page` button 50 | await buttons.at(2).find('a').trigger('click') 51 | 52 | // 首页、上一页按钮恢复可用状态 53 | expect(buttons.at(0).classes('sp-page-disabled')).toBe(false) 54 | expect(buttons.at(1).classes('sp-page-disabled')).toBe(false) 55 | // 分页栏信息中当前页更新为 2 56 | expect(wrapper.find('.sp-page-info').text()).toBe('Page 2 of 11 (101 records)') 57 | 58 | const [data] = wrapper.emitted()['fetch-data'].at(-1) 59 | 60 | // fetch-data 事件的当前页数据响应为 2 61 | expect(data.pageNumber).toBe(2) 62 | }) 63 | it('搜索关键字 `10`,搜索栏出现清空搜索内容图标', async () => { 64 | vi.useFakeTimers() 65 | 66 | const input = wrapper.find('.sp-search-input') 67 | // 输入 10 两个字符 68 | await input.setValue('10') 69 | await input.trigger('input') 70 | 71 | vi.runAllTimers() 72 | await nextTick() 73 | 74 | // console.log(wrapper.emitted()['fetch-data']) 75 | const [data, callback] = wrapper.emitted()['fetch-data'].at(-1) 76 | 77 | expect(data.search).toBe('10') 78 | expect(data.pageNumber).toBe(1) 79 | expect(wrapper.find('.sp-search-container .bi-x-lg').exists()).toBe(true) 80 | 81 | const result = dataListHandle(data) 82 | callback(result.list, result.count) 83 | 84 | vi.useRealTimers() 85 | }) 86 | it('搜索后,列表中应只有 3 个项目', () => { 87 | expect(wrapper.findAll('.sp-list-item')).toHaveLength(3) 88 | }) 89 | it('分页栏中显示信息应为 `Page 1 of 1 (3 records)`', () => { 90 | expect(wrapper.find('.sp-page-info').text()).toBe('Page 1 of 1 (3 records)') 91 | }) 92 | it('选中列表的第 2 个项目,该项目应呈现选中样式', async () => { 93 | const secondItem = wrapper.findAll('.sp-list-item').at(1) 94 | 95 | await secondItem.trigger('click') 96 | 97 | expect(secondItem.classes('sp-selected')).toBeTruthy() 98 | }) 99 | it('选中项目后,响应 `selection-change` 事件,且响应数据为 id 为 100 的数据', async () => { 100 | const [data] = wrapper.emitted()['selection-change'].at(-1) 101 | const [model] = wrapper.emitted()['update:modelValue'].at(-1) 102 | 103 | expect(data.at(0).id).toBe(100) 104 | expect(model).toEqual([100]) 105 | }) 106 | it('点击选中项目移除选中图标,应移除该项目的选中状态,且响应事件为空数组', async () => { 107 | const selectedItem = wrapper.find('.sp-list-item.sp-selected') 108 | await selectedItem.find('.sp-circle-btn').trigger('click') 109 | expect(selectedItem.classes('sp-selected')).toBeFalsy() 110 | 111 | const [data] = wrapper.emitted()['selection-change'].at(-1) 112 | expect(data).toHaveLength(0) 113 | }) 114 | it('再次选中第 3 个项目,`selection-change` 事件响应数据为 id 为 101 的数据', async () => { 115 | const thirdItem = wrapper.findAll('.sp-list-item').at(2) 116 | await thirdItem.trigger('click') 117 | expect(thirdItem.classes('sp-selected')).toBeTruthy() 118 | 119 | const [data] = wrapper.emitted()['selection-change'].at(-1) 120 | const [model] = wrapper.emitted()['update:modelValue'].at(-1) 121 | expect(data.at(0).id).toBe(101) 122 | expect(model).toEqual([101]) 123 | }) 124 | it('点击搜索栏尾部的清除所有选中项目图标,所有选中项目应被清除,并响应数据为空数组', async () => { 125 | await wrapper.find('.sp-search-control .sp-circle-btn').trigger('click') 126 | 127 | const [data] = wrapper.emitted()['selection-change'].at(-1) 128 | const [model] = wrapper.emitted()['update:modelValue'].at(-1) 129 | expect(data).toHaveLength(0) 130 | expect(model).toHaveLength(0) 131 | }) 132 | }) 133 | 134 | describe('通过设置默认选中项目与模式选项修改', () => { 135 | const wrapper = mount(SelectPageListCore, { 136 | props: { 137 | modelValue: [2], 138 | multiple: true, 139 | max: 2, 140 | pageSize: 5, 141 | labelProp: 'code' 142 | } 143 | }) 144 | const { dataListHandle, selectedItemsHandle } = useSelectPageHandle() 145 | 146 | it('初始化列表与设置默认选择项目', () => { 147 | const [data, callback] = wrapper.emitted()['fetch-data'].at(-1) 148 | const [selected, selectedCallback] = wrapper.emitted()['fetch-selected-data'].at(-1) 149 | const result = dataListHandle(data) 150 | const selectedResult = selectedItemsHandle(selected) 151 | 152 | callback(result.list, result.count) 153 | selectedCallback(selectedResult) 154 | 155 | expect(selected).toEqual([2]) 156 | }) 157 | it('列表中的第 1 项目文本内容应为 `编码-code-1`', () => { 158 | expect(wrapper.findAll('.sp-list-item').at(0).text()).toBe('编码-code-1') 159 | }) 160 | it('列表中应有 5 个项目', () => { 161 | expect(wrapper.findAll('.sp-list-item')).toHaveLength(5) 162 | }) 163 | it('分页栏中显示信息应为 `Page 1 of 21 (101 records)`', () => { 164 | expect(wrapper.find('.sp-page-info').text()).toBe('Page 1 of 21 (101 records)') 165 | }) 166 | it('id 为 2 的项目应被选中', () => { 167 | expect( 168 | wrapper.findAll('.sp-list-item').at(1).classes('sp-selected') 169 | ).toBeTruthy() 170 | }) 171 | it('修改 modelValue 值,对应 id 的项目应被选中,且原项目移除选择状态', async () => { 172 | await wrapper.setProps({ modelValue: [3] }) 173 | 174 | const [selected, selectedCallback] = wrapper.emitted()['fetch-selected-data'].at(-1) 175 | const selectedResult = selectedItemsHandle(selected) 176 | selectedCallback(selectedResult) 177 | 178 | await nextTick() 179 | 180 | expect( 181 | wrapper.findAll('.sp-list-item').at(2).classes('sp-selected') 182 | ).toBeTruthy() 183 | expect( 184 | wrapper.findAll('.sp-list-item').at(1).classes('sp-selected') 185 | ).toBeFalsy() 186 | }) 187 | it('多选模式下设置 max 为 2 时,选中第 3 个项目时,应不成功且有相应提示', async () => { 188 | await wrapper.setProps({ modelValue: [1, 2] }) 189 | 190 | const [selected, selectedCallback] = wrapper.emitted()['fetch-selected-data'].at(-1) 191 | const selectedResult = selectedItemsHandle(selected) 192 | selectedCallback(selectedResult) 193 | 194 | await nextTick() 195 | 196 | await wrapper.findAll('.sp-list-item').at(2).trigger('click') 197 | expect(wrapper.find('.sp-message').exists()).toBeTruthy() 198 | expect(wrapper.find('.sp-message').text()).toBe( 199 | 'You can only select up to 2 items' 200 | ) 201 | }) 202 | it('设置 v-model 为空数组时,应清除所有已选中的项目', async () => { 203 | await wrapper.setProps({ modelValue: [] }) 204 | 205 | expect(wrapper.findAll('.sp-list-item.sp-selected')).toHaveLength(0) 206 | }) 207 | }) 208 | 209 | describe('设置模块与 ui 样式', () => { 210 | const wrapper = mount(SelectPageListCore, { 211 | props: { 212 | pagination: false, 213 | rtl: true, 214 | width: 500, 215 | language: 'zh-chs' 216 | } 217 | }) 218 | 219 | const { dataListHandle } = useSelectPageHandle() 220 | 221 | const [data, callback] = wrapper.emitted()['fetch-data'].at(-1) 222 | const result = dataListHandle(data) 223 | 224 | callback(result.list, result.count) 225 | 226 | it('应不显示分页栏', () => { 227 | expect(wrapper.find('.sp-pagination').exists()).toBeFalsy() 228 | }) 229 | it('列表项目应用文字从右向左的书写方向', () => { 230 | expect(wrapper.find('.sp-list-item').classes('sp-rtl')).toBeTruthy() 231 | }) 232 | it('列表容器宽度应为 `500px`', () => { 233 | expect(wrapper.find('.sp-container').element.style.width).toBe('500px') 234 | }) 235 | it('width 设置为 `30rem`,列表容器宽度应为指定的内容', async () => { 236 | await wrapper.setProps({ width: '30rem' }) 237 | expect(wrapper.find('.sp-container').element.style.width).toBe('30rem') 238 | }) 239 | it('设置语言为简体中文,搜索栏中显示的提示文字应为 `搜索`', () => { 240 | expect(wrapper.find('.sp-search-input').attributes('placeholder')).toBe('搜索') 241 | }) 242 | }) 243 | }) 244 | -------------------------------------------------------------------------------- /src/__tests__/core-table.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { mount } from '@vue/test-utils' 3 | import { SelectPageTableCore } from '@/index' 4 | import { useSelectPageHandle } from '../../examples/handles' 5 | 6 | describe('v-selectpage SelectPageTableCore 表格模式核心模块', () => { 7 | describe('默认参数场景', () => { 8 | const wrapper = mount(SelectPageTableCore, { 9 | props: { 10 | columns: [ 11 | { title: '编码', data: 'code', width: 100 }, 12 | { title: '名称', data: 'name' }, 13 | { title: '单价', data: 'price', width: 80 } 14 | ] 15 | } 16 | }) 17 | const { dataListHandle } = useSelectPageHandle() 18 | const [data, callback] = wrapper.emitted()['fetch-data'].at(-1) 19 | 20 | const result = dataListHandle(data) 21 | callback(result.list, result.count) 22 | 23 | it('应存在 3 个数据列', () => { 24 | expect(wrapper.findAll('.sp-table table thead tr th')).toHaveLength(3) 25 | }) 26 | it('应存在 10 行数据', () => { 27 | expect(wrapper.findAll('.sp-table table tbody tr')).toHaveLength(10) 28 | }) 29 | it('3 个列标题应为 `编码`、`名称` 与 `单价`', () => { 30 | const header = wrapper.find('.sp-table table thead tr') 31 | 32 | expect(header.findAll('th').at(0).text()).toBe('编码') 33 | expect(header.findAll('th').at(1).text()).toBe('名称') 34 | expect(header.findAll('th').at(2).text()).toBe('单价') 35 | }) 36 | it('第 1 列单元格的宽度应都为 100px', () => { 37 | expect( 38 | wrapper 39 | .findAll('.sp-table table tbody tr').at(0) 40 | .findAll('td').at(0) 41 | .element.style.width 42 | ).toBe('100px') 43 | }) 44 | it('第 3 列单元格的宽度应都为 80px', () => { 45 | expect( 46 | wrapper 47 | .findAll('.sp-table table tbody tr').at(0) 48 | .findAll('td').at(2) 49 | .element.style.width 50 | ).toBe('80px') 51 | }) 52 | it('第 1 行编码列的内容应为 `编码-code-1`', () => { 53 | const firstRow = wrapper.findAll('.sp-table table tbody tr').at(0) 54 | expect(firstRow.findAll('td').at(0).text()).toBe('编码-code-1') 55 | }) 56 | it('第 1 行名称列的内容应为 `列表项目-item-1`', () => { 57 | const firstRow = wrapper.findAll('.sp-table table tbody tr').at(0) 58 | expect(firstRow.findAll('td').at(1).text()).toBe('列表项目-item-1') 59 | }) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /src/__tests__/dropdown-list.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | import { mount } from '@vue/test-utils' 3 | import { nextTick } from 'vue' 4 | import { 5 | SelectPageList, SelectPageListCore 6 | } from '@/index' 7 | import { useSelectPageHandle } from '../../examples/handles' 8 | 9 | describe('v-selectpage - SelectPageList 列表视图选择器模式', () => { 10 | const wrapper = mount(SelectPageList, { 11 | props: { 12 | customTriggerClass: 'custom-trigger', 13 | customContainerClass: 'custom-container' 14 | } 15 | }) 16 | const core = wrapper.getComponent(SelectPageListCore) 17 | 18 | const { dataListHandle, selectedItemsHandle } = useSelectPageHandle() 19 | const [data, callback] = core.emitted()['fetch-data'].at(-1) 20 | const result = dataListHandle(data) 21 | callback(result.list, result.count) 22 | 23 | test('设置 `customTriggerClass` prop,触发对象容器应添加相应样式类', () => { 24 | expect(wrapper.classes('custom-trigger')).toBeTruthy() 25 | }) 26 | test('设置 `customContainerClass` prop,下拉容器应添加相应样式类', () => { 27 | expect(core.element.parentElement.classList.contains('custom-container')).toBeTruthy() 28 | }) 29 | test('应存在触发器元素', () => { 30 | expect(wrapper.find('.sp-trigger-container').exists()).toBeTruthy() 31 | }) 32 | test('未选择项目时,触发器元素默认提示内容应为 `Select an option`', () => { 33 | expect(wrapper.find('.sp-placeholder').text()).toBe('Select an option') 34 | }) 35 | test('下拉容器应不显示', () => { 36 | expect(Object.hasOwn(core.element.parentElement.style, 'visibility')).toBeFalsy() 37 | expect(core.element.parentElement.style.display).toBe('none') 38 | }) 39 | test('点击触发元素后,应呈现激活状态', async () => { 40 | await wrapper.find('.sp-trigger-container').trigger('click') 41 | expect(wrapper.find('.sp-trigger-container').classes('sp-opened')).toBeTruthy() 42 | }) 43 | test('下拉容器应显示并展开', () => { 44 | expect(core.element.parentElement.style.visibility).toBe('visible') 45 | expect(Object.hasOwn(core.element.parentElement.style, 'display')).toBeFalsy() 46 | }) 47 | test('展开时响应 `visible-change` 事件,输出值为 true', () => { 48 | expect(wrapper.emitted()['visible-change'].length).toBeGreaterThan(0) 49 | const [visible] = wrapper.emitted()['visible-change'].at(-1) 50 | expect(visible).toBe(true) 51 | }) 52 | test('选中列表中的第 1 个项目,触发器中应显示 `列表项目-item-1`', async () => { 53 | await core.findAll('.sp-list-item').at(0).trigger('click') 54 | expect(wrapper.find('.sp-select-content').text()).toBe('列表项目-item-1') 55 | }) 56 | test('触发器中出现清除选择图标', () => { 57 | expect(wrapper.find('.sp-select .sp-circle-btn').exists()).toBeTruthy() 58 | }) 59 | test('设置组件为禁用状态,触发器应无法响应激活操作', async () => { 60 | await wrapper.setProps({ disabled: true }) 61 | const container = wrapper.find('.sp-trigger-container') 62 | expect(container.classes('sp-disabled')).toBeTruthy() 63 | 64 | await container.trigger('click') 65 | expect(container.classes('sp-opened')).toBeFalsy() 66 | }) 67 | test('禁用状态下,解发器中的移除图标应不渲染', () => { 68 | expect( 69 | wrapper.find('.sp-select').find('.sp-circle-btn').exists() 70 | ).toBeFalsy() 71 | }) 72 | test('设置 v-model 为空数组,选中的项目应被清除', async () => { 73 | await wrapper.setProps({ disabled: false, modelValue: [] }) 74 | 75 | expect(core.findAll('.sp-list-item.sp-selected')).toHaveLength(0) 76 | expect(wrapper.find('.sp-placeholder').text()).toBe('Select an option') 77 | }) 78 | test('调用组件的 removeAll api 应清除所有选中的项目', async () => { 79 | await wrapper.setProps({ modelValue: [2] }) 80 | 81 | const [selected, selectedCallback] = core.emitted()['fetch-selected-data'].at(-1) 82 | const selectedResult = selectedItemsHandle(selected) 83 | selectedCallback(selectedResult) 84 | 85 | await nextTick() 86 | 87 | expect(wrapper.find('.sp-select-content').text()).toBe('列表项目-item-2') 88 | expect(core.findAll('.sp-list-item.sp-selected')).toHaveLength(1) 89 | 90 | // 相当于使用 ref 声明了组件的引用,并调用 removeAll 函数 91 | await wrapper.vm.removeAll() 92 | 93 | expect(core.findAll('.sp-list-item.sp-selected')).toHaveLength(0) 94 | expect(wrapper.find('.sp-placeholder').text()).toBe('Select an option') 95 | }) 96 | }) 97 | -------------------------------------------------------------------------------- /src/__tests__/dropdown-table.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest' 2 | import { mount } from '@vue/test-utils' 3 | import { nextTick } from 'vue' 4 | import { 5 | SelectPageTable, SelectPageTableCore 6 | } from '@/index' 7 | import { useSelectPageHandle } from '../../examples/handles' 8 | 9 | describe('v-selectpage - SelectPageTable 表格视图选择器模式', () => { 10 | const wrapper = mount(SelectPageTable, { 11 | props: { 12 | columns: [ 13 | { title: '编码', data: 'code', width: 100 }, 14 | { title: '名称', data: 'name' }, 15 | { title: '单价', data: 'price', width: 80 } 16 | ], 17 | multiple: true, 18 | modelValue: [2, 3, 5], 19 | customTriggerClass: 'custom-trigger', 20 | customContainerClass: 'custom-container' 21 | } 22 | }) 23 | const core = wrapper.getComponent(SelectPageTableCore) 24 | 25 | const { dataListHandle, selectedItemsHandle } = useSelectPageHandle() 26 | const [data, callback] = core.emitted()['fetch-data'].at(-1) 27 | const [selected, selectedCallback] = core.emitted()['fetch-selected-data'].at(-1) 28 | const result = dataListHandle(data) 29 | const selectedResult = selectedItemsHandle(selected) 30 | callback(result.list, result.count) 31 | selectedCallback(selectedResult) 32 | 33 | test('设置 `customTriggerClass` prop,触发对象容器应添加相应样式类', () => { 34 | expect(wrapper.classes('custom-trigger')).toBeTruthy() 35 | }) 36 | test('设置 `customContainerClass` prop,下拉容器应添加相应样式类', () => { 37 | expect(core.element.parentElement.classList.contains('custom-container')).toBeTruthy() 38 | }) 39 | test('3 个项目被默认选中,触发器中应有两个标签元素', () => { 40 | expect(wrapper.findAll('.sp-chip')).toHaveLength(3) 41 | }) 42 | test('第 1 个标签显示文本应为 `列表项目-item-2`', () => { 43 | expect(wrapper.findAll('.sp-chip').at(0).find('.sp-chip--body').text()).toBe('列表项目-item-2') 44 | }) 45 | test('第 2 个标签显示文本应为 `列表项目-item-3`', () => { 46 | expect(wrapper.findAll('.sp-chip').at(1).find('.sp-chip--body').text()).toBe('列表项目-item-3') 47 | }) 48 | test('设置组件为禁用状态,触发器应无法响应激活操作', async () => { 49 | await wrapper.setProps({ disabled: true }) 50 | const container = wrapper.find('.sp-trigger-container') 51 | expect(container.classes('sp-disabled')).toBeTruthy() 52 | 53 | await container.trigger('click') 54 | expect(container.classes('sp-opened')).toBeFalsy() 55 | }) 56 | test('禁用状态下,标签中的移除图标应不渲染', () => { 57 | expect( 58 | wrapper.findAll('.sp-chip').at(0).find('.sp-circle-btn').exists() 59 | ).toBeFalsy() 60 | }) 61 | test('取消禁用状态,触发器恢复可操作状态', async () => { 62 | await wrapper.setProps({ disabled: false }) 63 | const container = wrapper.find('.sp-trigger-container') 64 | expect(container.classes('sp-disabled')).toBeFalsy() 65 | 66 | await container.trigger('click') 67 | expect(container.classes('sp-opened')).toBeTruthy() 68 | }) 69 | test('点击第 2 个标签中的移除图标,该标签应被移除', async () => { 70 | await wrapper.findAll('.sp-chip').at(1).find('.sp-circle-btn').trigger('click') 71 | expect(wrapper.findAll('.sp-chip')).toHaveLength(2) 72 | }) 73 | test('点击下拉界面中的垃圾桶图标,所有选中项目应被移除', async () => { 74 | await core.find('.sp-search-control .sp-circle-btn').trigger('click') 75 | expect(wrapper.findAll('.sp-chip')).toHaveLength(0) 76 | }) 77 | test('触发器元素内的文本应为 `Select an option`', () => { 78 | expect(wrapper.find('.sp-placeholder').text()).toBe('Select an option') 79 | }) 80 | test('在搜索框中按下方向下键 2 次,列表中的第 2 个项目,应处于高亮状态', async () => { 81 | await core.find('.sp-search-input').trigger('keydown.down') 82 | await core.find('.sp-search-input').trigger('keydown.down') 83 | 84 | expect( 85 | core.findAll('.sp-table tbody tr').at(1).classes('sp-over') 86 | ).toBeTruthy() 87 | }) 88 | test('按下方向上键,列表中的第 1 个项目,应处于高亮状态', async () => { 89 | await core.find('.sp-search-input').trigger('keydown.up') 90 | 91 | expect( 92 | core.findAll('.sp-table tbody tr').at(0).classes('sp-over') 93 | ).toBeTruthy() 94 | }) 95 | test('在存在高亮行的情况下,在搜索框中按回车键,当前高亮行将被选中', async () => { 96 | await core.find('.sp-search-input').trigger('keydown.enter') 97 | expect( 98 | core.findAll('.sp-table tbody tr').at(0).classes('sp-selected') 99 | ).toBeTruthy() 100 | }) 101 | test('在搜索框中按 esc 键,应收起下拉容器', async () => { 102 | await core.find('.sp-search-input').trigger('keydown.esc') 103 | expect(wrapper.find('.sp-trigger-container').classes('sp-opened')).toBeFalsy() 104 | expect(Object.hasOwn(core.element.parentElement.style, 'visibility')).toBeFalsy() 105 | expect(core.element.parentElement.style.display).toBe('none') 106 | }) 107 | test('收起下拉容器时响应 `visible-change` 事件,输出值为 false', () => { 108 | const [visible] = wrapper.emitted()['visible-change'].at(-1) 109 | expect(visible).toBe(false) 110 | }) 111 | test('设置 v-model 为空数组,选中的项目应被清除', async () => { 112 | await wrapper.setProps({ disabled: false, modelValue: [] }) 113 | 114 | expect(core.findAll('.sp-list-item.sp-selected')).toHaveLength(0) 115 | expect(wrapper.find('.sp-placeholder').text()).toBe('Select an option') 116 | }) 117 | test('调用组件的 removeAll api 应清除所有选中的项目', async () => { 118 | await wrapper.setProps({ modelValue: [2] }) 119 | 120 | const [selected, selectedCallback] = core.emitted()['fetch-selected-data'].at(-1) 121 | const selectedResult = selectedItemsHandle(selected) 122 | selectedCallback(selectedResult) 123 | 124 | await nextTick() 125 | 126 | expect(wrapper.findAll('.sp-chip').at(0).text()).toBe('列表项目-item-2') 127 | expect(core.findAll('.sp-table tr.sp-selected')).toHaveLength(1) 128 | 129 | // 相当于使用 ref 声明了组件的引用,并调用 removeAll 函数 130 | await wrapper.vm.removeAll() 131 | 132 | expect(core.findAll('.sp-list-item.sp-selected')).toHaveLength(0) 133 | expect(wrapper.find('.sp-placeholder').text()).toBe('Select an option') 134 | }) 135 | }) 136 | -------------------------------------------------------------------------------- /src/components/CircleButton.js: -------------------------------------------------------------------------------- 1 | import { ref, computed, h } from 'vue' 2 | 3 | export default { 4 | name: 'SelectPageCircleButton', 5 | props: { 6 | size: { type: String, default: '' }, 7 | disabled: { type: Boolean, default: false }, 8 | bgColor: { type: String, default: 'transparent' }, 9 | hoverBgColor: { type: String, default: '#f1f1f1' } 10 | }, 11 | setup (props, { slots }) { 12 | const backgroundColor = ref('') 13 | 14 | const classes = computed(() => ({ 15 | 'sp-circle-btn--disabled': props.disabled, 16 | 'sp-circle-btn--small': props.size === 'small', 17 | 'sp-circle-btn--large': props.size === 'large' 18 | })) 19 | const styles = computed(() => ({ 20 | 'font-size': props.fontSize, 21 | 'background-color': props.disabled ? 'transparent' : backgroundColor.value 22 | })) 23 | 24 | return () => { 25 | const option = { 26 | class: ['sp-circle-btn', classes.value], 27 | style: styles.value, 28 | onMouseenter () { 29 | backgroundColor.value = props.hoverBgColor 30 | }, 31 | onMouseleave () { 32 | backgroundColor.value = props.bgColor 33 | } 34 | } 35 | return h('div', option, slots.default && slots.default()) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/core/constants.js: -------------------------------------------------------------------------------- 1 | export const [ 2 | LEFT, UP, RIGHT, DOWN, ENTER, ESCAPE 3 | ] = [ 4 | 37, 38, 39, 40, 13, 27 5 | ] 6 | 7 | export const operationKeyCodes = [LEFT, UP, RIGHT, DOWN, ENTER, ESCAPE] 8 | 9 | export const NOT_SELECTED = -1 10 | export const UNLIMITED = 0 11 | 12 | export const NO_PAGINATION_PAGE_SIZE = 0 13 | 14 | export const FIRST_PAGE = 1 15 | 16 | export const DEFAULT_PAGE_SIZE = 10 17 | 18 | export const ACTION_FIRST = 'first' 19 | export const ACTION_PREVIOUS = 'previous' 20 | export const ACTION_NEXT = 'next' 21 | export const ACTION_LAST = 'last' 22 | 23 | export const LANG_PAGE_NUMBER = 'page_num' 24 | export const LANG_PAGE_COUNT = 'page_count' 25 | export const LANG_ROW_COUNT = 'row_count' 26 | export const LANG_MAX_SELECTED_LIMIT = 'max_select_limit' 27 | export const LANG_SELECTED_COUNT = 'selected_count' 28 | -------------------------------------------------------------------------------- /src/core/data.js: -------------------------------------------------------------------------------- 1 | import { ref, provide, watch, inject, onMounted, nextTick } from 'vue' 2 | import { 3 | FIRST_PAGE, 4 | DEFAULT_PAGE_SIZE, 5 | UNLIMITED, 6 | LANG_MAX_SELECTED_LIMIT, 7 | NO_PAGINATION_PAGE_SIZE 8 | } from './constants' 9 | import { EN } from '../language' 10 | import { useLanguage, useDebounce } from './helper' 11 | import { useItemSelection } from './list' 12 | import { isEmptyArray } from './utilities' 13 | 14 | export function selectPageProps () { 15 | return { 16 | /** 17 | * binding selected item keys, it must be match 'keyProp' option value 18 | */ 19 | modelValue: { type: Array, default: undefined }, 20 | placeholder: { type: String, default: '' }, 21 | /** multiple selection */ 22 | multiple: { type: Boolean, default: false }, 23 | language: { type: String, default: EN }, 24 | /** 25 | * specify property to be key field, the value will return by v-model 26 | */ 27 | keyProp: { type: String, default: 'id' }, 28 | /** 29 | * specify property to display in data row 30 | */ 31 | labelProp: { type: [String, Function], default: 'name' }, 32 | pageSize: { type: Number, default: DEFAULT_PAGE_SIZE }, 33 | /** 34 | * maximum number of selection, set 0 to unlimited 35 | * depend on `multiple` prop set to true 36 | */ 37 | max: { type: Number, default: UNLIMITED, validator: (val) => val >= 0 }, 38 | /** 39 | * pagination bar 40 | */ 41 | pagination: { type: Boolean, default: true }, 42 | /** 43 | * text written from right to left 44 | */ 45 | rtl: { type: Boolean, default: false }, 46 | /** 47 | * the width of drop down menu 48 | */ 49 | width: { type: [String, Number], default: undefined }, 50 | /** debounce delay when typing, in milliseconds */ 51 | debounce: { type: Number, default: 300 } 52 | } 53 | } 54 | 55 | export function dropdownProps () { 56 | return { 57 | disabled: { type: Boolean, default: false }, 58 | /** Add custom class to trigger container, work on dropdown selection mode */ 59 | customTriggerClass: { type: String, default: '' }, 60 | /** Add custom class to dropdown container, work on dropdown selection mode */ 61 | customContainerClass: { type: String, default: '' } 62 | } 63 | } 64 | 65 | export function selectPageEmits () { 66 | return [ 67 | 'update:modelValue', 68 | 'fetch-data', 69 | 'fetch-selected-data', 70 | 'selection-change', 71 | 'remove', 72 | 'close-dropdown', 73 | 'adjust-dropdown' 74 | ] 75 | } 76 | 77 | export function useData (props, emit) { 78 | const lang = useLanguage(props.language) 79 | const { 80 | selected, 81 | selectedCount, 82 | isItemSelected, 83 | removeAll, 84 | removeItem, 85 | selectItem, 86 | setSelected, 87 | isKeysEqualToSelected 88 | } = useItemSelection(props, emit) 89 | 90 | // query string for search input 91 | const query = ref('') 92 | // alert message 93 | const message = ref('') 94 | // current page number 95 | const currentPage = ref(FIRST_PAGE) 96 | // total row count 97 | const totalRows = ref(0) 98 | // data list 99 | const list = ref([]) 100 | // data loading state 101 | const loading = ref(false) 102 | 103 | const messageDebounce = useDebounce() 104 | 105 | const isDataEmpty = () => isEmptyArray(list.value) 106 | const renderCell = row => { 107 | if (!row || !Object.keys(row).length) return '' 108 | switch (typeof props.labelProp) { 109 | case 'string': return row[props.labelProp] 110 | case 'function': return props.labelProp(row) 111 | } 112 | } 113 | const checkAndSelectItem = row => { 114 | if (props.max === UNLIMITED) { 115 | return selectItem(row) 116 | } 117 | if (selected.value.length === props.max) { 118 | message.value = lang.maxSelected.replace(LANG_MAX_SELECTED_LIMIT, props.max) 119 | 120 | messageDebounce(() => { message.value = '' }) 121 | return 122 | } 123 | selectItem(row) 124 | } 125 | // fetch current page data 126 | const fetchData = () => { 127 | loading.value = true 128 | 129 | const fetchOption = { 130 | search: query.value, 131 | pageNumber: currentPage.value, 132 | pageSize: props.pagination ? props.pageSize : NO_PAGINATION_PAGE_SIZE 133 | } 134 | 135 | emit('fetch-data', fetchOption, (data, count) => { 136 | if (!Array.isArray(data)) return 137 | 138 | list.value = data 139 | totalRows.value = typeof count === 'number' ? count : 0 140 | 141 | nextTick(() => { loading.value = false }) 142 | }) 143 | } 144 | // fetch selected items data 145 | const fetchSelectedData = () => { 146 | const { modelValue } = props 147 | 148 | if (!Array.isArray(modelValue)) return 149 | 150 | if (!props.multiple && modelValue.length > 1) { 151 | console.warn('Invalid prop: Only one key can be passed to prop "modelValue/v-model" in single selection mode({ multiple: false }).') 152 | return 153 | } 154 | // empty array will not emit event 155 | if (!modelValue.length) { 156 | setSelected([], false) 157 | return 158 | } 159 | // each key exists in the selected models 160 | if (isKeysEqualToSelected(modelValue)) return 161 | 162 | emit('fetch-selected-data', modelValue, data => { 163 | if (!Array.isArray(data)) return 164 | /** 165 | * when key length not equal to data model length, required to 166 | * update `modelValue/v-model` value 167 | */ 168 | setSelected(data, modelValue.length !== data.length) 169 | }) 170 | } 171 | 172 | watch(query, () => { 173 | // reset current page to first page when query keyword change 174 | currentPage.value = FIRST_PAGE 175 | fetchData() 176 | }) 177 | watch(() => props.modelValue, fetchSelectedData) 178 | 179 | onMounted(() => { 180 | fetchData() 181 | if (!isEmptyArray(props.modelValue)) { 182 | fetchSelectedData() 183 | } 184 | }) 185 | 186 | provide('keyProp', props.keyProp) 187 | provide('rtl', props.rtl) 188 | provide('pageSize', props.pageSize) 189 | provide('debounce', props.debounce) 190 | provide('multiple', props.multiple) 191 | provide('loading', loading) 192 | provide('language', lang) 193 | provide('renderCell', renderCell) 194 | provide('isItemSelected', isItemSelected) 195 | provide('selectedCount', selectedCount) 196 | provide('removeAll', removeAll) 197 | provide('removeItem', removeItem) 198 | 199 | return { 200 | selected, 201 | query, 202 | message, 203 | currentPage, 204 | totalRows, 205 | lang, 206 | list, 207 | 208 | renderCell, 209 | isDataEmpty, 210 | isItemSelected, 211 | selectedCount, 212 | selectItem: checkAndSelectItem, 213 | removeAll, 214 | removeItem, 215 | fetchData 216 | } 217 | } 218 | 219 | export function useInject () { 220 | return { 221 | keyProp: inject('keyProp'), 222 | renderCell: inject('renderCell'), 223 | rtl: inject('rtl'), 224 | isItemSelected: inject('isItemSelected'), 225 | pageSize: inject('pageSize'), 226 | language: inject('language'), 227 | debounce: inject('debounce'), 228 | multiple: inject('multiple'), 229 | loading: inject('loading'), 230 | selectedCount: inject('selectedCount'), 231 | removeAll: inject('removeAll'), 232 | removeItem: inject('removeItem') 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/core/helper.js: -------------------------------------------------------------------------------- 1 | import { languages, EN } from '../language' 2 | import { UP, DOWN, LEFT, RIGHT, ENTER, ESCAPE } from './constants' 3 | 4 | export function useLanguage (lang) { 5 | if (!lang) return languages[EN] 6 | 7 | const key = String(lang).toLowerCase() 8 | 9 | if (Object.hasOwn(languages, key)) return languages[key] 10 | 11 | return languages[EN] 12 | } 13 | 14 | export function useDebounce (time = 3000) { 15 | let timer 16 | 17 | return callback => { 18 | clearTimeout(timer) 19 | timer = setTimeout(callback, time) 20 | } 21 | } 22 | 23 | export function isMultiple (attrs) { 24 | if (!attrs) return false 25 | if (!Object.hasOwn(attrs, 'multiple')) return false 26 | if (typeof attrs.multiple === 'boolean') return attrs.multiple 27 | if (attrs.multiple === '') return true 28 | return false 29 | } 30 | 31 | export function isHighlightOperation (keyCode) { 32 | return [UP, DOWN].includes(keyCode) 33 | } 34 | 35 | export function isPagingOperation (keyCode) { 36 | return [LEFT, RIGHT].includes(keyCode) 37 | } 38 | 39 | export function isSelectOperation (keyCode) { 40 | return ENTER === keyCode 41 | } 42 | 43 | export function isEscapeOperation (keyCode) { 44 | return ESCAPE === keyCode 45 | } 46 | -------------------------------------------------------------------------------- /src/core/list.js: -------------------------------------------------------------------------------- 1 | import { ref, computed } from 'vue' 2 | 3 | import { NOT_SELECTED, operationKeyCodes, UP, DOWN } from './constants' 4 | import { isEmptyArray } from './utilities' 5 | 6 | export const listProps = () => ({ 7 | list: { type: Array, default: undefined }, 8 | highlightIndex: { type: Number, default: NOT_SELECTED } 9 | }) 10 | 11 | export const listEmits = () => ['select', 'set-highlight'] 12 | 13 | /** list item manager */ 14 | export function useItemSelection (props, emit) { 15 | const selected = ref([]) 16 | 17 | const selectedCount = computed(() => selected.value.length) 18 | 19 | function isItemSelected (row) { 20 | if (!selected.value.length) return false 21 | return selected.value.some(val => val[props.keyProp] === row[props.keyProp]) 22 | } 23 | function isKeySelected (key) { 24 | if (!selected.value.length) return false 25 | if (typeof key === 'undefined') return false 26 | return selected.value.some(entry => entry[props.keyProp] === key) 27 | } 28 | function isKeysEqualToSelected (keys) { 29 | // ensure the uniqueness of the keys 30 | const keySet = new Set(keys) 31 | 32 | if (keySet.size !== selected.value.length) return false 33 | 34 | return Array.from(keySet).every(isKeySelected) 35 | } 36 | function selectItem (row) { 37 | if (isItemSelected(row)) return 38 | 39 | if (props.multiple) { 40 | setSelected([...selected.value, row]) 41 | return 42 | } 43 | setSelected([row]) 44 | } 45 | function removeAll () { 46 | emit('remove', selected.value) 47 | setSelected([]) 48 | } 49 | function removeItem (row) { 50 | emit('remove', [row]) 51 | setSelected( 52 | selected.value.filter(val => { 53 | return val[props.keyProp] !== row[props.keyProp] 54 | }) 55 | ) 56 | } 57 | function setSelected (data, updateVModel = true) { 58 | selected.value = data 59 | if (updateVModel) { 60 | emit('update:modelValue', data.map(value => value[props.keyProp])) 61 | } 62 | emit('selection-change', data) 63 | } 64 | 65 | return { 66 | selected, 67 | selectedCount, 68 | isItemSelected, 69 | selectItem, 70 | removeItem, 71 | removeAll, 72 | setSelected, 73 | isKeysEqualToSelected 74 | } 75 | } 76 | 77 | export function useListItemHighlight (props, emit, list) { 78 | const highlightIndex = ref(NOT_SELECTED) 79 | 80 | function setItemHighlight (index) { 81 | highlightIndex.value = index 82 | } 83 | function moveUp () { 84 | if (highlightIndex.value === NOT_SELECTED) return 85 | if (highlightIndex.value === 0) return 86 | highlightIndex.value -= 1 87 | } 88 | function moveDown () { 89 | if (isEmptyArray(list.value)) return 90 | if (highlightIndex.value === (list.value.length - 1)) return 91 | highlightIndex.value += 1 92 | } 93 | function highlightNavigation (keyCode) { 94 | if (keyCode === UP) return moveUp() 95 | if (keyCode === DOWN) return moveDown() 96 | } 97 | function isSomeRowHighlight () { 98 | return highlightIndex.value !== NOT_SELECTED 99 | } 100 | 101 | return { 102 | highlightIndex, 103 | setItemHighlight, 104 | highlightNavigation, 105 | isSomeRowHighlight 106 | } 107 | } 108 | 109 | export function isOperationKey (keyCode) { 110 | return operationKeyCodes.includes(keyCode) 111 | } 112 | -------------------------------------------------------------------------------- /src/core/pagination.js: -------------------------------------------------------------------------------- 1 | import { computed } from 'vue' 2 | 3 | import { 4 | FIRST_PAGE, 5 | ACTION_FIRST, ACTION_PREVIOUS, ACTION_NEXT, ACTION_LAST, 6 | LANG_PAGE_NUMBER, LANG_PAGE_COUNT, LANG_ROW_COUNT, 7 | LEFT, RIGHT 8 | } from './constants' 9 | 10 | export function usePagination (props, currentPage, totalRows, lang) { 11 | const totalPage = computed(() => Math.ceil(totalRows.value / props.pageSize)) 12 | const isFirstPage = computed(() => currentPage.value === FIRST_PAGE) 13 | const isLastPage = computed(() => currentPage.value === totalPage.value) 14 | const paginationInfo = computed(() => lang.pageInfo 15 | .replace(LANG_PAGE_NUMBER, currentPage.value) 16 | .replace(LANG_PAGE_COUNT, totalPage.value) 17 | .replace(LANG_ROW_COUNT, totalRows.value) 18 | ) 19 | 20 | const getNewPageNumber = function (action) { 21 | switch (action) { 22 | case ACTION_FIRST: return FIRST_PAGE 23 | case ACTION_PREVIOUS: return currentPage.value - 1 24 | case ACTION_NEXT: return currentPage.value + 1 25 | case ACTION_LAST: return totalPage.value 26 | } 27 | } 28 | const switchPage = function (action) { 29 | let pageNumber = getNewPageNumber(action) 30 | 31 | if (typeof pageNumber === 'undefined') return 32 | if (pageNumber < FIRST_PAGE) pageNumber = FIRST_PAGE 33 | if (pageNumber > totalPage.value) pageNumber = totalPage.value 34 | if (pageNumber === currentPage.value) return 35 | 36 | currentPage.value = pageNumber 37 | } 38 | 39 | const pagingNavigation = keyCode => { 40 | if (keyCode === LEFT) return switchPage(ACTION_PREVIOUS) 41 | if (keyCode === RIGHT) return switchPage(ACTION_NEXT) 42 | } 43 | 44 | return { 45 | paginationInfo, 46 | isFirstPage, 47 | isLastPage, 48 | switchPage, 49 | pagingNavigation 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/core/render.js: -------------------------------------------------------------------------------- 1 | import { h, Transition, ref, mergeProps } from 'vue' 2 | 3 | import '../styles/common.sass' 4 | import { useData } from './data' 5 | import { useListItemHighlight } from './list' 6 | import { usePagination } from './pagination' 7 | import { 8 | isHighlightOperation, 9 | isPagingOperation, 10 | isSelectOperation, 11 | isEscapeOperation, 12 | useDebounce 13 | } from './helper' 14 | import { parseWidth } from '../core/utilities' 15 | 16 | import Dropdown from 'v-dropdown' 17 | import Search from '../modules/Search' 18 | import Control from '../modules/Control' 19 | import List from '../modules/List' 20 | import Table from '../modules/Table' 21 | import Pagination from '../modules/Pagination' 22 | 23 | import IconMessage from '../icons/IconMessage.vue' 24 | 25 | export function useRender (props, emit) { 26 | const { 27 | lang, 28 | selected, 29 | query, 30 | message, 31 | currentPage, 32 | totalRows, 33 | list, 34 | isDataEmpty, 35 | selectItem, 36 | fetchData, 37 | renderCell, 38 | removeAll, 39 | removeItem 40 | } = useData(props, emit) 41 | const { 42 | highlightIndex, 43 | setItemHighlight, 44 | highlightNavigation, 45 | isSomeRowHighlight 46 | } = useListItemHighlight(props, emit, list) 47 | const { 48 | paginationInfo, 49 | isFirstPage, 50 | isLastPage, 51 | switchPage, 52 | pagingNavigation 53 | } = usePagination(props, currentPage, totalRows, lang) 54 | 55 | const keyboardDebounce = useDebounce(props.debounce) 56 | 57 | const search = ref() 58 | 59 | const setSearchFocus = () => { 60 | search.value && search.value.focus() 61 | } 62 | 63 | const renderSearch = () => { 64 | return h('div', { class: 'sp-search' }, [ 65 | h(Search, { 66 | ref: search, 67 | modelValue: query.value, 68 | 'onUpdate:modelValue' (val) { 69 | query.value = val 70 | }, 71 | onKeyboardOperation: keyCode => { 72 | // press UP or DOWN key to change highlight row 73 | if (isHighlightOperation(keyCode)) return highlightNavigation(keyCode) 74 | // press LEFT or RIGHT key to change current page 75 | if (isPagingOperation(keyCode)) { 76 | pagingNavigation(keyCode) 77 | keyboardDebounce(fetchData) 78 | return 79 | } 80 | // press ENTER key to selected the highlight row 81 | if (isSelectOperation(keyCode)) { 82 | if (!isSomeRowHighlight()) return 83 | return selectItem(list.value[highlightIndex.value]) 84 | } 85 | // press ESCAPE key to close dropdown 86 | if (isEscapeOperation(keyCode)) { 87 | emit('close-dropdown') 88 | } 89 | } 90 | }), 91 | h(Control) 92 | ]) 93 | } 94 | const renderMessage = () => { 95 | const child = [] 96 | if (message.value) { 97 | child.push( 98 | h('div', { class: 'sp-message' }, [ 99 | h(IconMessage), 100 | h('div', { class: 'sp-message-body', innerHTML: message.value }) 101 | ]) 102 | ) 103 | } 104 | 105 | const option = { 106 | name: 'sp-message-slide', 107 | appear: true, 108 | onEnter: () => emit('adjust-dropdown'), 109 | onAfterLeave: () => emit('adjust-dropdown') 110 | } 111 | 112 | return h(Transition, option, () => child) 113 | } 114 | const renderList = () => { 115 | if (isDataEmpty()) return renderNoDataMessage() 116 | 117 | return h(List, { 118 | list: list.value, 119 | highlightIndex: highlightIndex.value, 120 | onSelect: row => selectItem(row), 121 | onSetHighlight: index => setItemHighlight(index) 122 | }) 123 | } 124 | const renderTable = () => { 125 | if (isDataEmpty()) return renderNoDataMessage() 126 | 127 | return h(Table, { 128 | list: list.value, 129 | columns: props.columns, 130 | highlightIndex: highlightIndex.value, 131 | onSelect: row => selectItem(row), 132 | onSetHighlight: index => setItemHighlight(index) 133 | }) 134 | } 135 | const renderNoDataMessage = () => { 136 | return h('div', { class: 'sp-result-message' }, lang.notFound) 137 | } 138 | const renderPagination = () => { 139 | if (!props.pagination) return 140 | 141 | return h(Pagination, { 142 | pageInfo: paginationInfo.value, 143 | isFirstPage: isFirstPage.value, 144 | isLastPage: isLastPage.value, 145 | onPageChange (action) { 146 | switchPage(action) 147 | fetchData() 148 | } 149 | }) 150 | } 151 | const renderContainer = children => { 152 | const option = { class: 'sp-container' } 153 | 154 | if (props.width) { 155 | option.style = { width: parseWidth(props.width) } 156 | } 157 | 158 | return h('div', option, children) 159 | } 160 | 161 | return { 162 | selected, 163 | query, 164 | message, 165 | currentPage, 166 | lang, 167 | 168 | renderCell, 169 | removeAll, 170 | removeItem, 171 | setSearchFocus, 172 | 173 | renderSearch, 174 | renderMessage, 175 | renderList, 176 | renderTable, 177 | renderPagination, 178 | renderContainer 179 | } 180 | } 181 | 182 | export function useDropdown (props) { 183 | const visible = ref(false) 184 | const dropdownRef = ref() 185 | 186 | function closeDropdown () { 187 | dropdownRef.value && dropdownRef.value.close() 188 | } 189 | 190 | // adjust dropdown position 191 | function adjustDropdown () { 192 | dropdownRef.value && dropdownRef.value.adjust() 193 | } 194 | 195 | function renderDropdown (customProps, trigger, contents) { 196 | const dropdownOption = { 197 | ref: dropdownRef, 198 | border: false, 199 | fullWidth: true, 200 | disabled: props.disabled, 201 | customTriggerClass: props?.customTriggerClass, 202 | customContainerClass: props?.customContainerClass, 203 | onVisibleChange (val) { visible.value = val } 204 | } 205 | return h(Dropdown, mergeProps(dropdownOption, customProps), { 206 | trigger: () => trigger, 207 | default: () => contents 208 | }) 209 | } 210 | 211 | return { 212 | visible, 213 | dropdownRef, 214 | renderDropdown, 215 | closeDropdown, 216 | adjustDropdown 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/core/utilities.js: -------------------------------------------------------------------------------- 1 | export function setInputFocus (element) { 2 | if (!element) return 3 | element.focus({ preventScroll: true }) 4 | } 5 | 6 | export function isPromise (p) { 7 | return p && Object.prototype.toString.call(p) === '[object Promise]' 8 | } 9 | 10 | export function isEmptyArray (array) { 11 | if (!Array.isArray(array)) return true 12 | return !array.length 13 | } 14 | 15 | export function parseWidth (width) { 16 | if (typeof width === 'string') return width 17 | if (typeof width === 'number') return `${width}px` 18 | return '' 19 | } 20 | -------------------------------------------------------------------------------- /src/icons/IconChevronDown.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src/icons/IconClose.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /src/icons/IconFirst.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src/icons/IconLast.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src/icons/IconLoading.vue: -------------------------------------------------------------------------------- 1 | 24 | -------------------------------------------------------------------------------- /src/icons/IconMessage.vue: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /src/icons/IconNext.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src/icons/IconPrevious.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src/icons/IconSearch.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /src/icons/IconTrash.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as SelectPageListCore } from './SelectPageListCore' 2 | export { default as SelectPageList } from './SelectPageList' 3 | export { default as SelectPageTableCore } from './SelectPageTableCore' 4 | export { default as SelectPageTable } from './SelectPageTable' 5 | -------------------------------------------------------------------------------- /src/language.js: -------------------------------------------------------------------------------- 1 | import { 2 | LANG_PAGE_NUMBER, 3 | LANG_PAGE_COUNT, 4 | LANG_ROW_COUNT, 5 | LANG_MAX_SELECTED_LIMIT, 6 | LANG_SELECTED_COUNT 7 | } from './core/constants' 8 | 9 | export const [ 10 | ZH_CHS, EN, JA, AR, ES, DE, RO, RU, FR, PT_BR, PL, NL, ZH_CHT, TR 11 | ] = [ 12 | 'zh-chs', 'en', 'ja', 'ar', 'es', 'de', 'ro', 'ru', 'fr', 'pt-br', 'pl', 'nl', 'zh-cht', 'tr' 13 | ] 14 | 15 | export const languages = { 16 | [ZH_CHS]: { // Chinese 17 | next: '下一页', 18 | prev: '上一页', 19 | first: '首页', 20 | last: '尾页', 21 | pageInfo: `第 ${LANG_PAGE_NUMBER}/${LANG_PAGE_COUNT} 页(共 ${LANG_ROW_COUNT} 条记录)`, 22 | notFound: '无查询结果', 23 | clear: '清除内容', 24 | clearAll: '清除全部已选择项目', 25 | maxSelected: `最多只能选择 ${LANG_MAX_SELECTED_LIMIT} 个项目`, 26 | placeholder: '请选择一个项目', 27 | selectedCount: `已选择 ${LANG_SELECTED_COUNT} 个项目`, 28 | search: '搜索' 29 | }, 30 | [EN]: { // English 31 | next: 'Next page', 32 | prev: 'Previous page', 33 | first: 'First page', 34 | last: 'Last page', 35 | pageInfo: `Page ${LANG_PAGE_NUMBER} of ${LANG_PAGE_COUNT} (${LANG_ROW_COUNT} records)`, 36 | notFound: 'Data not found', 37 | clear: 'Clear content', 38 | clearAll: 'Clear all selected', 39 | maxSelected: `You can only select up to ${LANG_MAX_SELECTED_LIMIT} items`, 40 | placeholder: 'Select an option', 41 | selectedCount: `${LANG_SELECTED_COUNT} items selected`, 42 | search: 'Search' 43 | }, 44 | [JA]: { // Japanese 45 | next: '次へ', 46 | prev: '前へ', 47 | first: '最初のページへ', 48 | last: '最後のページへ', 49 | pageInfo: `${LANG_PAGE_NUMBER}/${LANG_PAGE_COUNT} 件 (全 ${LANG_ROW_COUNT} つ記録)`, 50 | notFound: '(0 件)', 51 | clear: 'コンテンツをクリアする', 52 | clearAll: '選択した項目をクリアする', 53 | maxSelected: `最多で ${LANG_MAX_SELECTED_LIMIT} のプロジェクトを選ぶことしかできません`, 54 | placeholder: 'プロジェクトを選択してください', 55 | selectedCount: `${LANG_SELECTED_COUNT} アイテムが選択されました`, 56 | search: '検索' 57 | }, 58 | [AR]: { // Arabic 59 | next: 'التالي', 60 | prev: 'السابق', 61 | first: 'الاول', 62 | last: 'الأخير', 63 | pageInfo: `صفحة ${LANG_PAGE_NUMBER}/${LANG_PAGE_COUNT} (${LANG_ROW_COUNT} سجلات)`, 64 | notFound: 'لا يوجد نتائج', 65 | clear: 'محو المحتوى', 66 | clearAll: 'إلغاء التحديد', 67 | maxSelected: `يمكنك فقط تحديد (${LANG_MAX_SELECTED_LIMIT}) عناصر`, 68 | placeholder: 'رجاء حدد الخيار', 69 | selectedCount: `تم تحديد (${LANG_SELECTED_COUNT}) عناصر`, 70 | search: 'يبحث' 71 | }, 72 | [ES]: { // Spanish 73 | next: 'Siguiente página', 74 | prev: 'Pagina anterior', 75 | first: 'Primera página', 76 | last: 'última página', 77 | pageInfo: `Página ${LANG_PAGE_NUMBER}/${LANG_PAGE_COUNT} (${LANG_ROW_COUNT} registros)`, 78 | notFound: 'no encontrado', 79 | clear: 'Borrar contenido', 80 | clearAll: 'Borrar todo lo seleccionado', 81 | maxSelected: `Solo puedes seleccionar hasta ${LANG_MAX_SELECTED_LIMIT} items`, 82 | placeholder: 'Seleccione una opción', 83 | selectedCount: `${LANG_SELECTED_COUNT} items Seleccionado`, 84 | search: 'Buscar' 85 | }, 86 | [DE]: { // German 87 | next: 'Nächste Seite', 88 | prev: 'Vorherige Seite', 89 | first: 'Erste Seite', 90 | last: 'Letzte Seite', 91 | pageInfo: `Seite ${LANG_PAGE_NUMBER}/${LANG_PAGE_COUNT} (${LANG_ROW_COUNT} Einträge)`, 92 | notFound: 'Nicht gefunden', 93 | clear: 'Inhalt löschen', 94 | clearAll: 'Alle ausgewählten löschen', 95 | maxSelected: `Sie können nur bis zu ${LANG_MAX_SELECTED_LIMIT} Elemente auswählen`, 96 | placeholder: 'Wählen', 97 | selectedCount: `${LANG_SELECTED_COUNT} Elemente ausgewählt`, 98 | search: 'Suchen' 99 | }, 100 | [RO]: { // Romanian 101 | next: 'Pagina următoare', 102 | prev: 'Pagina precedentă', 103 | first: 'Prima pagină', 104 | last: 'Ultima pagină', 105 | pageInfo: `Pagina ${LANG_PAGE_NUMBER}/${LANG_PAGE_COUNT} (${LANG_ROW_COUNT} înregistrări)`, 106 | notFound: 'nu a fost găsit', 107 | clear: 'Șterge conținutul', 108 | clearAll: 'Șterge înregistrările selectate', 109 | maxSelected: `Poți selecta până la ${LANG_MAX_SELECTED_LIMIT} înregistrări`, 110 | placeholder: 'Selectează o înregistrare', 111 | selectedCount: `${LANG_SELECTED_COUNT} înregistrări selectate`, 112 | search: 'Căutare' 113 | }, 114 | [RU]: { // Russian 115 | next: 'Вперед', 116 | prev: 'Назад', 117 | first: 'В начало', 118 | last: 'В конец', 119 | pageInfo: `Стр. ${LANG_PAGE_NUMBER}/${LANG_PAGE_COUNT} (всего - ${LANG_ROW_COUNT})`, 120 | notFound: 'Нет данных', 121 | clear: 'Очистить', 122 | clearAll: 'Очистить выбранное', 123 | maxSelected: `Нельзя выбрать более ${LANG_MAX_SELECTED_LIMIT} значений`, 124 | placeholder: 'Выберите значение', 125 | selectedCount: `${LANG_SELECTED_COUNT} - выбрано`, 126 | search: 'Поиск' 127 | }, 128 | [FR]: { // French 129 | next: 'Page suivante', 130 | prev: 'Page précédente', 131 | first: 'Première page', 132 | last: 'Dernière page', 133 | pageInfo: `Page ${LANG_PAGE_NUMBER}/${LANG_PAGE_COUNT} (${LANG_ROW_COUNT} lignes)`, 134 | notFound: 'Aucun résultat', 135 | clear: 'Effacer', 136 | clearAll: 'Tout déselectionner', 137 | maxSelected: `Vous ne pouvez pas sélectionner plus de ${LANG_MAX_SELECTED_LIMIT} élements`, 138 | placeholder: 'Sélectionnez une option', 139 | selectedCount: `${LANG_SELECTED_COUNT} éléments sélectionnés`, 140 | search: 'Recherche' 141 | }, 142 | [PT_BR]: { // Portuguese-Brazil 143 | next: 'Página seguinte', 144 | prev: 'Página anterior', 145 | first: 'Primera página', 146 | last: 'Última página', 147 | pageInfo: `Página ${LANG_PAGE_NUMBER}/${LANG_PAGE_COUNT} (${LANG_ROW_COUNT} registros)`, 148 | notFound: 'não encontrado', 149 | clear: 'Apagar conteúdo', 150 | clearAll: 'Apagar itens selecionados', 151 | maxSelected: `Máximo permitido ${LANG_MAX_SELECTED_LIMIT} itens`, 152 | placeholder: 'Selecione uma opção', 153 | selectedCount: `${LANG_SELECTED_COUNT} itens selecionados`, 154 | search: 'Procurar' 155 | }, 156 | [PL]: { // Polish 157 | next: 'Następna', 158 | prev: 'Poprzednia', 159 | first: 'Pierwsza', 160 | last: 'Ostatnia', 161 | pageInfo: `Strona ${LANG_PAGE_NUMBER}/${LANG_PAGE_COUNT} (${LANG_ROW_COUNT} rekordów)`, 162 | notFound: 'Nic nie znaleziono', 163 | clear: 'Wyczyść', 164 | clearAll: 'Usuń wszystkie zaznaczone', 165 | maxSelected: `Możesz zaznaczyć maksymalnie ${LANG_MAX_SELECTED_LIMIT}`, 166 | placeholder: 'Wybierz z listy', 167 | selectedCount: `${LANG_SELECTED_COUNT} zaznaczonych`, 168 | search: 'Szukaj' 169 | }, 170 | [NL]: { // Dutch 171 | next: 'Volgende pagina', 172 | prev: 'Vorige pagina', 173 | first: 'Eerste pagina', 174 | last: 'Laatste pagina', 175 | pageInfo: `Pagina ${LANG_PAGE_NUMBER}/${LANG_PAGE_COUNT} (${LANG_ROW_COUNT} items)`, 176 | notFound: 'Niet gevonden', 177 | clear: 'Wissen', 178 | clearAll: 'Wis selectie', 179 | maxSelected: `Je kunt maar ${LANG_MAX_SELECTED_LIMIT} items selecteren`, 180 | placeholder: 'Kies een optie', 181 | selectedCount: `${LANG_SELECTED_COUNT} Items geselecteerd`, 182 | search: 'Zoekopdracht' 183 | }, 184 | [ZH_CHT]: { // Traditional Chinese 185 | next: '下一頁', 186 | prev: '上一頁', 187 | first: '首頁', 188 | last: '尾頁', 189 | pageInfo: `第 ${LANG_PAGE_NUMBER}/${LANG_PAGE_COUNT} 頁(共 ${LANG_ROW_COUNT} 條記錄)`, 190 | notFound: '無查詢結果', 191 | clear: '清除內容', 192 | clearAll: '清除全部已選擇項目', 193 | maxSelected: `最多只能選擇 ${LANG_MAX_SELECTED_LIMIT} 個項目`, 194 | placeholder: '請選擇一個項目', 195 | selectedCount: `已選擇 ${LANG_SELECTED_COUNT} 個項目`, 196 | search: '搜索' 197 | }, 198 | [TR]: { // Turkish 199 | next: 'Sonraki', 200 | prev: 'Önceki', 201 | first: 'İlk', 202 | last: 'Son', 203 | pageInfo: 'Sayfa page_num/page_count ( row_count kayıt )', 204 | notFound: 'Bulunamadı', 205 | clear: 'İçeriği temizle', 206 | clearAll: 'Tüm seçilenleri bırak', 207 | maxSelected: 'Sadece max_selected_limit kadar seçim yapabilirsin.', 208 | placeholder: 'Seçim yapınız.', 209 | selectedCount: 'selected_count seçildi.', 210 | search: 'Aramak' 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/modules/Control.js: -------------------------------------------------------------------------------- 1 | import { h } from 'vue' 2 | 3 | import { useInject } from '../core/data' 4 | 5 | import CircleButton from '../components/CircleButton' 6 | import IconTrash from '../icons/IconTrash.vue' 7 | 8 | export default { 9 | setup () { 10 | const { selectedCount, removeAll, language } = useInject() 11 | 12 | return () => { 13 | const items = [] 14 | 15 | const option = { 16 | title: language.clearAll, 17 | size: 'large', 18 | // bgColor: '#f1f1f1', 19 | // hoverBgColor: '#ddd', 20 | disabled: !selectedCount.value, 21 | onClick: removeAll 22 | } 23 | items.push( 24 | h(CircleButton, option, () => h(IconTrash)) 25 | ) 26 | 27 | return h('div', { class: 'sp-search-control' }, items) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/modules/FormElementChips.js: -------------------------------------------------------------------------------- 1 | import { h, toRef } from 'vue' 2 | 3 | import CircleButton from '../components/CircleButton' 4 | import IconClose from '../icons/IconClose.vue' 5 | 6 | export default { 7 | name: 'SelectPageChips', 8 | props: { 9 | selected: { type: Object, default: undefined }, 10 | disabled: { type: Boolean, default: false }, 11 | renderCell: { type: Function, default: undefined } 12 | }, 13 | emits: ['remove'], 14 | setup (props, { emit }) { 15 | const selected = toRef(props, 'selected') 16 | 17 | return () => { 18 | const chips = selected.value.map((item, index) => { 19 | const chip = [ 20 | h('div', { class: 'sp-chip--body', innerHTML: props.renderCell(item) }) 21 | ] 22 | // close icon for chip 23 | if (!props.disabled) { 24 | const chipOption = { 25 | size: 'small', 26 | hoverBgColor: '#ccc', 27 | onClick: e => { 28 | e.stopPropagation() 29 | emit('remove', item) 30 | } 31 | } 32 | chip.push( 33 | h(CircleButton, chipOption, () => h(IconClose)) 34 | ) 35 | } 36 | return h('div', { class: 'sp-chip', key: index }, chip) 37 | }) 38 | return h('div', { class: 'sp-trigger sp-chips' }, chips) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/modules/FormElementSelect.js: -------------------------------------------------------------------------------- 1 | import { h, toRef } from 'vue' 2 | 3 | import CircleButton from '../components/CircleButton' 4 | import IconClose from '../icons/IconClose.vue' 5 | 6 | export default { 7 | name: 'SelectPageSelect', 8 | props: { 9 | selected: { type: Object, default: undefined }, 10 | disabled: { type: Boolean, default: false }, 11 | lang: { type: Object, default: undefined }, 12 | renderCell: { type: Function, default: undefined } 13 | }, 14 | emits: ['remove'], 15 | setup (props, { emit }) { 16 | const selected = toRef(props, 'selected') 17 | 18 | return () => { 19 | if (!selected.value?.length) return 20 | 21 | const items = [ 22 | h('div', { class: 'sp-select-content', innerHTML: props.renderCell(selected.value[0]) }) 23 | ] 24 | // clear button 25 | if (selected.value?.length && !props.disabled) { 26 | const option = { 27 | title: props.lang.clear, 28 | onClick: e => { 29 | e.stopPropagation() 30 | emit('remove') 31 | } 32 | } 33 | items.push( 34 | h(CircleButton, option, () => h(IconClose)) 35 | ) 36 | } 37 | return h('div', { class: 'sp-trigger sp-select' }, items) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/modules/List.js: -------------------------------------------------------------------------------- 1 | import { h } from 'vue' 2 | 3 | import '../styles/list-view.sass' 4 | import { NOT_SELECTED } from '../core/constants' 5 | import { listProps, listEmits } from '../core/list' 6 | import { useInject } from '../core/data' 7 | 8 | import ListItem from './ListItem' 9 | 10 | export default { 11 | name: 'SelectPageList', 12 | props: listProps(), 13 | emits: listEmits(), 14 | setup (props, { emit }) { 15 | const { isItemSelected, keyProp } = useInject() 16 | 17 | return () => { 18 | const items = props.list.map((item, index) => h(ListItem, { 19 | key: item[keyProp], 20 | data: item, 21 | isHover: props.highlightIndex === index, 22 | isSelected: isItemSelected(item), 23 | onSelect: () => emit('select', item), 24 | onHover: () => emit('set-highlight', index) 25 | })) 26 | const option = { 27 | class: 'sp-list', 28 | onMouseleave: () => emit('set-highlight', NOT_SELECTED) 29 | } 30 | return h('div', option, items) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/modules/ListItem.js: -------------------------------------------------------------------------------- 1 | import { h } from 'vue' 2 | import { useInject } from '../core/data' 3 | 4 | import CircleButton from '../components/CircleButton' 5 | import IconClose from '../icons/IconClose.vue' 6 | 7 | export default { 8 | props: { 9 | data: { type: Object, default: undefined }, 10 | isHover: { type: Boolean, default: false }, 11 | isSelected: { type: Boolean, default: false } 12 | }, 13 | emits: ['select', 'hover'], 14 | setup (props, { emit }) { 15 | const { renderCell, rtl, removeItem } = useInject() 16 | 17 | return () => { 18 | const itemLabel = renderCell(props.data) 19 | 20 | const option = { 21 | class: { 22 | 'sp-list-item': true, 23 | 'sp-over': !props.isSelected && props.isHover, 24 | 'sp-selected': props.isSelected, 25 | 'sp-rtl': rtl 26 | }, 27 | onClick: () => emit('select'), 28 | onMouseenter: () => emit('hover') 29 | } 30 | 31 | const items = [ 32 | h('div', { title: itemLabel, innerHTML: itemLabel }) 33 | ] 34 | 35 | if (props.isSelected) { 36 | const removeIconOption = { 37 | onClick: e => { 38 | e.stopPropagation() 39 | removeItem(props.data) 40 | } 41 | } 42 | items.push( 43 | h(CircleButton, removeIconOption, () => h(IconClose)) 44 | ) 45 | } 46 | 47 | return h('div', option, items) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/modules/Pagination.js: -------------------------------------------------------------------------------- 1 | import { h } from 'vue' 2 | 3 | import '../styles/pagination.sass' 4 | 5 | import { 6 | ACTION_FIRST, ACTION_PREVIOUS, ACTION_NEXT, ACTION_LAST 7 | } from '../core/constants' 8 | import { useInject } from '../core/data' 9 | 10 | import IconFirst from '../icons/IconFirst.vue' 11 | import IconPrevious from '../icons/IconPrevious.vue' 12 | import IconNext from '../icons/IconNext.vue' 13 | import IconLast from '../icons/IconLast.vue' 14 | 15 | export default { 16 | name: 'SelectPagePagination', 17 | props: { 18 | pageInfo: { type: String, default: '' }, 19 | isFirstPage: { type: Boolean, default: true }, 20 | isLastPage: { type: Boolean, default: false } 21 | }, 22 | emits: ['page-change'], 23 | setup (props, { emit }) { 24 | const { language } = useInject() 25 | 26 | return () => { 27 | const buttons = [ 28 | { action: ACTION_FIRST, title: language.first, disabled: props.isFirstPage, icon: IconFirst }, 29 | { action: ACTION_PREVIOUS, title: language.prev, disabled: props.isFirstPage, icon: IconPrevious }, 30 | { action: ACTION_NEXT, title: language.next, disabled: props.isLastPage, icon: IconNext }, 31 | { action: ACTION_LAST, title: language.last, disabled: props.isLastPage, icon: IconLast } 32 | ] 33 | 34 | const items = buttons.map(btn => { 35 | const linkOption = { 36 | href: 'javascript:void(0)', 37 | onClick: () => emit('page-change', btn.action) 38 | } 39 | const classes = [{ 'sp-page-disabled': btn.disabled }, 'sp-page-button'] 40 | return h('div', { class: classes, title: btn.title }, [ 41 | h('a', linkOption, h(btn.icon)) 42 | ]) 43 | }) 44 | 45 | return h('div', { class: 'sp-pagination' }, [ 46 | h('div', { class: 'sp-page-info' }, props.pageInfo), 47 | h('div', { class: 'sp-page-control' }, items) 48 | ]) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/modules/Search.js: -------------------------------------------------------------------------------- 1 | import { ref, computed, h } from 'vue' 2 | 3 | import '../styles/search.sass' 4 | import { useInject } from '../core/data' 5 | import { isOperationKey } from '../core/list' 6 | import { useDebounce } from '../core/helper' 7 | import { LANG_SELECTED_COUNT } from '../core/constants' 8 | import { setInputFocus } from '../core/utilities' 9 | 10 | import CircleButton from '../components/CircleButton' 11 | import IconSearch from '../icons/IconSearch.vue' 12 | import IconClose from '../icons/IconClose.vue' 13 | import IconLoading from '../icons/IconLoading.vue' 14 | 15 | export default { 16 | props: { 17 | modelValue: { type: String, default: '' } 18 | }, 19 | emits: ['update:modelValue', 'keyboard-operation'], 20 | setup (props, { emit, expose }) { 21 | const { rtl, debounce, loading, language, selectedCount, multiple } = useInject() 22 | 23 | const inFocus = ref(false) 24 | const searchRef = ref() 25 | 26 | const inputDebounce = useDebounce(debounce) 27 | 28 | const placeholder = computed(() => { 29 | if (!multiple || !selectedCount.value) { 30 | return language.search 31 | } 32 | return language.selectedCount.replace(LANG_SELECTED_COUNT, selectedCount.value) 33 | }) 34 | const focus = () => setInputFocus(searchRef.value) 35 | 36 | expose({ focus }) 37 | 38 | return () => { 39 | const icon = computed(() => { 40 | if (loading.value) { 41 | return h(IconLoading) 42 | } 43 | return h(IconSearch, { class: inFocus.value ? 'sp-search-in-focus' : '' }) 44 | }) 45 | 46 | const searchModules = [ 47 | icon.value, 48 | h('input', { 49 | type: 'text', 50 | autocomplete: 'off', 51 | value: props.modelValue.trim(), 52 | class: { 53 | 'sp-search-input': true, 54 | 'sp-search-input--rtl': rtl 55 | }, 56 | placeholder: placeholder.value, 57 | onKeydown: e => { 58 | e.stopPropagation() 59 | 60 | if (!isOperationKey(e.keyCode)) return 61 | emit('keyboard-operation', e.keyCode) 62 | }, 63 | onFocus: () => { inFocus.value = true }, 64 | onBlur: () => { inFocus.value = false }, 65 | onInput: e => { 66 | if (isOperationKey(e.keyCode)) return 67 | 68 | inputDebounce(() => { 69 | emit('update:modelValue', e.target.value.trim()) 70 | }) 71 | }, 72 | ref: searchRef 73 | }) 74 | ] 75 | 76 | if (props.modelValue.trim()) { 77 | const clearOption = { 78 | onClick () { 79 | emit('update:modelValue', '') 80 | focus() 81 | } 82 | } 83 | // clean input content 84 | searchModules.push( 85 | h(CircleButton, clearOption, () => h(IconClose)) 86 | ) 87 | } 88 | 89 | return h('div', { class: 'sp-search-container' }, searchModules) 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/modules/Table.js: -------------------------------------------------------------------------------- 1 | import { h } from 'vue' 2 | 3 | import '../styles/table-view.sass' 4 | import { NOT_SELECTED } from '../core/constants' 5 | import { useInject } from '../core/data' 6 | import { listEmits, listProps } from '../core/list' 7 | 8 | import TableRow from './TableRow' 9 | 10 | export default { 11 | name: 'SelectPageTable', 12 | props: { 13 | ...listProps(), 14 | columns: { type: Array, default: undefined } 15 | }, 16 | emits: listEmits(), 17 | setup (props, { emit }) { 18 | const { isItemSelected, rtl, keyProp } = useInject() 19 | 20 | return () => { 21 | const thCells = props.columns.map(val => h('th', val.title)) 22 | const rows = props.list.map((row, index) => h(TableRow, { 23 | key: row[keyProp], 24 | row, 25 | columns: props.columns, 26 | isHover: props.highlightIndex === index, 27 | isSelected: isItemSelected(row), 28 | onSelect: () => emit('select', row), 29 | onHover: () => emit('set-highlight', index) 30 | })) 31 | 32 | const table = h('table', [ 33 | // table thead 34 | h('thead', h('tr', { class: { 'sp-rtl': rtl } }, thCells)), 35 | // table tbody 36 | h('tbody', { onMouseleave: () => emit('set-highlight', NOT_SELECTED) }, rows) 37 | ]) 38 | return h('div', { class: 'sp-table' }, table) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/modules/TableRow.js: -------------------------------------------------------------------------------- 1 | import { h } from 'vue' 2 | 3 | import { useInject } from '../core/data' 4 | import { parseWidth } from '../core/utilities' 5 | 6 | export default { 7 | props: { 8 | columns: { type: Object, default: undefined }, 9 | row: { type: Object, default: undefined }, 10 | isHover: { type: Boolean, default: false }, 11 | isSelected: { type: Boolean, default: false } 12 | }, 13 | emits: ['select', 'hover'], 14 | setup (props, { emit }) { 15 | const { row } = props 16 | const { rtl } = useInject() 17 | 18 | const renderColumn = col => { 19 | if (!row || !Object.keys(row).length || !col?.data) return '' 20 | 21 | switch (typeof col.data) { 22 | case 'string': return row[col.data] 23 | case 'function': return col.data(row) 24 | } 25 | } 26 | 27 | return () => { 28 | const option = { 29 | class: { 30 | 'sp-over': !props.isSelected && props.isHover, 31 | 'sp-selected': props.isSelected, 32 | 'sp-rtl': rtl 33 | }, 34 | onClick: () => emit('select'), 35 | onMouseenter: () => emit('hover') 36 | } 37 | const cells = props.columns.map((col, idx) => { 38 | const cellOption = { 39 | key: idx, 40 | innerHTML: renderColumn(col) 41 | } 42 | if (Object.hasOwn(col, 'width')) { 43 | cellOption.style = { width: parseWidth(col.width) } 44 | } 45 | return h('td', cellOption) // table data cell 46 | }) 47 | // table row 48 | return h('tr', option, cells) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/modules/Trigger.js: -------------------------------------------------------------------------------- 1 | import { h } from 'vue' 2 | 3 | import '../styles/trigger.sass' 4 | 5 | import IconChevronDown from '../icons/IconChevronDown.vue' 6 | 7 | export default { 8 | props: { 9 | dropdownVisible: { type: Boolean, default: false }, 10 | disabled: { type: Boolean, default: false }, 11 | placeholder: { type: String, default: '' }, 12 | lang: { type: Object, default: undefined } 13 | }, 14 | setup (props, { slots }) { 15 | return () => { 16 | const items = [] 17 | 18 | if (Object.hasOwn(slots, 'default')) { 19 | items.push(slots.default()) 20 | } else { 21 | // slot default content(placeholder) 22 | items.push( 23 | h('div', { class: 'sp-placeholder' }, props.placeholder || props.lang?.placeholder) 24 | ) 25 | } 26 | 27 | items.push(h(IconChevronDown)) 28 | 29 | const btnOption = { 30 | class: { 31 | 'sp-trigger-container': true, 32 | 'sp-opened': props.dropdownVisible, 33 | 'sp-disabled': props.disabled 34 | } 35 | } 36 | 37 | return h('div', btnOption, items) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/styles/common.sass: -------------------------------------------------------------------------------- 1 | .sp-result-message 2 | padding: 20px 0 3 | text-align: center 4 | font-weight: bold 5 | color: #999 6 | .sp-container 7 | min-width: 300px 8 | display: inline-flex 9 | overflow: hidden 10 | flex-direction: column 11 | font-family: "Helvetica Neue", Helvetica, Arial, "PingFang SC", "Hiragino Sans GB", "Heiti SC", "Microsoft YaHei", "WenQuanYi Micro Hei", sans-serif 12 | // background-color: #fcfcfc 13 | 14 | .sp-message 15 | display: flex 16 | align-items: center 17 | padding: 10px 0 18 | background-color: #E4EAEE 19 | color: black 20 | flex-grow: 1 21 | .bi-chat-left-dots 22 | font-size: 1.2rem 23 | margin: 0 1rem 24 | .sp-message-body 25 | font-size: 14px 26 | line-height: 1 27 | flex-wrap: wrap 28 | max-width: 15rem 29 | .sp-message-slide-enter-active, 30 | .sp-message-slide-leave-active 31 | transition: opacity 300ms 32 | .sp-message-slide-enter, 33 | .sp-message-slide-leave-to 34 | opacity: 0 35 | .sp-message-slide-enter-to, 36 | .sp-message-slide-leave 37 | opacity: 1 38 | 39 | .sp-icon 40 | width: 1em 41 | height: 1em 42 | &.sp-icon-small 43 | font-size: 1rem 44 | &.sp-icon-medium 45 | font-size: 1.3rem 46 | 47 | .sp-circle-btn 48 | width: 20px 49 | height: 20px 50 | font-size: 14px 51 | line-height: 1 52 | display: inline-flex 53 | justify-content: center 54 | align-items: center 55 | cursor: pointer 56 | color: #aaa 57 | // background-color: transparent 58 | transition: all .3s ease 59 | border-radius: 50% 60 | &:hover 61 | color: black 62 | // background-color: #f1f1f1 63 | // .sp-circle-btn.sp-circle-btn-disabled 64 | &#{&}--disabled, 65 | &#{&}--disabled:hover 66 | cursor: default 67 | color: #eee 68 | &#{&}--small 69 | width: 16px 70 | height: 16px 71 | font-size: 12px 72 | &#{&}--large 73 | width: 28px 74 | height: 28px 75 | font-size: 16px 76 | -------------------------------------------------------------------------------- /src/styles/list-view.sass: -------------------------------------------------------------------------------- 1 | .sp-list 2 | min-width: 300px 3 | max-height: 320px 4 | overflow-y: auto 5 | padding: 0 .3rem 6 | transition: all .3s ease 7 | // background-color: #f7f7f7 8 | // padding-top: .3rem 9 | .sp-list-item 10 | display: flex 11 | align-items: center 12 | justify-content: space-between 13 | line-height: 1.43 14 | font-size: 14px 15 | text-align: left 16 | overflow: hidden 17 | white-space: nowrap 18 | margin: 0 19 | padding: .25rem .5rem 20 | color: #666 21 | cursor: pointer 22 | transition: all .2s ease 23 | &.sp-over 24 | background-color: #F6F8FA !important 25 | color: black !important 26 | border-radius: .4rem 27 | &.sp-selected 28 | color: #ccc 29 | cursor: default 30 | &.sp-rtl 31 | direction: rtl 32 | text-align: right 33 | -------------------------------------------------------------------------------- /src/styles/pagination.sass: -------------------------------------------------------------------------------- 1 | .sp-pagination 2 | display: flex 3 | justify-content: space-between 4 | align-items: center 5 | // border-top: 1px solid #eee 6 | // margin-top: 5px 7 | // background-color: #f7f7f7 8 | padding: 5px 10px 9 | .sp-page-info 10 | line-height: 1 11 | // color: #bbb 12 | color: rgba(0, 0, 0, 0.3) 13 | font-size: 14px 14 | font-weight: 600 15 | margin-right: 10px 16 | .sp-page-control 17 | padding: 0 18 | margin: 0 19 | .sp-page-button 20 | display: inline-flex 21 | a 22 | display: inline-flex 23 | padding: 7px 24 | font-size: 14px 25 | color: #333 26 | text-decoration: none 27 | cursor: pointer 28 | line-height: 1 29 | background: transparent 30 | transition: all .3s ease 31 | border-radius: .6rem 32 | &:hover 33 | color: black 34 | background-color: #f1f1f1 35 | // box-shadow: 0 2px 5px rgba(0, 0, 0, .2) 36 | &.sp-page-disabled a 37 | color: #ddd 38 | font-weight: normal 39 | background-color: transparent 40 | cursor: default 41 | // box-shadow: none 42 | -------------------------------------------------------------------------------- /src/styles/search.sass: -------------------------------------------------------------------------------- 1 | .sp-search 2 | display: flex 3 | align-items: center 4 | // margin: 10px 5 | padding: .5rem 0 6 | transition: all .3s ease 7 | // border-bottom: 1px solid #eee 8 | // background-color: rgba(200, 200, 200, .1) 9 | // padding-top: 10px 10 | // box-shadow: 0 1px 3px rgba(0, 0, 0, .3) 11 | .sp-search-container 12 | display: flex 13 | align-items: center 14 | flex-grow: 1 15 | padding: 0 16 | padding-left: 0.7rem 17 | // background-color: #fff 18 | // background-color: #fafafa 19 | // border: 1px solid #ddd 20 | // box-shadow: 0 1px 3px rgba(0, 0, 0, .3) 21 | // margin-top: 5px 22 | // margin-left: 5px 23 | // border-radius: .3rem 24 | // border-radius: 50rem 25 | transition: all .3s ease 26 | // margin-left: 10px 27 | .sp-search-input 28 | border: 0 29 | border-radius: 50rem 30 | // background-color: #f7f7f7 31 | background-color: transparent 32 | // background-color: white 33 | margin-left: 5px 34 | font-size: 14px 35 | line-height: 1.43 36 | padding: 4px 6px 37 | box-sizing: border-box 38 | outline: none !important 39 | color: #333 40 | font-weight: 600 41 | flex-grow: 1 42 | transition: all .3s ease 43 | &:focus 44 | // background-color: #f5f5f5 45 | &.sp-search-input--rtl 46 | direction: rtl 47 | &::placeholder 48 | color: #aaa 49 | font-weight: 500 50 | .sp-icon-loading 51 | opacity: .5 52 | .bi-search, 53 | .bi-x-lg 54 | transition: all .3s ease 55 | color: #aaa 56 | &.sp-search-in-focus 57 | color: #000 58 | .bi-x-lg 59 | cursor: pointer 60 | &:hover 61 | color: #000 62 | 63 | .sp-search-control 64 | display: inline-flex 65 | transition: all .3s ease 66 | padding: 0 0.5rem 67 | // border-left: 1px solid #eee 68 | -------------------------------------------------------------------------------- /src/styles/table-view.sass: -------------------------------------------------------------------------------- 1 | $row-radius: .4rem 2 | 3 | .sp-table 4 | padding: 0 .3rem 5 | min-width: 300px 6 | max-height: 320px 7 | overflow-y: auto 8 | table 9 | width: 100% 10 | border-spacing: 0 11 | td, 12 | th 13 | font-size: 14px 14 | line-height: 1.43 15 | border: 0 !important 16 | th 17 | padding: 0 8px 5px 18 | font-weight: 600 19 | font-size: 15px 20 | color: #333 21 | text-align: left 22 | td 23 | padding: .25rem .5rem 24 | color: #666 25 | cursor: pointer 26 | tbody tr 27 | &.sp-over td 28 | background-color: #F6F8FA !important 29 | color: black !important 30 | &:first-child 31 | border-top-left-radius: $row-radius 32 | border-bottom-left-radius: $row-radius 33 | &:last-child 34 | border-top-right-radius: $row-radius 35 | border-bottom-right-radius: $row-radius 36 | &.sp-selected td 37 | color: #ccc 38 | cursor: default 39 | thead .sp-rtl th, 40 | tbody .sp-rtl td 41 | direction: rtl 42 | text-align: right 43 | -------------------------------------------------------------------------------- /src/styles/trigger.sass: -------------------------------------------------------------------------------- 1 | .sp-trigger-container 2 | display: flex 3 | align-items: center 4 | justify-content: space-between 5 | flex-grow: 1 6 | padding: 6px 12px 6px 6px 7 | background-color: white 8 | border: 1px solid #ddd 9 | border-radius: .3rem 10 | font-size: 14px 11 | line-height: 1.42857143 12 | outline: 0 !important 13 | // color: #666 14 | cursor: pointer 15 | user-select: none 16 | transition: all .2s ease 17 | &:hover 18 | border: 1px solid #aaa 19 | // color: black 20 | &.sp-disabled, 21 | &.sp-disabled:hover 22 | border: 1px solid #eee 23 | background-color: #eee 24 | cursor: default 25 | color: #aaa 26 | .sp-select, 27 | .sp-chips 28 | color: #aaa 29 | background-color: #eee 30 | .sp-chip 31 | background-color: #d6d6d6 !important 32 | color: #666 !important 33 | .bi-chevron-down 34 | transition: transform .2s ease 35 | margin-left: .5rem 36 | font-size: 1rem 37 | color: #666 38 | &.sp-opened 39 | box-shadow: 3px 2px 6px rgba(0, 0, 0, 0.3) 40 | border: 1px solid #666 41 | color: black 42 | .bi-chevron-down 43 | transform: rotate(180deg) 44 | &:hover 45 | border: 1px solid #666 46 | .sp-placeholder 47 | color: #aaa 48 | padding: 5px 49 | line-height: 1 50 | .sp-trigger 51 | display: flex 52 | align-items: center 53 | flex-wrap: wrap 54 | &.sp-select 55 | justify-content: space-between 56 | flex-grow: 1 57 | .sp-select-content 58 | padding: 5px 59 | line-height: 1.143 60 | color: #666 61 | &.sp-chips 62 | gap: .5rem 63 | .sp-chip 64 | border-radius: .3rem 65 | background-color: #eee 66 | color: #666 67 | display: inline-flex 68 | align-items: center 69 | padding: 5px 7px 70 | transition: all .2s ease 71 | &--body 72 | display: inline-flex 73 | line-height: 1.143 74 | margin-right: 5px 75 | &:hover 76 | background-color: #f7f7f7 77 | color: black 78 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "outDir": "./built", 5 | "target": "es5", 6 | "strict": true, 7 | "module": "es2015", 8 | "moduleResolution": "node", 9 | "allowJs": true, 10 | "jsx": "preserve", 11 | "paths": { 12 | "@/*": ["./src/*"] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /types/common.d.ts: -------------------------------------------------------------------------------- 1 | import { AllowedComponentProps, ComponentCustomProps, VNodeProps } from 'vue' 2 | 3 | export declare interface ComponentProps extends AllowedComponentProps, ComponentCustomProps, VNodeProps {} 4 | 5 | export declare type SelectPageKey = string | number 6 | 7 | export declare interface BaseProps extends ComponentProps { 8 | /** 9 | * Binds the key value of the selected item to match the contents 10 | * of the field specified by `keyProp` 11 | */ 12 | modelValue?: SelectPageKey[] 13 | /** 14 | * The placeholder text content displayed when no item is selected. 15 | * If this parameter is not set, the component will use the placeholder 16 | * set in i18n by default 17 | * 18 | * This prop only work on `Selector mode` 19 | */ 20 | placeholder?: string 21 | /** 22 | * Multiple selection mode 23 | * 24 | * @default false 25 | */ 26 | multiple?: boolean 27 | /** 28 | * The language used by the component 29 | * 30 | * @default `en` 31 | */ 32 | language?: string 33 | /** 34 | * Specify a property as a key value field that will be used as 35 | * the basis field for `v-model/modelValue` and data matching 36 | * 37 | * @default `id` 38 | */ 39 | keyProp?: string 40 | /** 41 | * Specify a data property or a function to process the text content 42 | * displayed by the list item 43 | * 44 | * @default `name` 45 | */ 46 | labelProp?: string | Function 47 | /** 48 | * The number of records per page is displayed, and when the paging 49 | * bar is turned off, a fixed `0` is applied 50 | * 51 | * @default 10 52 | */ 53 | pageSize?: number 54 | /** 55 | * Maximum number of items that can be selected, set to `0` for no limit 56 | * 57 | * This option relies on the `multiple` prop being set to `true` 58 | * 59 | * @default 0 60 | */ 61 | max?: number 62 | /** 63 | * Data list using pagination bar 64 | * 65 | * @default true 66 | */ 67 | pagination?: boolean 68 | /** 69 | * Text rendering direction from right to left 70 | * 71 | * @default false 72 | */ 73 | rtl?: boolean 74 | /** 75 | * Specifies the width of the content container 76 | * specifying content in number format automatically uses pixels in px units 77 | * content in string format is applied directly 78 | */ 79 | width?: string | number 80 | /** 81 | * Debounce delay when typing, in milliseconds 82 | * 83 | * @default 300 84 | */ 85 | debounce?: number 86 | } 87 | export declare interface DropdownProps extends BaseProps { 88 | /** 89 | * Component disabled states, only work on `Selector mode` 90 | * 91 | * @default false 92 | */ 93 | disabled?: boolean 94 | /** 95 | * Add custom class to trigger container, work on `Selector mode` 96 | */ 97 | customTriggerClass?: string 98 | /** 99 | * Add custom class to dropdown container, work on `Selector mode` 100 | */ 101 | customContainerClass?: string 102 | } 103 | 104 | export declare interface PageParameters { 105 | /** search keyword */ 106 | search: string 107 | /** current page number */ 108 | pageNumber: number 109 | /** the number of records per page */ 110 | pageSize: number 111 | } 112 | export declare type FetchDataCallback = ( 113 | // data list 114 | dataList: Record[], 115 | // total number of records 116 | resultCount: number 117 | ) => void 118 | export declare type FetchSelectedDataCallback = ( 119 | dataList: Record[] 120 | ) => void 121 | 122 | export declare type EmitUpdateModelValue = ( 123 | event: "update:modelValue", 124 | keys: SelectPageKey[] 125 | ) => void 126 | export declare type EmitFetchData = ( 127 | event: "fetch-data", 128 | data: PageParameters, 129 | callback: FetchDataCallback 130 | ) => void 131 | export declare type EmitFetchSelectedData = ( 132 | event: "fetch-selected-data", 133 | keys: SelectPageKey[], 134 | callback: FetchSelectedDataCallback 135 | ) => void 136 | export declare type EmitSelectionChange = (event: "selection-change", items: Record[]) => void 137 | export declare type EmitRemove = (event: 'remove', items: Record[]) => void 138 | export declare type EmitAdjustDropdown = (event: 'adjust-dropdown') => void 139 | export declare type EmitCloseDropdown = (event: 'close-dropdown') => void 140 | export declare type EmitVisibleChange = (event: 'visible-change', visible: boolean) => void 141 | 142 | export declare type BaseEmits = EmitUpdateModelValue 143 | & EmitFetchData 144 | & EmitFetchSelectedData 145 | & EmitSelectionChange 146 | & EmitRemove 147 | export declare type CoreEmits = BaseEmits & EmitAdjustDropdown & EmitCloseDropdown 148 | export declare type DropdownEmits = BaseEmits & EmitVisibleChange 149 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | export { SelectPageList, SelectPageListCore } from './list' 2 | export { SelectPageTable, SelectPageTableCore, SelectPageTableColumn } from './table' 3 | 4 | export { 5 | SelectPageKey, PageParameters, 6 | FetchDataCallback, FetchSelectedDataCallback 7 | } from './common' 8 | -------------------------------------------------------------------------------- /types/list.d.ts: -------------------------------------------------------------------------------- 1 | import { BaseProps, DropdownProps,CoreEmits, DropdownEmits } from './common' 2 | 3 | declare interface ISelectPageListCore { 4 | new (): { 5 | $props: BaseProps 6 | $emit: CoreEmits 7 | } 8 | } 9 | 10 | declare interface ISelectPageList { 11 | new (): { 12 | $props: DropdownProps 13 | $emit: DropdownEmits 14 | } 15 | } 16 | 17 | export declare const SelectPageListCore: ISelectPageListCore 18 | export declare const SelectPageList: ISelectPageList 19 | -------------------------------------------------------------------------------- /types/table.d.ts: -------------------------------------------------------------------------------- 1 | import { BaseProps, DropdownProps,CoreEmits, DropdownEmits } from './common' 2 | 3 | export declare interface SelectPageTableColumn { 4 | /** title text */ 5 | title: string 6 | /** data property or data processing function */ 7 | data: string | Function 8 | /** column width */ 9 | width?: number | string 10 | } 11 | 12 | declare interface TableProps { 13 | /** 14 | * Tabular data column setting model 15 | */ 16 | columns?: SelectPageTableColumn[] 17 | } 18 | 19 | declare interface ISelectPageTableCore { 20 | new (): { 21 | $props: BaseProps & TableProps 22 | $emit: CoreEmits 23 | } 24 | } 25 | 26 | declare interface ISelectPageTable { 27 | new (): { 28 | $props: DropdownProps & TableProps 29 | $emit: DropdownEmits 30 | } 31 | } 32 | 33 | export declare const SelectPageTableCore: ISelectPageTableCore 34 | export declare const SelectPageTable: ISelectPageTable 35 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | import { resolve } from 'path' 3 | // import { defineConfig } from 'vite' 4 | import { defineConfig } from 'vitest/config' 5 | import vue from '@vitejs/plugin-vue' 6 | import vueJsx from '@vitejs/plugin-vue-jsx' 7 | import cssInJs from 'vite-plugin-css-injected-by-js' 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig({ 11 | resolve: { 12 | alias: { 13 | '@': fileURLToPath(new URL('./src', import.meta.url)) 14 | } 15 | }, 16 | build: { 17 | lib: { 18 | entry: resolve(__dirname, 'src/index.js'), 19 | name: 'VSelectpage', 20 | formats: ['es', 'umd'], 21 | fileName: 'v-selectpage' 22 | }, 23 | rollupOptions: { 24 | external: ['vue'], 25 | output: { 26 | globals: { 27 | vue: 'Vue' 28 | } 29 | } 30 | } 31 | }, 32 | test: { 33 | environment: 'jsdom', 34 | reporters: 'verbose', 35 | coverage: { 36 | provider: 'v8', 37 | reporter: ['text', 'json', 'html'], 38 | include: ['src/**'] 39 | } 40 | }, 41 | plugins: [ 42 | vue(), 43 | vueJsx(), 44 | cssInJs() 45 | ] 46 | }) 47 | --------------------------------------------------------------------------------