├── .browserslistrc ├── .editorconfig ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── actions │ └── gh-pages │ │ ├── Dockerfile │ │ ├── action.yml │ │ └── entrypoint.sh └── workflows │ └── docs.deploy.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── __tests__ ├── Arrow.spec.js ├── Option.spec.js ├── SelectMenu.spec.js ├── VueCascaderSelect.spec.js └── validators.spec.js ├── babel.config.js ├── build └── rollup.config.js ├── docs ├── .vuepress │ ├── components │ │ ├── TheFooter.vue │ │ ├── VCSBasic.vue │ │ └── VCSTheming.vue │ ├── config.js │ ├── enhanceApp.js │ └── public │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ └── logo.svg ├── README.md ├── es │ ├── README.md │ └── guide │ │ ├── README.md │ │ ├── basic_usage.md │ │ ├── installation.md │ │ └── theming.md └── guide │ ├── README.md │ ├── basic_usage.md │ ├── installation.md │ └── theming.md ├── jest.config.js ├── package.json ├── public └── logo.png ├── src ├── VueCascaderSelect.vue ├── components │ ├── Arrow.vue │ ├── Option.vue │ └── SelectMenu.vue ├── entry.js ├── serve-dev.js ├── serve-dev.vue └── utils │ ├── teams.js │ └── validators.js └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | current node 2 | last 2 versions and > 2% 3 | ie > 10 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | max_line_length = 100 8 | 9 | [*.{js,jsx,ts,tsx,vue}] 10 | indent_style = space 11 | indent_size = 2 12 | end_of_line = lf 13 | trim_trailing_whitespace = true 14 | insert_final_newline = true 15 | max_line_length = 100 16 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | 4 | env: { 5 | node: true, 6 | }, 7 | 8 | extends: [ 9 | 'plugin:vue/essential', 10 | '@vue/airbnb', 11 | ], 12 | 13 | rules: { 14 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 15 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 16 | }, 17 | 18 | parserOptions: { 19 | parser: 'babel-eslint', 20 | }, 21 | 22 | overrides: [ 23 | { 24 | files: [ 25 | '**/__tests__/*.{j,t}s?(x)', 26 | '**/tests/unit/**/*.spec.{j,t}s?(x)', 27 | ], 28 | env: { 29 | jest: true, 30 | }, 31 | }, 32 | ], 33 | }; 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Expected Behavior 2 | 3 | 4 | ## Actual Behavior 5 | 6 | 7 | ## Steps to Reproduce the Problem 8 | 9 | 1. 10 | 1. 11 | 1. 12 | 13 | ## Specifications 14 | 15 | - Version: 16 | - Platform: 17 | - Subsystem: -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Fixes # 2 | 3 | ## Proposed Changes 4 | 5 | - 6 | - 7 | - -------------------------------------------------------------------------------- /.github/actions/gh-pages/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:11 2 | 3 | ADD entrypoint.sh /entrypoint.sh 4 | ENTRYPOINT ["/entrypoint.sh"] 5 | -------------------------------------------------------------------------------- /.github/actions/gh-pages/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Deploy vuepress to specified branch' 2 | description: 'Buildi and deploy vuepress to GitHub Pages.' 3 | runs: 4 | using: 'docker' 5 | image: 'Dockerfile' 6 | branding: 7 | icon: 'git-commit' 8 | color: 'green' 9 | -------------------------------------------------------------------------------- /.github/actions/gh-pages/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | if [ -z "$ACCESS_TOKEN" ] && [ -z "$GITHUB_TOKEN" ] 6 | then 7 | echo "You must provide the action with either a Personal Access Token or the GitHub Token secret in order to deploy." 8 | exit 1 9 | fi 10 | 11 | if [ -z "$BRANCH" ] 12 | then 13 | echo "You must provide the action with a branch name it should deploy to, for example gh-pages or docs." 14 | exit 1 15 | fi 16 | 17 | if [ -z "$FOLDER" ] 18 | then 19 | echo "You must provide the action with the folder name in the repository where your compiled page lives." 20 | exit 1 21 | fi 22 | 23 | case "$FOLDER" in /*|./*) 24 | echo "The deployment folder cannot be prefixed with '/' or './'. Instead reference the folder name directly." 25 | exit 1 26 | esac 27 | 28 | # Installs Git and jq. 29 | apt-get update && \ 30 | apt-get install -y git && \ 31 | apt-get install -y jq && \ 32 | 33 | # Gets the commit email/name if it exists in the push event payload. 34 | COMMIT_EMAIL=`jq '.pusher.email' ${GITHUB_EVENT_PATH}` 35 | COMMIT_NAME=`jq '.pusher.name' ${GITHUB_EVENT_PATH}` 36 | 37 | # If the commit email/name is not found in the event payload then it falls back to the actor. 38 | if [ -z "$COMMIT_EMAIL" ] 39 | then 40 | COMMIT_EMAIL="${GITHUB_ACTOR:-github-pages-deploy-action}@users.noreply.github.com" 41 | fi 42 | 43 | if [ -z "$COMMIT_NAME" ] 44 | then 45 | COMMIT_NAME="${GITHUB_ACTOR:-GitHub Pages Deploy Action}" 46 | fi 47 | 48 | # Directs the action to the the Github workspace. 49 | cd $GITHUB_WORKSPACE && \ 50 | 51 | # Configures Git. 52 | git init && \ 53 | git config --global user.email "${COMMIT_EMAIL}" && \ 54 | git config --global user.name "${COMMIT_NAME}" && \ 55 | 56 | ## Initializes the repository path using the access token. 57 | REPOSITORY_PATH="https://${ACCESS_TOKEN:-"x-access-token:$GITHUB_TOKEN"}@github.com/${GITHUB_REPOSITORY}.git" && \ 58 | 59 | # Checks to see if the remote exists prior to deploying. 60 | # If the branch doesn't exist it gets created here as an orphan. 61 | if [ "$(git ls-remote --heads "$REPOSITORY_PATH" "$BRANCH" | wc -l)" -eq 0 ]; 62 | then 63 | echo "Creating remote branch ${BRANCH} as it doesn't exist..." 64 | git checkout "${BASE_BRANCH:-master}" && \ 65 | git checkout --orphan $BRANCH && \ 66 | git rm -rf . && \ 67 | touch README.md && \ 68 | git add README.md && \ 69 | git commit -m "Initial ${BRANCH} commit" && \ 70 | git push $REPOSITORY_PATH $BRANCH 71 | fi 72 | 73 | echo "Going to ${BASE_BRANCH}" 74 | git fetch --all && \ 75 | # Checks out the base branch to begin the deploy process. 76 | git checkout "${BASE_BRANCH:-master}" && \ 77 | 78 | # Builds the project if a build script is provided. 79 | echo "Running build scripts... $BUILD_SCRIPT" && \ 80 | eval "$BUILD_SCRIPT" && \ 81 | 82 | # Commits the data to Github. 83 | echo "Deploying to GitHub..." && \ 84 | 85 | git add -A . && \ 86 | git commit -m "Deploying to ${BRANCH} from ${BASE_BRANCH:-master} ${GITHUB_SHA}" --quiet --no-verify&& \ 87 | git push $REPOSITORY_PATH `git subtree split --prefix $FOLDER ${BASE_BRANCH:-master}`:$BRANCH --force && \ 88 | 89 | echo "Deployment succesful!" 90 | -------------------------------------------------------------------------------- /.github/workflows/docs.deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy Docs 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | jobs: 8 | build-and-deploy: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@master 14 | - name: Build and Deploy 15 | uses: ./.github/actions/gh-pages 16 | env: 17 | ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} 18 | BASE_BRANCH: master 19 | BRANCH: gh-pages 20 | FOLDER: docs/.vuepress/dist 21 | BUILD_SCRIPT: npm install && npm run docs:build 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/ 4 | !docs/.vuepress/dist 5 | coverage/ 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | 5 | cache: 6 | directories: 7 | - node_modules 8 | 9 | script: 10 | - npm run test:unit && npm run codecov -- --token=$CODECOV_TOKEN 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 NeoCoast 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 |
2 |
3 |
4 |
5 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
59 | Icons made by Flat Icons from www.flaticon.com 60 |
61 |
--------------------------------------------------------------------------------
/__tests__/Arrow.spec.js:
--------------------------------------------------------------------------------
1 | import { shallowMount, mount } from '@vue/test-utils';
2 | import Arrow from '@/components/Arrow.vue';
3 |
4 | const mountWithProps = props => (
5 | mount(Arrow, { propsData: props })
6 | );
7 |
8 | describe('Arrow.vue', () => {
9 | it('renders', () => {
10 | const wrapper = shallowMount(Arrow);
11 | expect(wrapper.exists()).toBe(true);
12 | });
13 |
14 | describe('props:', () => {
15 | it('direction: up', () => {
16 | const wrapper = mountWithProps({ direction: 'up' });
17 | expect(wrapper.classes('up')).toBe(true);
18 | });
19 |
20 | it('direction: down', () => {
21 | const wrapper = mountWithProps({ direction: 'down' });
22 | expect(wrapper.classes('down')).toBe(true);
23 | });
24 |
25 | it('direction: right', () => {
26 | const wrapper = mountWithProps({ direction: 'right' });
27 | expect(wrapper.classes('right')).toBe(true);
28 | });
29 |
30 | it('borderColor', () => {
31 | const borderColor = '#ccc';
32 | const wrapper = mountWithProps({ borderColor });
33 | expect(wrapper.attributes('style')).toBe(`border-color: ${borderColor};`);
34 | });
35 | });
36 |
37 | it('should change direction', (done) => {
38 | const wrapper = mountWithProps({ direction: 'up' });
39 | expect(wrapper.classes('up')).toBe(true);
40 |
41 | wrapper.setProps({ direction: 'down' });
42 |
43 | wrapper.vm.$nextTick(() => {
44 | expect(wrapper.classes('down')).toBe(true);
45 | done();
46 | });
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/__tests__/Option.spec.js:
--------------------------------------------------------------------------------
1 | import { shallowMount, mount } from '@vue/test-utils';
2 | import Arrow from '@/components/Arrow.vue';
3 | import Option from '@/components/Option.vue';
4 |
5 | const mountWithProps = props => (
6 | mount(Option, { propsData: props })
7 | );
8 |
9 | describe('Option.vue', () => {
10 | it('renders', () => {
11 | const wrapper = shallowMount(Option);
12 | expect(wrapper.exists()).toBe(true);
13 | });
14 |
15 | it('should render the label', () => {
16 | const label = 'NeoCoast';
17 | const wrapper = mountWithProps({ label });
18 | expect(wrapper.text()).toBe(label);
19 | });
20 |
21 | it('shouldn\'t be active', () => {
22 | let wrapper = mountWithProps({});
23 | expect(wrapper.classes('vcs__option--active')).toBe(false);
24 |
25 | wrapper = mountWithProps({ active: false });
26 | expect(wrapper.classes('vcs__option--active')).toBe(false);
27 | });
28 |
29 | it('should be active', () => {
30 | const wrapper = mountWithProps({ active: true });
31 | expect(wrapper.classes('vcs__option--active')).toBe(true);
32 | });
33 |
34 | it('shouldn\'t be disabled', () => {
35 | let wrapper = mountWithProps({});
36 | expect(wrapper.classes('vcs__option--disabled')).toBe(false);
37 |
38 | wrapper = mountWithProps({ disabled: false });
39 | expect(wrapper.classes('vcs__option--disabled')).toBe(false);
40 | });
41 |
42 | it('should be disabled', () => {
43 | const wrapper = mountWithProps({ disabled: true });
44 | expect(wrapper.classes('vcs__option--disabled')).toBe(true);
45 | });
46 |
47 | it('should render an Arrow', () => {
48 | const wrapper = mountWithProps({
49 | options: [{ label: 'NeoCoast', value: 'neocoast' }],
50 | });
51 |
52 | expect(wrapper.contains(Arrow)).toBe(true);
53 | });
54 |
55 | it('shouldn\'t render an Arrow', () => {
56 | const wrapper = mountWithProps({});
57 |
58 | expect(wrapper.contains(Arrow)).toBe(false);
59 | });
60 |
61 | it('should emit on mouseenter', () => {
62 | const wrapper = mountWithProps({});
63 |
64 | wrapper.trigger('mouseenter');
65 | wrapper.vm.$nextTick(() => {
66 | expect(wrapper.emitted().openMenu).toBeTruthy();
67 | });
68 | });
69 |
70 | it('should call onSelect on click if the option is selectable', () => {
71 | const onSelect = jest.fn();
72 | const wrapper = mountWithProps({ onSelect });
73 |
74 | wrapper.trigger('click');
75 | wrapper.vm.$nextTick(() => {
76 | expect(onSelect).toBeCalled();
77 | expect(onSelect).toBeCalledTimes(1);
78 | });
79 | });
80 |
81 | it('shouldn\'t call onSelect on click if the option isn\'t selectable', () => {
82 | const onSelect = jest.fn();
83 | const wrapper = mountWithProps({ onSelect, selectable: false });
84 |
85 | wrapper.trigger('click');
86 | wrapper.vm.$nextTick(() => {
87 | expect(onSelect).not.toBeCalled();
88 | expect(onSelect).toBeCalledTimes(0);
89 | });
90 | });
91 |
92 | it('shouldn\'t call onSelect on click if the option is disabled', () => {
93 | const onSelect = jest.fn();
94 | const wrapper = mountWithProps({ onSelect, disabled: true });
95 |
96 | wrapper.trigger('click');
97 | wrapper.vm.$nextTick(() => {
98 | expect(onSelect).not.toBeCalled();
99 | expect(onSelect).toBeCalledTimes(0);
100 | });
101 | });
102 | });
103 |
--------------------------------------------------------------------------------
/__tests__/SelectMenu.spec.js:
--------------------------------------------------------------------------------
1 | import { mount } from '@vue/test-utils';
2 | import Option from '@/components/Option.vue';
3 | import SelectMenu from '@/components/SelectMenu.vue';
4 |
5 | const options = [
6 | {
7 | label: 'Sibling 1',
8 | value: 'sibling1',
9 | options: [
10 | {
11 | label: 'Sibling 1 - Child 1',
12 | value: 'sibling1-child1',
13 | },
14 | ],
15 | },
16 | {
17 | label: 'Sibling 2',
18 | value: 'sibling2',
19 | },
20 | {
21 | label: 'Sibling 3',
22 | value: 'sibling3',
23 | options: [
24 | {
25 | label: 'Sibling 3 - Child 1',
26 | value: 'sibling3-child1',
27 | options: [
28 | {
29 | label: 'Sibling 3 - Grand Child 1',
30 | value: 'sibling3-grand-child1',
31 | },
32 | ],
33 | },
34 | ],
35 | },
36 | ];
37 |
38 | const mountWithProps = props => (
39 | mount(SelectMenu, {
40 | propsData: {
41 | onSelect: jest.fn(),
42 | ...props,
43 | },
44 | attachToDocument: true,
45 | })
46 | );
47 |
48 | describe('SelectMenu.vue', () => {
49 | it('renders', () => {
50 | const wrapper = mountWithProps({});
51 | expect(wrapper.exists()).toBe(true);
52 | });
53 |
54 | it('should be a "main" select menu', () => {
55 | const wrapper = mountWithProps({});
56 | expect(wrapper.classes('vcs__select-menu__not-main')).toBe(false);
57 | });
58 |
59 | it('shouldn\'t be a "main" select menu', () => {
60 | const wrapper = mountWithProps({ notMain: true });
61 | expect(wrapper.classes('vcs__select-menu__not-main')).toBe(true);
62 | });
63 |
64 | it('should open next menu on mouseenter if there are more options', (done) => {
65 | const wrapper = mountWithProps({ options });
66 |
67 | const option = wrapper.find(Option);
68 | expect(option.is(Option)).toBe(true);
69 |
70 | option.trigger('mouseenter');
71 | wrapper.vm.$nextTick(() => {
72 | expect(wrapper.findAll(SelectMenu).length).toBe(2);
73 | done();
74 | });
75 | });
76 |
77 | it('shouldn\'t open next menu on mouseenter if there\'re no more options', (done) => {
78 | const wrapper = mountWithProps({ options });
79 |
80 | wrapper.vm.$nextTick(() => {
81 | const optionsWrapper = wrapper.findAll(Option);
82 | expect(optionsWrapper.length).toBe(3);
83 |
84 | optionsWrapper.at(1).trigger('mouseenter');
85 | wrapper.vm.$nextTick(() => {
86 | expect(wrapper.findAll(SelectMenu).length).toBe(1);
87 | done();
88 | });
89 | });
90 | });
91 |
92 | it('should close childMenu', (done) => {
93 | const wrapper = mountWithProps({ options });
94 |
95 | wrapper.vm.$nextTick(() => {
96 | let optionsWrapper = wrapper.findAll(Option);
97 | expect(optionsWrapper.length).toBe(3);
98 |
99 | optionsWrapper.at(2).trigger('mouseenter');
100 | wrapper.vm.$nextTick(() => {
101 | expect(wrapper.findAll(SelectMenu).length).toBe(2);
102 |
103 | optionsWrapper = wrapper.findAll(Option);
104 | optionsWrapper.at(-1).trigger('mouseenter');
105 |
106 | wrapper.vm.$nextTick(() => {
107 | // Open grand child menu
108 | expect(wrapper.findAll(SelectMenu).length).toBe(3);
109 |
110 | optionsWrapper = wrapper.findAll(Option);
111 |
112 | // Change opened child menu
113 | optionsWrapper.at(0).trigger('mouseenter');
114 | wrapper.vm.$nextTick(() => {
115 | // Grand child menu shouldn't be rendered
116 | expect(wrapper.findAll(SelectMenu).length).toBe(2);
117 | done();
118 | });
119 | });
120 | });
121 | });
122 | });
123 |
124 |
125 | describe('should be able to navigate with keyboard', () => {
126 | let wrapper;
127 | beforeEach(() => {
128 | wrapper = mountWithProps({ options, withKeyboard: true });
129 | });
130 |
131 | it('should open and focus first option', () => {
132 | const optionsWrapper = wrapper.findAll(Option);
133 | expect(optionsWrapper.at(0).element).toBe(document.activeElement);
134 | });
135 |
136 | it('should go to next option', (done) => {
137 | const optionsWrapper = wrapper.findAll(Option);
138 |
139 | optionsWrapper.at(0).trigger('keydown.down');
140 | wrapper.vm.$nextTick(() => {
141 | expect(optionsWrapper.at(1).element).toBe(document.activeElement);
142 | done();
143 | });
144 | });
145 |
146 | it('should go to previous option', (done) => {
147 | const optionsWrapper = wrapper.findAll(Option);
148 |
149 | optionsWrapper.at(0).trigger('keydown.down');
150 | wrapper.vm.$nextTick(() => {
151 | optionsWrapper.at(1).trigger('keydown.up');
152 | wrapper.vm.$nextTick(() => {
153 | expect(optionsWrapper.at(0).element).toBe(document.activeElement);
154 | done();
155 | });
156 | });
157 | });
158 |
159 | it('should open next menu', (done) => {
160 | const option = wrapper.find(Option);
161 |
162 | option.trigger('keydown.right');
163 | wrapper.vm.$nextTick(() => {
164 | expect(wrapper.findAll(SelectMenu).length).toBe(2);
165 | done();
166 | });
167 | });
168 |
169 | it('should close next menu', (done) => {
170 | const option = wrapper.find(Option);
171 |
172 | option.trigger('keydown.right');
173 | wrapper.vm.$nextTick(() => {
174 | const nextMenu = wrapper.findAll(SelectMenu).at(1);
175 |
176 | nextMenu.find(Option).trigger('keydown.left');
177 | wrapper.vm.$nextTick(() => {
178 | expect(wrapper.findAll(SelectMenu).length).toBe(1);
179 | wrapper.find(SelectMenu).find(Option).trigger('keydown.left');
180 | done();
181 | });
182 | });
183 | });
184 |
185 | it('should go back to first option', (done) => {
186 | const optionsWrapper = wrapper.findAll(Option);
187 |
188 | optionsWrapper.at(0).trigger('keydown.down');
189 | wrapper.vm.$nextTick(() => {
190 | expect(optionsWrapper.at(1).element).toBe(document.activeElement);
191 | optionsWrapper.at(1).trigger('keydown.down');
192 |
193 | wrapper.vm.$nextTick(() => {
194 | expect(optionsWrapper.at(2).element).toBe(document.activeElement);
195 | optionsWrapper.at(2).trigger('keydown.down');
196 |
197 | wrapper.vm.$nextTick(() => {
198 | expect(optionsWrapper.at(0).element).toBe(document.activeElement);
199 | done();
200 | });
201 | });
202 | });
203 | });
204 |
205 | it('should go back the last option', (done) => {
206 | const optionsWrapper = wrapper.findAll(Option);
207 |
208 | optionsWrapper.at(0).trigger('keydown.up');
209 | wrapper.vm.$nextTick(() => {
210 | expect(optionsWrapper.at(2).element).toBe(document.activeElement);
211 | done();
212 | });
213 | });
214 |
215 | it('should close childMenu', (done) => {
216 | wrapper.vm.$nextTick(() => {
217 | let optionsWrapper = wrapper.findAll(Option);
218 | expect(optionsWrapper.length).toBe(3);
219 |
220 | optionsWrapper.at(2).trigger('keydown.right');
221 | wrapper.vm.$nextTick(() => {
222 | expect(wrapper.findAll(SelectMenu).length).toBe(2);
223 |
224 | optionsWrapper = wrapper.findAll(Option);
225 | optionsWrapper.at(-1).trigger('keydown.right');
226 |
227 | wrapper.vm.$nextTick(() => {
228 | // Open grand child menu
229 | expect(wrapper.findAll(SelectMenu).length).toBe(3);
230 |
231 | optionsWrapper = wrapper.findAll(Option);
232 |
233 | // Change opened child menu
234 | optionsWrapper.at(0).trigger('keydown.right');
235 | wrapper.vm.$nextTick(() => {
236 | // Grand child menu shouldn't be rendered
237 | expect(wrapper.findAll(SelectMenu).length).toBe(2);
238 | done();
239 | });
240 | });
241 | });
242 | });
243 | });
244 | });
245 | });
246 |
--------------------------------------------------------------------------------
/__tests__/VueCascaderSelect.spec.js:
--------------------------------------------------------------------------------
1 | import { mount } from '@vue/test-utils';
2 | import Arrow from '@/components/Arrow.vue';
3 | import Option from '@/components/Option.vue';
4 | import SelectMenu from '@/components/SelectMenu.vue';
5 | import VueCascaderSelect from '@/VueCascaderSelect.vue';
6 |
7 | const options = [
8 | {
9 | label: 'Sibling 1',
10 | value: 'sibling1',
11 | options: [
12 | {
13 | label: 'Sibling 1 - Child 1',
14 | value: 'sibling1-child1',
15 | },
16 | ],
17 | },
18 | {
19 | label: 'Sibling 2',
20 | value: 'sibling2',
21 | },
22 | {
23 | label: 'Sibling 3',
24 | value: 'sibling3',
25 | options: [
26 | {
27 | label: 'Sibling 3 - Child 1',
28 | value: 'sibling3-child1',
29 | options: [
30 | {
31 | label: 'Sibling 3 - Grand Child 1',
32 | value: 'sibling3-grand-child1',
33 | },
34 | ],
35 | },
36 | ],
37 | },
38 | ];
39 |
40 | const defaultProps = {
41 | options,
42 | value: '',
43 | };
44 |
45 | const mountWithProps = props => (
46 | mount(VueCascaderSelect, {
47 | propsData: {
48 | ...defaultProps,
49 | ...props,
50 | },
51 | })
52 | );
53 |
54 | describe('VueCascaderSelect.vue', () => {
55 | let wrapper;
56 | beforeEach(() => {
57 | wrapper = mountWithProps({});
58 | });
59 |
60 | it('renders', () => {
61 | expect(wrapper.exists()).toBe(true);
62 | });
63 |
64 | it('should have one Arrow pointing down', (done) => {
65 | const arrows = wrapper.findAll(Arrow);
66 |
67 | expect(arrows.length).toBe(1);
68 |
69 | const arrow = arrows.at(0);
70 | expect(arrow.classes('down')).toBe(true);
71 | done();
72 | });
73 |
74 | it('should have an input', () => {
75 | expect(wrapper.contains('input')).toBe(true);
76 | });
77 |
78 | it('the input should contain the default placeholder', () => {
79 | const input = wrapper.find('input');
80 | expect(input.attributes('placeholder'))
81 | .toBe(VueCascaderSelect.props.placeholder.default);
82 | });
83 |
84 | it('the input should contain the placeholder passed as a prop', (done) => {
85 | const placeholder = 'Custom placeholder';
86 | wrapper.setProps({ placeholder });
87 |
88 | wrapper.vm.$nextTick(() => {
89 | const input = wrapper.find('input');
90 | expect(input.attributes('placeholder')).toBe(placeholder);
91 | done();
92 | });
93 | });
94 |
95 | it('triggering the menu should make to Arrow point up', (done) => {
96 | wrapper.find('.vcs__picker').trigger('click');
97 |
98 | wrapper.vm.$nextTick(() => {
99 | const arrow = wrapper.find(Arrow);
100 | expect(arrow.classes('up')).toBe(true);
101 | done();
102 | });
103 | });
104 |
105 | it('triggering the menu should open the SelectMenu', (done) => {
106 | expect(wrapper.contains(SelectMenu)).toBe(false);
107 |
108 | wrapper.find('.vcs__picker').trigger('click');
109 |
110 | wrapper.vm.$nextTick(() => {
111 | expect(wrapper.contains(SelectMenu)).toBe(true);
112 | done();
113 | });
114 | });
115 |
116 | it('pressing escape should close the SelectMenu', (done) => {
117 | wrapper.find('.vcs__picker').trigger('click');
118 |
119 | wrapper.vm.$nextTick(() => {
120 | expect(wrapper.contains(SelectMenu)).toBe(true);
121 |
122 | wrapper.trigger('keydown.esc');
123 | wrapper.vm.$nextTick(() => {
124 | expect(wrapper.contains(SelectMenu)).toBe(false);
125 | done();
126 | });
127 | });
128 | });
129 |
130 | it('changing the value should close the SelectMenu', (done) => {
131 | wrapper.find('.vcs__picker').trigger('click');
132 |
133 | wrapper.vm.$nextTick(() => {
134 | wrapper.setProps({ value: 'test' });
135 |
136 | wrapper.vm.$nextTick(() => {
137 | expect(wrapper.contains(SelectMenu)).toBe(false);
138 | done();
139 | });
140 | });
141 | });
142 |
143 | it('shouldn\'t have a cross', () => {
144 | expect(wrapper.classes('vcs__cross')).toBe(false);
145 | });
146 |
147 | it('shoud have a cross if there\'s a value', (done) => {
148 | wrapper.setProps({ value: 'test' });
149 |
150 | wrapper.vm.$nextTick(() => {
151 | const cross = wrapper.find('.vcs__cross');
152 | expect(cross.contains('button')).toBe(true);
153 | expect(cross.find('button').text()).toBe('×');
154 | done();
155 | });
156 | });
157 |
158 | it('the cross should emit a "clear" event', (done) => {
159 | wrapper.setProps({ value: 'test' });
160 |
161 | wrapper.vm.$nextTick(() => {
162 | const cross = wrapper.find('.vcs__cross');
163 | cross.find('button').trigger('click');
164 |
165 | wrapper.vm.$nextTick(() => {
166 | expect(wrapper.emitted('clear')).toBeTruthy();
167 | expect(wrapper.emitted('clear').length).toBe(1);
168 | done();
169 | });
170 | });
171 | });
172 |
173 | it('selecting should emit a "select" event', (done) => {
174 | wrapper.find('.vcs__picker').trigger('click');
175 |
176 | wrapper.vm.$nextTick(() => {
177 | const opts = wrapper.findAll(Option);
178 | opts.at(0).trigger('click');
179 |
180 | wrapper.vm.$nextTick(() => {
181 | expect(wrapper.emitted('select')).toBeTruthy();
182 | expect(wrapper.emitted('select').length).toBe(1);
183 | done();
184 | });
185 | });
186 | });
187 | });
188 |
--------------------------------------------------------------------------------
/__tests__/validators.spec.js:
--------------------------------------------------------------------------------
1 | import * as validators from '@/utils/validators';
2 |
3 | describe('validateOptions', () => {
4 | const { validateOptions } = validators;
5 |
6 | it('should return true for valid options', () => {
7 | const validOptions = [
8 | { label: 'Test1', value: 'test1' },
9 | {
10 | label: 'Test2',
11 | value: 'test2',
12 | options: [
13 | { label: 'Test3', value: 'test3' },
14 | ],
15 | },
16 | ];
17 |
18 | expect(validateOptions(validOptions)).toBe(true);
19 | });
20 |
21 | it('should return false for invalid options', (done) => {
22 | let invalidOptions = [{ label: 'Test1' }];
23 | expect(validateOptions(invalidOptions)).toBe(false);
24 |
25 | invalidOptions = [{ value: 'test1' }];
26 | expect(validateOptions(invalidOptions)).toBe(false);
27 |
28 | invalidOptions = [{ label: 1 }];
29 | expect(validateOptions(invalidOptions)).toBe(false);
30 |
31 | invalidOptions = [{ label: 'Test1', options: 'options' }];
32 | expect(validateOptions(invalidOptions)).toBe(false);
33 |
34 | invalidOptions = [
35 | { label: 'Test1', value: 'test1' },
36 | {
37 | label: 'Test2',
38 | options: [
39 | { label: 'Test3' },
40 | ],
41 | },
42 | ];
43 | expect(validateOptions(invalidOptions)).toBe(false);
44 | done();
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | '@babel/preset-env',
4 | ],
5 | };
6 |
--------------------------------------------------------------------------------
/build/rollup.config.js:
--------------------------------------------------------------------------------
1 | // rollup.config.js
2 | import fs from 'fs';
3 | import path from 'path';
4 | import vue from 'rollup-plugin-vue';
5 | import alias from '@rollup/plugin-alias';
6 | import commonjs from '@rollup/plugin-commonjs';
7 | import replace from '@rollup/plugin-replace';
8 | import babel from 'rollup-plugin-babel';
9 | import { terser } from 'rollup-plugin-terser';
10 | import minimist from 'minimist';
11 |
12 | // Get browserslist config and remove ie from es build targets
13 | const esbrowserslist = fs.readFileSync('./.browserslistrc')
14 | .toString()
15 | .split('\n')
16 | .filter((entry) => entry && entry.substring(0, 2) !== 'ie');
17 |
18 | const argv = minimist(process.argv.slice(2));
19 |
20 | const projectRoot = path.resolve(__dirname, '..');
21 |
22 | const baseConfig = {
23 | input: 'src/entry.js',
24 | plugins: {
25 | preVue: [
26 | replace({
27 | 'process.env.NODE_ENV': JSON.stringify('production'),
28 | }),
29 | alias({
30 | resolve: ['.js', '.jsx', '.ts', '.tsx', '.vue'],
31 | entries: {
32 | '@': path.resolve(projectRoot, 'src'),
33 | },
34 | }),
35 | ],
36 | vue: {
37 | css: true,
38 | template: {
39 | isProduction: true,
40 | },
41 | },
42 | babel: {
43 | exclude: 'node_modules/**',
44 | extensions: ['.js', '.jsx', '.ts', '.tsx', '.vue'],
45 | },
46 | },
47 | };
48 |
49 | // ESM/UMD/IIFE shared settings: externals
50 | // Refer to https://rollupjs.org/guide/en/#warning-treating-module-as-external-dependency
51 | const external = [
52 | // list external dependencies, exactly the way it is written in the import statement.
53 | // eg. 'jquery'
54 | 'vue',
55 | ];
56 |
57 | // UMD/IIFE shared settings: output.globals
58 | // Refer to https://rollupjs.org/guide/en#output-globals for details
59 | const globals = {
60 | // Provide global variable names to replace your external imports
61 | // eg. jquery: '$'
62 | vue: 'Vue',
63 | };
64 |
65 | // Customize configs for individual targets
66 | const buildFormats = [];
67 | if (!argv.format || argv.format === 'es') {
68 | const esConfig = {
69 | ...baseConfig,
70 | external,
71 | output: {
72 | file: 'dist/vue-cascader-select.esm.js',
73 | format: 'esm',
74 | exports: 'named',
75 | },
76 | plugins: [
77 | ...baseConfig.plugins.preVue,
78 | vue(baseConfig.plugins.vue),
79 | babel({
80 | ...baseConfig.plugins.babel,
81 | presets: [
82 | [
83 | '@babel/preset-env',
84 | {
85 | targets: esbrowserslist,
86 | },
87 | ],
88 | ],
89 | }),
90 | commonjs(),
91 | ],
92 | };
93 | buildFormats.push(esConfig);
94 | }
95 |
96 | if (!argv.format || argv.format === 'cjs') {
97 | const umdConfig = {
98 | ...baseConfig,
99 | external,
100 | output: {
101 | compact: true,
102 | file: 'dist/vue-cascader-select.ssr.js',
103 | format: 'cjs',
104 | name: 'VueCascaderSelect',
105 | exports: 'named',
106 | globals,
107 | },
108 | plugins: [
109 | ...baseConfig.plugins.preVue,
110 | vue({
111 | ...baseConfig.plugins.vue,
112 | template: {
113 | ...baseConfig.plugins.vue.template,
114 | optimizeSSR: true,
115 | },
116 | }),
117 | babel(baseConfig.plugins.babel),
118 | commonjs(),
119 | ],
120 | };
121 | buildFormats.push(umdConfig);
122 | }
123 |
124 | if (!argv.format || argv.format === 'iife') {
125 | const unpkgConfig = {
126 | ...baseConfig,
127 | external,
128 | output: {
129 | compact: true,
130 | file: 'dist/vue-cascader-select.min.js',
131 | format: 'iife',
132 | name: 'VueCascaderSelect',
133 | exports: 'named',
134 | globals,
135 | },
136 | plugins: [
137 | ...baseConfig.plugins.preVue,
138 | vue(baseConfig.plugins.vue),
139 | babel(baseConfig.plugins.babel),
140 | commonjs(),
141 | terser({
142 | output: {
143 | ecma: 5,
144 | },
145 | }),
146 | ],
147 | };
148 | buildFormats.push(unpkgConfig);
149 | }
150 |
151 | // Export config
152 | export default buildFormats;
153 |
--------------------------------------------------------------------------------
/docs/.vuepress/components/TheFooter.vue:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
16 |
17 |
--------------------------------------------------------------------------------
/docs/.vuepress/components/VCSBasic.vue:
--------------------------------------------------------------------------------
1 |
2 |