├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ ├── cypress.yml │ └── jest.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── __tests__ ├── cypress │ ├── e2e │ │ ├── appendToBody │ │ │ └── index.cy.ts │ │ ├── base │ │ │ ├── check-uncheck-group.cy.ts │ │ │ ├── direction.cy.ts │ │ │ ├── keyboard.cy.ts │ │ │ ├── open-close-group.cy.ts │ │ │ ├── open-close-list.cy.ts │ │ │ ├── remove-values.cy.ts │ │ │ ├── search.cy.ts │ │ │ └── tags-values.cy.ts │ │ ├── disabled │ │ │ └── index.cy.ts │ │ ├── experemental │ │ │ └── is-boost-rendering-list.cy.ts │ │ ├── large-data │ │ │ └── save-scroll.cy.ts │ │ ├── single │ │ │ └── select-value.cy.ts │ │ └── slot │ │ │ └── index.cy.ts │ ├── helpers │ │ └── index.ts │ ├── pages │ │ ├── cypress-append-to-body.html │ │ ├── cypress-base.html │ │ ├── cypress-boost-rendering.html │ │ ├── cypress-disabled.html │ │ ├── cypress-large-data.html │ │ ├── cypress-single.html │ │ ├── cypress-slot.html │ │ └── runs.js │ └── support │ │ ├── commands.ts │ │ └── e2e.ts ├── jest │ ├── helpers │ │ ├── getElements.ts │ │ ├── index.ts │ │ └── renderTreeselect.ts │ ├── setup.ts │ └── tests │ │ ├── core-props │ │ ├── __snapshots__ │ │ │ ├── disabled.test.ts.snap │ │ │ ├── isGroupedValue.test.ts.snap │ │ │ ├── isIndependentNodes.test.ts.snap │ │ │ ├── isSingleSelect.test.ts.snap │ │ │ ├── options.test.ts.snap │ │ │ ├── parentHtmlContainer.test.ts.snap │ │ │ ├── rtl.test.ts.snap │ │ │ └── value.test.ts.snap │ │ ├── ariaLabel.test.ts │ │ ├── disabled.test.ts │ │ ├── id.test.ts │ │ ├── isGroupedValue.test.ts │ │ ├── isIndependentNodes.test.ts │ │ ├── isSingleSelect.test.ts │ │ ├── options.test.ts │ │ ├── parentHtmlContainer.test.ts │ │ ├── rtl.test.ts │ │ └── value.test.ts │ │ ├── input-props │ │ ├── __snapshots__ │ │ │ ├── grouped.test.ts.snap │ │ │ ├── searchable.test.ts.snap │ │ │ └── showTags.test.ts.snap │ │ ├── clearable.test.ts │ │ ├── grouped.test.ts │ │ ├── placeholder.test.ts │ │ ├── searchable.test.ts │ │ ├── showTags.test.ts │ │ ├── tagsCountText.test.ts │ │ └── tagsSortFn.test.ts │ │ ├── list-props │ │ ├── __snapshots__ │ │ │ ├── alwaysOpen.test.ts.snap │ │ │ ├── appendToBody.test.ts.snap │ │ │ ├── direction.test.ts.snap │ │ │ ├── disabledBranchNode.test.ts.snap │ │ │ ├── emptyText.test.ts.snap │ │ │ ├── expandSelected.test.ts.snap │ │ │ ├── listSlotHtmlComponent.test.ts.snap │ │ │ ├── openLevel.test.ts.snap │ │ │ ├── showCount.test.ts.snap │ │ │ └── stasticList.test.ts.snap │ │ ├── alwaysOpen.test.ts │ │ ├── appendToBody.test.ts │ │ ├── direction.test.ts │ │ ├── disabledBranchNode.test.ts │ │ ├── emptyText.test.ts │ │ ├── expandSelected.test.ts │ │ ├── listClassName.test.ts │ │ ├── listSlotHtmlComponent.test.ts │ │ ├── openLevel.test.ts │ │ ├── showCount.test.ts │ │ └── stasticList.test.ts │ │ └── methods │ │ ├── __snapshots__ │ │ └── focus.test.ts.snap │ │ ├── destroy.test.ts │ │ ├── emits-callbacks.test.ts │ │ ├── focus.test.ts │ │ ├── mount.test.ts │ │ ├── toggleOpenClose.test.ts │ │ └── updateValue.test.ts └── testHelpers │ ├── index.ts │ ├── options.ts │ └── selectors.ts ├── app ├── app.css ├── app.js ├── examples │ ├── default.js │ ├── disabled.js │ ├── icons.js │ ├── independentNodes.js │ ├── singleSelect.js │ └── slot.js ├── index.html ├── render │ └── renderExampleSection.js └── todo.js ├── cypress.config.ts ├── demo ├── index.css └── index.js ├── index.html ├── jest.config.ts ├── package-lock.json ├── package.json ├── public └── tree.png ├── src ├── input │ ├── index.ts │ ├── input.css │ └── inputTypes.ts ├── list │ ├── helpers │ │ ├── domHelper.ts │ │ ├── listCheckStateHelper.ts │ │ ├── listOptionsHelper.ts │ │ └── listVisibilityStateHelper.ts │ ├── index.ts │ ├── list.css │ └── listTypes.ts ├── svgIcons.ts ├── treeselectTypes.ts ├── treeselectjs.css ├── treeselectjs.ts └── vite-env.d.ts ├── treeselectjs.png ├── tsconfig.app.json ├── tsconfig.cypress.json ├── tsconfig.jest.json ├── tsconfig.json └── vite.config.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: ['https://www.buymeacoffee.com/dipson88'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build-test: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | # Checkout the repository 17 | - name: Checkout code 18 | uses: actions/checkout@v3 19 | 20 | # Set up Node.js 21 | - name: Set up Node.js 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: '20' 25 | 26 | # Install dependencies 27 | - name: Install dependencies 28 | run: npm install 29 | 30 | # Run build 31 | - name: Run build 32 | run: npm run build 33 | -------------------------------------------------------------------------------- /.github/workflows/cypress.yml: -------------------------------------------------------------------------------- 1 | name: Cypress Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | cypress-tests: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | # Checkout the repository 17 | - name: Checkout code 18 | uses: actions/checkout@v3 19 | 20 | # Set up Node.js 21 | - name: Set up Node.js 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: '20' 25 | 26 | # Install dependencies 27 | - name: Install dependencies 28 | run: npm install 29 | 30 | # Start the dev server in the background 31 | - name: Start dev server 32 | run: npm run dev & 33 | env: 34 | NODE_ENV: development 35 | 36 | # Run Cypress tests 37 | - name: Run Cypress tests 38 | run: npm run cypress:run 39 | -------------------------------------------------------------------------------- /.github/workflows/jest.yml: -------------------------------------------------------------------------------- 1 | name: Jest Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | pull_request: 8 | branches: 9 | - 'main' 10 | 11 | jobs: 12 | jest-tests: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | # Step 1: Checkout the repository 17 | - name: Checkout code 18 | uses: actions/checkout@v3 19 | 20 | # Step 2: Set up Node.js environment 21 | - name: Set up Node.js 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: '20' 25 | 26 | # Step 3: Install dependencies 27 | - name: Install dependencies 28 | run: npm install 29 | 30 | # Step 4: Run Jest tests 31 | - name: Run Jest tests 32 | run: npm run jest:run 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.vscode 3 | dist 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | demo/static 3 | dist 4 | *.yml 5 | README.md 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "semi": false 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Dzmitry Zhuraukou 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 | -------------------------------------------------------------------------------- /__tests__/cypress/e2e/appendToBody/index.cy.ts: -------------------------------------------------------------------------------- 1 | import { classesSelectors, classes } from '../../helpers' 2 | 3 | const { list: listSelectors } = classesSelectors 4 | const { list: litsClasses, input: inputClasses } = classes 5 | 6 | describe('appendToBody', () => { 7 | beforeEach(() => { 8 | cy.visitAppendToBodyPage() 9 | cy.clearClick() 10 | cy.treeselectClick() 11 | }) 12 | 13 | it('should render the list in the body', () => { 14 | cy.getTreeselect().find(listSelectors.base).should('not.exist') 15 | cy.get('body').find(listSelectors.base).should('exist') 16 | }) 17 | 18 | it('should change direction on change viewport', () => { 19 | cy.outsideClick() 20 | cy.get('body').invoke('css', 'padding-top', '400px').invoke('css', 'padding-bottom', '400px') 21 | cy.treeselectClick() 22 | cy.expandAllGroups() 23 | 24 | cy.scrollTo('top') 25 | 26 | cy.getList().should('have.class', litsClasses.topToBody) 27 | cy.getInput().should('have.class', inputClasses.top) 28 | 29 | cy.scrollTo('bottom') 30 | 31 | cy.getList().should('have.class', litsClasses.bottomToBody) 32 | cy.getInput().should('have.class', inputClasses.bottom) 33 | 34 | cy.getInput().then(($input) => { 35 | const inputRect = $input[0].getBoundingClientRect() 36 | 37 | cy.getList().then(($list) => { 38 | const listRect = $list[0].getBoundingClientRect() 39 | 40 | const distanceY = Math.abs(inputRect.bottom - listRect.top) 41 | const distanceX = Math.abs(inputRect.left - listRect.left) 42 | 43 | cy.log('distanceY', distanceY) 44 | cy.log('distanceX', distanceX) 45 | 46 | expect(distanceY).to.be.lessThan(1) 47 | expect(distanceX).to.be.lessThan(1) 48 | }) 49 | }) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /__tests__/cypress/e2e/base/check-uncheck-group.cy.ts: -------------------------------------------------------------------------------- 1 | import { classes, optionNames } from '../../helpers' 2 | 3 | const { list: listClass } = classes 4 | 5 | describe('check-uncheck-group', () => { 6 | beforeEach(() => { 7 | cy.visitBasePage() 8 | cy.clearClick() 9 | cy.treeselectClick() 10 | cy.expandAllGroups() 11 | }) 12 | 13 | // Without sub groups 14 | it('should check group on group check (without sub groups)', () => { 15 | cy.getListItem(optionNames.FranceGroup) 16 | .click() 17 | .should('have.class', listClass.itemChecked) 18 | .should('have.class', listClass.itemFocused) 19 | .should('not.have.class', listClass.itemPartialChecked) 20 | 21 | cy.getListItem(optionNames.ParisItem).should('have.class', listClass.itemChecked) 22 | cy.getListItem(optionNames.LyonItem).should('have.class', listClass.itemChecked) 23 | }) 24 | 25 | it('should uncheck group on group uncheck (without sub groups)', () => { 26 | cy.getListItem(optionNames.FranceGroup) 27 | .click() 28 | .should('have.class', listClass.itemChecked) 29 | .click() 30 | .should('not.have.class', listClass.itemChecked) 31 | 32 | cy.getListItem(optionNames.ParisItem).should('not.have.class', listClass.itemChecked) 33 | cy.getListItem(optionNames.LyonItem).should('not.have.class', listClass.itemChecked) 34 | }) 35 | 36 | it('should partially check group on group partial check (without sub groups)', () => { 37 | cy.getListItem(optionNames.ParisItem) 38 | .click() 39 | .should('have.class', listClass.itemFocused) 40 | .should('have.class', listClass.itemChecked) 41 | 42 | cy.getListItem(optionNames.FranceGroup).should('have.class', listClass.itemPartialChecked) 43 | }) 44 | 45 | it('should be partially checked when one item are unchecked', () => { 46 | cy.getListItem(optionNames.FranceGroup).click().should('have.class', listClass.itemChecked) 47 | 48 | cy.getListItem(optionNames.ParisItem).should('have.class', listClass.itemChecked) 49 | cy.getListItem(optionNames.LyonItem).should('have.class', listClass.itemChecked) 50 | 51 | cy.getListItem(optionNames.ParisItem).click().should('not.have.class', listClass.itemChecked) 52 | cy.getListItem(optionNames.FranceGroup).should('have.class', listClass.itemPartialChecked) 53 | }) 54 | 55 | // With sub groups 56 | it('should check group on group check (with sub groups)', () => { 57 | cy.getListItem(optionNames.EnglandGroup) 58 | .click() 59 | .should('have.class', listClass.itemFocused) 60 | .should('have.class', listClass.itemChecked) 61 | 62 | cy.getListItem(optionNames.LondonGroup).should('have.class', listClass.itemChecked) 63 | cy.getListItem(optionNames.ChelseaItem).should('have.class', listClass.itemChecked) 64 | cy.getListItem(optionNames.WestEndItem).should('have.class', listClass.itemChecked) 65 | cy.getListItem(optionNames.BrightonItem).should('have.class', listClass.itemChecked) 66 | }) 67 | 68 | it('should uncheck group on group uncheck (with sub groups)', () => { 69 | cy.getListItem(optionNames.EnglandGroup) 70 | .click() 71 | .should('have.class', listClass.itemChecked) 72 | .click() 73 | .should('not.have.class', listClass.itemChecked) 74 | 75 | cy.getListItem(optionNames.LondonGroup).should('not.have.class', listClass.itemChecked) 76 | cy.getListItem(optionNames.ChelseaItem).should('not.have.class', listClass.itemChecked) 77 | cy.getListItem(optionNames.WestEndItem).should('not.have.class', listClass.itemChecked) 78 | cy.getListItem(optionNames.BrightonItem).should('not.have.class', listClass.itemChecked) 79 | }) 80 | 81 | it('should partially check group on group partial check (with sub groups)', () => { 82 | cy.getListItem(optionNames.LondonGroup) 83 | .click() 84 | .should('have.class', listClass.itemFocused) 85 | .should('have.class', listClass.itemChecked) 86 | 87 | cy.getListItem(optionNames.EnglandGroup).should('have.class', listClass.itemPartialChecked) 88 | }) 89 | 90 | it('should be partially checked when one item are unchecked (with sub groups)', () => { 91 | cy.getListItem(optionNames.EnglandGroup).click().should('have.class', listClass.itemChecked) 92 | 93 | cy.getListItem(optionNames.LondonGroup).click() 94 | cy.getListItem(optionNames.EnglandGroup).should('have.class', listClass.itemPartialChecked) 95 | }) 96 | 97 | it('should be partially checked when one sub group checked (with sub groups)', () => { 98 | cy.getListItem(optionNames.EnglandGroup).click().should('have.class', listClass.itemChecked) 99 | 100 | cy.getListItem(optionNames.BrightonItem).click() 101 | cy.getListItem(optionNames.EnglandGroup).should('have.class', listClass.itemPartialChecked) 102 | }) 103 | }) 104 | -------------------------------------------------------------------------------- /__tests__/cypress/e2e/base/direction.cy.ts: -------------------------------------------------------------------------------- 1 | import { classes } from '../../helpers' 2 | 3 | const { input: inputClass, list: listClass } = classes 4 | 5 | describe('direction', () => { 6 | beforeEach(() => { 7 | cy.visitBasePage() 8 | cy.clearClick() 9 | cy.treeselectClick() 10 | cy.expandAllGroups() 11 | }) 12 | 13 | afterEach(() => { 14 | cy.outsideClick() 15 | cy.get('body').invoke('css', 'padding-top', '0').invoke('css', 'padding-bottom', '0') 16 | }) 17 | 18 | it('should open list to the bottom if there is enough space', () => { 19 | cy.getList().should('have.class', listClass.bottom) 20 | }) 21 | 22 | it('should open list to the top if there is not enough space', () => { 23 | cy.get('body').invoke('css', 'padding-top', '400px').invoke('css', 'padding-bottom', '400px') 24 | cy.scrollTo('bottom') 25 | cy.getList().should('have.class', listClass.bottom) 26 | cy.getInput().should('have.class', inputClass.bottom) 27 | 28 | cy.scrollTo('top') 29 | cy.getList().should('have.class', listClass.top) 30 | cy.getInput().should('have.class', inputClass.top) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /__tests__/cypress/e2e/base/keyboard.cy.ts: -------------------------------------------------------------------------------- 1 | import { classes, classesSelectors, optionNames } from '../../helpers' 2 | 3 | const { input: inputClass, list: listClass } = classes 4 | const { input: inputSelector, list: listSelector } = classesSelectors 5 | 6 | describe('keyboard', () => { 7 | beforeEach(() => { 8 | cy.visitBasePage() 9 | cy.treeselectClick() 10 | }) 11 | 12 | it('should open/close list on Space key', () => { 13 | cy.get(inputSelector.base).should('have.class', inputClass.opened) 14 | cy.get(listSelector.base).should('exist') 15 | 16 | cy.treeselectType('{ }') 17 | cy.get(inputSelector.base).should('not.have.class', inputClass.opened) 18 | cy.get(listSelector.base).should('not.exist') 19 | 20 | cy.treeselectType('{ }') 21 | cy.get(inputSelector.base).should('have.class', inputClass.opened) 22 | cy.get(listSelector.base).should('exist') 23 | }) 24 | 25 | it('should remove values on Backspace key', () => { 26 | cy.getTagsElements().should('have.length', 2) 27 | 28 | cy.treeselectType('{backspace}') 29 | cy.getTagsElements().should('have.length', 1) 30 | 31 | cy.treeselectType('{backspace}') 32 | cy.getTagsElements().should('have.length', 0) 33 | 34 | cy.get(listSelector.itemChecked).should('have.length', 0) 35 | cy.get(listSelector.itemPartialChecked).should('have.length', 0) 36 | }) 37 | 38 | it('should select/unselect first item on Enter key', () => { 39 | cy.clearClick() 40 | 41 | cy.getListItem(optionNames.EnglandGroup).should('not.have.class', listClass.itemChecked) 42 | cy.getTagsElements().should('have.length', 0) 43 | 44 | cy.treeselectType('{enter}') 45 | cy.getListItem(optionNames.EnglandGroup).should('have.class', listClass.itemChecked) 46 | cy.getTagsElements().should('have.length', 1) 47 | 48 | cy.treeselectType('{enter}') 49 | cy.getListItem(optionNames.EnglandGroup).should('not.have.class', listClass.itemChecked) 50 | cy.getTagsElements().should('have.length', 0) 51 | }) 52 | 53 | it('should change group on ArrowDown/ArrowUp keys', () => { 54 | cy.clearClick() 55 | cy.treeselectType('{downarrow}') 56 | cy.getListItem(optionNames.FranceGroup).should('have.class', listClass.itemFocused) 57 | 58 | cy.treeselectType('{downarrow}') 59 | cy.getListItem(optionNames.EnglandGroup).should('have.class', listClass.itemFocused) 60 | 61 | cy.treeselectType('{uparrow}') 62 | cy.getListItem(optionNames.FranceGroup).should('have.class', listClass.itemFocused) 63 | 64 | cy.treeselectType('{uparrow}') 65 | cy.getListItem(optionNames.EnglandGroup).should('have.class', listClass.itemFocused) 66 | }) 67 | 68 | it('should open/close group on ArrowRight/ArrowLeft keys', () => { 69 | cy.clearClick() 70 | cy.getListItem(optionNames.LondonGroup).should('have.class', listClass.itemHidden) 71 | 72 | cy.treeselectType('{rightarrow}') 73 | cy.getListItem(optionNames.LondonGroup).should('not.have.class', listClass.itemHidden) 74 | 75 | cy.treeselectType('{leftarrow}') 76 | cy.getListItem(optionNames.ChelseaItem).should('have.class', listClass.itemHidden) 77 | }) 78 | 79 | it('should run full keyboard navigation', () => { 80 | cy.clearClick() 81 | 82 | cy.getListItem(optionNames.EnglandGroup).should('have.class', listClass.itemFocused) 83 | 84 | cy.treeselectType('{rightarrow}') 85 | cy.getListItem(optionNames.LondonGroup).should('not.have.class', listClass.itemHidden) 86 | cy.getListItem(optionNames.BrightonItem).should('not.have.class', listClass.itemHidden) 87 | 88 | cy.treeselectType('{downarrow}') 89 | cy.getListItem(optionNames.LondonGroup).should('have.class', listClass.itemFocused) 90 | 91 | cy.treeselectType('{rightarrow}') 92 | cy.getListItem(optionNames.ChelseaItem).should('not.have.class', listClass.itemHidden) 93 | cy.getListItem(optionNames.WestEndItem).should('not.have.class', listClass.itemHidden) 94 | 95 | cy.treeselectType('{downarrow}') 96 | cy.getListItem(optionNames.ChelseaItem).should('have.class', listClass.itemFocused) 97 | 98 | cy.treeselectType('{downarrow}') 99 | cy.getListItem(optionNames.WestEndItem).should('have.class', listClass.itemFocused) 100 | 101 | cy.treeselectType('{downarrow}') 102 | cy.getListItem(optionNames.BrightonItem).should('have.class', listClass.itemFocused) 103 | 104 | cy.treeselectType('{downarrow}') 105 | cy.getListItem(optionNames.FranceGroup).should('have.class', listClass.itemFocused) 106 | 107 | cy.treeselectType('{rightarrow}') 108 | cy.getListItem(optionNames.ParisItem).should('not.have.class', listClass.itemHidden) 109 | cy.getListItem(optionNames.LyonItem).should('not.have.class', listClass.itemHidden) 110 | 111 | cy.treeselectType('{leftarrow}') 112 | cy.getListItem(optionNames.ParisItem).should('have.class', listClass.itemHidden) 113 | cy.getListItem(optionNames.LyonItem).should('have.class', listClass.itemHidden) 114 | 115 | cy.treeselectType('{uparrow}') 116 | cy.getListItem(optionNames.BrightonItem).should('have.class', listClass.itemFocused) 117 | 118 | cy.treeselectType('{uparrow}') 119 | cy.getListItem(optionNames.WestEndItem).should('have.class', listClass.itemFocused) 120 | 121 | cy.treeselectType('{uparrow}') 122 | cy.getListItem(optionNames.ChelseaItem).should('have.class', listClass.itemFocused) 123 | 124 | cy.treeselectType('{uparrow}') 125 | cy.getListItem(optionNames.LondonGroup).should('have.class', listClass.itemFocused) 126 | 127 | cy.treeselectType('{leftarrow}') 128 | cy.getListItem(optionNames.ChelseaItem).should('have.class', listClass.itemHidden) 129 | cy.getListItem(optionNames.WestEndItem).should('have.class', listClass.itemHidden) 130 | 131 | cy.treeselectType('{uparrow}') 132 | cy.getListItem(optionNames.EnglandGroup).should('have.class', listClass.itemFocused) 133 | 134 | cy.treeselectType('{leftarrow}') 135 | cy.getListItem(optionNames.LondonGroup).should('have.class', listClass.itemHidden) 136 | }) 137 | }) 138 | -------------------------------------------------------------------------------- /__tests__/cypress/e2e/base/open-close-group.cy.ts: -------------------------------------------------------------------------------- 1 | import { classes, classesSelectors, optionNames } from '../../helpers' 2 | 3 | const { list: listClass } = classes 4 | const { list: listSelector } = classesSelectors 5 | 6 | describe('open-close-group', () => { 7 | beforeEach(() => { 8 | cy.visitBasePage() 9 | }) 10 | 11 | it('should groups can be opened and closed', () => { 12 | // Open list 13 | cy.treeselectClick() 14 | 15 | cy.getListItem(optionNames.EnglandGroup).should('not.have.class', listClass.itemHidden) 16 | cy.getListItem(optionNames.LondonGroup).should('have.class', listClass.itemHidden) 17 | cy.getListItem(optionNames.ChelseaItem).should('have.class', listClass.itemHidden) 18 | cy.getListItem(optionNames.WestEndItem).should('have.class', listClass.itemHidden) 19 | cy.getListItem(optionNames.BrightonItem).should('have.class', listClass.itemHidden) 20 | 21 | cy.getListItem(optionNames.FranceGroup).should('not.have.class', listClass.itemHidden) 22 | cy.getListItem(optionNames.ParisItem).should('have.class', listClass.itemHidden) 23 | cy.getListItem(optionNames.LyonItem).should('have.class', listClass.itemHidden) 24 | 25 | // Open on England group 26 | cy.getListItem(optionNames.EnglandGroup).find(listSelector.itemArrow).click() 27 | cy.getListItem(optionNames.LondonGroup).should('not.have.class', listClass.itemHidden) 28 | cy.getListItem(optionNames.ChelseaItem).should('have.class', listClass.itemHidden) 29 | cy.getListItem(optionNames.WestEndItem).should('have.class', listClass.itemHidden) 30 | cy.getListItem(optionNames.BrightonItem).should('have.not.class', listClass.itemHidden) 31 | 32 | // Open on France group 33 | cy.getListItem(optionNames.FranceGroup).find(listSelector.itemArrow).click() 34 | cy.getListItem(optionNames.ParisItem).should('have.not.class', listClass.itemHidden) 35 | cy.getListItem(optionNames.LyonItem).should('have.not.class', listClass.itemHidden) 36 | 37 | // Open on London group 38 | cy.getListItem(optionNames.LondonGroup).find(listSelector.itemArrow).click() 39 | cy.getListItem(optionNames.ChelseaItem).should('have.not.class', listClass.itemHidden) 40 | cy.getListItem(optionNames.WestEndItem).should('have.not.class', listClass.itemHidden) 41 | 42 | // Close groups 43 | 44 | // Close London group 45 | cy.getListItem(optionNames.LondonGroup).find(listSelector.itemArrow).click() 46 | cy.getListItem(optionNames.ChelseaItem).should('have.class', listClass.itemHidden) 47 | cy.getListItem(optionNames.WestEndItem).should('have.class', listClass.itemHidden) 48 | 49 | // Close France group 50 | cy.getListItem(optionNames.FranceGroup).find(listSelector.itemArrow).click() 51 | cy.getListItem(optionNames.ParisItem).should('have.class', listClass.itemHidden) 52 | cy.getListItem(optionNames.LyonItem).should('have.class', listClass.itemHidden) 53 | 54 | // Close England group 55 | cy.getListItem(optionNames.EnglandGroup).find(listSelector.itemArrow).click() 56 | cy.getListItem(optionNames.LondonGroup).should('have.class', listClass.itemHidden) 57 | cy.getListItem(optionNames.ChelseaItem).should('have.class', listClass.itemHidden) 58 | cy.getListItem(optionNames.WestEndItem).should('have.class', listClass.itemHidden) 59 | cy.getListItem(optionNames.BrightonItem).should('have.class', listClass.itemHidden) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /__tests__/cypress/e2e/base/open-close-list.cy.ts: -------------------------------------------------------------------------------- 1 | import { classes, classesSelectors } from '../../helpers' 2 | 3 | const { input: inputClass } = classes 4 | const { input: inputSelector, list: listSelector } = classesSelectors 5 | 6 | describe('open-close-list', () => { 7 | beforeEach(() => { 8 | cy.visitBasePage() 9 | }) 10 | 11 | it('should open list on click', () => { 12 | cy.treeselectClick() 13 | cy.get(inputSelector.base).should('have.class', inputClass.opened) 14 | cy.get(listSelector.base).should('exist') 15 | }) 16 | 17 | it('should open list on arrow icon click', () => { 18 | cy.inputArrowClick() 19 | cy.get(inputSelector.base).should('have.class', inputClass.opened) 20 | cy.get(listSelector.base).should('exist') 21 | }) 22 | 23 | it('should close list on arrow icon click', () => { 24 | cy.inputArrowClick() 25 | cy.get(inputSelector.base).should('have.class', inputClass.opened) 26 | cy.get(listSelector.base).should('exist') 27 | 28 | cy.inputArrowClick() 29 | cy.get(inputSelector.base).should('not.have.class', inputClass.opened) 30 | cy.get(listSelector.base).should('not.exist') 31 | }) 32 | 33 | it('should close list on click outside', () => { 34 | cy.treeselectClick() 35 | cy.get(inputSelector.base).should('have.class', inputClass.opened) 36 | cy.get(listSelector.base).should('exist') 37 | 38 | cy.outsideClick() 39 | cy.get(inputSelector.base).should('not.have.class', inputClass.opened) 40 | cy.get(listSelector.base).should('not.exist') 41 | }) 42 | 43 | it('should not be opened on focus', () => { 44 | cy.treeselectFocus() 45 | cy.get(inputSelector.base).should('have.class', inputClass.focused) 46 | cy.get(inputSelector.base).should('not.have.class', inputClass.opened) 47 | cy.get(listSelector.base).should('not.exist') 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /__tests__/cypress/e2e/base/remove-values.cy.ts: -------------------------------------------------------------------------------- 1 | import { classesSelectors } from '../../helpers' 2 | 3 | const { parent: parentSelector, list: listSelector } = classesSelectors 4 | 5 | describe('remove-values', () => { 6 | beforeEach(() => { 7 | cy.visitBasePage() 8 | }) 9 | 10 | it('should remove values on clear click', () => { 11 | cy.treeselectClick() 12 | cy.clearClick() 13 | 14 | cy.get(parentSelector).find(listSelector.itemChecked).should('not.exist') 15 | cy.get(parentSelector).find(listSelector.itemPartialChecked).should('not.exist') 16 | }) 17 | 18 | it('should remove values on tag click', () => { 19 | cy.treeselectClick() 20 | cy.getTagsElements().should('have.length', 2) 21 | 22 | cy.getTagsElements().first().click() 23 | cy.getTagsElements().should('have.length', 1) 24 | }) 25 | 26 | it('should not open list on clear click', () => { 27 | cy.treeselectFocus() 28 | cy.clearClick() 29 | 30 | cy.get(parentSelector).find(listSelector.base).should('not.exist') 31 | }) 32 | 33 | it('should not open list on Backspace key', () => { 34 | cy.treeselectFocus() 35 | cy.treeselectType('{backspace}') 36 | 37 | cy.get(parentSelector).find(listSelector.base).should('not.exist') 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /__tests__/cypress/e2e/base/search.cy.ts: -------------------------------------------------------------------------------- 1 | import { classes, classesSelectors, optionNames } from '../../helpers' 2 | 3 | const { list: listClass } = classes 4 | const { list: listSelectors } = classesSelectors 5 | 6 | describe('check-uncheck-group', () => { 7 | beforeEach(() => { 8 | cy.visitBasePage() 9 | cy.clearClick() 10 | cy.treeselectClick() 11 | }) 12 | 13 | it('should open list on search', () => { 14 | cy.outsideClick() 15 | cy.getList().should('not.exist') 16 | 17 | cy.treeselectType('Paris') 18 | cy.getList().should('exist') 19 | }) 20 | 21 | it('should filter and show items on search', () => { 22 | cy.treeselectType('Chel') 23 | 24 | cy.getListItem(optionNames.EnglandGroup).should('not.have.class', listClass.itemHidden) 25 | cy.getListItem(optionNames.LondonGroup).should('not.have.class', listClass.itemHidden) 26 | cy.getListItem(optionNames.ChelseaItem).should('not.have.class', listClass.itemHidden) 27 | 28 | cy.getListItem(optionNames.WestEndItem).should('have.class', listClass.itemHidden) 29 | cy.getListItem(optionNames.BrightonItem).should('have.class', listClass.itemHidden) 30 | cy.getListItem(optionNames.FranceGroup).should('have.class', listClass.itemHidden) 31 | 32 | cy.getInput().find('input').should('have.value', 'Chel') 33 | }) 34 | 35 | it('should clear search on blur', () => { 36 | cy.treeselectType('Paris') 37 | 38 | cy.getListItem(optionNames.ParisItem).should('not.have.class', listClass.itemHidden) 39 | cy.outsideClick() 40 | cy.getInput().find('input').should('have.value', '') 41 | }) 42 | 43 | it('should show no results message', () => { 44 | cy.get(listSelectors.empty).should('not.be.visible') 45 | cy.treeselectType('No results') 46 | cy.get(listSelectors.empty).should('be.visible') 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /__tests__/cypress/e2e/base/tags-values.cy.ts: -------------------------------------------------------------------------------- 1 | import { optionNames } from '../../helpers' 2 | 3 | describe('input-tags-values', () => { 4 | beforeEach(() => { 5 | cy.visitBasePage() 6 | cy.clearClick() 7 | cy.treeselectClick() 8 | cy.expandAllGroups() 9 | }) 10 | 11 | it('should show group tag on group click', () => { 12 | cy.getListItem(optionNames.EnglandGroup).click() 13 | cy.getTagsElements().should('have.length', 1).should('contain', 'England') 14 | }) 15 | 16 | it('should show item tag on item click', () => { 17 | cy.getListItem(optionNames.BrightonItem).click() 18 | cy.getTagsElements().should('have.length', 1).should('contain', 'Brighton') 19 | }) 20 | 21 | it('should show sub group tag on sub group click', () => { 22 | cy.getListItem(optionNames.LondonGroup).click() 23 | cy.getTagsElements().should('have.length', 1).should('contain', 'London') 24 | }) 25 | 26 | it('should show all tags on groups click', () => { 27 | cy.getListItem(optionNames.EnglandGroup).click() 28 | cy.getListItem(optionNames.FranceGroup).click() 29 | cy.getTagsElements().should('have.length', 2).should('contain', 'England').should('contain', 'France') 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /__tests__/cypress/e2e/disabled/index.cy.ts: -------------------------------------------------------------------------------- 1 | import { classes, optionNames } from '../../helpers' 2 | 3 | const { list: listClasses } = classes 4 | 5 | describe('select-value', () => { 6 | beforeEach(() => { 7 | cy.visitDisabledPage() 8 | cy.clearClick() 9 | cy.treeselectClick() 10 | cy.expandAllGroups() 11 | }) 12 | 13 | it('should render disabled elements', () => { 14 | cy.getListItem(optionNames.ChelseaItem).should('have.class', listClasses.itemDisabled) 15 | cy.getListItem(optionNames.FranceGroup).should('have.class', listClasses.itemDisabled) 16 | cy.getListItem(optionNames.ParisItem).should('have.class', listClasses.itemDisabled) 17 | cy.getListItem(optionNames.LyonItem).should('have.class', listClasses.itemDisabled) 18 | }) 19 | 20 | it('should not select disabled element on group click', () => { 21 | cy.getListItem(optionNames.EnglandGroup).click() 22 | cy.getInput().should('contain', 'Brighton').should('contain', 'West End').should('not.contain', 'Chelsea') 23 | }) 24 | 25 | it('should not select disabled element on item click', () => { 26 | cy.getListItem(optionNames.ChelseaItem).click().should('not.have.class', listClasses.itemChecked) 27 | cy.getInput().should('not.contain', 'Chelsea') 28 | }) 29 | 30 | it('should not select disabled element on group click', () => { 31 | cy.getListItem(optionNames.FranceGroup).click() 32 | cy.getInput().should('not.contain', 'France').should('not.contain', 'Paris').should('not.contain', 'Lyon') 33 | }) 34 | 35 | it('should not select disabled element on Enter key', () => { 36 | cy.getListItem(optionNames.ChelseaItem).trigger('mouseover').should('have.class', listClasses.itemFocused) 37 | 38 | cy.treeselectType('{enter}') 39 | 40 | cy.getInput().should('not.contain', 'Chelsea') 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /__tests__/cypress/e2e/experemental/is-boost-rendering-list.cy.ts: -------------------------------------------------------------------------------- 1 | import { classes, classesSelectors } from '../../helpers' 2 | 3 | const { list: listClass } = classes 4 | const { list: listSelector } = classesSelectors 5 | 6 | const optionsVisibility = { 7 | visible: ['Option 495', 'Option 504'], 8 | hidden: ['Option 494', 'Option 505'] 9 | } 10 | 11 | const visibleSubOptions = ['SubOption 495-0', 'SubOption 495-1', 'SubOption 495-2'] 12 | 13 | describe('is-boost-rendering-list', () => { 14 | beforeEach(() => { 15 | cy.visitBoostRenderingPage() 16 | cy.treeselectClick() 17 | }) 18 | 19 | it('should render list with boost rendering', () => { 20 | cy.treeselectClick() 21 | cy.getList().scrollTo('center') 22 | 23 | optionsVisibility.visible.forEach((option) => { 24 | cy.getListItem(option).should('not.have.class', listClass.itemScrollNotVisible) 25 | }) 26 | 27 | optionsVisibility.hidden.forEach((option) => { 28 | cy.getListItem(option).should('have.class', listClass.itemScrollNotVisible) 29 | }) 30 | }) 31 | 32 | it('should work correctly with search', () => { 33 | cy.treeselectClick() 34 | cy.getList().scrollTo('center') 35 | 36 | const [firstVisibleOption] = optionsVisibility.visible 37 | 38 | cy.getListItem(firstVisibleOption).find(listSelector.itemArrow).click() 39 | 40 | const lastVisibleOption = optionsVisibility.visible.at(-1) ?? '' 41 | cy.getListItem(lastVisibleOption).should('have.class', listClass.itemScrollNotVisible) 42 | 43 | visibleSubOptions.forEach((subOption) => { 44 | cy.getListItem(subOption).should('not.have.class', listClass.itemScrollNotVisible) 45 | cy.getListItem(subOption).should('not.have.class', listClass.itemHidden) 46 | }) 47 | 48 | cy.getListItem(firstVisibleOption).click() 49 | cy.getListItem(firstVisibleOption).should('have.class', listClass.itemChecked) 50 | 51 | visibleSubOptions.forEach((subOption) => { 52 | cy.getListItem(subOption).should('have.class', listClass.itemChecked) 53 | }) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /__tests__/cypress/e2e/large-data/save-scroll.cy.ts: -------------------------------------------------------------------------------- 1 | describe('save-scroll', () => { 2 | beforeEach(() => { 3 | cy.visitLargeDataPage() 4 | cy.treeselectClick() 5 | }) 6 | 7 | it('should render large data', () => { 8 | cy.getList().should('exist') 9 | }) 10 | 11 | it('should save scroll position', async () => { 12 | const middleSelector = '[title="Option 25"]' 13 | 14 | cy.getList().scrollTo('center') 15 | cy.get(middleSelector).should('be.visible') 16 | 17 | cy.wait(100) 18 | cy.outsideClick() 19 | cy.wait(100) 20 | 21 | cy.treeselectClick() 22 | cy.get(middleSelector).should('be.visible') 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /__tests__/cypress/e2e/single/select-value.cy.ts: -------------------------------------------------------------------------------- 1 | import { optionNames } from '../../helpers' 2 | 3 | describe('select-value', () => { 4 | beforeEach(() => { 5 | cy.visitSinglePage() 6 | cy.clearClick() 7 | cy.treeselectClick() 8 | cy.expandAllGroups() 9 | }) 10 | 11 | it('should select one element on click', () => { 12 | cy.getListItem(optionNames.BrightonItem).click() 13 | cy.getInput().should('contain', 'Brighton') 14 | }) 15 | 16 | it('should not unselect selected element on click', () => { 17 | cy.getListItem(optionNames.BrightonItem).click() 18 | cy.getListItem(optionNames.BrightonItem).click() 19 | cy.getInput().should('contain', 'Brighton') 20 | }) 21 | 22 | it('should select group on click', () => { 23 | cy.getListItem(optionNames.LondonGroup).click() 24 | cy.getInput().should('contain', 'London') 25 | }) 26 | 27 | it('should close list on select', () => { 28 | cy.getListItem(optionNames.BrightonItem).click() 29 | cy.getList().should('not.exist') 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /__tests__/cypress/e2e/slot/index.cy.ts: -------------------------------------------------------------------------------- 1 | import { classesSelectors } from '../../helpers' 2 | 3 | const { list: listSelectors } = classesSelectors 4 | 5 | describe('slot', () => { 6 | beforeEach(() => { 7 | cy.visitSlotPage() 8 | cy.clearClick() 9 | cy.treeselectClick() 10 | }) 11 | 12 | it('should be opened after slot click', () => { 13 | cy.get('.treeselect-demo-slot__slot').should('exist') 14 | cy.get('.treeselect-demo-slot__slot').click() 15 | cy.get(listSelectors.base).should('exist') 16 | }) 17 | 18 | it('should be opened after btn slot click', () => { 19 | cy.get('.treeselect-demo-slot__slot-btn').should('exist') 20 | cy.get('.treeselect-demo-slot__slot-btn').click() 21 | cy.get(listSelectors.base).should('exist') 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /__tests__/cypress/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from '../../testHelpers' 2 | -------------------------------------------------------------------------------- /__tests__/cypress/pages/cypress-append-to-body.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Cypress Append To Body 8 | 9 | 10 | 11 | 12 |
13 |

Cypress Append To Body

14 |
15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /__tests__/cypress/pages/cypress-base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Cypress Base 8 | 9 | 10 | 11 | 12 |
13 |

Cypress Base

14 |
15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /__tests__/cypress/pages/cypress-boost-rendering.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Cypress Boost Rendering 8 | 9 | 10 | 11 | 12 |
13 |

Cypress Boost Rendering

14 |
15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /__tests__/cypress/pages/cypress-disabled.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Cypress Disabled 8 | 9 | 10 | 11 | 12 |
13 |

Cypress Disabled

14 |
15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /__tests__/cypress/pages/cypress-large-data.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Cypress Large Data 8 | 9 | 10 | 11 | 12 |
13 |

Cypress Large Data

14 |
15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /__tests__/cypress/pages/cypress-single.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Cypress Single 8 | 9 | 10 | 11 | 12 |
13 |

Cypress Single

14 |
15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /__tests__/cypress/pages/cypress-slot.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Cypress Single 8 | 9 | 10 | 11 | 12 |
13 |

Cypress Single

14 |
15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /__tests__/cypress/pages/runs.js: -------------------------------------------------------------------------------- 1 | import '../../../src/treeselectjs.css' 2 | import Treeselect from '../../../src/treeselectjs' 3 | import { defaultOptions, largeOptionsList, largeNestedOptionsList, optionsWithDisabled } from '../../testHelpers' 4 | 5 | export const runBaseTest = () => { 6 | const className = '.treeselect-demo-base' 7 | const domElement = document.querySelector(className) 8 | 9 | if (!domElement) { 10 | return 11 | } 12 | 13 | const treeselect = new Treeselect({ 14 | parentHtmlContainer: domElement, 15 | value: [4, 7, 8], 16 | options: defaultOptions 17 | }) 18 | } 19 | 20 | export const runSingleTest = () => { 21 | const className = '.treeselect-demo-single-select' 22 | const domElement = document.querySelector(className) 23 | 24 | if (!domElement) { 25 | return 26 | } 27 | 28 | const treeselect = new Treeselect({ 29 | parentHtmlContainer: domElement, 30 | value: 4, 31 | options: defaultOptions, 32 | isSingleSelect: true, 33 | showTags: false 34 | }) 35 | } 36 | 37 | export const runDisabledTest = () => { 38 | const className = '.treeselect-demo-disabled' 39 | const domElement = document.querySelector(className) 40 | 41 | if (!domElement) { 42 | return 43 | } 44 | 45 | const treeselect = new Treeselect({ 46 | parentHtmlContainer: domElement, 47 | value: [], 48 | options: optionsWithDisabled 49 | }) 50 | } 51 | 52 | export const runLargeDataTest = () => { 53 | const className = '.treeselect-demo-large-data' 54 | const domElement = document.querySelector(className) 55 | 56 | if (!domElement) { 57 | return 58 | } 59 | 60 | const treeselect = new Treeselect({ 61 | parentHtmlContainer: domElement, 62 | value: [], 63 | options: largeOptionsList, 64 | saveScrollPosition: true 65 | }) 66 | } 67 | 68 | export const runAppendedToBodyTest = () => { 69 | const className = '.treeselect-demo-appended-to-body' 70 | const domElement = document.querySelector(className) 71 | 72 | if (!domElement) { 73 | return 74 | } 75 | 76 | const treeselect = new Treeselect({ 77 | parentHtmlContainer: domElement, 78 | value: [], 79 | options: defaultOptions, 80 | appendToBody: true 81 | }) 82 | } 83 | 84 | export const runBoostRenderingTest = () => { 85 | const className = '.treeselect-demo-boost-rendering' 86 | const domElement = document.querySelector(className) 87 | 88 | if (!domElement) { 89 | return 90 | } 91 | 92 | const treeselect = new Treeselect({ 93 | parentHtmlContainer: domElement, 94 | value: [], 95 | options: largeNestedOptionsList, 96 | isBoostedRendering: true 97 | }) 98 | } 99 | 100 | export const runSlotTest = () => { 101 | const className = '.treeselect-demo-slot' 102 | const domElement = document.querySelector(className) 103 | 104 | if (!domElement) { 105 | return 106 | } 107 | 108 | const slot = document.createElement('div') 109 | slot.classList.add('treeselect-demo-slot__slot') 110 | const btn = document.createElement('button') 111 | btn.className = 'treeselect-demo-slot__slot-btn' 112 | btn.innerText = 'Click!' 113 | slot.appendChild(btn) 114 | 115 | const treeselect = new Treeselect({ 116 | parentHtmlContainer: domElement, 117 | value: [], 118 | options: defaultOptions, 119 | listSlotHtmlComponent: slot 120 | }) 121 | } 122 | 123 | runBaseTest() 124 | runSingleTest() 125 | runDisabledTest() 126 | runLargeDataTest() 127 | runAppendedToBodyTest() 128 | runBoostRenderingTest() 129 | runSlotTest() 130 | -------------------------------------------------------------------------------- /__tests__/cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | import { classesSelectors } from '../../testHelpers' 2 | 3 | const { parent: parentSelector, input: inputSelector, list: listSelector } = classesSelectors 4 | const baseUrl = 'http://localhost:5173/__tests__/cypress/pages' 5 | 6 | declare global { 7 | namespace Cypress { 8 | interface Chainable { 9 | // Visits 10 | visitBasePage(): void 11 | visitSinglePage(): void 12 | visitDisabledPage(): void 13 | visitLargeDataPage(): void 14 | visitAppendToBodyPage(): void 15 | visitBoostRenderingPage(): void 16 | visitSlotPage(): void 17 | 18 | // Actions 19 | treeselectClick(): void 20 | treeselectFocus(): void 21 | treeselectType(query: string): void 22 | inputArrowClick(): void 23 | outsideClick(): void 24 | clearClick(): void 25 | expandAllGroups(): void 26 | 27 | // Getters 28 | getTreeselect(): Chainable> 29 | getList(): Chainable> 30 | getListItem(selector: string): Chainable> 31 | getInput(): Chainable> 32 | getTagsElements(): Chainable> 33 | } 34 | } 35 | } 36 | 37 | // Visits 38 | Cypress.Commands.add('visitBasePage', () => { 39 | cy.visit(`${baseUrl}/cypress-base.html`) 40 | }) 41 | 42 | Cypress.Commands.add('visitSinglePage', () => { 43 | cy.visit(`${baseUrl}/cypress-single.html`) 44 | }) 45 | 46 | Cypress.Commands.add('visitDisabledPage', () => { 47 | cy.visit(`${baseUrl}/cypress-disabled.html`) 48 | }) 49 | 50 | Cypress.Commands.add('visitLargeDataPage', () => { 51 | cy.visit(`${baseUrl}/cypress-large-data.html`) 52 | }) 53 | 54 | Cypress.Commands.add('visitAppendToBodyPage', () => { 55 | cy.visit(`${baseUrl}/cypress-append-to-body.html`) 56 | }) 57 | 58 | Cypress.Commands.add('visitBoostRenderingPage', () => { 59 | cy.visit(`${baseUrl}/cypress-boost-rendering.html`) 60 | }) 61 | 62 | Cypress.Commands.add('visitSlotPage', () => { 63 | cy.visit(`${baseUrl}/cypress-slot.html`) 64 | }) 65 | 66 | // Actions 67 | Cypress.Commands.add('treeselectClick', () => { 68 | cy.get(parentSelector).click() 69 | }) 70 | 71 | Cypress.Commands.add('treeselectFocus', () => { 72 | cy.get(parentSelector).find('input').focus() 73 | }) 74 | 75 | Cypress.Commands.add('treeselectType', (query: string) => { 76 | cy.get(parentSelector).type(query) 77 | }) 78 | 79 | Cypress.Commands.add('inputArrowClick', () => { 80 | cy.get(inputSelector.arrow).click() 81 | }) 82 | 83 | Cypress.Commands.add('outsideClick', () => { 84 | cy.get('body').click(0, 0) 85 | }) 86 | 87 | Cypress.Commands.add('clearClick', () => { 88 | cy.get(inputSelector.clear).first().click() 89 | }) 90 | 91 | Cypress.Commands.add('expandAllGroups', () => { 92 | cy.get(listSelector.itemGroup).each(($group) => { 93 | cy.wrap($group).find(listSelector.itemArrow).click() 94 | }) 95 | }) 96 | 97 | // Getters 98 | Cypress.Commands.add('getTreeselect', () => { 99 | return cy.get(parentSelector) 100 | }) 101 | 102 | Cypress.Commands.add('getList', () => { 103 | return cy.get(listSelector.base) 104 | }) 105 | 106 | Cypress.Commands.add('getListItem', (optionName: string) => { 107 | return cy.get(`[title="${optionName}"]`) 108 | }) 109 | 110 | Cypress.Commands.add('getInput', () => { 111 | return cy.get(inputSelector.base) 112 | }) 113 | 114 | Cypress.Commands.add('getTagsElements', () => { 115 | return cy.get(inputSelector.tagsElement) 116 | }) 117 | -------------------------------------------------------------------------------- /__tests__/cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | import './commands' 2 | -------------------------------------------------------------------------------- /__tests__/jest/helpers/getElements.ts: -------------------------------------------------------------------------------- 1 | import { classesSelectors } from '../../testHelpers' 2 | 3 | const { input: inputSelectors, list: listSelectors } = classesSelectors 4 | 5 | // Input 6 | export const getInputElement = (parentHtmlContainer: HTMLElement) => 7 | parentHtmlContainer.querySelector(inputSelectors.base) as HTMLElement 8 | 9 | export const getTagsElements = (parentHtmlContainer: HTMLElement) => 10 | parentHtmlContainer.querySelectorAll(inputSelectors.tagsElement) as NodeListOf 11 | 12 | export const getTagsElement = (parentHtmlContainer: HTMLElement) => 13 | parentHtmlContainer.querySelector(inputSelectors.tags) as HTMLElement 14 | 15 | export const getEditElement = (parentHtmlContainer: HTMLElement) => 16 | parentHtmlContainer.querySelector(inputSelectors.edit) as HTMLElement 17 | 18 | export const getArrowElement = (parentHtmlContainer: HTMLElement) => 19 | parentHtmlContainer.querySelector(inputSelectors.arrow) as HTMLElement 20 | 21 | export const getClearElement = (parentHtmlContainer: HTMLElement) => 22 | parentHtmlContainer.querySelector(inputSelectors.clear) as HTMLElement 23 | 24 | // List 25 | export const getListElement = (parentHtmlContainer: HTMLElement) => 26 | parentHtmlContainer.querySelector(listSelectors.base) as HTMLElement 27 | 28 | export const getListItems = (parentHtmlContainer: HTMLElement) => 29 | parentHtmlContainer.querySelectorAll(listSelectors.item) as NodeListOf 30 | 31 | export const getNoResultsElement = (parentHtmlContainer: HTMLElement) => 32 | parentHtmlContainer.querySelector(listSelectors.empty) as HTMLElement 33 | 34 | export const getListGroupsItems = (parentHtmlContainer: HTMLElement) => 35 | parentHtmlContainer.querySelectorAll(listSelectors.itemGroup) as NodeListOf 36 | 37 | export const getListSlotElement = (parentHtmlContainer: HTMLElement) => 38 | parentHtmlContainer.querySelector(listSelectors.slot) as HTMLElement 39 | 40 | export const getGroupArrowIcons = (parentHtmlContainer: HTMLElement) => 41 | parentHtmlContainer.querySelectorAll(listSelectors.itemArrow) as NodeListOf 42 | -------------------------------------------------------------------------------- /__tests__/jest/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from '../../testHelpers' 2 | export * from './getElements' 3 | export * from './renderTreeselect' 4 | -------------------------------------------------------------------------------- /__tests__/jest/helpers/renderTreeselect.ts: -------------------------------------------------------------------------------- 1 | import Treeselect, { ITreeselectParams } from '../../../src/treeselectjs' 2 | 3 | export const renderTreeselect = (params: Omit) => { 4 | document.body.innerHTML = '
' 5 | const container = document.body.querySelector('#test-container') as HTMLElement 6 | 7 | return new Treeselect({ 8 | parentHtmlContainer: container, 9 | ...params 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /__tests__/jest/setup.ts: -------------------------------------------------------------------------------- 1 | import ResizeObserver from 'resize-observer-polyfill' 2 | import 'intersection-observer' 3 | 4 | global.ResizeObserver = ResizeObserver 5 | -------------------------------------------------------------------------------- /__tests__/jest/tests/core-props/__snapshots__/disabled.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`disabled prop should render a disabled Treeselect 1`] = ` 4 |
8 |
12 |
15 | 21 |
22 |
25 | 29 | 40 | 45 | 51 | 57 | 58 | 59 | 62 | 73 | 76 | 77 | 78 |
79 |
80 |
81 | `; 82 | -------------------------------------------------------------------------------- /__tests__/jest/tests/core-props/__snapshots__/isGroupedValue.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`isGroupedValue prop should group values 1`] = ` 4 |
8 |
12 |
15 |
21 | 24 | England 25 | 26 | 29 | 40 | 46 | 52 | 53 | 54 |
55 | 58 |
59 |
62 | 66 | 77 | 82 | 88 | 94 | 95 | 96 | 99 | 110 | 113 | 114 | 115 |
116 |
117 |
118 | `; 119 | -------------------------------------------------------------------------------- /__tests__/jest/tests/core-props/__snapshots__/rtl.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`rtl prop should render RTL with rtl prop 1`] = ` 4 |
9 |
13 |
16 | 20 |
21 |
24 | 28 | 39 | 44 | 50 | 56 | 57 | 58 | 61 | 72 | 75 | 76 | 77 |
78 |
79 |
80 | `; 81 | 82 | exports[`rtl prop should render RTL with rtl prop and appendToBody 1`] = ` 83 |
88 |
92 |
95 | 99 |
100 |
103 | 107 | 118 | 123 | 129 | 135 | 136 | 137 | 140 | 151 | 154 | 155 | 156 |
157 |
158 |
159 | `; 160 | -------------------------------------------------------------------------------- /__tests__/jest/tests/core-props/ariaLabel.test.ts: -------------------------------------------------------------------------------- 1 | import { renderTreeselect, getEditElement } from '../../helpers' 2 | 3 | describe('ariaLabel prop', () => { 4 | it('should render a Treeselect with the given aria-label', () => { 5 | const ariaLabel = 'test-aria-label' 6 | 7 | const treeselect = renderTreeselect({ 8 | value: [], 9 | options: [], 10 | ariaLabel 11 | }) 12 | 13 | const input = getEditElement(treeselect.parentHtmlContainer) 14 | expect(input.getAttribute('aria-label')).toBe(ariaLabel) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /__tests__/jest/tests/core-props/disabled.test.ts: -------------------------------------------------------------------------------- 1 | import { fireEvent } from '@testing-library/dom' 2 | import { renderTreeselect, getTagsElement, getTagsElements, getArrowElement, classesSelectors } from '../../helpers' 3 | 4 | const { list: listSelectors } = classesSelectors 5 | 6 | describe('disabled prop', () => { 7 | it('should render a disabled Treeselect', () => { 8 | const treeselect = renderTreeselect({ 9 | value: [], 10 | options: [], 11 | disabled: true 12 | }) 13 | 14 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 15 | }) 16 | 17 | it('should not open the dropdown when Treeselect is disabled', () => { 18 | const treeselect = renderTreeselect({ 19 | value: [], 20 | options: [], 21 | disabled: true 22 | }) 23 | 24 | const arrow = getArrowElement(treeselect.parentHtmlContainer) 25 | fireEvent.mouseDown(arrow) 26 | expect(document.body.innerHTML).not.toContain(listSelectors.base) 27 | }) 28 | 29 | it('should not remove tags when Treeselect is disabled', () => { 30 | const treeselect = renderTreeselect({ 31 | value: [1], 32 | options: [{ value: 1, name: 'Option 1', children: [] }], 33 | disabled: true 34 | }) 35 | 36 | const tagsElement = getTagsElement(treeselect.parentHtmlContainer) 37 | fireEvent.mouseDown(tagsElement) 38 | const tagsElements = getTagsElements(treeselect.parentHtmlContainer) 39 | 40 | expect(tagsElements.length).toBe(1) 41 | expect(treeselect.value).toEqual([1]) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /__tests__/jest/tests/core-props/id.test.ts: -------------------------------------------------------------------------------- 1 | import { renderTreeselect, getEditElement } from '../../helpers' 2 | 3 | describe('id prop', () => { 4 | it('should render a Treeselect with the given id', () => { 5 | const id = 'test-id' 6 | 7 | const treeselect = renderTreeselect({ 8 | value: [], 9 | options: [], 10 | id 11 | }) 12 | 13 | const input = getEditElement(treeselect.parentHtmlContainer) 14 | expect(input.getAttribute('id')).toBe(id) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /__tests__/jest/tests/core-props/isGroupedValue.test.ts: -------------------------------------------------------------------------------- 1 | import { renderTreeselect, defaultOptions, optionsValues } from '../../helpers' 2 | 3 | describe('isGroupedValue prop', () => { 4 | it('should not group values by default', () => { 5 | const treeselect = renderTreeselect({ 6 | value: [optionsValues.EnglandGroup], 7 | options: defaultOptions 8 | }) 9 | 10 | expect(treeselect.value).toEqual([optionsValues.ChelseaItem, optionsValues.WestEndItem, optionsValues.BrightonItem]) 11 | }) 12 | 13 | it('should group values', () => { 14 | const treeselect = renderTreeselect({ 15 | isGroupedValue: true, 16 | value: [optionsValues.EnglandGroup], 17 | options: defaultOptions 18 | }) 19 | 20 | expect(treeselect.value).toEqual([optionsValues.EnglandGroup]) 21 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /__tests__/jest/tests/core-props/isIndependentNodes.test.ts: -------------------------------------------------------------------------------- 1 | import { renderTreeselect, defaultOptions, optionsValues } from '../../helpers' 2 | 3 | describe('isIndependentNodes prop', () => { 4 | it('should not be independent nodes by default', () => { 5 | const treeselect = renderTreeselect({ 6 | value: [optionsValues.EnglandGroup], 7 | options: defaultOptions 8 | }) 9 | 10 | treeselect.toggleOpenClose() 11 | 12 | expect(treeselect.value).toEqual([optionsValues.ChelseaItem, optionsValues.WestEndItem, optionsValues.BrightonItem]) 13 | }) 14 | 15 | it('should be independent nodes', () => { 16 | const treeselect = renderTreeselect({ 17 | isIndependentNodes: true, 18 | value: [optionsValues.EnglandGroup, optionsValues.BrightonItem], 19 | options: defaultOptions 20 | }) 21 | 22 | treeselect.toggleOpenClose() 23 | 24 | expect(treeselect.value).toEqual([optionsValues.EnglandGroup, optionsValues.BrightonItem]) 25 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /__tests__/jest/tests/core-props/isSingleSelect.test.ts: -------------------------------------------------------------------------------- 1 | import { renderTreeselect, defaultOptions, optionsValues } from '../../helpers' 2 | 3 | describe('isSingleSelect prop', () => { 4 | it('should render a Treeselect with single-select mode', () => { 5 | const treeselect = renderTreeselect({ 6 | isSingleSelect: true, 7 | value: optionsValues.ChelseaItem, 8 | options: defaultOptions 9 | }) 10 | 11 | treeselect.toggleOpenClose() 12 | 13 | expect(treeselect.value).toEqual(optionsValues.ChelseaItem) 14 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 15 | }) 16 | 17 | it('should select only one value in single-select mode', () => { 18 | const treeselect = renderTreeselect({ 19 | isSingleSelect: true, 20 | value: optionsValues.EnglandGroup, 21 | options: defaultOptions 22 | }) 23 | 24 | treeselect.toggleOpenClose() 25 | 26 | expect(treeselect.value).toEqual(optionsValues.EnglandGroup) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /__tests__/jest/tests/core-props/parentHtmlContainer.test.ts: -------------------------------------------------------------------------------- 1 | import { renderTreeselect, defaultOptions } from '../../helpers' 2 | 3 | describe('parentHtmlContainer prop', () => { 4 | it('should render a Treeselect component', () => { 5 | const treeselect = renderTreeselect({ 6 | value: [], 7 | options: defaultOptions 8 | }) 9 | 10 | treeselect.toggleOpenClose() 11 | 12 | expect(treeselect).toBeDefined() 13 | expect(document.body.contains(treeselect.parentHtmlContainer)).toBe(true) 14 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /__tests__/jest/tests/core-props/rtl.test.ts: -------------------------------------------------------------------------------- 1 | import { renderTreeselect, defaultOptions, classesSelectors } from '../../helpers' 2 | 3 | describe('rtl prop', () => { 4 | it('should render RTL with rtl prop', () => { 5 | const treeselect = renderTreeselect({ 6 | value: [], 7 | options: defaultOptions, 8 | rtl: true 9 | }) 10 | 11 | expect(treeselect.parentHtmlContainer.getAttribute('dir')).toBe('rtl') 12 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 13 | }) 14 | 15 | it('should render RTL with rtl prop and appendToBody', () => { 16 | const treeselect = renderTreeselect({ 17 | value: [], 18 | options: defaultOptions, 19 | rtl: true, 20 | appendToBody: true 21 | }) 22 | 23 | treeselect.toggleOpenClose() 24 | 25 | const list = document.querySelector(classesSelectors.list.base)! 26 | expect(list.getAttribute('dir')).toBe('rtl') 27 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /__tests__/jest/tests/core-props/value.test.ts: -------------------------------------------------------------------------------- 1 | import { renderTreeselect, getTagsElements, defaultOptions, optionsValues } from '../../helpers' 2 | 3 | describe('value prop', () => { 4 | it('should render a Treeselect with empty tags', () => { 5 | const treeselect = renderTreeselect({ 6 | value: [], 7 | options: defaultOptions 8 | }) 9 | 10 | const tagsElements = getTagsElements(treeselect.parentHtmlContainer) 11 | treeselect.toggleOpenClose() 12 | 13 | expect(treeselect.value).toEqual([]) 14 | expect(tagsElements.length).toBe(0) 15 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 16 | }) 17 | 18 | it('should render a Treeselect with one tag', () => { 19 | const treeselect = renderTreeselect({ 20 | value: [optionsValues.BrightonItem], 21 | options: defaultOptions 22 | }) 23 | 24 | const tagsElements = getTagsElements(treeselect.parentHtmlContainer) 25 | treeselect.toggleOpenClose() 26 | 27 | expect(treeselect.value).toEqual([optionsValues.BrightonItem]) 28 | expect(tagsElements.length).toBe(1) 29 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 30 | }) 31 | 32 | it('should render a Treeselect with multiple tags', () => { 33 | const treeselect = renderTreeselect({ 34 | value: [optionsValues.ChelseaItem, optionsValues.BrightonItem], 35 | options: defaultOptions 36 | }) 37 | 38 | const tagsElements = getTagsElements(treeselect.parentHtmlContainer) 39 | treeselect.toggleOpenClose() 40 | 41 | expect(tagsElements.length).toBe(2) 42 | expect(treeselect.value).toEqual([optionsValues.ChelseaItem, optionsValues.BrightonItem]) 43 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 44 | }) 45 | 46 | it('should not contain non existent tags', () => { 47 | const treeselect = renderTreeselect({ 48 | value: [100], 49 | options: defaultOptions 50 | }) 51 | 52 | const tagsElements = getTagsElements(treeselect.parentHtmlContainer) 53 | 54 | expect(tagsElements.length).toBe(0) 55 | expect(treeselect.value).toEqual([]) 56 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 57 | }) 58 | 59 | it('should not contain duplicate tags', () => { 60 | const treeselect = renderTreeselect({ 61 | value: [optionsValues.ChelseaItem, optionsValues.ChelseaItem], 62 | options: defaultOptions 63 | }) 64 | 65 | const tagsElements = getTagsElements(treeselect.parentHtmlContainer) 66 | treeselect.toggleOpenClose() 67 | 68 | expect(tagsElements.length).toBe(1) 69 | expect(treeselect.value).toEqual([optionsValues.ChelseaItem]) 70 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 71 | }) 72 | 73 | // Single mode 74 | it('should render a Treeselect with empty value in single mode', () => { 75 | const treeselect = renderTreeselect({ 76 | isSingleSelect: true, 77 | value: null, 78 | options: defaultOptions 79 | }) 80 | 81 | const tagsElements = getTagsElements(treeselect.parentHtmlContainer) 82 | treeselect.toggleOpenClose() 83 | 84 | expect(treeselect.value).toEqual(null) 85 | expect(tagsElements.length).toBe(0) 86 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 87 | }) 88 | 89 | it('should render a Treeselect with one value in single mode', () => { 90 | const treeselect = renderTreeselect({ 91 | isSingleSelect: true, 92 | value: optionsValues.ChelseaItem, 93 | options: defaultOptions 94 | }) 95 | 96 | const tagsElements = getTagsElements(treeselect.parentHtmlContainer) 97 | treeselect.toggleOpenClose() 98 | 99 | expect(treeselect.value).toEqual(optionsValues.ChelseaItem) 100 | expect(tagsElements.length).toBe(1) 101 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 102 | }) 103 | 104 | it('should not select non existent value in single mode', () => { 105 | const treeselect = renderTreeselect({ 106 | isSingleSelect: true, 107 | value: 100, 108 | options: defaultOptions 109 | }) 110 | 111 | const tagsElements = getTagsElements(treeselect.parentHtmlContainer) 112 | treeselect.toggleOpenClose() 113 | 114 | expect(treeselect.value).toEqual(null) 115 | expect(tagsElements.length).toBe(0) 116 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 117 | }) 118 | }) 119 | -------------------------------------------------------------------------------- /__tests__/jest/tests/input-props/__snapshots__/grouped.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`grouped prop should grouped by default 1`] = ` 4 |
8 |
12 |
15 |
21 | 24 | England 25 | 26 | 29 | 40 | 46 | 52 | 53 | 54 |
55 | 58 |
59 |
62 | 66 | 77 | 82 | 88 | 94 | 95 | 96 | 99 | 110 | 113 | 114 | 115 |
116 |
117 |
118 | `; 119 | 120 | exports[`grouped prop should not be grouped 1`] = ` 121 |
125 |
129 |
132 |
138 | 141 | Chelsea 142 | 143 | 146 | 157 | 163 | 169 | 170 | 171 |
172 |
178 | 181 | West End 182 | 183 | 186 | 197 | 203 | 209 | 210 | 211 |
212 |
218 | 221 | Brighton 222 | 223 | 226 | 237 | 243 | 249 | 250 | 251 |
252 | 255 |
256 |
259 | 263 | 274 | 279 | 285 | 291 | 292 | 293 | 296 | 307 | 310 | 311 | 312 |
313 |
314 |
315 | `; 316 | -------------------------------------------------------------------------------- /__tests__/jest/tests/input-props/__snapshots__/showTags.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`showTags prop should show group name if group is selected and showTags is false 1`] = ` 4 |
8 |
12 |
15 | 19 | England 20 | 21 | 24 |
25 |
28 | 32 | 43 | 48 | 54 | 60 | 61 | 62 | 65 | 76 | 79 | 80 | 81 |
82 |
83 |
84 | `; 85 | 86 | exports[`showTags prop should show tags by default 1`] = ` 87 |
91 |
95 |
98 |
104 | 107 | England 108 | 109 | 112 | 123 | 129 | 135 | 136 | 137 |
138 | 141 |
142 |
145 | 149 | 160 | 165 | 171 | 177 | 178 | 179 | 182 | 193 | 196 | 197 | 198 |
199 |
200 |
201 | `; 202 | 203 | exports[`showTags prop should show text with count if multiple values are selected and showTags is false 1`] = ` 204 |
208 |
212 |
215 | 219 | 2 elements selected 220 | 221 | 224 |
225 |
228 | 232 | 243 | 248 | 254 | 260 | 261 | 262 | 265 | 276 | 279 | 280 | 281 |
282 |
283 |
284 | `; 285 | -------------------------------------------------------------------------------- /__tests__/jest/tests/input-props/clearable.test.ts: -------------------------------------------------------------------------------- 1 | import { fireEvent } from '@testing-library/dom' 2 | import { renderTreeselect, defaultOptions, getClearElement, optionsValues } from '../../helpers' 3 | 4 | describe('clearable prop', () => { 5 | it('should render a clear button', () => { 6 | const treeselect = renderTreeselect({ 7 | value: [optionsValues.LondonGroup, optionsValues.FranceGroup], 8 | options: defaultOptions 9 | }) 10 | 11 | const clearElement = getClearElement(treeselect.parentHtmlContainer) 12 | expect(clearElement).toBeDefined() 13 | }) 14 | 15 | it('should not render a clear button', () => { 16 | const treeselect = renderTreeselect({ 17 | clearable: false, 18 | value: [optionsValues.LondonGroup, optionsValues.FranceGroup], 19 | options: defaultOptions 20 | }) 21 | 22 | const clearElement = getClearElement(treeselect.parentHtmlContainer) 23 | expect(clearElement).toBeNull() 24 | }) 25 | 26 | it('should clear the value when the clear button is clicked', () => { 27 | const treeselect = renderTreeselect({ 28 | value: [optionsValues.LondonGroup, optionsValues.FranceGroup], 29 | options: defaultOptions 30 | }) 31 | 32 | const clearElement = getClearElement(treeselect.parentHtmlContainer) 33 | fireEvent.mouseDown(clearElement) 34 | treeselect.toggleOpenClose() 35 | 36 | expect(treeselect.value).toEqual([]) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /__tests__/jest/tests/input-props/grouped.test.ts: -------------------------------------------------------------------------------- 1 | import { renderTreeselect, defaultOptions, getTagsElements, optionsValues } from '../../helpers' 2 | 3 | describe('grouped prop', () => { 4 | it('should grouped by default', () => { 5 | const treeselect = renderTreeselect({ 6 | value: [optionsValues.EnglandGroup], 7 | options: defaultOptions 8 | }) 9 | 10 | const tagsElements = getTagsElements(treeselect.parentHtmlContainer) 11 | 12 | expect(tagsElements).toHaveLength(1) 13 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 14 | }) 15 | 16 | it('should not be grouped', () => { 17 | const treeselect = renderTreeselect({ 18 | grouped: false, 19 | value: [optionsValues.EnglandGroup], 20 | options: defaultOptions 21 | }) 22 | 23 | const tagsElements = getTagsElements(treeselect.parentHtmlContainer) 24 | 25 | expect(tagsElements).toHaveLength(3) 26 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /__tests__/jest/tests/input-props/placeholder.test.ts: -------------------------------------------------------------------------------- 1 | import { renderTreeselect, getEditElement } from '../../helpers' 2 | 3 | describe('placeholder prop', () => { 4 | it('should render default placeholder', () => { 5 | const treeselect = renderTreeselect({ 6 | value: [], 7 | options: [] 8 | }) 9 | 10 | const input = getEditElement(treeselect.parentHtmlContainer) 11 | expect(input.getAttribute('placeholder')).toBe('Search...') 12 | }) 13 | 14 | it('should render a Treeselect with the given placeholder', () => { 15 | const placeholder = 'test-placeholder' 16 | 17 | const treeselect = renderTreeselect({ 18 | value: [], 19 | options: [], 20 | placeholder 21 | }) 22 | 23 | const input = getEditElement(treeselect.parentHtmlContainer) 24 | expect(input.getAttribute('placeholder')).toBe(placeholder) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /__tests__/jest/tests/input-props/searchable.test.ts: -------------------------------------------------------------------------------- 1 | import { fireEvent } from '@testing-library/dom' 2 | import { 3 | classes, 4 | defaultOptions, 5 | getEditElement, 6 | getListItems, 7 | getNoResultsElement, 8 | optionNames, 9 | renderTreeselect 10 | } from '../../helpers' 11 | 12 | const { list: listClasses } = classes 13 | 14 | describe('searchable prop', () => { 15 | const awaitInput = async () => new Promise((resolve) => setTimeout(resolve, 400)) 16 | 17 | it('should be searchable by default', async () => { 18 | const treeselect = renderTreeselect({ 19 | value: [], 20 | options: defaultOptions 21 | }) 22 | 23 | const input = getEditElement(treeselect.parentHtmlContainer) 24 | fireEvent.input(input, { target: { value: optionNames.ParisItem } }) 25 | await awaitInput() 26 | 27 | const listItems = Array.from(getListItems(treeselect.parentHtmlContainer)).filter( 28 | (item) => !item.classList.contains(listClasses.itemHidden) 29 | ) 30 | const visibleItems = [optionNames.FranceGroup, optionNames.ParisItem] 31 | 32 | expect(listItems).toHaveLength(2) 33 | expect(listItems.every((item) => visibleItems.includes(item.textContent!))).toBe(true) 34 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 35 | }) 36 | 37 | it('should not be searchable', async () => { 38 | const treeselect = renderTreeselect({ 39 | searchable: false, 40 | value: [], 41 | options: defaultOptions 42 | }) 43 | 44 | const input = getEditElement(treeselect.parentHtmlContainer) 45 | fireEvent.input(input, { target: { value: optionNames.ParisItem } }) 46 | await awaitInput() 47 | 48 | const listItems = Array.from(getListItems(treeselect.parentHtmlContainer)).filter( 49 | (item) => !item.classList.contains(listClasses.itemHidden) 50 | ) 51 | 52 | expect(listItems).toHaveLength(0) 53 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 54 | }) 55 | 56 | it('should show no results message', async () => { 57 | const treeselect = renderTreeselect({ 58 | value: [], 59 | options: defaultOptions 60 | }) 61 | 62 | const input = getEditElement(treeselect.parentHtmlContainer) 63 | fireEvent.input(input, { target: { value: 'Not found' } }) 64 | await awaitInput() 65 | 66 | const listItems = Array.from(getListItems(treeselect.parentHtmlContainer)).filter( 67 | (item) => !item.classList.contains(listClasses.itemHidden) 68 | ) 69 | 70 | const emptyElement = getNoResultsElement(treeselect.parentHtmlContainer) 71 | 72 | expect(listItems).toHaveLength(0) 73 | expect(emptyElement).toBeDefined() 74 | expect(emptyElement.classList.contains(listClasses.itemHidden)).toBe(false) 75 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /__tests__/jest/tests/input-props/showTags.test.ts: -------------------------------------------------------------------------------- 1 | import { renderTreeselect, defaultOptions, optionsValues } from '../../helpers' 2 | 3 | describe('showTags prop', () => { 4 | it('should show tags by default', () => { 5 | const treeselect = renderTreeselect({ 6 | value: [optionsValues.EnglandGroup], 7 | options: defaultOptions 8 | }) 9 | 10 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 11 | }) 12 | 13 | it('should show group name if group is selected and showTags is false', () => { 14 | const treeselect = renderTreeselect({ 15 | value: [optionsValues.EnglandGroup], 16 | showTags: false, 17 | options: defaultOptions 18 | }) 19 | 20 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 21 | }) 22 | 23 | it('should show text with count if multiple values are selected and showTags is false', () => { 24 | const treeselect = renderTreeselect({ 25 | value: [optionsValues.EnglandGroup, optionsValues.FranceGroup], 26 | showTags: false, 27 | options: defaultOptions 28 | }) 29 | 30 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /__tests__/jest/tests/input-props/tagsCountText.test.ts: -------------------------------------------------------------------------------- 1 | import { renderTreeselect, getTagsElement, defaultOptions, optionsValues } from '../../helpers' 2 | 3 | describe('tagsCountText prop', () => { 4 | it('should render a Treeselect with the default tagsCountText', () => { 5 | const treeselect = renderTreeselect({ 6 | value: [optionsValues.EnglandGroup, optionsValues.FranceGroup], 7 | options: defaultOptions, 8 | showTags: false 9 | }) 10 | 11 | const tagsElement = getTagsElement(treeselect.parentHtmlContainer) 12 | expect(tagsElement.outerHTML).toContain('2 elements selected') 13 | }) 14 | 15 | it('should render a Treeselect with the given tagsCountText', () => { 16 | const treeselect = renderTreeselect({ 17 | value: [optionsValues.EnglandGroup, optionsValues.FranceGroup], 18 | options: defaultOptions, 19 | showTags: false, 20 | tagsCountText: 'test elements' 21 | }) 22 | 23 | const tagsElement = getTagsElement(treeselect.parentHtmlContainer) 24 | expect(tagsElement.outerHTML).toContain('2 test elements') 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /__tests__/jest/tests/input-props/tagsSortFn.test.ts: -------------------------------------------------------------------------------- 1 | import { renderTreeselect, getTagsElements } from '../../helpers' 2 | import { TagsSortItem } from './../../../../src/treeselectTypes' 3 | 4 | const { brighton, chelsea, paris } = { 5 | chelsea: { value: 1, name: 'Chelsea' }, 6 | brighton: { value: 2, name: 'Brighton' }, 7 | paris: { value: 3, name: 'Paris' } 8 | } 9 | 10 | const mixedOptions = [ 11 | { value: paris.value, name: paris.name, children: [] }, 12 | { value: brighton.value, name: brighton.name, children: [] }, 13 | { value: chelsea.value, name: chelsea.name, children: [] } 14 | ] 15 | 16 | describe('tagsSortFn prop', () => { 17 | it('should use sorting by options list by default', () => { 18 | const treeselect = renderTreeselect({ 19 | value: [paris.value, brighton.value, chelsea.value], 20 | options: mixedOptions 21 | }) 22 | 23 | const tagsElements = getTagsElements(treeselect.parentHtmlContainer) 24 | const sortedNamesByOptions = mixedOptions.map(({ name }) => name) 25 | 26 | sortedNamesByOptions.forEach((name, index) => { 27 | expect(tagsElements[index].innerHTML).toContain(name) 28 | }) 29 | }) 30 | 31 | it('should sort tags by name', () => { 32 | const sortNodesByName = (a: TagsSortItem, b: TagsSortItem) => a.name.localeCompare(b.name) 33 | 34 | const treeselect = renderTreeselect({ 35 | value: [paris.value, brighton.value, chelsea.value], 36 | tagsSortFn: sortNodesByName, 37 | options: mixedOptions 38 | }) 39 | 40 | const tagsElements = getTagsElements(treeselect.parentHtmlContainer) 41 | const sortedNamesByName = mixedOptions.toSorted(sortNodesByName) 42 | 43 | sortedNamesByName.forEach(({ name }, index) => { 44 | expect(tagsElements[index].innerHTML).toContain(name) 45 | }) 46 | }) 47 | 48 | it('should sort tags by value', () => { 49 | const sortNodesByValue = (a: TagsSortItem, b: TagsSortItem) => { 50 | const aValue = parseFloat(a.value.toString()) 51 | const bValue = parseFloat(b.value.toString()) 52 | 53 | const isAValueNumber = !isNaN(aValue) 54 | const isBValueNumber = !isNaN(bValue) 55 | 56 | if (isAValueNumber && isBValueNumber) { 57 | return aValue - bValue 58 | } 59 | 60 | if (!isAValueNumber && !isBValueNumber) { 61 | return a.value.toString().localeCompare(b.value.toString()) 62 | } 63 | 64 | return isAValueNumber ? -1 : 1 65 | } 66 | 67 | const treeselect = renderTreeselect({ 68 | value: [paris.value, brighton.value, chelsea.value], 69 | options: mixedOptions, 70 | tagsSortFn: sortNodesByValue 71 | }) 72 | 73 | const tagsElements = getTagsElements(treeselect.parentHtmlContainer) 74 | const sortedNamesByValue = mixedOptions.toSorted(sortNodesByValue) 75 | 76 | sortedNamesByValue.forEach(({ name }, index) => { 77 | expect(tagsElements[index].innerHTML).toContain(name) 78 | }) 79 | }) 80 | 81 | it('should sort tags by order', () => { 82 | const value: (number | string)[] = [paris.value, chelsea.value, brighton.value] 83 | const sortNodesByOrder = (a: TagsSortItem, b: TagsSortItem) => { 84 | const orderMap = new Map(value.map((id, index) => [id.toString(), index])) 85 | const indexA = orderMap.get(a.value.toString()) ?? Infinity 86 | const indexB = orderMap.get(b.value.toString()) ?? Infinity 87 | 88 | return indexA - indexB 89 | } 90 | const treeselect = renderTreeselect({ 91 | value, 92 | tagsSortFn: sortNodesByOrder, 93 | options: mixedOptions 94 | }) 95 | 96 | const tagsElements = getTagsElements(treeselect.parentHtmlContainer) 97 | const sortedNamesByOrder = mixedOptions.toSorted(sortNodesByOrder) 98 | 99 | sortedNamesByOrder.forEach(({ name }, index) => { 100 | expect(tagsElements[index].innerHTML).toContain(name) 101 | }) 102 | }) 103 | 104 | it('should sort tags by order during the update', () => { 105 | let value: (number | string)[] = [] 106 | const sortNodesByOrder = (a: TagsSortItem, b: TagsSortItem) => { 107 | const orderMap = new Map(value.map((id, index) => [id.toString(), index])) 108 | const indexA = orderMap.get(a.value.toString()) ?? Infinity 109 | const indexB = orderMap.get(b.value.toString()) ?? Infinity 110 | 111 | return indexA - indexB 112 | } 113 | 114 | const treeselect = renderTreeselect({ 115 | value: [], 116 | tagsSortFn: sortNodesByOrder, 117 | options: mixedOptions 118 | }) 119 | 120 | value = [paris.value] 121 | treeselect.updateValue(value) 122 | const tagsElements1 = getTagsElements(treeselect.parentHtmlContainer) ?? [] 123 | expect(tagsElements1[0].innerHTML).toContain(paris.name) 124 | 125 | value = [paris.value, chelsea.value] 126 | treeselect.updateValue(value) 127 | const tagsElements2 = getTagsElements(treeselect.parentHtmlContainer) ?? [] 128 | expect(tagsElements2[0].innerHTML).toContain(paris.name) 129 | expect(tagsElements2[1].innerHTML).toContain(chelsea.name) 130 | 131 | value = [paris.value, chelsea.value, brighton.value] 132 | treeselect.updateValue(value) 133 | const tagsElements3 = getTagsElements(treeselect.parentHtmlContainer) ?? [] 134 | expect(tagsElements3[0].innerHTML).toContain(paris.name) 135 | expect(tagsElements3[1].innerHTML).toContain(chelsea.name) 136 | expect(tagsElements3[2].innerHTML).toContain(brighton.name) 137 | }) 138 | }) 139 | -------------------------------------------------------------------------------- /__tests__/jest/tests/list-props/__snapshots__/appendToBody.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`appendToBody prop should render the dropdown list in the body 1`] = ` 4 |
8 |
12 |
15 | 19 |
20 |
23 | 27 | 38 | 43 | 49 | 55 | 56 | 57 | 60 | 71 | 74 | 75 | 76 |
77 |
78 |
79 | `; 80 | -------------------------------------------------------------------------------- /__tests__/jest/tests/list-props/__snapshots__/emptyText.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`emptyText prop should render the custom empty text 1`] = ` 4 |
8 |
12 |
15 | 19 |
20 |
23 | 27 | 38 | 43 | 49 | 55 | 56 | 57 | 60 | 71 | 74 | 75 | 76 |
77 |
78 |
82 |
86 | 89 | 100 | 103 | 109 | 115 | 116 | 117 | 120 | No data 121 | 122 |
123 |
124 |
125 | `; 126 | 127 | exports[`emptyText prop should render the empty text by default 1`] = ` 128 |
132 |
136 |
139 | 143 |
144 |
147 | 151 | 162 | 167 | 173 | 179 | 180 | 181 | 184 | 195 | 198 | 199 | 200 |
201 |
202 |
206 |
210 | 213 | 224 | 227 | 233 | 239 | 240 | 241 | 244 | No results found... 245 | 246 |
247 |
248 |
249 | `; 250 | -------------------------------------------------------------------------------- /__tests__/jest/tests/list-props/alwaysOpen.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultOptions, renderTreeselect, getListElement } from '../../helpers' 2 | 3 | describe('alwaysOpen prop', () => { 4 | it('should be closed by default', () => { 5 | const treeselect = renderTreeselect({ 6 | value: [], 7 | options: defaultOptions 8 | }) 9 | 10 | const list = getListElement(treeselect.parentHtmlContainer) 11 | 12 | expect(list).toBe(null) 13 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 14 | }) 15 | 16 | it('should be opened when alwaysOpen is set', () => { 17 | const treeselect = renderTreeselect({ 18 | value: [], 19 | options: defaultOptions, 20 | alwaysOpen: true 21 | }) 22 | 23 | const list = getListElement(treeselect.parentHtmlContainer) 24 | 25 | expect(list).not.toBe(null) 26 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /__tests__/jest/tests/list-props/appendToBody.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultOptions, renderTreeselect, classes } from '../../helpers' 2 | 3 | const { list: listClasses } = classes 4 | 5 | describe('appendToBody prop', () => { 6 | it('should render the dropdown list in the body', () => { 7 | const treeselect = renderTreeselect({ 8 | value: [], 9 | options: defaultOptions, 10 | appendToBody: true 11 | }) 12 | 13 | treeselect.toggleOpenClose() 14 | 15 | expect(treeselect.parentHtmlContainer.innerHTML).not.toContain(listClasses.base) 16 | expect(document.body.innerHTML).toContain(listClasses.base) 17 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /__tests__/jest/tests/list-props/direction.test.ts: -------------------------------------------------------------------------------- 1 | import { renderTreeselect, defaultOptions, getListElement } from '../../helpers' 2 | 3 | describe('direction prop', () => { 4 | it('should render the dropdown list with top direction', () => { 5 | const treeselect = renderTreeselect({ 6 | value: [], 7 | options: defaultOptions, 8 | direction: 'top' 9 | }) 10 | 11 | document.body.style.paddingTop = '300px' 12 | 13 | treeselect.toggleOpenClose() 14 | 15 | const list = getListElement(treeselect.parentHtmlContainer) 16 | 17 | expect(list.getAttribute('direction')).toBe('top') 18 | expect(document.body).toMatchSnapshot() 19 | }) 20 | 21 | it('should render the dropdown list with bottom direction', () => { 22 | const treeselect = renderTreeselect({ 23 | value: [], 24 | options: defaultOptions, 25 | direction: 'bottom' 26 | }) 27 | 28 | document.body.style.paddingBottom = '300px' 29 | 30 | treeselect.toggleOpenClose() 31 | 32 | const list = getListElement(treeselect.parentHtmlContainer) 33 | 34 | expect(list.getAttribute('direction')).toBe('bottom') 35 | expect(document.body).toMatchSnapshot() 36 | }) 37 | 38 | it('should render the dropdown list with auto direction', () => { 39 | const treeselect = renderTreeselect({ 40 | value: [], 41 | options: defaultOptions, 42 | direction: 'auto' 43 | }) 44 | 45 | treeselect.toggleOpenClose() 46 | 47 | const list = getListElement(treeselect.parentHtmlContainer) 48 | 49 | expect(list.getAttribute('direction')).toBe('bottom') 50 | expect(document.body).toMatchSnapshot() 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /__tests__/jest/tests/list-props/disabledBranchNode.test.ts: -------------------------------------------------------------------------------- 1 | import { fireEvent } from '@testing-library/dom' 2 | import { defaultOptions, renderTreeselect, getListGroupsItems, optionsValues, optionNames } from '../../helpers' 3 | 4 | describe('disabledBranchNode prop', () => { 5 | it('group should be selectable by default', () => { 6 | const treeselect = renderTreeselect({ 7 | value: [], 8 | options: defaultOptions, 9 | openLevel: 5 10 | }) 11 | 12 | treeselect.toggleOpenClose() 13 | 14 | const [groupItem] = Array.from(getListGroupsItems(treeselect.parentHtmlContainer)).filter( 15 | (item) => item.getAttribute('title') === optionNames.FranceGroup 16 | ) 17 | 18 | fireEvent.mouseDown(groupItem) 19 | 20 | const checkboxes = groupItem.querySelectorAll('input') as NodeListOf 21 | const isEveryIsChecked = Array.from(checkboxes).every((checkbox) => checkbox.checked) 22 | 23 | expect(treeselect.value).toEqual([optionsValues.ParisItem, optionsValues.LyonItem]) 24 | expect(isEveryIsChecked).toBe(true) 25 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 26 | }) 27 | 28 | it('group should not be selectable when disabledBranchNode is true', () => { 29 | const treeselect = renderTreeselect({ 30 | value: [], 31 | options: defaultOptions, 32 | openLevel: 5, 33 | disabledBranchNode: true 34 | }) 35 | 36 | treeselect.toggleOpenClose() 37 | 38 | const [groupItem] = Array.from(getListGroupsItems(treeselect.parentHtmlContainer)).filter( 39 | (item) => item.getAttribute('title') === optionNames.FranceGroup 40 | ) 41 | 42 | fireEvent.mouseDown(groupItem) 43 | 44 | const checkboxes = groupItem.querySelectorAll('input') as NodeListOf 45 | const isEveryIsChecked = Array.from(checkboxes).every((checkbox) => checkbox.checked) 46 | 47 | expect(treeselect.value).toEqual([]) 48 | expect(isEveryIsChecked).toBe(false) 49 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /__tests__/jest/tests/list-props/emptyText.test.ts: -------------------------------------------------------------------------------- 1 | import { renderTreeselect, getNoResultsElement, noResultsText } from '../../helpers' 2 | 3 | describe('emptyText prop', () => { 4 | it('should render the empty text by default', () => { 5 | const treeselect = renderTreeselect({ 6 | value: [], 7 | options: [] 8 | }) 9 | 10 | treeselect.toggleOpenClose() 11 | 12 | const emptyItem = getNoResultsElement(treeselect.parentHtmlContainer) 13 | 14 | expect(emptyItem.innerHTML).toContain(noResultsText) 15 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 16 | }) 17 | 18 | it('should render the custom empty text', () => { 19 | const newNotResultsText = 'No data' 20 | const treeselect = renderTreeselect({ 21 | value: [], 22 | options: [], 23 | emptyText: newNotResultsText 24 | }) 25 | 26 | treeselect.toggleOpenClose() 27 | 28 | const emptyItem = getNoResultsElement(treeselect.parentHtmlContainer) 29 | 30 | expect(emptyItem.innerHTML).toContain(newNotResultsText) 31 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /__tests__/jest/tests/list-props/expandSelected.test.ts: -------------------------------------------------------------------------------- 1 | import { renderTreeselect, defaultOptions, optionsValues, optionNames, getListItems, classes } from '../../helpers' 2 | 3 | const { list: listClasses } = classes 4 | 5 | describe('expandSelected prop', () => { 6 | it('should not expand selected items by default', () => { 7 | const treeselect = renderTreeselect({ 8 | value: [optionsValues.BrightonItem, optionsValues.LyonItem], 9 | options: defaultOptions 10 | }) 11 | 12 | treeselect.toggleOpenClose() 13 | 14 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 15 | }) 16 | 17 | it('should expand selected items when expandSelected is set', () => { 18 | const treeselect = renderTreeselect({ 19 | value: [optionsValues.BrightonItem, optionsValues.LyonItem], 20 | options: defaultOptions, 21 | expandSelected: true 22 | }) 23 | 24 | treeselect.toggleOpenClose() 25 | 26 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 27 | }) 28 | 29 | it('should expand selected item with single select', () => { 30 | const treeselect = renderTreeselect({ 31 | value: optionsValues.WestEndItem, 32 | options: defaultOptions, 33 | expandSelected: true, 34 | isSingleSelect: true 35 | }) 36 | 37 | treeselect.toggleOpenClose() 38 | 39 | const items = getListItems(treeselect.parentHtmlContainer) 40 | const itemsToShow = [ 41 | optionNames.EnglandGroup, 42 | optionNames.LondonGroup, 43 | optionNames.ChelseaItem, 44 | optionNames.WestEndItem, 45 | optionNames.BrightonItem, 46 | optionNames.FranceGroup 47 | ] 48 | const itemsToHide = [optionNames.LyonItem, optionNames.ParisItem] 49 | 50 | items.forEach((item) => { 51 | if (itemsToShow.includes(item.textContent!)) { 52 | expect(item.classList.contains(listClasses.itemHidden)).toBe(false) 53 | } else if (itemsToHide.includes(item.textContent!)) { 54 | expect(item.classList.contains(listClasses.itemHidden)).toBe(true) 55 | } 56 | }) 57 | 58 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 59 | }) 60 | 61 | it('should expand selected item with isIndependentNodes prop', () => { 62 | const treeselect = renderTreeselect({ 63 | value: [optionsValues.BrightonItem], 64 | options: defaultOptions, 65 | expandSelected: true, 66 | isIndependentNodes: true 67 | }) 68 | 69 | treeselect.toggleOpenClose() 70 | 71 | const items = getListItems(treeselect.parentHtmlContainer) 72 | const itemsToShow = [ 73 | optionNames.EnglandGroup, 74 | optionNames.LondonGroup, 75 | optionNames.BrightonItem, 76 | optionNames.FranceGroup 77 | ] 78 | const itemsToHide = [optionNames.ChelseaItem, optionNames.WestEndItem, optionNames.LyonItem, optionNames.ParisItem] 79 | 80 | items.forEach((item) => { 81 | if (itemsToShow.includes(item.textContent!)) { 82 | expect(item.classList.contains(listClasses.itemHidden)).toBe(false) 83 | } else if (itemsToHide.includes(item.textContent!)) { 84 | expect(item.classList.contains(listClasses.itemHidden)).toBe(true) 85 | } 86 | }) 87 | 88 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /__tests__/jest/tests/list-props/listClassName.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultOptions, renderTreeselect, getListElement } from '../../helpers' 2 | 3 | describe('listClassName prop', () => { 4 | it('should add custom class to the dropdown list', () => { 5 | const listClassName = 'custom-list-class' 6 | const treeselect = renderTreeselect({ 7 | value: [], 8 | options: defaultOptions, 9 | listClassName 10 | }) 11 | 12 | treeselect.toggleOpenClose() 13 | 14 | const list = getListElement(treeselect.parentHtmlContainer) 15 | 16 | expect(list.classList.contains(listClassName)).toBe(true) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /__tests__/jest/tests/list-props/listSlotHtmlComponent.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultOptions, renderTreeselect, getListSlotElement } from '../../helpers' 2 | 3 | describe('listSlotHtmlComponent prop', () => { 4 | it('should render the dropdown list with custom slot content', () => { 5 | const slotElement = document.createElement('div') 6 | slotElement.classList.add('custom-list-slot') 7 | slotElement.innerHTML = 'Custom List Slot' 8 | 9 | const treeselect = renderTreeselect({ 10 | value: [], 11 | options: defaultOptions, 12 | listSlotHtmlComponent: slotElement 13 | }) 14 | 15 | treeselect.toggleOpenClose() 16 | 17 | const listSlotElement = getListSlotElement(treeselect.parentHtmlContainer) 18 | 19 | expect(listSlotElement).toBeTruthy() 20 | expect(listSlotElement.querySelector('.custom-list-slot')).toBeTruthy() 21 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /__tests__/jest/tests/list-props/openLevel.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultOptions, renderTreeselect, getListItems, classes } from '../../helpers' 2 | 3 | const { list: listClasses } = classes 4 | 5 | describe('openLevel prop', () => { 6 | it('groups should be closed by default', () => { 7 | const treeselect = renderTreeselect({ 8 | value: [], 9 | options: defaultOptions 10 | }) 11 | 12 | treeselect.toggleOpenClose() 13 | 14 | const topGroups = Array.from(getListItems(treeselect.parentHtmlContainer)).filter( 15 | (item) => !item.classList.contains(listClasses.itemHidden) 16 | ) 17 | 18 | expect(topGroups).toHaveLength(2) 19 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 20 | }) 21 | 22 | it('groups should be opened when openLevel is set', () => { 23 | const treeselect = renderTreeselect({ 24 | value: [], 25 | options: defaultOptions, 26 | openLevel: 10 27 | }) 28 | 29 | treeselect.toggleOpenClose() 30 | 31 | const hiddenItems = Array.from(getListItems(treeselect.parentHtmlContainer)).filter((item) => 32 | item.classList.contains(listClasses.itemHidden) 33 | ) 34 | 35 | expect(hiddenItems).toHaveLength(0) 36 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /__tests__/jest/tests/list-props/showCount.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultOptions, renderTreeselect, getListGroupsItems } from '../../helpers' 2 | 3 | describe('showCount prop', () => { 4 | it('should not show count by default', () => { 5 | const treeselect = renderTreeselect({ 6 | value: [], 7 | options: defaultOptions 8 | }) 9 | 10 | treeselect.toggleOpenClose() 11 | 12 | const groupItems = Array.from(getListGroupsItems(treeselect.parentHtmlContainer)) 13 | const isEveryItemNotContainCount = groupItems.every((groupItem) => !groupItem.innerHTML.includes('(2)')) 14 | 15 | expect(isEveryItemNotContainCount).toBe(true) 16 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 17 | }) 18 | 19 | it('should show count when showCount is true', () => { 20 | const treeselect = renderTreeselect({ 21 | value: [], 22 | options: defaultOptions, 23 | showCount: true 24 | }) 25 | 26 | treeselect.toggleOpenClose() 27 | 28 | const groupItems = Array.from(getListGroupsItems(treeselect.parentHtmlContainer)) 29 | const isEveryItemContainCount = groupItems.every((groupItem) => groupItem.innerHTML.includes('(2)')) 30 | 31 | expect(isEveryItemContainCount).toBe(true) 32 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /__tests__/jest/tests/list-props/stasticList.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultOptions, renderTreeselect, getListElement, classes, getInputElement } from '../../helpers' 2 | 3 | describe('staticList prop', () => { 4 | it('should not be static by default', () => { 5 | const treeselect = renderTreeselect({ 6 | value: [], 7 | options: defaultOptions 8 | }) 9 | 10 | const list = getListElement(treeselect.parentHtmlContainer) 11 | 12 | expect(list).toBeFalsy() 13 | }) 14 | 15 | it('should be static when staticList is set', () => { 16 | const treeselect = renderTreeselect({ 17 | value: [], 18 | options: defaultOptions, 19 | staticList: true 20 | }) 21 | 22 | treeselect.toggleOpenClose() 23 | 24 | const list = getListElement(treeselect.parentHtmlContainer) 25 | 26 | expect(list).toBeTruthy() 27 | expect(list.classList).toContain(classes.list.static) 28 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 29 | }) 30 | 31 | it('should have always bottom direction when staticList is set', () => { 32 | const treeselect = renderTreeselect({ 33 | value: [], 34 | options: defaultOptions, 35 | direction: 'top', 36 | alwaysOpen: true, 37 | staticList: true 38 | }) 39 | 40 | const list = getListElement(treeselect.parentHtmlContainer) 41 | 42 | expect(list.getAttribute('direction')).toBe('bottom') 43 | expect(list.classList).toContain(classes.list.bottom) 44 | 45 | const input = getInputElement(treeselect.parentHtmlContainer) 46 | expect(input.classList).toContain(classes.input.bottom) 47 | }) 48 | 49 | it('should not have static position with appendToBody', () => { 50 | const treeselect = renderTreeselect({ 51 | value: [], 52 | options: defaultOptions, 53 | staticList: true, 54 | appendToBody: true 55 | }) 56 | 57 | treeselect.toggleOpenClose() 58 | const list = getListElement(treeselect.parentHtmlContainer) 59 | expect(list).toBeFalsy() 60 | }) 61 | 62 | it('should correctly render static list with alwaysOpen', () => { 63 | const treeselect = renderTreeselect({ 64 | value: [], 65 | options: defaultOptions, 66 | alwaysOpen: true, 67 | staticList: true 68 | }) 69 | 70 | const list = getListElement(treeselect.parentHtmlContainer) 71 | 72 | expect(list).toBeTruthy() 73 | expect(list.classList).toContain(classes.list.static) 74 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /__tests__/jest/tests/methods/__snapshots__/focus.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`focus method should focus on the input 1`] = ` 4 |
8 |
12 |
15 | 19 |
20 |
23 | 27 | 38 | 43 | 49 | 55 | 56 | 57 | 60 | 71 | 74 | 75 | 76 |
77 |
78 |
79 | `; 80 | -------------------------------------------------------------------------------- /__tests__/jest/tests/methods/destroy.test.ts: -------------------------------------------------------------------------------- 1 | import { renderTreeselect, getEditElement, getListElement, defaultOptions } from '../../helpers' 2 | 3 | describe('destroy method', () => { 4 | it('should remove treeselect form the DOM', () => { 5 | const treeselect = renderTreeselect({ 6 | value: [], 7 | options: defaultOptions 8 | }) 9 | 10 | treeselect.destroy() 11 | 12 | const input = getEditElement(document.body) 13 | const list = getListElement(document.body) 14 | 15 | expect(input).toBeFalsy() 16 | expect(list).toBeFalsy() 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /__tests__/jest/tests/methods/emits-callbacks.test.ts: -------------------------------------------------------------------------------- 1 | import { fireEvent } from '@testing-library/dom' 2 | import { renderTreeselect, getTagsElements, getArrowElement, getEditElement, getGroupArrowIcons } from '../../helpers' 3 | 4 | describe('emits-callbacks method', () => { 5 | it('should call input callback if change value', () => { 6 | const inputFn = jest.fn() 7 | 8 | const treeselect = renderTreeselect({ 9 | value: [1, 2, 3], 10 | options: [ 11 | { value: 1, name: '1', children: [] }, 12 | { value: 2, name: '2', children: [] }, 13 | { value: 3, name: '3', children: [] } 14 | ], 15 | inputCallback: inputFn 16 | }) 17 | 18 | treeselect.srcElement?.addEventListener('input', (e: any) => { 19 | inputFn(e.detail) 20 | }) 21 | 22 | const tags = getTagsElements(treeselect.parentHtmlContainer) 23 | fireEvent.mouseDown(tags[0]) 24 | 25 | expect(inputFn).toHaveBeenNthCalledWith(2, [2, 3]) 26 | }) 27 | 28 | it('should call open callback if open', () => { 29 | const openFn = jest.fn() 30 | 31 | const treeselect = renderTreeselect({ 32 | value: [1], 33 | options: [{ value: 1, name: '1', children: [] }], 34 | openCallback: openFn 35 | }) 36 | 37 | treeselect.srcElement?.addEventListener('open', (e: any) => { 38 | openFn(e.detail) 39 | }) 40 | 41 | const arrow = getArrowElement(treeselect.parentHtmlContainer) 42 | fireEvent.mouseDown(arrow) 43 | 44 | expect(openFn).toHaveBeenNthCalledWith(2, [1]) 45 | }) 46 | 47 | it('should call close callback if close', () => { 48 | const closeFn = jest.fn() 49 | 50 | const treeselect = renderTreeselect({ 51 | value: [1], 52 | options: [{ value: 1, name: '1', children: [] }], 53 | closeCallback: closeFn 54 | }) 55 | 56 | treeselect.srcElement?.addEventListener('close', (e: any) => { 57 | closeFn(e.detail) 58 | }) 59 | 60 | const arrow = getArrowElement(treeselect.parentHtmlContainer) 61 | fireEvent.mouseDown(arrow) 62 | fireEvent.mouseDown(arrow) 63 | 64 | expect(closeFn).toHaveBeenNthCalledWith(2, [1]) 65 | }) 66 | 67 | it('should call search callback if search', () => { 68 | const searchFn = jest.fn() 69 | 70 | const treeselect = renderTreeselect({ 71 | value: [1], 72 | options: [{ value: 1, name: '1', children: [] }], 73 | searchCallback: searchFn 74 | }) 75 | 76 | treeselect.srcElement?.addEventListener('search', (e: any) => { 77 | searchFn(e.detail) 78 | }) 79 | 80 | const input = getEditElement(treeselect.parentHtmlContainer) 81 | fireEvent.input(input, { target: { value: '123' } }) 82 | 83 | expect(searchFn).toHaveBeenNthCalledWith(2, '123') 84 | }) 85 | 86 | it('should call name-change callback if change name', () => { 87 | const nameChangeFn = jest.fn() 88 | 89 | const treeselect = renderTreeselect({ 90 | value: [1, 2, 3], 91 | options: [ 92 | { value: 1, name: '1', children: [] }, 93 | { value: 2, name: '2', children: [] }, 94 | { value: 3, name: '3', children: [] } 95 | ], 96 | nameChangeCallback: nameChangeFn 97 | }) 98 | 99 | treeselect.srcElement?.addEventListener('name-change', (e: any) => { 100 | nameChangeFn(e.detail) 101 | }) 102 | 103 | const tags = getTagsElements(treeselect.parentHtmlContainer) 104 | fireEvent.mouseDown(tags[0]) 105 | 106 | expect(nameChangeFn).toHaveBeenNthCalledWith(2, '2, 3') 107 | }) 108 | 109 | it('should call open-close-group callback if open-close group', () => { 110 | const openCloseGroupFn = jest.fn() 111 | const openCloseGroupListener = jest.fn() 112 | 113 | const treeselect = renderTreeselect({ 114 | value: [1], 115 | openLevel: 0, 116 | options: [ 117 | { 118 | value: 1, 119 | name: '1', 120 | children: [ 121 | { value: 2, name: '2', children: [] }, 122 | { value: 3, name: '3', children: [] } 123 | ] 124 | } 125 | ], 126 | openCloseGroupCallback: openCloseGroupFn 127 | }) 128 | 129 | treeselect.srcElement?.addEventListener('open-close-group', (e: any) => { 130 | openCloseGroupListener(e.detail) 131 | }) 132 | 133 | const arrow = getArrowElement(treeselect.parentHtmlContainer) 134 | fireEvent.mouseDown(arrow) 135 | const firstGroupArrow = getGroupArrowIcons(treeselect.parentHtmlContainer)[0] 136 | fireEvent.mouseDown(firstGroupArrow) 137 | 138 | expect(openCloseGroupListener).toHaveBeenNthCalledWith(1, { groupId: 1, isClosed: false }) 139 | expect(openCloseGroupFn).toHaveBeenNthCalledWith(1, 1, false) 140 | }) 141 | }) 142 | -------------------------------------------------------------------------------- /__tests__/jest/tests/methods/focus.test.ts: -------------------------------------------------------------------------------- 1 | import { renderTreeselect, getEditElement } from '../../helpers' 2 | 3 | describe('focus method', () => { 4 | it('should focus on the input', async () => { 5 | const treeselect = renderTreeselect({ 6 | value: [], 7 | options: [] 8 | }) 9 | 10 | treeselect.focus() 11 | await new Promise((resolve) => setTimeout(resolve, 0)) 12 | const input = getEditElement(treeselect.parentHtmlContainer) 13 | 14 | expect(document.activeElement).toBe(input) 15 | expect(treeselect.parentHtmlContainer).toMatchSnapshot() 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /__tests__/jest/tests/methods/mount.test.ts: -------------------------------------------------------------------------------- 1 | import { renderTreeselect, getTagsElements } from '../../helpers' 2 | 3 | const values = { Option1: 1, Option2: 2, Option3: 3 } 4 | const options = [ 5 | { value: values.Option1, name: 'Option 1', children: [] }, 6 | { value: values.Option2, name: 'Option 2', children: [] }, 7 | { value: values.Option3, name: 'Option 3', children: [] } 8 | ] 9 | 10 | describe('mount method', () => { 11 | it('should update params with mount method', () => { 12 | const newValue = [values.Option1, values.Option2] 13 | const ariaLabel = 'test-aria-label' 14 | const treeselect = renderTreeselect({ 15 | value: [], 16 | options: options 17 | }) 18 | 19 | treeselect.value = newValue 20 | treeselect.ariaLabel = ariaLabel 21 | treeselect.mount() 22 | 23 | const tags = getTagsElements(treeselect.parentHtmlContainer) 24 | 25 | expect(tags.length).toBe(newValue.length) 26 | expect(treeselect.value).toEqual(newValue) 27 | expect(treeselect.parentHtmlContainer.innerHTML).toContain(ariaLabel) 28 | expect(treeselect.ariaLabel).toBe(ariaLabel) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /__tests__/jest/tests/methods/toggleOpenClose.test.ts: -------------------------------------------------------------------------------- 1 | import { renderTreeselect, getListElement } from '../../helpers' 2 | 3 | describe('toggleOpenClose method', () => { 4 | it('should open and close the dropdown', () => { 5 | const treeselect = renderTreeselect({ 6 | value: [], 7 | options: [] 8 | }) 9 | 10 | treeselect.toggleOpenClose() 11 | const listElementOpened = getListElement(treeselect.parentHtmlContainer) 12 | 13 | expect(treeselect.isListOpened).toBe(true) 14 | expect(listElementOpened).toBeTruthy() 15 | 16 | treeselect.toggleOpenClose() 17 | const listElementClosed = getListElement(treeselect.parentHtmlContainer) 18 | 19 | expect(treeselect.isListOpened).toBe(false) 20 | expect(listElementClosed).toBeFalsy() 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /__tests__/jest/tests/methods/updateValue.test.ts: -------------------------------------------------------------------------------- 1 | import { renderTreeselect, getTagsElements } from '../../helpers' 2 | 3 | const values = { Option1: 1, Option2: 2, Option3: 3 } 4 | const options = [ 5 | { value: values.Option1, name: 'Option 1', children: [] }, 6 | { value: values.Option2, name: 'Option 2', children: [] }, 7 | { value: values.Option3, name: 'Option 3', children: [] } 8 | ] 9 | 10 | describe('updateValue method', () => { 11 | it('should update valid value', () => { 12 | const newValues = [values.Option1, values.Option2] 13 | const treeselect = renderTreeselect({ 14 | value: [], 15 | options 16 | }) 17 | 18 | treeselect.updateValue(newValues) 19 | 20 | const tags = getTagsElements(treeselect.parentHtmlContainer) 21 | 22 | expect(tags.length).toBe(newValues.length) 23 | expect(treeselect.value).toEqual(newValues) 24 | }) 25 | 26 | it("shouldn't update invalid value", () => { 27 | const treeselect = renderTreeselect({ 28 | value: [], 29 | options 30 | }) 31 | 32 | treeselect.updateValue([values.Option1, 4]) 33 | 34 | const tags = getTagsElements(treeselect.parentHtmlContainer) 35 | 36 | expect(tags.length).toBe(1) 37 | expect(treeselect.value).toEqual([values.Option1]) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /__tests__/testHelpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './options' 2 | export * from './selectors' 3 | -------------------------------------------------------------------------------- /__tests__/testHelpers/options.ts: -------------------------------------------------------------------------------- 1 | export const optionNames = { 2 | EnglandGroup: 'England', 3 | LondonGroup: 'London', 4 | ChelseaItem: 'Chelsea', 5 | WestEndItem: 'West End', 6 | BrightonItem: 'Brighton', 7 | FranceGroup: 'France', 8 | ParisItem: 'Paris', 9 | LyonItem: 'Lyon' 10 | } 11 | 12 | export const optionsValues = { 13 | EnglandGroup: 1, 14 | LondonGroup: 2, 15 | ChelseaItem: 3, 16 | WestEndItem: 4, 17 | BrightonItem: 5, 18 | FranceGroup: 6, 19 | ParisItem: 7, 20 | LyonItem: 8 21 | } 22 | 23 | export const noResultsText = 'No results found...' 24 | 25 | export const defaultOptions = [ 26 | { 27 | name: optionNames.EnglandGroup, 28 | value: optionsValues.EnglandGroup, 29 | children: [ 30 | { 31 | name: optionNames.LondonGroup, 32 | value: optionsValues.LondonGroup, 33 | children: [ 34 | { 35 | name: optionNames.ChelseaItem, 36 | value: optionsValues.ChelseaItem, 37 | children: [] 38 | }, 39 | { 40 | name: optionNames.WestEndItem, 41 | value: optionsValues.WestEndItem, 42 | children: [] 43 | } 44 | ] 45 | }, 46 | { 47 | name: optionNames.BrightonItem, 48 | value: optionsValues.BrightonItem, 49 | children: [] 50 | } 51 | ] 52 | }, 53 | { 54 | name: optionNames.FranceGroup, 55 | value: optionsValues.FranceGroup, 56 | children: [ 57 | { 58 | name: optionNames.ParisItem, 59 | value: optionsValues.ParisItem, 60 | children: [] 61 | }, 62 | { 63 | name: optionNames.LyonItem, 64 | value: optionsValues.LyonItem, 65 | children: [] 66 | } 67 | ] 68 | } 69 | ] 70 | 71 | export const optionsWithDisabled = [ 72 | { 73 | name: optionNames.EnglandGroup, 74 | value: optionsValues.EnglandGroup, 75 | children: [ 76 | { 77 | name: optionNames.LondonGroup, 78 | value: optionsValues.LondonGroup, 79 | children: [ 80 | { 81 | name: optionNames.ChelseaItem, 82 | value: optionsValues.ChelseaItem, 83 | children: [], 84 | disabled: true 85 | }, 86 | { 87 | name: optionNames.WestEndItem, 88 | value: optionsValues.WestEndItem, 89 | children: [] 90 | } 91 | ] 92 | }, 93 | { 94 | name: optionNames.BrightonItem, 95 | value: optionsValues.BrightonItem, 96 | children: [] 97 | } 98 | ] 99 | }, 100 | { 101 | name: optionNames.FranceGroup, 102 | value: optionsValues.FranceGroup, 103 | disabled: true, 104 | children: [ 105 | { 106 | name: optionNames.ParisItem, 107 | value: optionsValues.ParisItem, 108 | children: [] 109 | }, 110 | { 111 | name: optionNames.LyonItem, 112 | value: optionsValues.LyonItem, 113 | children: [] 114 | } 115 | ] 116 | } 117 | ] 118 | 119 | export const largeOptionsList = Array.from({ length: 30 }, (_, index) => ({ 120 | name: `Option ${index}`, 121 | value: index, 122 | children: [] 123 | })) 124 | 125 | export const largeNestedOptionsList = Array.from({ length: 1000 }, (_, index) => ({ 126 | name: `Option ${index}`, 127 | value: index, 128 | children: Array.from({ length: 3 }, (_, subIndex) => ({ 129 | name: `SubOption ${index}-${subIndex}`, 130 | value: `${index}-${subIndex}`, 131 | children: Array.from({ length: 3 }, (_, subSubIndex) => ({ 132 | name: `SubSubOption ${index}-${subIndex}-${subSubIndex}`, 133 | value: `${index}-${subIndex}-${subSubIndex}`, 134 | children: [] 135 | })) 136 | })) 137 | })) 138 | -------------------------------------------------------------------------------- /__tests__/testHelpers/selectors.ts: -------------------------------------------------------------------------------- 1 | type Classes = { 2 | parent: string 3 | input: { 4 | arrow: string 5 | base: string 6 | bottom: string 7 | clear: string 8 | edit: string 9 | focused: string 10 | opened: string 11 | tags: string 12 | tagsElement: string 13 | top: string 14 | } 15 | list: { 16 | base: string 17 | bottom: string 18 | bottomToBody: string 19 | empty: string 20 | item: string 21 | itemArrow: string 22 | itemChecked: string 23 | itemDisabled: string 24 | itemFocused: string 25 | itemGroup: string 26 | itemHidden: string 27 | itemPartialChecked: string 28 | itemScrollNotVisible: string 29 | itemNonSelectableGroup: string 30 | slot: string 31 | top: string 32 | topToBody: string 33 | static: string 34 | } 35 | } 36 | 37 | export const classes: Classes = { 38 | parent: 'treeselect', 39 | input: { 40 | arrow: 'treeselect-input__arrow', 41 | base: 'treeselect-input', 42 | bottom: 'treeselect-input--bottom', 43 | clear: 'treeselect-input__clear', 44 | edit: 'treeselect-input__edit', 45 | focused: 'treeselect-input--focused', 46 | opened: 'treeselect-input--opened', 47 | tags: 'treeselect-input__tags', 48 | tagsElement: 'treeselect-input__tags-element', 49 | top: 'treeselect-input--top' 50 | }, 51 | list: { 52 | base: 'treeselect-list', 53 | bottom: 'treeselect-list--bottom', 54 | bottomToBody: 'treeselect-list--bottom-to-body', 55 | empty: 'treeselect-list__empty', 56 | item: 'treeselect-list__item', 57 | itemArrow: 'treeselect-list__item-icon', 58 | itemChecked: 'treeselect-list__item--checked', 59 | itemDisabled: 'treeselect-list__item--disabled', 60 | itemFocused: 'treeselect-list__item--focused', 61 | itemGroup: 'treeselect-list__item--group', 62 | itemHidden: 'treeselect-list__item--hidden', 63 | itemPartialChecked: 'treeselect-list__item--partial-checked', 64 | itemScrollNotVisible: 'treeselect-list__item--scroll-not-visible', 65 | itemNonSelectableGroup: 'treeselect-list__item--non-selectable-group', 66 | slot: 'treeselect-list__slot', 67 | top: 'treeselect-list--top', 68 | topToBody: 'treeselect-list--top-to-body', 69 | static: 'treeselect-list--static' 70 | } 71 | } 72 | 73 | export const classesSelectors: Classes = { 74 | parent: `.${classes.parent}`, 75 | input: { 76 | arrow: `.${classes.input.arrow}`, 77 | base: `.${classes.input.base}`, 78 | bottom: `.${classes.input.bottom}`, 79 | clear: `.${classes.input.clear}`, 80 | edit: `.${classes.input.edit}`, 81 | focused: `.${classes.input.focused}`, 82 | opened: `.${classes.input.opened}`, 83 | tags: `.${classes.input.tags}`, 84 | tagsElement: `.${classes.input.tagsElement}`, 85 | top: `.${classes.input.top}` 86 | }, 87 | list: { 88 | base: `.${classes.list.base}`, 89 | bottom: `.${classes.list.bottom}`, 90 | bottomToBody: `.${classes.list.bottomToBody}`, 91 | empty: `.${classes.list.empty}`, 92 | item: `.${classes.list.item}`, 93 | itemArrow: `.${classes.list.itemArrow}`, 94 | itemChecked: `.${classes.list.itemChecked}`, 95 | itemDisabled: `.${classes.list.itemDisabled}`, 96 | itemFocused: `.${classes.list.itemFocused}`, 97 | itemGroup: `.${classes.list.itemGroup}`, 98 | itemHidden: `.${classes.list.itemHidden}`, 99 | itemPartialChecked: `.${classes.list.itemPartialChecked}`, 100 | itemScrollNotVisible: `.${classes.list.itemScrollNotVisible}`, 101 | itemNonSelectableGroup: `.${classes.list.itemNonSelectableGroup}`, 102 | slot: `.${classes.list.slot}`, 103 | top: `.${classes.list.top}`, 104 | topToBody: `.${classes.list.topToBody}`, 105 | static: `.${classes.list.static}` 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /app/app.css: -------------------------------------------------------------------------------- 1 | .treeselect-demo-slot__slot { 2 | height: 30px; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | } 7 | 8 | a.treeselect-demo-slot__slot { 9 | color: #101010; 10 | } 11 | 12 | body { 13 | font-family: 'Open Sans', sans-serif; 14 | background-color: #fbfbfb; 15 | margin: 0; 16 | } 17 | 18 | .loaded .root { 19 | opacity: 1; 20 | } 21 | 22 | .root { 23 | opacity: 0; 24 | transition: opacity 0.3s; 25 | padding: 40px 20px; 26 | } 27 | 28 | .section { 29 | min-height: 445px; 30 | } 31 | 32 | .section__separator { 33 | margin: 30px 0; 34 | height: 1px; 35 | background-color: #e5e5e5; 36 | } 37 | 38 | .root__title { 39 | text-align: center; 40 | margin-top: 0; 41 | } 42 | 43 | .section__title { 44 | text-align: center; 45 | } 46 | 47 | .section__data { 48 | display: flex; 49 | align-items: flex-start; 50 | justify-content: center; 51 | gap: 15px; 52 | flex-wrap: wrap; 53 | } 54 | 55 | .section__select { 56 | width: 50%; 57 | max-width: 400px; 58 | } 59 | 60 | .section__props { 61 | padding-left: 10px; 62 | width: 50%; 63 | max-width: 400px; 64 | border: 1px solid #e5e5e5; 65 | border-radius: 10px 0 0 10px; 66 | margin-top: 0; 67 | font-size: 12px; 68 | white-space: pre-wrap; 69 | max-height: 400px; 70 | overflow: auto; 71 | margin: 0; 72 | background-color: white; 73 | } 74 | 75 | @media screen and (max-width: 850px) { 76 | .section__data { 77 | flex-direction: column; 78 | align-items: center; 79 | } 80 | 81 | .section__select { 82 | width: 100%; 83 | } 84 | 85 | .section__props { 86 | width: 100%; 87 | max-width: 100%; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /app/app.js: -------------------------------------------------------------------------------- 1 | import '../src/treeselectjs.css' 2 | import Treeselect from '../src/treeselectjs' 3 | 4 | import { runDefaultExample } from './examples/default.js' 5 | import { runSlotExample } from './examples/slot.js' 6 | import { runDisabledExample } from './examples/disabled.js' 7 | import { runSingleSelectExample } from './examples/singleSelect.js' 8 | import { runIndependentNodesExample } from './examples/independentNodes.js' 9 | import { runIconsExample } from './examples/icons.js' 10 | 11 | const runApp = (Treeselect) => { 12 | runDefaultExample(Treeselect) 13 | runSlotExample(Treeselect) 14 | runDisabledExample(Treeselect) 15 | runSingleSelectExample(Treeselect) 16 | runIndependentNodesExample(Treeselect) 17 | runIconsExample(Treeselect) 18 | 19 | document.body.classList.add('loaded') 20 | } 21 | 22 | runApp(Treeselect) 23 | -------------------------------------------------------------------------------- /app/examples/default.js: -------------------------------------------------------------------------------- 1 | import { renderExampleSection } from '../render/renderExampleSection.js' 2 | 3 | const options = [ 4 | { 5 | name: 'England', 6 | value: 1, 7 | children: [ 8 | { 9 | name: 'London', 10 | value: 2, 11 | children: [ 12 | { 13 | name: 'Chelsea', 14 | value: 3, 15 | children: [] 16 | }, 17 | { 18 | name: 'West End', 19 | value: 4, 20 | children: [] 21 | } 22 | ] 23 | }, 24 | { 25 | name: 'Brighton', 26 | value: 5, 27 | children: [] 28 | } 29 | ] 30 | }, 31 | { 32 | name: 'France', 33 | value: 6, 34 | children: [ 35 | { 36 | name: 'Paris', 37 | value: 7, 38 | children: [] 39 | }, 40 | { 41 | name: 'Lyon', 42 | value: 8, 43 | children: [] 44 | } 45 | ] 46 | } 47 | ] 48 | 49 | const value = [4, 7, 8] 50 | 51 | const treeselectId = 'treeselect-demo-default' 52 | 53 | export const runDefaultExample = (Treeselect) => { 54 | renderExampleSection({ sectionId: 'default-section', value, options, treeselectId }) 55 | 56 | const domElement = document.getElementById(treeselectId) 57 | const treeselect = new Treeselect({ 58 | parentHtmlContainer: domElement, 59 | value, 60 | options 61 | }) 62 | 63 | treeselect.srcElement.addEventListener('input', (e) => { 64 | console.log('default: Selected value ', e.detail) 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /app/examples/disabled.js: -------------------------------------------------------------------------------- 1 | import { renderExampleSection } from '../render/renderExampleSection.js' 2 | 3 | const options = [ 4 | { 5 | name: 'England', 6 | value: 1, 7 | children: [ 8 | { 9 | name: 'London', 10 | value: 2, 11 | children: [ 12 | { 13 | name: 'Chelsea', 14 | value: 3, 15 | children: [], 16 | disabled: true 17 | }, 18 | { 19 | name: 'West End', 20 | value: 4, 21 | children: [] 22 | } 23 | ] 24 | }, 25 | { 26 | name: 'Brighton', 27 | value: 5, 28 | children: [] 29 | } 30 | ] 31 | }, 32 | { 33 | name: 'France', 34 | value: 6, 35 | disabled: true, 36 | children: [ 37 | { 38 | name: 'Paris', 39 | value: 7, 40 | children: [] 41 | }, 42 | { 43 | name: 'Lyon', 44 | value: 8, 45 | children: [] 46 | } 47 | ] 48 | } 49 | ] 50 | 51 | const value = [] 52 | 53 | const treeselectId = 'treeselect-demo-disabled' 54 | 55 | export const runDisabledExample = (Treeselect) => { 56 | renderExampleSection({ sectionId: 'disabled-section', options, value, treeselectId }) 57 | 58 | const domElement = document.getElementById(treeselectId) 59 | const treeselect = new Treeselect({ 60 | parentHtmlContainer: domElement, 61 | value, 62 | options 63 | }) 64 | 65 | treeselect.srcElement.addEventListener('input', (e) => { 66 | console.log('disabled: Selected value ', e.detail) 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /app/examples/independentNodes.js: -------------------------------------------------------------------------------- 1 | import { renderExampleSection } from '../render/renderExampleSection.js' 2 | 3 | const options = [ 4 | { 5 | name: 'England', 6 | value: 1, 7 | children: [ 8 | { 9 | name: 'London', 10 | value: 2, 11 | children: [ 12 | { 13 | name: 'Chelsea', 14 | value: 3, 15 | children: [] 16 | }, 17 | { 18 | name: 'West End', 19 | value: 4, 20 | children: [] 21 | } 22 | ] 23 | }, 24 | { 25 | name: 'Brighton', 26 | value: 5, 27 | children: [] 28 | } 29 | ] 30 | }, 31 | { 32 | name: 'France', 33 | value: 6, 34 | children: [ 35 | { 36 | name: 'Paris', 37 | value: 7, 38 | children: [] 39 | }, 40 | { 41 | name: 'Lyon', 42 | value: 8, 43 | children: [] 44 | } 45 | ] 46 | } 47 | ] 48 | 49 | const value = [1, 4, 7, 8] 50 | 51 | const treeselectId = 'treeselect-demo-independent-nodes' 52 | 53 | export const runIndependentNodesExample = (Treeselect) => { 54 | renderExampleSection({ sectionId: 'independent-nodes-section', options, value, treeselectId }) 55 | 56 | const domElement = document.getElementById(treeselectId) 57 | const treeselect = new Treeselect({ 58 | parentHtmlContainer: domElement, 59 | value, 60 | options, 61 | isIndependentNodes: true 62 | }) 63 | 64 | treeselect.srcElement.addEventListener('input', (e) => { 65 | console.log('independentNodes: Selected value ', e.detail) 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /app/examples/singleSelect.js: -------------------------------------------------------------------------------- 1 | import { renderExampleSection } from '../render/renderExampleSection.js' 2 | 3 | const options = [ 4 | { 5 | name: 'England', 6 | value: 1, 7 | children: [ 8 | { 9 | name: 'London', 10 | value: 2, 11 | children: [ 12 | { 13 | name: 'Chelsea', 14 | value: 3, 15 | children: [] 16 | }, 17 | { 18 | name: 'West End', 19 | value: 4, 20 | children: [] 21 | } 22 | ] 23 | }, 24 | { 25 | name: 'Brighton', 26 | value: 5, 27 | children: [] 28 | } 29 | ] 30 | }, 31 | { 32 | name: 'France', 33 | value: 6, 34 | children: [ 35 | { 36 | name: 'Paris', 37 | value: 7, 38 | children: [] 39 | }, 40 | { 41 | name: 'Lyon', 42 | value: 8, 43 | children: [] 44 | } 45 | ] 46 | } 47 | ] 48 | 49 | const value = 4 50 | 51 | const treeselectId = 'treeselect-demo-single-select' 52 | 53 | export const runSingleSelectExample = (Treeselect) => { 54 | renderExampleSection({ sectionId: 'single-select-section', options, value, treeselectId }) 55 | 56 | const domElement = document.getElementById(treeselectId) 57 | const treeselect = new Treeselect({ 58 | parentHtmlContainer: domElement, 59 | value, 60 | options, 61 | isSingleSelect: true, 62 | showTags: false 63 | }) 64 | 65 | treeselect.srcElement.addEventListener('input', (e) => { 66 | console.log('singleSelect: Selected value ', e.detail) 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /app/examples/slot.js: -------------------------------------------------------------------------------- 1 | import { renderExampleSection } from '../render/renderExampleSection.js' 2 | 3 | const options = [ 4 | { 5 | name: 'England', 6 | value: 1, 7 | children: [ 8 | { 9 | name: 'London', 10 | value: 2, 11 | children: [ 12 | { 13 | name: 'Chelsea', 14 | value: 3, 15 | children: [] 16 | }, 17 | { 18 | name: 'West End', 19 | value: 4, 20 | children: [] 21 | } 22 | ] 23 | }, 24 | { 25 | name: 'Brighton', 26 | value: 5, 27 | children: [] 28 | } 29 | ] 30 | } 31 | ] 32 | 33 | const value = [] 34 | 35 | const treeselectId = 'treeselect-demo-slot' 36 | 37 | export const runSlotExample = (Treeselect) => { 38 | renderExampleSection({ sectionId: 'slot-section', options, value, treeselectId }) 39 | 40 | const slot = document.createElement('div') 41 | slot.innerHTML = 'Click!' 42 | 43 | const domElement = document.getElementById(treeselectId) 44 | const treeselect = new Treeselect({ 45 | parentHtmlContainer: domElement, 46 | value, 47 | options, 48 | listSlotHtmlComponent: slot 49 | }) 50 | 51 | treeselect.srcElement.addEventListener('input', (e) => { 52 | console.log('slot: Selected value', e.detail) 53 | }) 54 | 55 | slot.addEventListener('click', (e) => { 56 | e.preventDefault() 57 | alert('Slot click!') 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Treeselect JS App 12 | 16 | 17 | 18 | 19 |
20 |

Treeselect JS Demo

21 | 22 | 23 |
24 |

Treeselect with default props

25 |
26 |
27 | 28 | 29 |
30 |

Treeselect with slot

31 |
32 |
33 | 34 | 35 |
36 |

Treeselect with disabled

37 |
38 |
39 | 40 | 41 |
42 |

Treeselect with single select prop

43 |
44 |
45 | 46 | 47 |
48 |

Treeselect with independent nodes prop

49 |
50 |
51 | 52 | 53 |
54 |

Treeselect with icons

55 |
56 |
57 | 58 | 59 | -------------------------------------------------------------------------------- /app/render/renderExampleSection.js: -------------------------------------------------------------------------------- 1 | export const renderExampleSection = ({ sectionId, value, options, treeselectId, codeSnipped }) => { 2 | const section = document.getElementById(sectionId) 3 | const div = document.createElement('div') 4 | div.classList.add('section__data') 5 | 6 | if (codeSnipped) { 7 | div.innerHTML = ` 8 |
9 |
10 |         
11 |   ${codeSnipped}
12 |         
13 |       
14 | ` 15 | } else { 16 | div.innerHTML = ` 17 |
18 |
19 |         
20 |   parentHtmlContainer: document.querySelector(className),
21 |   value: ${JSON.stringify(value, null, 0).replace(/,/g, ', ')},
22 |   options: ${JSON.stringify(options, null, 2)}
23 |         
24 |       
25 | ` 26 | } 27 | 28 | section.appendChild(div) 29 | } 30 | -------------------------------------------------------------------------------- /app/todo.js: -------------------------------------------------------------------------------- 1 | // IMPROVEMENTS (need to review) 2 | // !We need to review focus/blur system, because it is too complicated. 3 | // Slot is not selected by key navigation. if we use appendToBody mode. It is related to the new focus/blur approach. 4 | // Recheck performance 5 | // list scroll overlaps border 6 | // updateDisabled methods to avoid a remount 7 | // create update function for the list to avoid a remount 8 | // disable change focused element on fast scroll appendToBody 9 | // Should we add a list direction if we use staticList prop? 10 | 11 | // FEATURES (TODO) 12 | // opportunity to add empty groups without children 13 | // select input text with mouse (now it is unselectable) (done - experimental) 14 | // Add more e2e tests (cypress, jest) 15 | // Add "+ Count elements" additional text if tags are hidden 16 | 17 | // REQUESTS 18 | // Investigate an ability to add a diff names but the same values, and check them. 19 | 20 | // PERFORMANCE UPDATES TODO (List) 21 | // 1. Do we need uncheck all checkboxes if group is partial checked or otherwise we need to check all checkboxes if group is partial checked? 22 | // 2. Do we need to call disabled nodes and uncheck them? in adjustTreeItemOptions 23 | // 3. Do we need this functionality with isAllDisabled because we uncheck everything in the start and then we can't check disabled node? in updateFlattedOptionStateWithChildren 24 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress' 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | downloadsFolder: '__tests__/cypress/downloads', 6 | fileServerFolder: '__tests__/cypress/fixtures', 7 | screenshotsFolder: '__tests__/cypress/screenshots', 8 | specPattern: '__tests__/cypress/**/*.cy.ts', 9 | supportFile: '__tests__/cypress/support/e2e.ts', 10 | supportFolder: '__tests__/cypress/support', 11 | videosFolder: '__tests__/cypress/videos' 12 | } 13 | }) 14 | -------------------------------------------------------------------------------- /demo/index.css: -------------------------------------------------------------------------------- 1 | @import '../app/app.css'; 2 | -------------------------------------------------------------------------------- /demo/index.js: -------------------------------------------------------------------------------- 1 | import { runDefaultExample } from '../app/examples/default.js' 2 | import { runSlotExample } from '../app/examples/slot.js' 3 | import { runDisabledExample } from '../app/examples/disabled.js' 4 | import { runSingleSelectExample } from '../app/examples/singleSelect.js' 5 | import { runIndependentNodesExample } from '../app/examples/independentNodes.js' 6 | import { runIconsExample } from '../app/examples/icons.js' 7 | 8 | // Treeselect is available via the link in the index.html file (treeselectjs.umd.js) 9 | const Treeselect = globalThis.Treeselect 10 | 11 | if (!Treeselect) { 12 | throw new Error('Treeselect is not available. Make sure you have included the script in the index.html file') 13 | } 14 | 15 | const runApp = (Treeselect) => { 16 | runDefaultExample(Treeselect) 17 | runSlotExample(Treeselect) 18 | runDisabledExample(Treeselect) 19 | runSingleSelectExample(Treeselect) 20 | runIndependentNodesExample(Treeselect) 21 | runIconsExample(Treeselect) 22 | 23 | document.body.classList.add('loaded') 24 | } 25 | 26 | runApp(Treeselect) 27 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Treeselect JS Demo 16 | 20 | 21 | 22 | 23 |
24 |

Treeselect JS Demo

25 | 26 | 27 |
28 |

Treeselect with default props

29 |
30 |
31 | 32 | 33 |
34 |

Treeselect with slot

35 |
36 |
37 | 38 | 39 |
40 |

Treeselect with disabled

41 |
42 |
43 | 44 | 45 |
46 |

Treeselect with single select prop

47 |
48 |
49 | 50 | 51 |
52 |

Treeselect with independent nodes prop

53 |
54 |
55 | 56 | 57 |
58 |

Treeselect with icons

59 |
60 |
61 | 62 | 63 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jest-environment-jsdom', 4 | transform: { 5 | '^.+\\.ts?$': 'ts-jest' 6 | }, 7 | moduleFileExtensions: ['ts', 'js'], 8 | modulePathIgnorePatterns: ['/__tests__/jest/helpers'], 9 | moduleNameMapper: { 10 | '\\.(css|less|scss|sass)$': 'identity-obj-proxy' 11 | }, 12 | testMatch: ['**/__tests__/**/*.test.ts'], 13 | setupFilesAfterEnv: ['/__tests__/jest/setup.ts'] 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "treeselectjs", 3 | "version": "0.13.1", 4 | "description": "Treeselect JS", 5 | "main": "./dist/treeselectjs.umd.js", 6 | "module": "./dist/treeselectjs.mjs", 7 | "types": "./dist/treeselectjs.d.ts", 8 | "exports": { 9 | ".": { 10 | "import": { 11 | "types": "./dist/treeselectjs.d.ts", 12 | "default": "./dist/treeselectjs.mjs" 13 | }, 14 | "require": { 15 | "types": "./dist/treeselectjs.d.ts", 16 | "default": "./dist/treeselectjs.umd.js" 17 | } 18 | }, 19 | "./dist/treeselectjs.css": { 20 | "import": "./dist/treeselectjs.css", 21 | "require": "./dist/treeselectjs.css" 22 | } 23 | }, 24 | "scripts": { 25 | "dev": "vite --port 5173", 26 | "build": "tsc && vite build", 27 | "typecheck": "tsc --project tsconfig.app.json --noEmit && tsc --project tsconfig.jest.json --noEmit && tsc --project tsconfig.cypress.json --noEmit", 28 | "preview": "vite preview", 29 | "prettier": "prettier --w .", 30 | "jest:watch": "jest --watch", 31 | "jest:run": "jest", 32 | "cypress:open": "cypress open", 33 | "cypress:run": "cypress run", 34 | "test": "npm run typecheck && npm run jest:run && npm run cypress:run" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "https://github.com/dipson88/treeselectjs.git" 39 | }, 40 | "keywords": [ 41 | "treeselect", 42 | "select", 43 | "groupselect", 44 | "js", 45 | "ts", 46 | "typescript", 47 | "vanilla" 48 | ], 49 | "author": "Dzmitry Zhuraukou", 50 | "license": "MIT", 51 | "bugs": { 52 | "url": "https://github.com/dipson88/treeselectjs/issues" 53 | }, 54 | "homepage": "https://github.com/dipson88/treeselectjs#readme", 55 | "devDependencies": { 56 | "@testing-library/dom": "^10.4.0", 57 | "@testing-library/jest-dom": "^6.6.3", 58 | "@types/jest": "^29.5.14", 59 | "cypress": "^13.17.0", 60 | "identity-obj-proxy": "^3.0.0", 61 | "intersection-observer": "^0.12.2", 62 | "jest": "^29.7.0", 63 | "jest-environment-jsdom": "^29.7.0", 64 | "prettier": "^3.5.1", 65 | "resize-observer-polyfill": "^1.5.1", 66 | "ts-jest": "^29.2.5", 67 | "ts-node": "^10.9.2", 68 | "typescript": "^5.7.3", 69 | "vite": "^6.2.0", 70 | "vite-plugin-dts": "^4.5.0" 71 | }, 72 | "files": [ 73 | "dist" 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /public/tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dipson88/treeselectjs/298b041a298463d167464f13f76f9d505fb8988c/public/tree.png -------------------------------------------------------------------------------- /src/input/input.css: -------------------------------------------------------------------------------- 1 | .treeselect-input { 2 | width: 100%; 3 | box-sizing: border-box; 4 | border: 1px solid #d7dde4; 5 | border-radius: 4px; 6 | display: flex; 7 | align-items: center; 8 | flex-wrap: wrap; 9 | padding: 2px 4px; 10 | padding-right: 40px; 11 | position: relative; 12 | min-height: 37px; 13 | background-color: #ffffff; 14 | cursor: text; 15 | } 16 | 17 | .treeselect-input--unsearchable { 18 | cursor: default; 19 | } 20 | 21 | .treeselect-input--unsearchable .treeselect-input__edit { 22 | caret-color: transparent; 23 | cursor: default; 24 | } 25 | 26 | .treeselect-input--unsearchable .treeselect-input__edit:focus { 27 | position: absolute; 28 | z-index: -1; 29 | left: 0; 30 | min-width: 0; 31 | width: 0; 32 | } 33 | 34 | .treeselect-input--value-not-selected .treeselect-input__edit, 35 | .treeselect-input--value-not-selected.treeselect-input--unsearchable .treeselect-input__edit:focus { 36 | z-index: auto; 37 | position: static; 38 | width: 100%; 39 | max-width: 100%; 40 | } 41 | 42 | .treeselect-input--value-not-selected .treeselect-input__tags { 43 | gap: 0; 44 | } 45 | 46 | /* rtl */ 47 | [dir='rtl'] .treeselect-input { 48 | padding-right: 4px; 49 | padding-left: 40px; 50 | } 51 | 52 | [dir='rtl'] .treeselect-input__operators { 53 | right: unset; 54 | left: 2px; 55 | } 56 | /* rtl end */ 57 | 58 | .treeselect-input__tags { 59 | display: inline-flex; 60 | align-items: center; 61 | flex-wrap: wrap; 62 | gap: 4px; 63 | max-width: 100%; 64 | width: 100%; 65 | box-sizing: border-box; 66 | } 67 | 68 | .treeselect-input__tags-element { 69 | display: inline-flex; 70 | align-items: center; 71 | background-color: #d7dde4; 72 | cursor: pointer; 73 | padding: 2px 5px; 74 | border-radius: 2px; 75 | font-size: 14px; 76 | max-width: 100%; 77 | box-sizing: border-box; 78 | } 79 | 80 | .treeselect-input__tags-element:hover { 81 | background-color: #c5c7cb; 82 | } 83 | 84 | .treeselect-input__tags-element:hover .treeselect-input__tags-cross svg { 85 | stroke: #eb4c42; 86 | } 87 | 88 | .treeselect-input__tags-name { 89 | overflow: hidden; 90 | white-space: nowrap; 91 | text-overflow: ellipsis; 92 | } 93 | 94 | .treeselect-input__tags-cross { 95 | display: flex; 96 | margin-left: 2px; 97 | } 98 | 99 | .treeselect-input__tags-cross svg { 100 | width: 12px; 101 | height: 12px; 102 | } 103 | 104 | .treeselect-input__tags-count { 105 | font-size: 14px; 106 | overflow: hidden; 107 | white-space: nowrap; 108 | text-overflow: ellipsis; 109 | } 110 | 111 | .treeselect-input__edit { 112 | flex: 1; 113 | border: none; 114 | font-size: 14px; 115 | text-overflow: ellipsis; 116 | 117 | width: 100%; 118 | max-width: calc(100% - 45px); 119 | padding: 0; 120 | 121 | /* Hide input for user */ 122 | position: absolute; 123 | z-index: -1; 124 | min-width: 0; 125 | } 126 | 127 | .treeselect-input__edit:focus { 128 | outline: none; 129 | min-width: 30px; 130 | max-width: 100%; 131 | 132 | /* Show input for user */ 133 | z-index: auto; 134 | position: static; 135 | } 136 | 137 | .treeselect-input__operators { 138 | display: flex; 139 | max-width: 40px; 140 | position: absolute; 141 | right: 2px; 142 | } 143 | 144 | .treeselect-input__clear { 145 | display: flex; 146 | cursor: pointer; 147 | } 148 | 149 | .treeselect-input__clear svg { 150 | stroke: #c5c7cb; 151 | width: 17px; 152 | min-width: 17px; 153 | height: 20px; 154 | } 155 | 156 | .treeselect-input__clear:hover svg { 157 | stroke: #838790; 158 | } 159 | 160 | .treeselect-input__arrow { 161 | display: flex; 162 | cursor: pointer; 163 | } 164 | 165 | .treeselect-input__arrow svg { 166 | stroke: #c5c7cb; 167 | width: 20px; 168 | min-width: 20px; 169 | height: 20px; 170 | } 171 | 172 | .treeselect-input__arrow:hover svg { 173 | stroke: #838790; 174 | } 175 | -------------------------------------------------------------------------------- /src/input/inputTypes.ts: -------------------------------------------------------------------------------- 1 | import { type InnerOptionType, type IconsType } from '../treeselectTypes' 2 | 3 | export interface ITreeselectInputParams { 4 | value: InnerOptionType[] 5 | showTags: boolean 6 | tagsCountText: string 7 | clearable: boolean 8 | isAlwaysOpened: boolean 9 | searchable: boolean 10 | placeholder: string 11 | disabled: boolean 12 | isSingleSelect: boolean 13 | id: string 14 | ariaLabel: string 15 | iconElements: IconsType 16 | inputCallback: (value: InnerOptionType[]) => void 17 | searchCallback: (value: string) => void 18 | openCallback: () => void 19 | closeCallback: () => void 20 | keydownCallback: (e: KeyboardEvent) => void 21 | focusCallback: () => void 22 | nameChangeCallback: (name: string) => void 23 | } 24 | 25 | export interface ITreeselectInput extends ITreeselectInputParams { 26 | isOpened: boolean 27 | searchText: string 28 | srcElement: HTMLElement | Element 29 | focus: () => void 30 | blur: () => void 31 | updateValue: (newValue: InnerOptionType[]) => void 32 | removeItem: (id: string) => void 33 | clear: () => void 34 | openClose: () => void 35 | clearSearch: () => void 36 | } 37 | -------------------------------------------------------------------------------- /src/list/helpers/domHelper.ts: -------------------------------------------------------------------------------- 1 | import { type ValueOptionType, type IconsType } from '../../treeselectTypes' 2 | import { type OptionsTreeMap, type TreeItem } from '../listTypes' 3 | import { appendIconToElement } from '../../svgIcons' 4 | 5 | export const updateDOM = ({ 6 | optionsTreeMap, 7 | emptyListHtmlElement, 8 | iconElements, 9 | previousSingleSelectedValue, 10 | rtl 11 | }: { 12 | optionsTreeMap: OptionsTreeMap 13 | emptyListHtmlElement: HTMLElement | null 14 | iconElements: IconsType 15 | previousSingleSelectedValue: ValueOptionType[] 16 | rtl: boolean 17 | }) => { 18 | optionsTreeMap.forEach((option) => { 19 | const input = option.checkboxHtmlElement 20 | 21 | if (input) { 22 | input.checked = option.checked 23 | } 24 | 25 | updateCheckedClass({ option, previousSingleSelectedValue }) 26 | updatePartialCheckedClass(option) 27 | updateDisabledCheckedClass(option) 28 | updateClosedClass({ option, iconElements }) 29 | updateHiddenClass(option) 30 | updateLeftPaddingItems({ option, optionsTreeMap, rtl }) 31 | updateCheckboxClass({ option, iconElements }) 32 | updateGroupSelectableClass(option) 33 | }) 34 | 35 | updateEmptyListClass({ optionsTreeMap, emptyListHtmlElement }) 36 | } 37 | 38 | const updateLeftPaddingItems = ({ 39 | option, 40 | optionsTreeMap, 41 | rtl 42 | }: { 43 | option: TreeItem 44 | optionsTreeMap: OptionsTreeMap 45 | rtl: boolean 46 | }) => { 47 | const isZeroLevel = option.level === 0 48 | const defaultPadding = 20 49 | const zeroLevelItemPadding = 5 50 | let padding = '0' 51 | 52 | if (isZeroLevel) { 53 | let isGroupsExistOnLevel = false 54 | 55 | for (const [_, item] of optionsTreeMap) { 56 | if (item.isGroup && item.level === option.level) { 57 | isGroupsExistOnLevel = true 58 | break 59 | } 60 | } 61 | 62 | const itemPadding = !option.isGroup && isGroupsExistOnLevel ? `${defaultPadding}px` : `${zeroLevelItemPadding}px` 63 | padding = option.isGroup ? '0' : itemPadding 64 | } else { 65 | padding = option.isGroup 66 | ? `${option.level * defaultPadding}px` 67 | : `${option.level * defaultPadding + defaultPadding}px` 68 | } 69 | 70 | const listItem = option.itemHtmlElement 71 | 72 | if (listItem) { 73 | if (rtl) { 74 | listItem.style.paddingRight = padding 75 | } else { 76 | listItem.style.paddingLeft = padding 77 | } 78 | 79 | // We can use css selectors to reset params with !important 80 | listItem.setAttribute('level', option.level.toString()) 81 | listItem.setAttribute('group', option.isGroup.toString()) 82 | } 83 | } 84 | 85 | const updateEmptyListClass = ({ 86 | optionsTreeMap, 87 | emptyListHtmlElement 88 | }: { 89 | optionsTreeMap: OptionsTreeMap 90 | emptyListHtmlElement: HTMLElement | null 91 | }) => { 92 | let isNotEmpty = false 93 | 94 | for (const [_, option] of optionsTreeMap) { 95 | if (!option.hidden) { 96 | isNotEmpty = true 97 | break 98 | } 99 | } 100 | 101 | emptyListHtmlElement?.classList.toggle('treeselect-list__empty--hidden', isNotEmpty) 102 | } 103 | 104 | export const setAttributesFromHtmlAttr = (itemElement: HTMLDivElement, htmlAttr?: object) => { 105 | if (!htmlAttr) { 106 | return 107 | } 108 | 109 | Object.keys(htmlAttr).forEach((key) => { 110 | const value = htmlAttr[key as keyof object] 111 | 112 | if (typeof value === 'string') { 113 | itemElement.setAttribute(key, value) 114 | } 115 | }) 116 | } 117 | 118 | const updateCheckedClass = ({ 119 | option, 120 | previousSingleSelectedValue 121 | }: { 122 | option: TreeItem 123 | previousSingleSelectedValue: ValueOptionType[] 124 | }) => { 125 | const listItem = option.itemHtmlElement 126 | listItem?.classList.toggle('treeselect-list__item--checked', option.checked) 127 | const isCheckSingleSelected = 128 | Array.isArray(previousSingleSelectedValue) && previousSingleSelectedValue[0] === option.id && !option.disabled 129 | listItem?.classList.toggle('treeselect-list__item--single-selected', isCheckSingleSelected) 130 | } 131 | 132 | const updatePartialCheckedClass = (option: TreeItem) => { 133 | const listItem = option.itemHtmlElement 134 | listItem?.classList.toggle('treeselect-list__item--partial-checked', option.isPartialChecked) 135 | } 136 | 137 | const updateDisabledCheckedClass = (option: TreeItem) => { 138 | const listItem = option.itemHtmlElement 139 | listItem?.classList.toggle('treeselect-list__item--disabled', option.disabled) 140 | } 141 | 142 | const updateClosedClass = ({ option, iconElements }: { option: TreeItem; iconElements: IconsType }) => { 143 | const arrowIcon = option.arrowItemHtmlElement 144 | 145 | if (option.isGroup && arrowIcon) { 146 | const iconInnerElement = option.isClosed ? iconElements.arrowRight : iconElements.arrowDown 147 | appendIconToElement(iconInnerElement, arrowIcon) 148 | 149 | const listItem = option.itemHtmlElement 150 | listItem?.classList.toggle('treeselect-list__item--closed', option.isClosed) 151 | } 152 | } 153 | 154 | const updateHiddenClass = (option: TreeItem) => { 155 | const listItem = option.itemHtmlElement 156 | listItem?.classList.toggle('treeselect-list__item--hidden', option.hidden) 157 | } 158 | 159 | const updateCheckboxClass = ({ option, iconElements }: { option: TreeItem; iconElements: IconsType }) => { 160 | const icon = option.checkboxIconHtmlElement 161 | 162 | if (icon) { 163 | if (option.checked) { 164 | appendIconToElement(iconElements.check, icon) 165 | } else if (option.isPartialChecked) { 166 | appendIconToElement(iconElements.partialCheck, icon) 167 | } else { 168 | icon.innerHTML = '' 169 | } 170 | } 171 | } 172 | 173 | const updateGroupSelectableClass = (option: TreeItem) => { 174 | const listItem = option.itemHtmlElement 175 | listItem?.classList.toggle('treeselect-list__item--non-selectable-group', !option.isGroupSelectable) 176 | } 177 | -------------------------------------------------------------------------------- /src/list/helpers/listCheckStateHelper.ts: -------------------------------------------------------------------------------- 1 | import { type ValueOptionType } from '../../treeselectTypes' 2 | import { type TreeItem, type OptionsTreeMap } from '../listTypes' 3 | import { getDirectChildrenOptions } from './listOptionsHelper' 4 | 5 | export const updateOptionsByValue = ({ 6 | newValue, 7 | optionsTreeMap, 8 | isSingleSelect, 9 | isIndependentNodes 10 | }: { 11 | newValue: ValueOptionType[] 12 | optionsTreeMap: OptionsTreeMap 13 | isSingleSelect: boolean 14 | isIndependentNodes: boolean 15 | }) => { 16 | uncheckedAllTreeItemOptions(optionsTreeMap) 17 | const optionsToCheck = newValue 18 | .map((id) => optionsTreeMap.get(id) ?? null) 19 | .filter((option) => option !== null && !option.disabled) as TreeItem[] 20 | const [firstItem] = optionsToCheck 21 | 22 | if (isSingleSelect && optionsToCheck.length && firstItem) { 23 | firstItem.checked = true 24 | return 25 | } 26 | 27 | optionsToCheck.forEach((option) => { 28 | option.checked = true 29 | const resultChecked = updateOptionByCheckState({ 30 | option, 31 | optionsTreeMap, 32 | isIndependentNodes 33 | }) 34 | option.checked = resultChecked 35 | }) 36 | } 37 | 38 | export const updateOptionByCheckState = ({ 39 | option: { id, checked }, 40 | optionsTreeMap, 41 | isIndependentNodes 42 | }: { 43 | option: Pick 44 | optionsTreeMap: OptionsTreeMap 45 | isIndependentNodes: boolean 46 | }) => { 47 | const currentOption = optionsTreeMap.get(id) ?? null 48 | 49 | if (currentOption === null) { 50 | return false 51 | } 52 | 53 | if (isIndependentNodes) { 54 | currentOption.checked = !currentOption.disabled && checked 55 | 56 | return currentOption.checked 57 | } 58 | 59 | const resultCheckedState = updateTreeItemOptionStateWithChildren({ 60 | checked, 61 | currentOption, 62 | optionsTreeMap 63 | }) 64 | updateParentTreeItemOptions({ 65 | childOption: currentOption, 66 | optionsTreeMap 67 | }) 68 | 69 | return resultCheckedState 70 | } 71 | 72 | const updateTreeItemOptionStateWithChildren = ({ 73 | checked, 74 | currentOption, 75 | optionsTreeMap 76 | }: { 77 | checked: boolean 78 | currentOption: TreeItem 79 | optionsTreeMap: OptionsTreeMap 80 | }) => { 81 | if (!currentOption.isGroup) { 82 | currentOption.checked = !currentOption.disabled && checked 83 | currentOption.isPartialChecked = false 84 | 85 | return currentOption.checked 86 | } 87 | 88 | const childrenOptions = getDirectChildrenOptions({ id: currentOption.id, optionsTreeMap }) 89 | const falseOrDisabledOrPartial = !checked || currentOption.disabled || currentOption.isPartialChecked 90 | 91 | if (falseOrDisabledOrPartial) { 92 | currentOption.checked = false 93 | currentOption.isPartialChecked = false 94 | checkUncheckAllChildren({ 95 | option: currentOption, 96 | children: childrenOptions, 97 | optionsTreeMap 98 | }) 99 | 100 | return currentOption.checked 101 | } 102 | 103 | const canWeCheckAllChildren = !isSomeChildrenDisabled({ 104 | children: childrenOptions, 105 | optionsTreeMap 106 | }) 107 | 108 | if (canWeCheckAllChildren) { 109 | currentOption.checked = true 110 | currentOption.isPartialChecked = false 111 | checkUncheckAllChildren({ 112 | option: currentOption, 113 | children: childrenOptions, 114 | optionsTreeMap 115 | }) 116 | 117 | return currentOption.checked 118 | } 119 | 120 | const isAllDisabled = isAllDirectChildrenDisabled(childrenOptions) 121 | 122 | if (isAllDisabled) { 123 | currentOption.checked = false 124 | currentOption.isPartialChecked = false 125 | currentOption.disabled = true 126 | 127 | return currentOption.checked 128 | } 129 | 130 | currentOption.checked = false 131 | currentOption.isPartialChecked = true 132 | 133 | childrenOptions.forEach((options) => { 134 | updateTreeItemOptionStateWithChildren({ 135 | checked, 136 | currentOption: options, 137 | optionsTreeMap 138 | }) 139 | }) 140 | 141 | return currentOption.checked 142 | } 143 | 144 | const updateParentTreeItemOptions = ({ 145 | childOption, 146 | optionsTreeMap 147 | }: { 148 | childOption: TreeItem 149 | optionsTreeMap: OptionsTreeMap 150 | }) => { 151 | const parentOption = optionsTreeMap.get(childOption.childOf) ?? null 152 | 153 | if (parentOption === null) { 154 | return 155 | } 156 | 157 | updateParentOption({ parentOption, optionsTreeMap }) 158 | updateParentTreeItemOptions({ 159 | childOption: parentOption, 160 | optionsTreeMap 161 | }) 162 | } 163 | 164 | const updateParentOption = ({ 165 | parentOption, 166 | optionsTreeMap 167 | }: { 168 | parentOption: TreeItem 169 | optionsTreeMap: OptionsTreeMap 170 | }) => { 171 | const children = getDirectChildrenOptions({ id: parentOption.id, optionsTreeMap }) 172 | const isAllDisabled = isAllDirectChildrenDisabled(children) 173 | 174 | if (isAllDisabled) { 175 | parentOption.checked = false 176 | parentOption.isPartialChecked = false 177 | parentOption.disabled = true 178 | 179 | return 180 | } 181 | 182 | const isAllChecked = isAllDirectChildrenChecked(children) 183 | 184 | if (isAllChecked) { 185 | parentOption.checked = true 186 | parentOption.isPartialChecked = false 187 | 188 | return 189 | } 190 | 191 | const isSomeCheckedOrPartial = isSomeDirectChildrenCheckedOrPartial(children) 192 | 193 | if (isSomeCheckedOrPartial) { 194 | parentOption.checked = false 195 | parentOption.isPartialChecked = true 196 | 197 | return 198 | } 199 | 200 | parentOption.checked = false 201 | parentOption.isPartialChecked = false 202 | } 203 | 204 | const checkUncheckAllChildren = ({ 205 | option: { checked, disabled }, 206 | children, 207 | optionsTreeMap 208 | }: { 209 | option: Pick 210 | children: TreeItem[] 211 | optionsTreeMap: OptionsTreeMap 212 | }) => { 213 | children.forEach((option) => { 214 | option.disabled = disabled || option.disabled 215 | option.checked = checked && !option.disabled 216 | option.isPartialChecked = false 217 | 218 | const subChildren = getDirectChildrenOptions({ id: option.id, optionsTreeMap }) 219 | checkUncheckAllChildren({ 220 | option: { checked, disabled }, 221 | children: subChildren, 222 | optionsTreeMap 223 | }) 224 | }) 225 | } 226 | 227 | const isSomeChildrenDisabled = ({ 228 | children, 229 | optionsTreeMap 230 | }: { 231 | children: TreeItem[] 232 | optionsTreeMap: OptionsTreeMap 233 | }): boolean => { 234 | const isSomeDisabled = children.some((option) => option.disabled) 235 | 236 | if (isSomeDisabled) { 237 | return true 238 | } 239 | 240 | return children.some((option) => { 241 | if (!option.isGroup) { 242 | return false 243 | } 244 | 245 | const subChildren = getDirectChildrenOptions({ id: option.id, optionsTreeMap }) 246 | 247 | return isSomeChildrenDisabled({ children: subChildren, optionsTreeMap }) 248 | }) 249 | } 250 | 251 | const isAllDirectChildrenDisabled = (children: TreeItem[]) => { 252 | return children.every((option) => !!option.disabled) 253 | } 254 | 255 | const isAllDirectChildrenChecked = (children: TreeItem[]) => { 256 | return children.every((option) => option.checked) 257 | } 258 | 259 | const isSomeDirectChildrenCheckedOrPartial = (children: TreeItem[]) => { 260 | return children.some((option) => option.checked || option.isPartialChecked) 261 | } 262 | 263 | const uncheckedAllTreeItemOptions = (optionsTreeMap: OptionsTreeMap) => { 264 | optionsTreeMap.forEach((option) => { 265 | option.checked = false 266 | option.isPartialChecked = false 267 | }) 268 | } 269 | -------------------------------------------------------------------------------- /src/list/helpers/listOptionsHelper.ts: -------------------------------------------------------------------------------- 1 | import { type OptionType, type ValueOptionType } from '../../treeselectTypes' 2 | import { TreeItem, type OptionsTreeMap } from '../listTypes' 3 | import { updateOptionByCheckState } from './listCheckStateHelper' 4 | 5 | export const getOptionsTreeMap = ({ 6 | options, 7 | openLevel, 8 | isIndependentNodes 9 | }: { 10 | options: OptionType[] 11 | openLevel: number 12 | isIndependentNodes: boolean 13 | }) => { 14 | const defaultParams = { level: 0, groupId: '' } 15 | const optionsTreeMap: OptionsTreeMap = new Map() 16 | 17 | getTreeOptions({ 18 | optionsTreeMap, 19 | options, 20 | openLevel, 21 | groupId: defaultParams.groupId, 22 | level: defaultParams.level 23 | }) 24 | 25 | adjustTreeItemOptions({ optionsTreeMap, isIndependentNodes }) 26 | 27 | return optionsTreeMap 28 | } 29 | 30 | const getTreeOptions = ({ 31 | optionsTreeMap, 32 | options, 33 | openLevel, 34 | groupId, 35 | level 36 | }: { 37 | optionsTreeMap: OptionsTreeMap 38 | options: OptionType[] 39 | openLevel: number 40 | groupId: ValueOptionType 41 | level: number 42 | }) => { 43 | options.forEach((option) => { 44 | const isGroup = (option.children?.length ?? 0) > 0 45 | const isClosed = level >= openLevel && isGroup 46 | const hidden = level > openLevel 47 | 48 | const children = option.children?.map((child) => child.value) ?? [] 49 | const optionId = option.value 50 | 51 | if (optionsTreeMap.has(optionId)) { 52 | console.error( 53 | `Validation: You have duplicated option value: ${optionId}! You should use unique values. Duplicates will lead to unexpected behavior.` 54 | ) 55 | } 56 | 57 | optionsTreeMap.set(optionId, { 58 | id: optionId, 59 | name: option.name, 60 | childOf: groupId, 61 | isGroup, 62 | checked: false, 63 | isPartialChecked: false, 64 | level, 65 | isClosed, 66 | hidden, 67 | disabled: option.disabled ?? false, 68 | isGroupSelectable: !isGroup || (option.isGroupSelectable ?? true), 69 | children, 70 | // Html elements will be added during their creation 71 | checkboxHtmlElement: null, 72 | itemHtmlElement: null, 73 | arrowItemHtmlElement: null, 74 | checkboxIconHtmlElement: null 75 | }) 76 | 77 | if (isGroup) { 78 | getTreeOptions({ 79 | optionsTreeMap, 80 | options: option.children, 81 | openLevel, 82 | groupId: optionId, 83 | level: level + 1 84 | }) 85 | } 86 | }) 87 | } 88 | 89 | export const getTreeItemOptionByInputId = (inputId: string | null, optionsTreeMap: OptionsTreeMap) => { 90 | if (inputId === null) { 91 | return null 92 | } 93 | 94 | return optionsTreeMap.get(inputId) ?? optionsTreeMap.get(parseInt(inputId)) ?? null 95 | } 96 | 97 | export const getDirectChildrenOptions = ({ 98 | id, 99 | optionsTreeMap 100 | }: { 101 | id: ValueOptionType 102 | optionsTreeMap: OptionsTreeMap 103 | }) => { 104 | const option = optionsTreeMap.get(id) ?? null 105 | 106 | if (option === null) { 107 | return [] 108 | } 109 | 110 | return option.children.reduce((acc, curr) => { 111 | const child = optionsTreeMap.get(curr) ?? null 112 | 113 | if (child !== null) { 114 | acc.push(child) 115 | } 116 | 117 | return acc 118 | }, []) 119 | } 120 | 121 | export const getCheckedOptions = (optionsTreeMap: OptionsTreeMap) => { 122 | const ungroupedNodes: TreeItem[] = [] 123 | const allGroupedNodes: TreeItem[] = [] 124 | const allNodes: TreeItem[] = [] 125 | 126 | optionsTreeMap.forEach((option) => { 127 | if (!option.checked) { 128 | return 129 | } 130 | 131 | allNodes.push(option) 132 | 133 | if (option.isGroup) { 134 | allGroupedNodes.push(option) 135 | } else { 136 | ungroupedNodes.push(option) 137 | } 138 | }) 139 | 140 | const groupedNodes = allNodes.filter((node) => !allGroupedNodes.some(({ id }) => id === node.childOf)) 141 | 142 | return { ungroupedNodes, groupedNodes, allNodes } 143 | } 144 | 145 | const adjustTreeItemOptions = ({ 146 | optionsTreeMap, 147 | isIndependentNodes 148 | }: { 149 | optionsTreeMap: OptionsTreeMap 150 | isIndependentNodes: boolean 151 | }) => { 152 | // Disabled update 153 | const disabledNodes: TreeItem[] = [] 154 | 155 | optionsTreeMap.forEach((option) => { 156 | if (option.disabled) { 157 | disabledNodes.push(option) 158 | } 159 | }) 160 | 161 | disabledNodes.forEach(({ id }) => 162 | updateOptionByCheckState({ 163 | option: { id, checked: false }, 164 | optionsTreeMap, 165 | isIndependentNodes 166 | }) 167 | ) 168 | } 169 | -------------------------------------------------------------------------------- /src/list/helpers/listVisibilityStateHelper.ts: -------------------------------------------------------------------------------- 1 | import { type ValueOptionType } from '../../treeselectTypes' 2 | import { type TreeItem, type OptionsTreeMap, type BeforeSearchStateMap } from '../listTypes' 3 | import { getDirectChildrenOptions } from './listOptionsHelper' 4 | 5 | export const hideShowChildrenOptions = ( 6 | optionsTreeMap: OptionsTreeMap, 7 | { id, isClosed }: Pick 8 | ) => { 9 | const allChildrenOptions = getDirectChildrenOptions({ id, optionsTreeMap }) 10 | 11 | allChildrenOptions.forEach((option) => { 12 | option.hidden = isClosed ?? false 13 | 14 | if (option.isGroup && !option.isClosed) { 15 | hideShowChildrenOptions(optionsTreeMap, { id: option.id, isClosed }) 16 | } 17 | }) 18 | } 19 | 20 | export const expandSelectedItems = (optionsTreeMap: OptionsTreeMap, isSingleSelect: boolean) => { 21 | if (isSingleSelect) { 22 | expandSingleSelect(optionsTreeMap) 23 | return 24 | } 25 | 26 | optionsTreeMap.forEach((option) => { 27 | if (option.checked) { 28 | expandAllParents(option.childOf, optionsTreeMap) 29 | } 30 | 31 | if (option.isGroup && !option.disabled && (option.checked || option.isPartialChecked)) { 32 | option.isClosed = false 33 | hideShowChildrenOptions(optionsTreeMap, option) 34 | } 35 | }) 36 | } 37 | 38 | const expandSingleSelect = (optionsTreeMap: OptionsTreeMap) => { 39 | let checkedOption: TreeItem | null = null 40 | for (const [_, option] of optionsTreeMap) { 41 | if (option.checked && !option.disabled) { 42 | checkedOption = option 43 | break 44 | } 45 | } 46 | 47 | if (!checkedOption) { 48 | return 49 | } 50 | 51 | if (checkedOption.isGroup) { 52 | checkedOption.isClosed = false 53 | hideShowChildrenOptions(optionsTreeMap, checkedOption) 54 | } 55 | 56 | expandAllParents(checkedOption.childOf, optionsTreeMap) 57 | } 58 | 59 | const expandAllParents = (childOf: ValueOptionType, optionsTreeMap: OptionsTreeMap) => { 60 | const parentNode = optionsTreeMap.get(childOf) ?? null 61 | 62 | if (parentNode) { 63 | parentNode.isClosed = false 64 | hideShowChildrenOptions(optionsTreeMap, parentNode) 65 | expandAllParents(parentNode.childOf, optionsTreeMap) 66 | } 67 | } 68 | 69 | export const updateVisibleBySearchTreeItemOptions = (optionsTreeMap: OptionsTreeMap, searchText: string) => { 70 | optionsTreeMap.forEach((option) => { 71 | const isSearched = option.name.toLowerCase().includes(searchText.toLowerCase()) 72 | 73 | if (isSearched) { 74 | if (option.isGroup) { 75 | option.isClosed = true 76 | } 77 | 78 | if (option.childOf) { 79 | openShowAllParents(option.childOf, optionsTreeMap) 80 | } 81 | } 82 | 83 | option.hidden = !isSearched 84 | }) 85 | } 86 | 87 | const openShowAllParents = (childOf: ValueOptionType, optionsTreeMap: OptionsTreeMap) => { 88 | const parentNode = optionsTreeMap.get(childOf) ?? null 89 | 90 | if (parentNode) { 91 | parentNode.hidden = false 92 | parentNode.isClosed = false 93 | openShowAllParents(parentNode.childOf, optionsTreeMap) 94 | } 95 | } 96 | 97 | export const updateOptionsMapBySearchState = ({ 98 | optionsTreeMap, 99 | beforeSearchStateMap 100 | }: { 101 | optionsTreeMap: OptionsTreeMap 102 | beforeSearchStateMap: BeforeSearchStateMap 103 | }) => { 104 | optionsTreeMap.forEach((option) => { 105 | const beforeSearchState = beforeSearchStateMap.get(option.id) 106 | 107 | if (beforeSearchState) { 108 | option.hidden = beforeSearchState.hidden 109 | option.isClosed = beforeSearchState.isClosed 110 | } 111 | }) 112 | 113 | beforeSearchStateMap.clear() 114 | } 115 | 116 | export const updateBeforeSearchStateMap = ({ 117 | optionsTreeMap, 118 | beforeSearchStateMap 119 | }: { 120 | optionsTreeMap: OptionsTreeMap 121 | beforeSearchStateMap: BeforeSearchStateMap 122 | }) => { 123 | beforeSearchStateMap.clear() 124 | 125 | optionsTreeMap.forEach((option) => { 126 | beforeSearchStateMap.set(option.id, { 127 | hidden: option.hidden, 128 | isClosed: option.isClosed 129 | }) 130 | }) 131 | } 132 | 133 | export const createIntersectionScrollObserver = (srcElementList: HTMLElement) => 134 | new IntersectionObserver( 135 | (entries) => { 136 | entries.forEach((entry) => { 137 | entry.target.classList.toggle('treeselect-list__item--scroll-not-visible', !entry.isIntersecting) 138 | }) 139 | }, 140 | { root: srcElementList, threshold: 0.5 } 141 | ) 142 | -------------------------------------------------------------------------------- /src/list/list.css: -------------------------------------------------------------------------------- 1 | .treeselect-list { 2 | width: 100%; 3 | box-sizing: border-box; 4 | border: 1px solid #d7dde4; 5 | overflow-y: auto; 6 | background-color: #ffffff; 7 | max-height: 300px; 8 | } 9 | 10 | .treeselect-list__group-container { 11 | box-sizing: border-box; 12 | } 13 | 14 | .treeselect-list__item { 15 | display: flex; 16 | align-items: center; 17 | box-sizing: border-box; 18 | cursor: pointer; 19 | height: 30px; 20 | } 21 | 22 | .treeselect-list__item:focus { 23 | outline: none; 24 | } 25 | 26 | .treeselect-list__item--focused { 27 | background-color: #f0ffff !important; 28 | } 29 | 30 | .treeselect-list__item--hidden { 31 | display: none; 32 | } 33 | 34 | .treeselect-list__item--scroll-not-visible { 35 | visibility: hidden; 36 | } 37 | 38 | .treeselect-list__item-icon { 39 | display: flex; 40 | align-items: center; 41 | cursor: pointer; 42 | height: 20px; 43 | width: 20px; 44 | min-width: 20px; 45 | } 46 | 47 | .treeselect-list__item-icon svg { 48 | pointer-events: none; 49 | width: 100%; 50 | height: 100%; 51 | stroke: #c5c7cb; 52 | } 53 | 54 | .treeselect-list__item-icon * { 55 | pointer-events: none; 56 | } 57 | 58 | .treeselect-list__item-icon:hover svg { 59 | stroke: #838790; 60 | } 61 | 62 | .treeselect-list__item-checkbox-container { 63 | width: 20px; 64 | height: 20px; 65 | min-width: 20px; 66 | border: 1px solid #d7dde4; 67 | border-radius: 3px; 68 | position: relative; 69 | background-color: #ffffff; 70 | pointer-events: none; 71 | box-sizing: border-box; 72 | } 73 | 74 | .treeselect-list__item-checkbox-container svg { 75 | position: absolute; 76 | height: 100%; 77 | width: 100%; 78 | } 79 | 80 | .treeselect-list__item-checkbox { 81 | margin: 0; 82 | width: 0; 83 | height: 0; 84 | pointer-events: none; 85 | position: absolute; 86 | z-index: -1; 87 | } 88 | 89 | .treeselect-list__item-checkbox-icon { 90 | position: absolute; 91 | height: 100%; 92 | width: 100%; 93 | left: 0; 94 | top: 0; 95 | text-align: left; 96 | } 97 | 98 | .treeselect-list__item-label { 99 | width: 100%; 100 | overflow: hidden; 101 | text-overflow: ellipsis; 102 | word-break: keep-all; 103 | white-space: nowrap; 104 | font-size: 14px; 105 | padding-left: 5px; 106 | pointer-events: none; 107 | text-align: left; 108 | } 109 | 110 | .treeselect-list__item-label-counter { 111 | margin-left: 3px; 112 | color: #838790; 113 | font-size: 13px; 114 | } 115 | 116 | .treeselect-list__empty { 117 | display: flex; 118 | align-items: center; 119 | height: 30px; 120 | padding-left: 4px; 121 | } 122 | 123 | .treeselect-list__empty--hidden { 124 | display: none; 125 | } 126 | 127 | .treeselect-list__empty-icon { 128 | display: flex; 129 | align-items: center; 130 | } 131 | 132 | .treeselect-list__empty-text { 133 | font-size: 14px; 134 | padding-left: 5px; 135 | overflow: hidden; 136 | text-overflow: ellipsis; 137 | word-break: keep-all; 138 | white-space: nowrap; 139 | } 140 | 141 | .treeselect-list__slot { 142 | position: sticky; 143 | box-sizing: border-box; 144 | width: 100%; 145 | max-width: 100%; 146 | bottom: 0; 147 | background-color: #ffffff; 148 | } 149 | 150 | /* single-select styles */ 151 | .treeselect-list.treeselect-list--single-select .treeselect-list__item-checkbox-container { 152 | display: none; 153 | } 154 | 155 | /* disabled-branch-node styles */ 156 | .treeselect-list.treeselect-list--disabled-branch-node 157 | .treeselect-list__item--group 158 | .treeselect-list__item-checkbox-container { 159 | display: none; 160 | } 161 | 162 | .treeselect-list__item--non-selectable-group .treeselect-list__item-checkbox-container { 163 | display: none; 164 | } 165 | 166 | /* checked styles */ 167 | .treeselect-list__item--checked { 168 | background-color: #e9f1f1; 169 | } 170 | 171 | .treeselect-list.treeselect-list--single-select .treeselect-list__item--checked { 172 | background-color: transparent; 173 | } 174 | 175 | .treeselect-list.treeselect-list--single-select .treeselect-list__item--single-selected { 176 | background-color: #e9f1f1; 177 | } 178 | 179 | .treeselect-list__item .treeselect-list__item-checkbox-container svg { 180 | stroke: transparent; 181 | } 182 | 183 | .treeselect-list__item--checked .treeselect-list__item-checkbox-container svg, 184 | .treeselect-list__item--partial-checked .treeselect-list__item-checkbox-container svg { 185 | stroke: #ffffff; 186 | } 187 | 188 | .treeselect-list__item--checked .treeselect-list__item-checkbox-container, 189 | .treeselect-list__item--partial-checked .treeselect-list__item-checkbox-container { 190 | background-color: #52c67e; 191 | } 192 | 193 | .treeselect-list__item--disabled .treeselect-list__item-checkbox-container { 194 | background-color: #e9f1f1; 195 | } 196 | 197 | .treeselect-list__item--disabled .treeselect-list__item-label { 198 | color: #c5c7cb; 199 | } 200 | 201 | /* rtl */ 202 | [dir='rtl'] .treeselect-list__item-checkbox-icon { 203 | text-align: right; 204 | } 205 | 206 | [dir='rtl'] .treeselect-list__item-label { 207 | text-align: right; 208 | padding-right: 5px; 209 | padding-left: unset; 210 | } 211 | 212 | [dir='rtl'] .treeselect-list__item--closed .treeselect-list__item-icon { 213 | transform: rotate(180deg); 214 | } 215 | 216 | [dir='rtl'] .treeselect-list__empty { 217 | padding-right: 4px; 218 | padding-left: unset; 219 | } 220 | 221 | [dir='rtl'] .treeselect-list__empty-text { 222 | padding-right: 5px; 223 | padding-left: unset; 224 | } 225 | /* rtl end */ 226 | -------------------------------------------------------------------------------- /src/list/listTypes.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ValueOptionType, 3 | type OptionType, 4 | type IconsType, 5 | type SelectedNodesType, 6 | type TagsSortFnType 7 | } from '../treeselectTypes' 8 | 9 | export interface TreeItem { 10 | id: string | number 11 | name: string 12 | childOf: string | number 13 | isGroup: boolean 14 | checked: boolean 15 | isPartialChecked: boolean 16 | level: number 17 | isClosed: boolean 18 | hidden: boolean 19 | disabled: boolean 20 | isGroupSelectable: boolean 21 | children: ValueOptionType[] 22 | checkboxHtmlElement: HTMLInputElement | null 23 | itemHtmlElement: HTMLElement | null 24 | arrowItemHtmlElement: HTMLElement | null 25 | checkboxIconHtmlElement: HTMLElement | null 26 | } 27 | 28 | export type OptionsTreeMap = Map 29 | 30 | export type BeforeSearchStateMap = Map> 31 | 32 | export interface ITreeselectListParams { 33 | options: OptionType[] 34 | value: ValueOptionType[] 35 | openLevel: number 36 | listSlotHtmlComponent: HTMLElement | null 37 | tagsSortFn: TagsSortFnType 38 | emptyText: string 39 | isSingleSelect: boolean 40 | showCount: boolean 41 | disabledBranchNode: boolean 42 | expandSelected: boolean 43 | isIndependentNodes: boolean 44 | rtl: boolean 45 | listClassName: string 46 | isBoostedRendering: boolean 47 | iconElements: IconsType 48 | inputCallback: (value: SelectedNodesType) => void 49 | arrowClickCallback: (groupId: ValueOptionType, isClosed: boolean) => void 50 | mouseupCallback: () => void 51 | } 52 | 53 | export interface ITreeselectList extends ITreeselectListParams { 54 | searchText: string 55 | selectedNodes: SelectedNodesType 56 | srcElement: HTMLElement 57 | emptyListHtmlElement: HTMLElement | null 58 | optionsTreeMap: OptionsTreeMap 59 | beforeSearchStateMap: BeforeSearchStateMap 60 | intersectionItemsObserver: IntersectionObserver | null 61 | updateValue: (value: ValueOptionType[]) => void 62 | updateSearchValue: (searchText: string) => void 63 | callKeyAction: (e: KeyboardEvent) => void 64 | focusFirstListElement: () => void 65 | isLastFocusedElementExist: () => boolean 66 | destroy: () => void 67 | } 68 | -------------------------------------------------------------------------------- /src/svgIcons.ts: -------------------------------------------------------------------------------- 1 | import { type IconsType } from './treeselectTypes' 2 | 3 | export const icons: IconsType = { 4 | arrowUp: 5 | '', 6 | arrowDown: 7 | '', 8 | arrowRight: 9 | '', 10 | attention: 11 | '', 12 | clear: 13 | '', 14 | cross: 15 | '', 16 | check: 17 | '', 18 | partialCheck: 19 | '' 20 | } 21 | 22 | export const appendIconToElement = (icon: string | HTMLElement, element: HTMLElement) => { 23 | element.innerHTML = '' 24 | 25 | if (typeof icon === 'string') { 26 | element.innerHTML = icon 27 | } else { 28 | const insertedIcon = icon.cloneNode(true) 29 | element.appendChild(insertedIcon) 30 | } 31 | } 32 | 33 | export const getDefaultIcons = (iconsFromProps?: Partial) => { 34 | const newIcons = iconsFromProps ? { ...iconsFromProps } : {} 35 | 36 | Object.keys(icons).forEach((key) => { 37 | const iconValue = newIcons[key as keyof IconsType] 38 | 39 | if (!iconValue) { 40 | newIcons[key as keyof IconsType] = icons[key as keyof IconsType] 41 | } 42 | }) 43 | 44 | return newIcons as IconsType 45 | } 46 | -------------------------------------------------------------------------------- /src/treeselectTypes.ts: -------------------------------------------------------------------------------- 1 | export type ValueOptionType = string | number 2 | 3 | export type ValueType = ValueOptionType[] | ValueOptionType | null 4 | 5 | export type ValueInputType = ValueOptionType[] | ValueOptionType | null | undefined 6 | 7 | export type OptionType = { 8 | value: ValueOptionType 9 | name: string 10 | disabled?: boolean 11 | isGroupSelectable?: boolean 12 | htmlAttr?: object 13 | children: OptionType[] 14 | } 15 | 16 | export type DirectionType = 'auto' | 'top' | 'bottom' 17 | 18 | export type TagsSortItem = { value: ValueOptionType; name: string } 19 | export type TagsSortFnType = ((itemA: TagsSortItem, itemB: TagsSortItem) => number) | null 20 | 21 | export interface ITreeselect { 22 | parentHtmlContainer: HTMLElement 23 | value: ValueType 24 | options: OptionType[] 25 | openLevel: number 26 | appendToBody: boolean 27 | alwaysOpen: boolean 28 | showTags: boolean 29 | tagsCountText: string 30 | tagsSortFn: TagsSortFnType 31 | clearable: boolean 32 | searchable: boolean 33 | placeholder: string 34 | grouped: boolean 35 | isGroupedValue: boolean 36 | listSlotHtmlComponent: HTMLElement | null 37 | disabled: boolean 38 | emptyText: string 39 | staticList: boolean 40 | id: string 41 | ariaLabel: string 42 | isSingleSelect: boolean 43 | showCount: boolean 44 | disabledBranchNode: boolean 45 | direction: DirectionType 46 | expandSelected: boolean 47 | saveScrollPosition: boolean 48 | isIndependentNodes: boolean 49 | rtl: boolean 50 | iconElements: IconsType 51 | ungroupedValue: ValueOptionType[] 52 | groupedValue: ValueOptionType[] 53 | isListOpened: boolean 54 | selectedName: string 55 | srcElement: HTMLElement | null 56 | inputCallback: ((value: ValueType) => void) | undefined 57 | openCallback: ((value: ValueType) => void) | undefined 58 | closeCallback: ((value: ValueType) => void) | undefined 59 | nameChangeCallback: ((name: string) => void) | undefined 60 | searchCallback: ((value: string) => void) | undefined 61 | openCloseGroupCallback: ((groupId: ValueOptionType, isClosed: boolean) => void) | undefined 62 | mount: () => void 63 | updateValue: (newValue: ValueInputType) => void 64 | destroy: () => void 65 | focus: () => void 66 | toggleOpenClose: () => void 67 | } 68 | 69 | export interface ITreeselectParams { 70 | parentHtmlContainer: HTMLElement 71 | value?: ValueInputType 72 | options?: OptionType[] 73 | openLevel?: number 74 | appendToBody?: boolean 75 | alwaysOpen?: boolean 76 | showTags?: boolean 77 | tagsCountText?: string 78 | tagsSortFn?: TagsSortFnType 79 | clearable?: boolean 80 | searchable?: boolean 81 | placeholder?: string 82 | grouped?: boolean 83 | isGroupedValue?: boolean 84 | listSlotHtmlComponent?: HTMLElement | null 85 | disabled?: boolean 86 | emptyText?: string 87 | staticList?: boolean 88 | id?: string 89 | ariaLabel?: string 90 | isSingleSelect?: boolean 91 | showCount?: boolean 92 | disabledBranchNode?: boolean 93 | direction?: DirectionType 94 | expandSelected?: boolean 95 | saveScrollPosition?: boolean 96 | isIndependentNodes?: boolean 97 | rtl?: boolean 98 | listClassName?: string 99 | isBoostedRendering?: boolean 100 | iconElements?: Partial 101 | inputCallback?: (value: ValueType) => void 102 | openCallback?: (value: ValueType) => void 103 | closeCallback?: (value: ValueType) => void 104 | nameChangeCallback?: (name: string) => void 105 | searchCallback?: (value: string) => void 106 | openCloseGroupCallback?: (groupId: ValueOptionType, isClosed: boolean) => void 107 | } 108 | 109 | export type InnerOptionType = { 110 | id: ValueOptionType 111 | name: string 112 | } 113 | 114 | export type IconsType = { 115 | arrowUp: string | HTMLElement 116 | arrowDown: string | HTMLElement 117 | arrowRight: string | HTMLElement 118 | attention: string | HTMLElement 119 | clear: string | HTMLElement 120 | cross: string | HTMLElement 121 | check: string | HTMLElement 122 | partialCheck: string | HTMLElement 123 | } 124 | 125 | export type SelectedNodesType = { 126 | nodes: InnerOptionType[] 127 | groupedNodes: InnerOptionType[] 128 | allNodes: InnerOptionType[] 129 | } 130 | -------------------------------------------------------------------------------- /src/treeselectjs.css: -------------------------------------------------------------------------------- 1 | @import './input/input.css'; 2 | @import './list/list.css'; 3 | 4 | .treeselect { 5 | width: 100%; 6 | position: relative; 7 | box-sizing: border-box; 8 | } 9 | 10 | .treeselect--disabled { 11 | pointer-events: none; 12 | } 13 | 14 | .treeselect-list { 15 | position: absolute; 16 | left: 0; 17 | border-radius: 4px; 18 | box-sizing: border-box; 19 | z-index: 1000; 20 | } 21 | 22 | .treeselect .treeselect-list { 23 | position: absolute; 24 | } 25 | 26 | .treeselect .treeselect-list--static { 27 | position: static; 28 | } 29 | 30 | .treeselect-input--focused { 31 | border-color: #101010; 32 | } 33 | 34 | .treeselect-input--opened.treeselect-input--top { 35 | border-top-color: transparent; 36 | border-top-left-radius: 0; 37 | border-top-right-radius: 0; 38 | } 39 | 40 | .treeselect-input--opened.treeselect-input--bottom { 41 | border-bottom-color: transparent; 42 | border-bottom-left-radius: 0; 43 | border-bottom-right-radius: 0; 44 | } 45 | 46 | .treeselect-list--focused { 47 | border-color: #101010; 48 | } 49 | 50 | .treeselect-list--top, 51 | .treeselect-list--top-to-body { 52 | border-bottom-color: #d7dde4; 53 | border-bottom-left-radius: 0; 54 | border-bottom-right-radius: 0; 55 | } 56 | 57 | .treeselect-list--bottom, 58 | .treeselect-list--bottom-to-body { 59 | border-top-color: #d7dde4; 60 | border-top-left-radius: 0; 61 | border-top-right-radius: 0; 62 | } 63 | 64 | .treeselect-list--top { 65 | left: 0; 66 | bottom: 100%; 67 | } 68 | 69 | .treeselect-list--bottom { 70 | left: 0; 71 | top: 100%; 72 | } 73 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /treeselectjs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dipson88/treeselectjs/298b041a298463d167464f13f76f9d505fb8988c/treeselectjs.png -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*.ts", "src/**/*.tsx"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "isolatedModules": false, 5 | "types": ["cypress"] 6 | }, 7 | "include": ["__tests__/cypress/**/*.ts", "__tests__/testHelpers/**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "isolatedModules": false, 5 | "types": ["jest", "node"] 6 | }, 7 | "include": ["__tests__/jest/**/*.test.ts", "__tests__/jest/**/*.spec.ts", "__tests__/testHelpers/**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ESNext", "DOM"], 7 | "moduleResolution": "Node", 8 | "strict": true, 9 | "resolveJsonModule": true, 10 | "isolatedModules": true, 11 | "esModuleInterop": true, 12 | "noEmit": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitReturns": true, 16 | "skipLibCheck": true 17 | }, 18 | "exclude": ["node_modules"], 19 | "files": [], 20 | "references": [ 21 | { "path": "./tsconfig.app.json" }, 22 | { "path": "./tsconfig.jest.json" }, 23 | { "path": "./tsconfig.cypress.json" } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import { defineConfig } from 'vite' 3 | import dts from 'vite-plugin-dts' 4 | 5 | export default defineConfig({ 6 | build: { 7 | copyPublicDir: false, 8 | lib: { 9 | entry: resolve(__dirname, 'src/treeselectjs.ts'), 10 | name: 'treeselectjs', 11 | fileName: 'treeselectjs' 12 | }, 13 | rollupOptions: { 14 | output: { 15 | assetFileNames: (assetInfo) => { 16 | if (assetInfo.names.includes('style.css')) { 17 | return 'treeselectjs.css' 18 | } 19 | 20 | return assetInfo.names[0] 21 | }, 22 | globals: { 23 | treeselectjs: 'Treeselect' 24 | } 25 | } 26 | } 27 | }, 28 | server: { 29 | open: './app/index.html' 30 | }, 31 | plugins: [ 32 | dts({ 33 | insertTypesEntry: true, 34 | rollupTypes: true, 35 | tsconfigPath: './tsconfig.app.json' 36 | }) 37 | ] 38 | }) 39 | --------------------------------------------------------------------------------