├── .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 |
5 |
6 |
7 | [](https://dl.circleci.com/status-badge/redirect/gh/TerryZ/v-selectpage/tree/master) [](https://codecov.io/gh/TerryZ/v-selectpage) [](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 | [](https://opencollective.com/v-selectpage)
12 | [](https://standardjs.com)
13 | [](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://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 |
79 |
84 |
85 |
86 |
120 | ```
121 |
122 | Set default selected items
123 |
124 | ```vue
125 |
126 |
134 |
135 |
136 |
163 | ```
164 |
165 | ## Plugin preview
166 |
167 | List view for Single selection
168 |
169 | 
170 |
171 | List view for multiple selection with tags form
172 |
173 | 
174 |
175 | Table view for single selection
176 |
177 | 
178 |
179 | ## Dependencies
180 |
181 | - [v-dropdown](https://github.com/TerryZ/v-dropdown) - The dropdown container
182 |
183 | ## License
184 |
185 | [](https://mit-license.org/)
186 |
--------------------------------------------------------------------------------
/examples/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/examples/ExamplesCoreList.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
List View 列表视图
4 |
5 |
6 |
单选模式
7 |
8 | 选择的项目 key:
9 |
13 |
14 |
15 |
24 |
25 |
26 |
33 |
40 |
47 |
48 |
49 |
50 |
多选模式
51 |
52 | 选择的项目 key:
56 |
57 |
58 |
70 |
71 |
72 |
79 |
86 |
87 |
88 |
89 |
90 |
91 |
关闭分页栏
92 |
93 |
103 |
104 |
105 |
106 |
107 |
108 |
133 | ./example-data
134 |
--------------------------------------------------------------------------------
/examples/ExamplesCoreTable.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Table View 表格视图
4 |
5 |
6 |
单选模式
7 |
8 | 选择的项目 key:
9 |
13 |
14 |
15 |
25 |
26 |
27 |
34 |
41 |
48 |
49 |
50 |
51 |
多选模式
52 |
53 | 选择的项目 key:
57 |
58 |
59 |
71 |
72 |
73 |
74 |
75 |
76 |
关闭分页栏
77 |
78 |
87 |
88 |
89 |
90 |
91 |
92 |
119 |
--------------------------------------------------------------------------------
/examples/ExamplesDropdownList.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
List View 列表视图
4 |
5 |
单选模式
6 |
7 |
19 |
20 |
21 |
28 |
35 |
42 |
49 |
50 |
51 |
多选模式
52 |
53 |
64 |
65 |
66 |
禁用状态
67 |
68 |
69 |
79 |
80 |
81 |
92 |
93 |
94 |
95 |
Demo
96 |
97 |
98 |
102 |
103 |
104 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
146 |
--------------------------------------------------------------------------------
/examples/ExamplesDropdownTable.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Table View 表格视图
4 |
5 |
单选模式
6 |
7 |
18 |
19 |
20 |
27 |
34 |
41 |
48 |
49 |
50 |
多选模式
51 |
52 |
63 |
64 |
65 |
禁用状态
66 |
67 |
68 |
79 |
80 |
81 |
93 |
94 |
95 |
96 |
Demo
97 |
98 |
99 |
104 |
105 |
106 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
159 |
--------------------------------------------------------------------------------
/examples/ExamplesIndex.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
26 |
27 |
39 |
--------------------------------------------------------------------------------
/examples/LayoutAside.vue:
--------------------------------------------------------------------------------
1 |
2 |
13 |
14 |
15 |
27 |
--------------------------------------------------------------------------------
/examples/LayoutHeader.vue:
--------------------------------------------------------------------------------
1 |
2 |
42 |
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 |
2 |
15 |
16 |
--------------------------------------------------------------------------------
/src/icons/IconClose.vue:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
--------------------------------------------------------------------------------
/src/icons/IconFirst.vue:
--------------------------------------------------------------------------------
1 |
2 |
15 |
16 |
--------------------------------------------------------------------------------
/src/icons/IconLast.vue:
--------------------------------------------------------------------------------
1 |
2 |
15 |
16 |
--------------------------------------------------------------------------------
/src/icons/IconLoading.vue:
--------------------------------------------------------------------------------
1 |
2 |
23 |
24 |
--------------------------------------------------------------------------------
/src/icons/IconMessage.vue:
--------------------------------------------------------------------------------
1 |
2 |
13 |
14 |
--------------------------------------------------------------------------------
/src/icons/IconNext.vue:
--------------------------------------------------------------------------------
1 |
2 |
15 |
16 |
--------------------------------------------------------------------------------
/src/icons/IconPrevious.vue:
--------------------------------------------------------------------------------
1 |
2 |
15 |
16 |
--------------------------------------------------------------------------------
/src/icons/IconSearch.vue:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
--------------------------------------------------------------------------------
/src/icons/IconTrash.vue:
--------------------------------------------------------------------------------
1 |
2 |
12 |
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 |
--------------------------------------------------------------------------------