├── .gitattributes ├── .gitignore ├── .npmignore ├── .travis.yml ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── bundle ├── easydropdown.js └── easydropdown.js.map ├── config ├── mocha │ └── mocha.opts ├── nyc │ └── .nycrc.json ├── stylelint │ └── .stylelintrc.json ├── tslint │ └── tslint.json └── webpack │ ├── Constants │ └── Environment.ts │ ├── Rules │ └── typescriptRule.ts │ └── config.ts ├── demos ├── 01-basic-list.html ├── 02-basic-list-with-placeholder.html ├── 03-groups.html ├── 04-mixed-groups.html ├── 05-disabled-select.html ├── 06-disabled-group.html ├── 07-disabled-options.html ├── 08-preselected-value.html ├── 09-form-reset.html ├── 10-form-validation.html ├── 11-show-placeholder-when-open.html ├── 12-collision-detection.html ├── 13-live-updates.html ├── 14-loop.html ├── 15-callbacks.html ├── 16-programmatic-validation.html ├── easydropdown.js ├── easydropdown.js.map ├── index.html ├── scripts │ ├── optionAdder.js │ ├── submitHandler.js │ └── themeSwitcher.js ├── style.css └── themes │ ├── README.md │ ├── beanstalk.css │ ├── flax.css │ ├── ivy.css │ └── theme.css.d.ts ├── docs └── easydropdown-anatomy.png ├── package-lock.json ├── package.json ├── src ├── Components │ ├── arrow.ts │ ├── body.ts │ ├── group.ts │ ├── head.ts │ ├── option.ts │ ├── root.ts │ └── value.ts ├── Config │ ├── Behavior.ts │ ├── Callbacks.ts │ ├── ClassNames.ts │ ├── Config.ts │ └── Interfaces │ │ ├── IBehavior.ts │ │ ├── ICallback.ts │ │ ├── ICallbacks.ts │ │ ├── IClassNames.ts │ │ ├── IConfig.ts │ │ └── ISelectCallback.ts ├── Easydropdown │ ├── Easydropdown.test.ts │ ├── Easydropdown.ts │ ├── EasydropdownFacade.test.ts │ ├── EasydropdownFacade.ts │ ├── Interfaces │ │ └── IFactory.ts │ ├── Timers.ts │ ├── cache.ts │ ├── factory.test.ts │ └── factory.ts ├── Events │ ├── Constants │ │ ├── KeyCodes.ts │ │ └── Selectors.ts │ ├── EventBinding.ts │ ├── Handlers │ │ ├── handleBodyClick.test.ts │ │ ├── handleBodyClick.ts │ │ ├── handleBodyMousedown.test.ts │ │ ├── handleBodyMousedown.ts │ │ ├── handleBodyMouseover.test.ts │ │ ├── handleBodyMouseover.ts │ │ ├── handleHeadClick.test.ts │ │ ├── handleHeadClick.ts │ │ ├── handleItemsListScroll.test.ts │ │ ├── handleItemsListScroll.ts │ │ ├── handleSelectBlur.test.ts │ │ ├── handleSelectBlur.ts │ │ ├── handleSelectFocus.test.ts │ │ ├── handleSelectFocus.ts │ │ ├── handleSelectInvalid.test.ts │ │ ├── handleSelectInvalid.ts │ │ ├── handleSelectKeydown.test.ts │ │ ├── handleSelectKeydown.ts │ │ ├── handleSelectKeydownDown.test.ts │ │ ├── handleSelectKeydownDown.ts │ │ ├── handleSelectKeydownUp.test.ts │ │ ├── handleSelectKeydownUp.ts │ │ ├── handleSelectKeypress.test.ts │ │ ├── handleSelectKeypress.ts │ │ ├── handleWindowClick.test.ts │ │ ├── handleWindowClick.ts │ │ ├── handleWindowResize.test.ts │ │ └── handleWindowResize.ts │ ├── Interfaces │ │ ├── IEventBinding.ts │ │ ├── IEventHandler.ts │ │ └── IHandlerParams.ts │ ├── Mock │ │ ├── createMockEvent.ts │ │ ├── createMockGroups.ts │ │ └── createMockHandlerParams.ts │ ├── bindEvents.test.ts │ ├── bindEvents.ts │ └── getEventsList.ts ├── Renderer │ ├── Constants │ │ ├── AttributeChangeType.ts │ │ └── DomChangeType.ts │ ├── Dom.ts │ ├── Interfaces │ │ ├── IAttributeChange.ts │ │ └── IPatchCommand.ts │ ├── PatchCommand.ts │ ├── Renderer.test.ts │ ├── Renderer.ts │ ├── dom.test.ts │ ├── domDiff.test.ts │ ├── domDiff.ts │ ├── domPatch.test.ts │ └── domPatch.ts ├── Shared │ ├── Polyfills │ │ └── Element.matches.ts │ └── Util │ │ ├── Constants │ │ └── CollisionType.ts │ │ ├── Interfaces │ │ ├── ICollisionData.ts │ │ └── IDispatchOpen.ts │ │ ├── closestParent.test.ts │ │ ├── closestParent.ts │ │ ├── composeClassName.test.ts │ │ ├── composeClassName.ts │ │ ├── createDomElementFromHtml.ts │ │ ├── detectBodyCollision.test.ts │ │ ├── detectBodyCollision.ts │ │ ├── dispatchOpen.test.ts │ │ ├── dispatchOpen.ts │ │ ├── getIsMobilePlatform.test.ts │ │ ├── getIsMobilePlatform.ts │ │ ├── killSelectReaction.ts │ │ ├── pollForSelectChange.ts │ │ ├── pollForSelectMutation.test.ts │ │ ├── pollForSelectMutation.ts │ │ ├── throttle.test.ts │ │ └── throttle.ts ├── State │ ├── Constants │ │ ├── BodyStatus.ts │ │ └── ScrollStatus.ts │ ├── Group.ts │ ├── InjectedActions │ │ ├── closeOthers.test.ts │ │ ├── closeOthers.ts │ │ ├── scrollToView.test.ts │ │ └── scrollToView.ts │ ├── Interfaces │ │ ├── IActions.ts │ │ ├── IOnAction.ts │ │ └── IPropertyDescriptor.ts │ ├── Option.ts │ ├── State.test.ts │ ├── State.ts │ ├── StateManager.ts │ ├── StateMapper.test.ts │ ├── StateMapper.ts │ ├── resolveActions.test.ts │ └── resolveActions.ts ├── index.ts └── umd.ts └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | *.ts linguist-language=JavaScript -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .tmp 2 | .DS_Store 3 | npm-debug.* 4 | node_modules 5 | dist 6 | bundle/dist 7 | bundle/easydropdown.dev.js 8 | bundle/easydropdown.dev.js.map 9 | coverage 10 | .nyc_output -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .nyc_output 2 | .vscode 3 | /config 4 | coverage 5 | demos 6 | docs 7 | bundle/dist 8 | bundle/*.dev.js 9 | *.map 10 | src 11 | 12 | .travis.yml 13 | tsconfig.json -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | after_success: 5 | - npm run coveralls -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "tslint.configFile": "./config/tslint/tslint.json", 3 | "css.validate": false, 4 | "stylelint.config": { 5 | "extends": "./config/stylelint/.stylelintrc.json" 6 | } 7 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | ## 4.2.0 5 | - Fixes issue where native select UI would be shown on iOS, regardless of whether `behavior.useNativeUiOnMobile` was set to `false`. 6 | - Adds a `.validate()` method to programmatically validate an instance, and a new demo (#16). 7 | - Adds a new callback `onOptionClick`. 8 | 9 | ## 4.1.1 10 | - Fixes issue introduced in 4.1.0 where UI no longer closed on select by default 11 | - Fixes issue introduced in 4.1.0 where clicking an option while `behavior.openOnFocus` set would close the UI without selecting any option. 12 | 13 | ## 4.1.0 14 | - Fixes a styling issue in Beanstalk and Ivy themes where native select element was discoverable on click to the left of the head element. 15 | - Fixes the `behavior.closeOnSelect` configuration option which not previously implemented internally. 16 | - Adds ensures that when `behavior.openOnFocus` is set, that selects also close on `blur`. 17 | - Updates and locks dependencies. 18 | 19 | ## 4.0.5 20 | 21 | - Fixes a styling issue with Firefox which caused a long scrolling page to scroll to the top when a dropdown is focused. 22 | 23 | ## 4.0.4 24 | 25 | - Fixes a styling issue with Firefox which caused the `head` element to jump when focused. -------------------------------------------------------------------------------- /config/mocha/mocha.opts: -------------------------------------------------------------------------------- 1 | --require ts-node/register 2 | --recursive 3 | --watch-extensions ts 4 | **/*.test.ts -------------------------------------------------------------------------------- /config/nyc/.nycrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": [ 3 | "ts-node/register", 4 | "source-map-support/register" 5 | ], 6 | "reporter": [ 7 | "lcov", 8 | "text" 9 | ], 10 | "extension": [ 11 | ".ts" 12 | ], 13 | "exclude": [ 14 | "demos", 15 | "config", 16 | "**/umd.ts", 17 | "**/index.ts", 18 | "**/*.d.ts", 19 | "**/**.test.ts", 20 | "**/Interfaces", 21 | "**/Constants", 22 | "**/Polyfills", 23 | "**/Mock", 24 | "bundle", 25 | "dist", 26 | "coverage" 27 | ], 28 | "statements": 100, 29 | "branches": 100, 30 | "functions": 100, 31 | "lines": 100, 32 | "all": true 33 | } -------------------------------------------------------------------------------- /config/stylelint/.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-standard" 4 | ], 5 | "rules": { 6 | "block-no-empty": true, 7 | "indentation": 4, 8 | "no-missing-end-of-source-newline": null 9 | } 10 | } -------------------------------------------------------------------------------- /config/tslint/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended", 5 | "tslint-eslint-rules" 6 | ], 7 | "rules": { 8 | "arrow-parens": false, 9 | "curly": false, 10 | "eofline": false, 11 | "forin": false, 12 | "import-spacing": false, 13 | "ordered-imports": [true, { 14 | "grouped-imports": true 15 | }], 16 | "member-ordering": [true, { 17 | "order": [ 18 | "public-instance-field", 19 | "protected-instance-field", 20 | "private-instance-field", 21 | "public-static-field", 22 | "protected-static-field", 23 | "private-static-field", 24 | "constructor", 25 | "public-instance-method", 26 | "protected-instance-method", 27 | "private-instance-method", 28 | "public-static-method", 29 | "protected-static-method", 30 | "private-static-method" 31 | ] 32 | }], 33 | "member-access": [true, "check-accessor"], 34 | "newline-before-return": true, 35 | "no-conditional-assignment": false, 36 | "no-console": [true, "log"], 37 | "object-curly-spacing": [true, "never"], 38 | "object-literal-sort-keys": false, 39 | "quotemark": [true, "single", "jsx-double"], 40 | "radix": false, 41 | "trailing-comma": [true, {"multiline": "never", "singleline": "never"}], 42 | "typedef": [true, "call-signature"], 43 | "typedef-whitespace": false 44 | }, 45 | "rulesDirectory": [] 46 | } -------------------------------------------------------------------------------- /config/webpack/Constants/Environment.ts: -------------------------------------------------------------------------------- 1 | enum Environment { 2 | DEVELOPMENT = 'development', 3 | PRODUCTION = 'production' 4 | } 5 | 6 | export default Environment; -------------------------------------------------------------------------------- /config/webpack/Rules/typescriptRule.ts: -------------------------------------------------------------------------------- 1 | import {Rule} from 'webpack'; 2 | 3 | const typescriptRule: Rule = { 4 | test: /\.tsx?$/, 5 | exclude: /node_modules/, 6 | loader: 'ts-loader' 7 | }; 8 | 9 | export default typescriptRule; -------------------------------------------------------------------------------- /config/webpack/config.ts: -------------------------------------------------------------------------------- 1 | import {resolve} from 'path'; 2 | import {Configuration} from 'webpack'; 3 | 4 | import Environment from './Constants/Environment'; 5 | import typescriptRule from './Rules/typescriptRule'; 6 | 7 | const config = (env: Environment = Environment.DEVELOPMENT): Configuration => { 8 | const isProductionEnvironment = env === Environment.PRODUCTION; 9 | 10 | return { 11 | mode: env, 12 | entry: './src/umd.ts', 13 | output: { 14 | filename: isProductionEnvironment ? 'easydropdown.js' : 'easydropdown.dev.js', 15 | path: resolve(__dirname, '..', '..', 'bundle'), 16 | library: 'easydropdown', 17 | libraryTarget: 'umd' 18 | }, 19 | devtool: 'source-map', 20 | optimization: { 21 | minimize: isProductionEnvironment 22 | }, 23 | resolve: { 24 | extensions: ['.ts', '.js'] 25 | }, 26 | module: { 27 | rules: [ 28 | typescriptRule 29 | ] 30 | } 31 | }; 32 | }; 33 | 34 | export default config; -------------------------------------------------------------------------------- /demos/03-groups.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Groups | EasyDropDown Demo 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |

EasyDropDown Demo

18 | 19 |

03. Groups

20 | 21 |
22 | 35 |
36 | 37 |
38 |
Theme:
39 | 40 |
41 | | 42 | | 43 | 44 |
45 |
46 |
47 | 48 | 52 | 53 | 54 | 57 | 58 | -------------------------------------------------------------------------------- /demos/04-mixed-groups.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Mixed Groups | EasyDropDown Demo 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |

EasyDropDown Demo

18 | 19 |

04. Mixed Groups

20 | 21 |
22 | 37 |
38 | 39 |
40 |
Theme:
41 | 42 |
43 | | 44 | | 45 | 46 |
47 |
48 |
49 | 50 | 54 | 55 | 56 | 59 | 60 | -------------------------------------------------------------------------------- /demos/05-disabled-select.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Disabled Select | EasyDropDown Demo 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |

EasyDropDown Demo

18 | 19 |

05. Disabled Select

20 | 21 |
22 | 28 |
29 | 30 |
31 |
Theme:
32 | 33 |
34 | | 35 | | 36 | 37 |
38 |
39 |
40 | 41 | 45 | 46 | 47 | 50 | 51 | -------------------------------------------------------------------------------- /demos/06-disabled-group.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Disabled Group | EasyDropDown Demo 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |

EasyDropDown Demo

18 | 19 |

05. Disabled Group

20 | 21 |
22 | 35 |
36 | 37 |
38 |
Theme:
39 | 40 |
41 | | 42 | | 43 | 44 |
45 |
46 |
47 | 48 | 52 | 53 | 54 | 57 | 58 | -------------------------------------------------------------------------------- /demos/07-disabled-options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Disabled Options | EasyDropDown Demo 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |

EasyDropDown Demo

18 | 19 |

07. Disabled Options

20 | 21 |
22 | 37 |
38 | 39 |
40 |
Theme:
41 | 42 |
43 | | 44 | | 45 | 46 |
47 |
48 |
49 | 50 | 54 | 55 | 56 | 59 | 60 | -------------------------------------------------------------------------------- /demos/10-form-validation.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Form Validation | EasyDropDown Demo 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |

EasyDropDown Demo

19 | 20 |

10. Form Validation (Required Field)

21 | 22 |
23 | 38 | 39 | 40 |
41 | 42 |
43 |
Theme:
44 | 45 |
46 | | 47 | | 48 | 49 |
50 |
51 |
52 | 53 | 57 | 58 | 59 | 62 | 63 | -------------------------------------------------------------------------------- /demos/13-live-updates.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Live Updates | EasyDropDown Demo 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |

EasyDropDown Demo

19 | 20 |

13. Live Updates

21 | 22 |
23 | 26 | 27 | 28 |
29 | 30 |
31 |
Theme:
32 | 33 |
34 | | 35 | | 36 | 37 |
38 |
39 |
40 | 41 | 45 | 46 | 47 | 54 | 55 | -------------------------------------------------------------------------------- /demos/14-loop.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Loop | EasyDropDown Demo 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |

EasyDropDown Demo

18 | 19 |

14. Loop

20 | 21 |
22 | 28 |
29 | 30 |
31 |
Theme:
32 | 33 |
34 | | 35 | | 36 | 37 |
38 |
39 |
40 | 41 | 45 | 46 | 47 | 54 | 55 | -------------------------------------------------------------------------------- /demos/15-callbacks.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Callbacks | EasyDropDown Demo 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |

EasyDropDown Demo

18 | 19 |

15. Callbacks

20 | 21 |
22 | 28 |
29 | 30 |
31 |
Theme:
32 | 33 |
34 | | 35 | | 36 | 37 |
38 |
39 |
40 | 41 | 45 | 46 | 47 | 66 | 67 | -------------------------------------------------------------------------------- /demos/16-programmatic-validation.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Programmatic Validation | EasyDropDown Demo 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |

EasyDropDown Demo

19 | 20 |

16. Programmatic Validation

21 | 22 |
23 | 38 | 39 | 40 |
41 | 42 |
43 |
Theme:
44 | 45 |
46 | | 47 | | 48 | 49 |
50 |
51 |
52 | 53 | 57 | 58 | 59 | 69 | 70 | -------------------------------------------------------------------------------- /demos/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | EasyDropDown Demos 10 | 11 | 12 |

EasyDropDown Demos

13 | 14 |
    15 |
  1. Basic List
  2. 16 |
  3. Basic List with Placeholder
  4. 17 |
  5. Groups
  6. 18 |
  7. Mixed Groups
  8. 19 |
  9. Disabled Select
  10. 20 |
  11. Disabled Group
  12. 21 |
  13. Disabled Options
  14. 22 |
  15. Pre-selected Value
  16. 23 |
  17. Form Reset
  18. 24 |
  19. Form Validation
  20. 25 |
  21. Show Placeholder When Open
  22. 26 |
  23. Collision Detection
  24. 27 |
  25. Live Updates
  26. 28 |
  27. Loop
  28. 29 |
  29. Callbacks
  30. 30 |
  31. Programmatic Validation
  32. 31 |
32 | 33 | -------------------------------------------------------------------------------- /demos/scripts/optionAdder.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var addOptionButton, select; 3 | 4 | function initOptionAdder() { 5 | addOptionButton = document.getElementById('add-option'); 6 | select = document.getElementById('demo-select'); 7 | 8 | addOptionButton.addEventListener('click', handleClick); 9 | } 10 | 11 | function handleClick() { 12 | var totalOptions = select.options.length; 13 | var newOption = document.createElement('option'); 14 | 15 | newOption.textContent = 'Option ' + totalOptions; 16 | 17 | select.appendChild(newOption); 18 | } 19 | 20 | document.addEventListener("DOMContentLoaded", initOptionAdder); 21 | })(); 22 | -------------------------------------------------------------------------------- /demos/scripts/submitHandler.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var form; 3 | 4 | function initSubmitHandler() { 5 | form = document.querySelector('form'); 6 | 7 | form.addEventListener('submit', handleSubmit); 8 | } 9 | 10 | function handleSubmit(e) { 11 | e.preventDefault(); 12 | 13 | alert('Valid!'); 14 | } 15 | 16 | document.addEventListener("DOMContentLoaded", initSubmitHandler); 17 | })(); 18 | -------------------------------------------------------------------------------- /demos/scripts/themeSwitcher.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var themeSwitcher, themeSheet; 3 | 4 | function initThemeSwitcher() { 5 | themeSwitcher = document.querySelector('.theme-switcher'); 6 | themeSheet = document.querySelector('#theme-sheet'); 7 | 8 | themeSwitcher.addEventListener('click', handleThemeClick); 9 | } 10 | 11 | function handleThemeClick(e) { 12 | var all = document.querySelectorAll('[data-theme]'); 13 | var target = e.target; 14 | var themeUrl = target.getAttribute('data-theme'); 15 | 16 | if (!themeUrl) return; 17 | 18 | Array.prototype.forEach.call(all, function(link) { 19 | link.removeAttribute('class'); 20 | }); 21 | 22 | themeSheet.href = themeUrl; 23 | 24 | target.className = 'active'; 25 | } 26 | 27 | document.addEventListener("DOMContentLoaded", initThemeSwitcher); 28 | })(); 29 | -------------------------------------------------------------------------------- /demos/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | * The following styles pertain to the demo elements only, and are 3 | * not related to or necessary for any EasyDropDown functionality. 4 | */ 5 | 6 | * { 7 | margin: 0; 8 | padding: 0; 9 | box-sizing: border-box; 10 | } 11 | 12 | body { 13 | box-sizing: border-box; 14 | display: flex; 15 | flex-direction: column; 16 | align-items: center; 17 | background: #fafafa; 18 | font-family: 'Helvetica Neue', arial, helvetica, sans-serif; 19 | min-height: 100vh; 20 | -webkit-font-smoothing: antialiased; 21 | -moz-osx-font-smoothing: grayscale; 22 | } 23 | 24 | .main { 25 | padding: 16px; 26 | flex: 1; 27 | } 28 | 29 | .main--at-bottom { 30 | display: flex; 31 | flex-direction: column; 32 | justify-content: flex-end; 33 | } 34 | 35 | .heading, 36 | .sub-heading, 37 | .demo-container { 38 | width: calc(100vw - 20px); 39 | max-width: 400px; 40 | } 41 | 42 | .heading, 43 | .sub-heading { 44 | font-weight: 600; 45 | letter-spacing: -0.02em; 46 | } 47 | 48 | .heading { 49 | font-size: 24px; 50 | margin-bottom: 0.8em; 51 | } 52 | 53 | .sub-heading { 54 | font-size: 14px; 55 | margin-bottom: 0.5em; 56 | } 57 | 58 | .demo-container { 59 | display: flex; 60 | height: 150px; 61 | flex-direction: column; 62 | align-items: center; 63 | background: white; 64 | justify-content: center; 65 | box-shadow: 0 0 20px rgba(0, 0, 0, 0.03); 66 | } 67 | 68 | .demo-container > select { 69 | visibility: hidden; 70 | } 71 | 72 | .label { 73 | font-size: 13px; 74 | width: 180px; 75 | font-weight: 700; 76 | margin-bottom: 5px; 77 | } 78 | 79 | .edd-body { 80 | opacity: 0; 81 | } 82 | 83 | .button { 84 | background-color: #333; 85 | padding: 10px; 86 | min-width: 180px; 87 | color: white; 88 | font-weight: 700; 89 | font-size: 14px; 90 | border: 3px solid #333; 91 | border-radius: 0; 92 | margin-top: 20px; 93 | cursor: pointer; 94 | outline: 0 none; 95 | transition: background-color 150ms, color 150ms; 96 | } 97 | 98 | .button:hover { 99 | background-color: white; 100 | color: #333; 101 | } 102 | 103 | .theme-switcher { 104 | padding: 8px 0; 105 | color: #ddd; 106 | display: flex; 107 | justify-content: space-between; 108 | } 109 | 110 | .theme-switcher-options * { 111 | display: inline; 112 | } 113 | 114 | .theme-switcher-label { 115 | color: black; 116 | } 117 | 118 | .theme-switcher button { 119 | color: #333; 120 | cursor: pointer; 121 | background: transparent; 122 | font-family: 'Helvetica Neue', arial, helvetica, sans-serif; 123 | font-size: 16px; 124 | border: 0 none; 125 | outline: 0 none; 126 | } 127 | 128 | .theme-switcher .active { 129 | font-weight: 600; 130 | cursor: pointer; 131 | } 132 | 133 | .footer { 134 | width: 100%; 135 | background: #2a2a2a; 136 | display: flex; 137 | justify-content: space-between; 138 | padding: 16px; 139 | font-size: 12px; 140 | color: #fff; 141 | } 142 | 143 | .footer a { 144 | color: #ccc; 145 | text-decoration: none; 146 | transition: color 150ms; 147 | } 148 | 149 | .footer a:hover { 150 | color: #fff; 151 | } -------------------------------------------------------------------------------- /demos/themes/README.md: -------------------------------------------------------------------------------- 1 | # Themes 2 | 3 | These example themes demonstrate 3 varied but idiomatic approaches to styling the dropdown menu. 4 | 5 | For maximum consumability, they are written in vanilla CSS, and as such do not include any niceties such as variables or vendor prefixes. 6 | 7 | If you plan to use or adapt any of these themes for your project, it is your responsibility to ensure the appropriate vendor prefixes (e.g for CSS transforms) are applied for your desired browser support. 8 | 9 | We recommend [post-CSS](http://postcss.org/) for this purpose. -------------------------------------------------------------------------------- /demos/themes/beanstalk.css: -------------------------------------------------------------------------------- 1 | @import '//fonts.googleapis.com/css?family=Open+Sans:400,600'; 2 | 3 | .edd-root, 4 | .edd-root *, 5 | .edd-root *::before, 6 | .edd-root *::after { 7 | margin: 0; 8 | padding: 0; 9 | box-sizing: border-box; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | } 13 | 14 | .edd-root { 15 | display: inline-block; 16 | position: relative; 17 | width: 180px; 18 | user-select: none; 19 | font-family: 'Open Sans', arial, helvetica, sans-serif; 20 | font-size: 16px; 21 | color: #333; 22 | } 23 | 24 | .edd-root-disabled { 25 | color: #ccc; 26 | cursor: not-allowed; 27 | } 28 | 29 | .edd-head { 30 | position: relative; 31 | overflow: hidden; 32 | border: 1px solid #eee; 33 | transition: box-shadow 200ms, border-color 150ms; 34 | background: white; 35 | } 36 | 37 | .edd-head, 38 | .edd-body { 39 | border-radius: 4px; 40 | } 41 | 42 | .edd-root-focused .edd-head { 43 | box-shadow: 0 0 5px rgba(105, 215, 255, 0.4); 44 | } 45 | 46 | .edd-root-invalid .edd-head { 47 | box-shadow: 0 0 5px rgba(255, 105, 105, 0.671); 48 | } 49 | 50 | .edd-root:not(.edd-root-disabled):not(.edd-root-open) .edd-head:hover { 51 | border-color: #ccc; 52 | } 53 | 54 | .edd-value { 55 | width: calc(100% - 50px); 56 | display: inline-block; 57 | vertical-align: middle; 58 | margin: 8px 0 8px 8px; 59 | border-right: 1px solid #eee; 60 | } 61 | 62 | .edd-arrow { 63 | position: absolute; 64 | width: 18px; 65 | height: 10px; 66 | top: calc(50% - 5px); 67 | right: calc(24px - 9px); 68 | transition: transform 150ms; 69 | pointer-events: none; 70 | } 71 | 72 | .edd-arrow::before { 73 | content: ''; 74 | position: absolute; 75 | width: 13px; 76 | height: 13px; 77 | border-right: 1px solid currentColor; 78 | border-bottom: 1px solid currentColor; 79 | top: -5px; 80 | right: 0; 81 | transform: rotate(45deg); 82 | transform-origin: 50% 25%; 83 | } 84 | 85 | .edd-root-open .edd-arrow { 86 | transform: rotate(180deg); 87 | } 88 | 89 | .edd-value, 90 | .edd-option, 91 | .edd-group-label { 92 | white-space: nowrap; 93 | text-overflow: ellipsis; 94 | overflow: hidden; 95 | } 96 | 97 | .edd-root:not(.edd-root-disabled) .edd-value, 98 | .edd-option { 99 | cursor: pointer; 100 | } 101 | 102 | .edd-select { 103 | position: absolute; 104 | opacity: 0; 105 | width: 100%; 106 | left: -100%; 107 | top: 0; 108 | } 109 | 110 | .edd-root-native .edd-select { 111 | left: 0; 112 | top: 0; 113 | width: 100%; 114 | height: 100%; 115 | } 116 | 117 | .edd-body { 118 | opacity: 0; 119 | position: absolute; 120 | left: 0; 121 | right: 0; 122 | border: 1px solid #eee; 123 | pointer-events: none; 124 | overflow: hidden; 125 | margin: 8px 0; 126 | z-index: 999; 127 | box-shadow: 0 0 20px rgba(0, 0, 0, 0.1); 128 | transform: scale(0.95); 129 | background: white; 130 | } 131 | 132 | .edd-root-open .edd-body { 133 | opacity: 1; 134 | pointer-events: all; 135 | transform: scale(1); 136 | transition: opacity 200ms, transform 100ms cubic-bezier(0.25, 0.46, 0.45, 0.94); 137 | } 138 | 139 | .edd-root-open-above .edd-body { 140 | bottom: 100%; 141 | } 142 | 143 | .edd-root-open-below .edd-body { 144 | top: 100%; 145 | } 146 | 147 | .edd-items-list { 148 | overflow: auto; 149 | max-height: 0; 150 | transition: max-height 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94); 151 | -webkit-overflow-scrolling: touch; 152 | } 153 | 154 | .edd-group-label { 155 | font-size: 11px; 156 | text-transform: uppercase; 157 | font-weight: bold; 158 | letter-spacing: 0.1em; 159 | padding: 12px 8px 4px; 160 | color: #999; 161 | } 162 | 163 | .edd-group-has-label { 164 | border-bottom: 1px solid #eee; 165 | } 166 | 167 | .edd-option { 168 | padding: 4px 8px; 169 | } 170 | 171 | .edd-group-has-label .edd-option { 172 | padding-left: 20px; 173 | } 174 | 175 | .edd-option-selected { 176 | font-weight: bold; 177 | } 178 | 179 | .edd-option-focused:not(.edd-option-disabled) { 180 | color: #4ac5f1; 181 | } 182 | 183 | .edd-option-disabled, 184 | .edd-group-disabled .edd-option { 185 | cursor: default; 186 | color: #ccc; 187 | } 188 | 189 | .edd-gradient-top, 190 | .edd-gradient-bottom { 191 | content: ''; 192 | position: absolute; 193 | left: 2px; 194 | right: 2px; 195 | height: 32px; 196 | background-image: 197 | linear-gradient( 198 | 0deg, 199 | rgba(255, 255, 255, 0) 0%, 200 | rgba(255, 255, 255, 1) 40%, 201 | rgba(255, 255, 255, 1) 60%, 202 | rgba(255, 255, 255, 0) 100% 203 | ); 204 | background-repeat: repeat-x; 205 | background-size: 100% 200%; 206 | pointer-events: none; 207 | transition: opacity 100ms; 208 | opacity: 0; 209 | } 210 | 211 | .edd-gradient-top { 212 | background-position: bottom; 213 | top: 0; 214 | } 215 | 216 | .edd-gradient-bottom { 217 | background-position: top; 218 | bottom: 0; 219 | } 220 | 221 | .edd-body-scrollable .edd-gradient-top, 222 | .edd-body-scrollable .edd-gradient-bottom { 223 | opacity: 1; 224 | } 225 | 226 | .edd-body-scrollable.edd-body-at-top .edd-gradient-top, 227 | .edd-body-scrollable.edd-body-at-bottom .edd-gradient-bottom { 228 | opacity: 0; 229 | } -------------------------------------------------------------------------------- /demos/themes/flax.css: -------------------------------------------------------------------------------- 1 | @import '//fonts.googleapis.com/css?family=Roboto:300,400"'; 2 | 3 | .edd-root, 4 | .edd-root *, 5 | .edd-root *::before, 6 | .edd-root *::after { 7 | margin: 0; 8 | padding: 0; 9 | box-sizing: border-box; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | } 13 | 14 | .edd-root { 15 | display: inline-block; 16 | position: relative; 17 | width: 180px; 18 | user-select: none; 19 | font-family: 'Roboto', arial, helvetica, sans-serif; 20 | font-weight: 300; 21 | font-size: 16px; 22 | color: #333; 23 | } 24 | 25 | .edd-root-disabled { 26 | color: #ccc; 27 | cursor: not-allowed; 28 | } 29 | 30 | .edd-root::after { 31 | content: ''; 32 | position: absolute; 33 | bottom: 0; 34 | left: 0; 35 | right: 0; 36 | height: 2px; 37 | background: #45bce7; 38 | transition: transform 150ms ease-out; 39 | transform: scaleX(0); 40 | } 41 | 42 | .edd-root.edd-root-focused::after, 43 | .edd-root.edd-root-invalid::after { 44 | transform: scaleX(1); 45 | } 46 | 47 | .edd-root.edd-root-invalid::after { 48 | background: rgb(255, 105, 105); 49 | } 50 | 51 | .edd-head { 52 | position: relative; 53 | overflow: hidden; 54 | border-bottom: 1px solid #ddd; 55 | transition: border-color 200ms; 56 | } 57 | 58 | .edd-root:not(.edd-root-disabled) .edd-head:hover { 59 | border-bottom-color: #aaa; 60 | } 61 | 62 | .edd-value { 63 | width: 100%; 64 | display: inline-block; 65 | vertical-align: middle; 66 | padding: 8px 25px 8px 0; 67 | } 68 | 69 | .edd-arrow { 70 | position: absolute; 71 | width: 14px; 72 | height: 10px; 73 | top: calc(50% - 5px); 74 | right: 3px; 75 | transition: transform 150ms; 76 | pointer-events: none; 77 | color: #666; 78 | } 79 | 80 | .edd-root-disabled .edd-arrow { 81 | color: #ccc; 82 | } 83 | 84 | .edd-arrow::before { 85 | content: ''; 86 | position: absolute; 87 | width: 8px; 88 | height: 8px; 89 | border-right: 2px solid currentColor; 90 | border-bottom: 2px solid currentColor; 91 | top: 0; 92 | right: 2px; 93 | transform: rotate(45deg); 94 | transform-origin: 50% 25%; 95 | } 96 | 97 | .edd-root-open .edd-arrow { 98 | transform: rotate(180deg); 99 | } 100 | 101 | .edd-value, 102 | .edd-option, 103 | .edd-group-label { 104 | white-space: nowrap; 105 | text-overflow: ellipsis; 106 | overflow: hidden; 107 | } 108 | 109 | .edd-root:not(.edd-root-disabled) .edd-value, 110 | .edd-option { 111 | cursor: pointer; 112 | } 113 | 114 | .edd-select { 115 | position: absolute; 116 | opacity: 0; 117 | width: 100%; 118 | left: -100%; 119 | top: 0; 120 | } 121 | 122 | .edd-root-native .edd-select { 123 | left: 0; 124 | top: 0; 125 | width: 100%; 126 | height: 100%; 127 | } 128 | 129 | .edd-body { 130 | opacity: 0; 131 | position: absolute; 132 | left: 0; 133 | right: 0; 134 | pointer-events: none; 135 | overflow: hidden; 136 | z-index: 999; 137 | background: white; 138 | box-shadow: 0 0 6px rgba(0, 0, 0, 0.08); 139 | border: 1px solid #eee; 140 | border-top: 0; 141 | border-right: 0; 142 | } 143 | 144 | .edd-root-open .edd-body { 145 | opacity: 1; 146 | pointer-events: all; 147 | transform: scale(1); 148 | transition: opacity 200ms, transform 100ms cubic-bezier(0.25, 0.46, 0.45, 0.94); 149 | } 150 | 151 | .edd-root-open-above .edd-body { 152 | bottom: 100%; 153 | } 154 | 155 | .edd-root-open-below .edd-body { 156 | top: 100%; 157 | } 158 | 159 | .edd-items-list { 160 | overflow: auto; 161 | max-height: 0; 162 | transition: max-height 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94); 163 | -webkit-overflow-scrolling: touch; 164 | } 165 | 166 | .edd-items-list::-webkit-scrollbar { 167 | width: 12px; 168 | } 169 | 170 | .edd-items-list::-webkit-scrollbar-track { 171 | background: #efefef; 172 | } 173 | 174 | .edd-items-list::-webkit-scrollbar-thumb { 175 | background: #ccc; 176 | } 177 | 178 | .edd-group-label { 179 | font-size: 13px; 180 | padding: 4px 8px 4px 0; 181 | color: #555; 182 | font-weight: 600; 183 | } 184 | 185 | .edd-group-has-label { 186 | padding-left: 22px; 187 | } 188 | 189 | .edd-option { 190 | position: relative; 191 | padding: 4px 8px 4px 22px; 192 | } 193 | 194 | .edd-option-selected { 195 | font-weight: 400; 196 | } 197 | 198 | .edd-option-selected::before { 199 | content: ''; 200 | position: absolute; 201 | width: 8px; 202 | height: 4px; 203 | border-bottom: 2px solid #4ac5f1; 204 | border-left: 2px solid #4ac5f1; 205 | left: 6px; 206 | top: calc(50% - 4px); 207 | transform: rotate(-45deg); 208 | } 209 | 210 | .edd-option-focused:not(.edd-option-disabled) { 211 | color: #4ac5f1; 212 | } 213 | 214 | .edd-option-disabled, 215 | .edd-group-disabled .edd-option { 216 | cursor: default; 217 | color: #ccc; 218 | } 219 | 220 | .edd-gradient-top, 221 | .edd-gradient-bottom { 222 | content: ''; 223 | position: absolute; 224 | left: 2px; 225 | right: 12px; 226 | height: 32px; 227 | background-image: 228 | linear-gradient( 229 | 0deg, 230 | rgba(255, 255, 255, 0) 0%, 231 | rgba(255, 255, 255, 1) 40%, 232 | rgba(255, 255, 255, 1) 60%, 233 | rgba(255, 255, 255, 0) 100% 234 | ); 235 | background-repeat: repeat-x; 236 | background-size: 100% 200%; 237 | pointer-events: none; 238 | transition: opacity 100ms; 239 | opacity: 0; 240 | } 241 | 242 | .edd-gradient-top { 243 | background-position: bottom; 244 | top: 0; 245 | } 246 | 247 | .edd-gradient-bottom { 248 | background-position: top; 249 | bottom: 0; 250 | } 251 | 252 | .edd-body-scrollable .edd-gradient-top, 253 | .edd-body-scrollable .edd-gradient-bottom { 254 | opacity: 1; 255 | } 256 | 257 | .edd-body-scrollable.edd-body-at-top .edd-gradient-top, 258 | .edd-body-scrollable.edd-body-at-bottom .edd-gradient-bottom { 259 | opacity: 0; 260 | } -------------------------------------------------------------------------------- /demos/themes/ivy.css: -------------------------------------------------------------------------------- 1 | .edd-root, 2 | .edd-root *, 3 | .edd-root *::before, 4 | .edd-root *::after { 5 | margin: 0; 6 | padding: 0; 7 | box-sizing: border-box; 8 | } 9 | 10 | .edd-root { 11 | display: inline-block; 12 | position: relative; 13 | width: 180px; 14 | user-select: none; 15 | font-family: 'Helvetica Neue', arial, helvetica, sans-serif; 16 | font-size: 16px; 17 | font-weight: 300; 18 | color: #333; 19 | } 20 | 21 | .edd-root-disabled { 22 | color: #ccc; 23 | cursor: not-allowed; 24 | } 25 | 26 | .edd-head { 27 | position: relative; 28 | overflow: hidden; 29 | border: 1px solid #eee; 30 | transition: box-shadow 200ms; 31 | background: white; 32 | } 33 | 34 | .edd-head, 35 | .edd-body { 36 | border-radius: 20px; 37 | } 38 | 39 | .edd-root-focused .edd-head { 40 | border-color: blue; 41 | } 42 | 43 | .edd-root-invalid .edd-head { 44 | border-color: #ff6969; 45 | } 46 | 47 | .edd-value { 48 | width: 100%; 49 | display: inline-block; 50 | vertical-align: middle; 51 | padding: 10px 35px 10px 10px; 52 | } 53 | 54 | .edd-arrow { 55 | position: absolute; 56 | width: 18px; 57 | height: 10px; 58 | top: calc(50% - 5px); 59 | right: calc(25px - 9px); 60 | transition: transform 150ms; 61 | pointer-events: none; 62 | color: #888; 63 | } 64 | 65 | .edd-arrow::before { 66 | content: ''; 67 | position: absolute; 68 | width: 13px; 69 | height: 13px; 70 | border-right: 1px solid currentColor; 71 | border-bottom: 1px solid currentColor; 72 | top: -5px; 73 | right: 0; 74 | transform: rotate(45deg); 75 | transform-origin: 50% 25%; 76 | } 77 | 78 | .edd-root-open .edd-arrow { 79 | transform: rotate(180deg); 80 | } 81 | 82 | .edd-root-open .edd-arrow, 83 | .edd-root:not(.edd-root-disabled):not(.edd-root-open) .edd-head:hover .edd-arrow { 84 | color: blue; 85 | } 86 | 87 | .edd-value, 88 | .edd-option, 89 | .edd-group-label { 90 | white-space: nowrap; 91 | text-overflow: ellipsis; 92 | overflow: hidden; 93 | } 94 | 95 | .edd-root:not(.edd-root-disabled) .edd-value, 96 | .edd-option { 97 | cursor: pointer; 98 | } 99 | 100 | .edd-select { 101 | position: absolute; 102 | opacity: 0; 103 | width: 100%; 104 | left: -100%; 105 | top: 0; 106 | } 107 | 108 | .edd-root-native .edd-select { 109 | left: 0; 110 | top: 0; 111 | width: 100%; 112 | height: 100%; 113 | } 114 | 115 | .edd-body { 116 | opacity: 0; 117 | position: absolute; 118 | left: 0; 119 | right: 0; 120 | border: 1px solid #eee; 121 | pointer-events: none; 122 | overflow: hidden; 123 | margin: 8px 0; 124 | z-index: 999; 125 | box-shadow: 0 0 20px rgba(0, 0, 0, 0.1); 126 | transform: scale(0.95); 127 | background: white; 128 | } 129 | 130 | .edd-root-open .edd-body { 131 | opacity: 1; 132 | pointer-events: all; 133 | transform: scale(1); 134 | transition: opacity 200ms, transform 100ms cubic-bezier(0.25, 0.46, 0.45, 0.94); 135 | } 136 | 137 | .edd-root-open-above .edd-body { 138 | bottom: 100%; 139 | } 140 | 141 | .edd-root-open-below .edd-body { 142 | top: 100%; 143 | } 144 | 145 | .edd-items-list { 146 | overflow: auto; 147 | max-height: 0; 148 | transition: max-height 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94); 149 | -webkit-overflow-scrolling: touch; 150 | } 151 | 152 | .edd-group-label { 153 | font-size: 12px; 154 | font-weight: 400; 155 | padding: 12px 10px 4px; 156 | } 157 | 158 | .edd-option { 159 | padding: 6px 10px; 160 | border-bottom: 1px solid #eee; 161 | transition: background-color 250ms, color 250ms, border-color 250ms; 162 | } 163 | 164 | .edd-group-has-label .edd-option { 165 | padding-left: 14px; 166 | } 167 | 168 | .edd-option-selected { 169 | font-weight: 400; 170 | color: blue; 171 | } 172 | 173 | .edd-option-focused:not(.edd-option-disabled) { 174 | background: blue; 175 | border-bottom-color: blue; 176 | color: white; 177 | } 178 | 179 | .edd-option-disabled, 180 | .edd-group-disabled .edd-option { 181 | cursor: default; 182 | color: #ccc; 183 | } -------------------------------------------------------------------------------- /demos/themes/theme.css.d.ts: -------------------------------------------------------------------------------- 1 | export const root: string; 2 | export const rootOpen: string; 3 | export const rootOpenAbove: string; 4 | export const rootOpenBelow: string; 5 | export const rootDisabled: string; 6 | export const rootInvalid: string; 7 | export const rootFocused: string; 8 | export const rootHasValue: string; 9 | export const rootNative: string; 10 | export const head: string; 11 | export const value: string; 12 | export const arrow: string; 13 | export const select: string; 14 | export const body: string; 15 | export const bodyScrollable: string; 16 | export const bodyAtTop: string; 17 | export const bodyAtBottom: string; 18 | export const gradientTop: string; 19 | export const gradientBottom: string; 20 | export const itemsList: string; 21 | export const group: string; 22 | export const groupDisabled: string; 23 | export const groupHasLabel: string; 24 | export const groupLabel: string; 25 | export const option: string; 26 | export const optionDisabled: string; 27 | export const optionFocused: string; 28 | export const optionSelected: string; -------------------------------------------------------------------------------- /docs/easydropdown-anatomy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patrickkunka/easydropdown/caa479bd3647eb9e689b9d428f5ba41d4988b8ae/docs/easydropdown-anatomy.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "easydropdown", 3 | "version": "4.2.0", 4 | "description": "A lightweight library for building beautiful styleable select elements", 5 | "author": "KunkaLabs Limited", 6 | "private": false, 7 | "license": "Apache-2.0", 8 | "main": "./dist/index.js", 9 | "types": "./dist/index.d.ts", 10 | "browser": "./dist/index.js", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/patrickkunka/easydropdown/" 14 | }, 15 | "scripts": { 16 | "test": "mocha --opts ./config/mocha/mocha.opts --exit", 17 | "test:watch": "npm run test -- --watch", 18 | "test:cover": "nyc npm run test", 19 | "test:size": "bundlesize", 20 | "lint": "npm run lint:ts && npm run lint:css", 21 | "lint:ts": "tslint --project tsconfig.json -c './config/tslint/tslint.json' './src/**/*.ts' './src/**/*.test.ts'", 22 | "lint:css": "stylelint --config './config/stylelint/.stylelintrc.json' './demos/**/*.css'", 23 | "watch": "tsc --watch", 24 | "build": "tsc", 25 | "bundle": "webpack --config ./config/webpack/config.ts", 26 | "bundle:watch": "npm run bundle -- --watch", 27 | "bundle:build": "npm run bundle -- --env=production && npm run copy:bundle", 28 | "copy:bundle": "cp ./bundle/easydropdown.js ./demos/easydropdown.js && cp ./bundle/easydropdown.js.map ./demos/easydropdown.js.map", 29 | "prepublishOnly": "npm run test && npm run lint && npm run bundle:build && npm run build", 30 | "coveralls": "npm run test:cover -- --report lcovonly && cat ./coverage/lcov.info | coveralls" 31 | }, 32 | "nyc": { 33 | "extends": "./config/nyc/.nycrc.json" 34 | }, 35 | "bundlesize": [ 36 | { 37 | "path": "./bundle/easydropdown.js", 38 | "maxSize": "9.17 kB" 39 | } 40 | ], 41 | "dependencies": { 42 | "custom-event-polyfill": "1.0.6", 43 | "helpful-merge": "0.2.0" 44 | }, 45 | "devDependencies": { 46 | "@types/chai": "4.1.7", 47 | "@types/mocha": "5.2.5", 48 | "@types/node": "10.12.12", 49 | "@types/sinon": "7.0.4", 50 | "bundlesize": "0.17.1", 51 | "chai": "4.2.0", 52 | "chai-shallow-deep-equal": "1.4.6", 53 | "coveralls": "3.0.3", 54 | "istanbul": "0.4.5", 55 | "jsdom": "13.0.0", 56 | "jsdom-global": "3.0.2", 57 | "mocha": "5.2.0", 58 | "nyc": "13.3.0", 59 | "sinon": "7.2.3", 60 | "source-map-support": "0.5.0", 61 | "stylelint": "9.10.1", 62 | "stylelint-config-standard": "18.2.0", 63 | "ts-loader": "5.3.0", 64 | "ts-node": "7.0.1", 65 | "tsconfig-paths": "3.3.1", 66 | "tslint": "5.14.0", 67 | "tslint-eslint-rules": "5.4.0", 68 | "typescript": "3.1.6", 69 | "webpack": "4.29.6", 70 | "webpack-cli": "3.1.0" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Components/arrow.ts: -------------------------------------------------------------------------------- 1 | import ClassNames from '../Config/ClassNames'; 2 | 3 | const arrow = (_, classNames: ClassNames) => ``; 4 | 5 | export default arrow; -------------------------------------------------------------------------------- /src/Components/body.ts: -------------------------------------------------------------------------------- 1 | import ClassNames from '../Config/ClassNames'; 2 | import composeClassName from '../Shared/Util/composeClassName'; 3 | import State from '../State/State'; 4 | 5 | import group from './group'; 6 | 7 | function body(state: State, classNames: ClassNames): string { 8 | const className = composeClassName([ 9 | classNames.body, 10 | [state.isAtTop, classNames.bodyAtTop], 11 | [state.isAtBottom, classNames.bodyAtBottom], 12 | [state.isScrollable, classNames.bodyScrollable] 13 | ]); 14 | 15 | const styleAttr = state.isOpen ? 16 | `style="max-height: ${state.maxBodyHeight}px;"` : ''; 17 | 18 | return (` 19 |
25 |
28 | ${state.groups.map(groupState => group(groupState, state, classNames)).join('')} 29 |
30 | 31 | 32 |
33 | `); 34 | } 35 | 36 | export default body; -------------------------------------------------------------------------------- /src/Components/group.ts: -------------------------------------------------------------------------------- 1 | import ClassNames from '../Config/ClassNames'; 2 | import composeClassName from '../Shared/Util/composeClassName'; 3 | import Group from '../State/Group'; 4 | import State from '../State/State'; 5 | 6 | import option from './option'; 7 | 8 | const group = (groupState: Group, state: State, classNames: ClassNames) => { 9 | const className = composeClassName([ 10 | classNames.group, 11 | [groupState.isDisabled, classNames.groupDisabled], 12 | [groupState.hasLabel, classNames.groupHasLabel] 13 | ]); 14 | 15 | return (` 16 |
17 | ${groupState.hasLabel ? 18 | `
${groupState.label}
` : '' 19 | } 20 | ${groupState.options.map(optionState => option(optionState, state, classNames)).join('')} 21 |
22 | `); 23 | }; 24 | 25 | export default group; -------------------------------------------------------------------------------- /src/Components/head.ts: -------------------------------------------------------------------------------- 1 | import ClassNames from '../Config/ClassNames'; 2 | import State from '../State/State'; 3 | 4 | import arrow from './arrow'; 5 | import value from './value'; 6 | 7 | const head = (state: State, classNames: ClassNames) => (` 8 |
9 | ${value(state, classNames)} 10 | ${arrow(state, classNames)} 11 | 12 |
13 | `); 14 | 15 | export default head; -------------------------------------------------------------------------------- /src/Components/option.ts: -------------------------------------------------------------------------------- 1 | import ClassNames from '../Config/ClassNames'; 2 | import composeClassName from '../Shared/Util/composeClassName'; 3 | import Option from '../State/Option'; 4 | import State from '../State/State'; 5 | 6 | function option(optionState: Option, state: State, classNames: ClassNames): string { 7 | const isSelected = state.selectedOption === optionState; 8 | 9 | const className = composeClassName([ 10 | classNames.option, 11 | [isSelected, classNames.optionSelected], 12 | [optionState === state.focusedOption, classNames.optionFocused], 13 | [optionState.isDisabled, classNames.optionDisabled] 14 | ]); 15 | 16 | return (` 17 |
25 | ${optionState.label} 26 |
27 | `); 28 | } 29 | 30 | export default option; -------------------------------------------------------------------------------- /src/Components/root.ts: -------------------------------------------------------------------------------- 1 | import ClassNames from '../Config/ClassNames'; 2 | import composeClassName from '../Shared/Util/composeClassName'; 3 | import State from '../State/State'; 4 | 5 | import body from './body'; 6 | import head from './head'; 7 | 8 | const root = (state: State, classNames: ClassNames) => { 9 | const className = composeClassName([ 10 | classNames.root, 11 | [state.isDisabled, classNames.rootDisabled], 12 | [state.isInvalid, classNames.rootInvalid], 13 | [state.isOpen, classNames.rootOpen], 14 | [state.isFocused, classNames.rootFocused], 15 | [state.hasValue, classNames.rootHasValue], 16 | [state.isOpenAbove, classNames.rootOpenAbove], 17 | [state.isOpenBelow, classNames.rootOpenBelow], 18 | [state.isUseNativeMode, classNames.rootNative] 19 | ]); 20 | 21 | return (` 22 |
31 | ${head(state, classNames)} 32 | ${state.isUseNativeMode ? '' : body(state, classNames)} 33 |
34 | `); 35 | }; 36 | 37 | export default root; -------------------------------------------------------------------------------- /src/Components/value.ts: -------------------------------------------------------------------------------- 1 | import ClassNames from '../Config/ClassNames'; 2 | import State from '../State/State'; 3 | 4 | const value = (state: State, classNames: ClassNames) => { 5 | return (` 6 |
11 | ${state.humanReadableValue} 12 |
13 | `); 14 | }; 15 | 16 | export default value; -------------------------------------------------------------------------------- /src/Config/Behavior.ts: -------------------------------------------------------------------------------- 1 | import IBehavior from './Interfaces/IBehavior'; 2 | 3 | class Behavior implements IBehavior { 4 | public showPlaceholderWhenOpen: boolean = false; 5 | public openOnFocus: boolean = false; 6 | public closeOnSelect: boolean = true; 7 | public useNativeUiOnMobile: boolean = true; 8 | public loop: boolean = false; 9 | public clampMaxVisibleItems: boolean = true; 10 | public liveUpdates: boolean = false; 11 | public maxVisibleItems: number = 15; 12 | 13 | constructor() { 14 | Object.seal(this); 15 | } 16 | } 17 | 18 | export default Behavior; -------------------------------------------------------------------------------- /src/Config/Callbacks.ts: -------------------------------------------------------------------------------- 1 | import ICallback from './Interfaces/ICallback'; 2 | import ISelectCallback from './Interfaces/ISelectCallback'; 3 | 4 | class Callbacks { 5 | public onOpen: ICallback = null; 6 | public onClose: ICallback = null; 7 | public onSelect: ISelectCallback = null; 8 | public onOptionClick: ISelectCallback = null; 9 | 10 | constructor() { 11 | Object.seal(this); 12 | } 13 | } 14 | 15 | export default Callbacks; -------------------------------------------------------------------------------- /src/Config/ClassNames.ts: -------------------------------------------------------------------------------- 1 | import IClassNames from './Interfaces/IClassNames'; 2 | 3 | class ClassNames implements IClassNames { 4 | public root: string = 'edd-root'; 5 | public rootOpen: string = 'edd-root-open'; 6 | public rootOpenAbove: string = 'edd-root-open-above'; 7 | public rootOpenBelow: string = 'edd-root-open-below'; 8 | public rootDisabled: string = 'edd-root-disabled'; 9 | public rootInvalid: string = 'edd-root-invalid'; 10 | public rootFocused: string = 'edd-root-focused'; 11 | public rootHasValue: string = 'edd-root-has-value'; 12 | public rootNative: string = 'edd-root-native'; 13 | public gradientTop: string = 'edd-gradient-top'; 14 | public gradientBottom: string = 'edd-gradient-bottom'; 15 | public head: string = 'edd-head'; 16 | public value: string = 'edd-value'; 17 | public arrow: string = 'edd-arrow'; 18 | public select: string = 'edd-select'; 19 | public body: string = 'edd-body'; 20 | public bodyScrollable: string = 'edd-body-scrollable'; 21 | public bodyAtTop: string = 'edd-body-at-top'; 22 | public bodyAtBottom: string = 'edd-body-at-bottom'; 23 | public itemsList: string = 'edd-items-list'; 24 | public group: string = 'edd-group'; 25 | public groupDisabled: string = 'edd-group-disabled'; 26 | public groupHasLabel: string = 'edd-group-has-label'; 27 | public groupLabel: string = 'edd-group-label'; 28 | public option: string = 'edd-option'; 29 | public optionDisabled: string = 'edd-option-disabled'; 30 | public optionFocused: string = 'edd-option-focused'; 31 | public optionSelected: string = 'edd-option-selected'; 32 | 33 | constructor() { 34 | Object.seal(this); 35 | } 36 | } 37 | 38 | export default ClassNames; -------------------------------------------------------------------------------- /src/Config/Config.ts: -------------------------------------------------------------------------------- 1 | import Behavior from './Behavior'; 2 | import Callbacks from './Callbacks'; 3 | import ClassNames from './ClassNames'; 4 | import IConfig from './Interfaces/IConfig'; 5 | 6 | class Config implements IConfig { 7 | public callbacks = new Callbacks(); 8 | public classNames = new ClassNames(); 9 | public behavior = new Behavior(); 10 | 11 | constructor() { 12 | Object.seal(this); 13 | } 14 | } 15 | 16 | export default Config; -------------------------------------------------------------------------------- /src/Config/Interfaces/IBehavior.ts: -------------------------------------------------------------------------------- 1 | interface IBehavior { 2 | /** 3 | * A boolean dictating whether or not to further 4 | * reduce the `maxVisibleItems` value of the dropdown 5 | * menu when a collision occurs. 6 | * 7 | * @default true 8 | */ 9 | 10 | clampMaxVisibleItems?: boolean; 11 | 12 | /** 13 | * A boolean dictating whether or not the dropdown 14 | * should close when a value is selected. 15 | * 16 | * @default true 17 | */ 18 | 19 | closeOnSelect?: boolean; 20 | 21 | /** 22 | * A boolean dictating whether or not the dropdown should 23 | * watch for updates to the underyling `` UI on mobile devices (while 76 | * maintaing a styled "head"). 77 | * 78 | * @default true 79 | */ 80 | 81 | useNativeUiOnMobile?: boolean; 82 | } 83 | 84 | export default IBehavior; -------------------------------------------------------------------------------- /src/Config/Interfaces/ICallback.ts: -------------------------------------------------------------------------------- 1 | type ICallback = () => void; 2 | 3 | export default ICallback; -------------------------------------------------------------------------------- /src/Config/Interfaces/ICallbacks.ts: -------------------------------------------------------------------------------- 1 | import ICallback from './ICallback'; 2 | import ISelectCallback from './ISelectCallback'; 3 | 4 | interface ICallbacks { 5 | /** 6 | * An optional callback function to be invoked whenever 7 | * the dropdown is closed. 8 | */ 9 | 10 | onClose?: ICallback; 11 | 12 | /** 13 | * An optional callback function to be invoked whenever 14 | * the dropdown is opened. 15 | */ 16 | 17 | onOpen?: ICallback; 18 | 19 | /** 20 | * An optional callback function to be invoked whenever 21 | * an option is selected. The selected option's value 22 | * is passed as the first argument to the callback. 23 | */ 24 | 25 | onSelect?: ISelectCallback; 26 | 27 | /** 28 | * An optional callback function to be invoked whenever 29 | * an option is clicked, regardless of whether that option 30 | * is already selected, selectable or disabled. The clicked 31 | * option's value is passed as the first argument to the 32 | * callback. 33 | */ 34 | 35 | onOptionClick?: ISelectCallback; 36 | } 37 | 38 | export default ICallbacks; -------------------------------------------------------------------------------- /src/Config/Interfaces/IClassNames.ts: -------------------------------------------------------------------------------- 1 | interface IClassNames { 2 | root: string; 3 | rootOpen: string; 4 | rootOpenAbove: string; 5 | rootOpenBelow: string; 6 | rootDisabled: string; 7 | rootInvalid: string; 8 | rootFocused: string; 9 | rootHasValue: string; 10 | rootNative: string; 11 | head: string; 12 | value: string; 13 | arrow: string; 14 | select: string; 15 | body: string; 16 | bodyScrollable: string; 17 | bodyAtTop: string; 18 | bodyAtBottom: string; 19 | gradientTop: string; 20 | gradientBottom: string; 21 | itemsList: string; 22 | group: string; 23 | groupDisabled: string; 24 | groupHasLabel: string; 25 | groupLabel: string; 26 | option: string; 27 | optionDisabled: string; 28 | optionFocused: string; 29 | optionSelected: string; 30 | } 31 | 32 | export default IClassNames; -------------------------------------------------------------------------------- /src/Config/Interfaces/IConfig.ts: -------------------------------------------------------------------------------- 1 | import IBehavior from './IBehavior'; 2 | import ICallbacks from './ICallbacks'; 3 | import IClassNames from './IClassNames'; 4 | 5 | interface IConfig { 6 | behavior?: IBehavior; 7 | callbacks?: ICallbacks; 8 | classNames?: IClassNames; 9 | } 10 | 11 | export default IConfig; -------------------------------------------------------------------------------- /src/Config/Interfaces/ISelectCallback.ts: -------------------------------------------------------------------------------- 1 | type ISelectCallback = (value?: string) => void; 2 | 3 | export default ISelectCallback; -------------------------------------------------------------------------------- /src/Easydropdown/Easydropdown.ts: -------------------------------------------------------------------------------- 1 | import merge from 'helpful-merge'; 2 | 3 | import Config from '../Config/Config'; 4 | import ICallback from '../Config/Interfaces/ICallback'; 5 | import IConfig from '../Config/Interfaces/IConfig'; 6 | import bindEvents from '../Events/bindEvents'; 7 | import EventBinding from '../Events/EventBinding'; 8 | import Dom from '../Renderer/Dom'; 9 | import Renderer from '../Renderer/Renderer'; 10 | import dispatchOpen from '../Shared/Util/dispatchOpen'; 11 | import pollForSelectChange from '../Shared/Util/pollForSelectChange'; 12 | import pollForSelectMutation from '../Shared/Util/pollForSelectMutation'; 13 | import closeOthers from '../State/InjectedActions/closeOthers'; 14 | import scrollToView from '../State/InjectedActions/scrollToView'; 15 | import IActions from '../State/Interfaces/IActions'; 16 | import State from '../State/State'; 17 | import StateManager from '../State/StateManager'; 18 | import StateMapper from '../State/StateMapper'; 19 | 20 | import cache from './cache'; 21 | import Timers from './Timers'; 22 | 23 | class Easydropdown { 24 | public actions: IActions; 25 | 26 | private config: Config; 27 | private state: State; 28 | private dom: Dom; 29 | private eventBindings: EventBinding[]; 30 | private renderer: Renderer; 31 | private timers: Timers; 32 | 33 | constructor(selectElement: HTMLSelectElement, options: IConfig) { 34 | this.config = merge(new Config(), options, true); 35 | this.state = StateMapper.mapFromSelect(selectElement, this.config); 36 | this.renderer = new Renderer(this.config.classNames); 37 | this.dom = this.renderer.render(this.state, selectElement); 38 | this.timers = new Timers(); 39 | 40 | this.actions = StateManager.proxyActions(this.state, { 41 | closeOthers: closeOthers.bind(null, this, cache), 42 | scrollToView: scrollToView.bind(null, this.dom, this.timers) 43 | }, this.handleStateUpdate.bind(this)); 44 | 45 | this.eventBindings = bindEvents({ 46 | actions: this.actions, 47 | config: this.config, 48 | dom: this.dom, 49 | state: this.state, 50 | timers: this.timers 51 | }); 52 | 53 | this.timers.pollChangeIntervalId = pollForSelectChange(this.dom.select, this.state, this.actions, this.config); 54 | 55 | if (this.config.behavior.liveUpdates) { 56 | this.timers.pollMutationIntervalId = pollForSelectMutation( 57 | this.dom.select, 58 | this.state, 59 | this.refresh.bind(this) 60 | ); 61 | } 62 | } 63 | 64 | public get selectElement(): HTMLSelectElement { 65 | return this.dom.select; 66 | } 67 | 68 | public get value(): string { 69 | return this.state.value; 70 | } 71 | 72 | public set value(nextValue: string) { 73 | if (typeof nextValue !== 'string') { 74 | throw new TypeError('[EasyDropDown] Provided value not a valid string'); 75 | } 76 | 77 | this.dom.select.value = nextValue; 78 | } 79 | 80 | public open(): void { 81 | dispatchOpen(this.actions, this.config, this.dom); 82 | } 83 | 84 | public close(): void { 85 | this.actions.close(); 86 | } 87 | 88 | public refresh(): void { 89 | this.state = merge( 90 | this.state, 91 | StateMapper.mapFromSelect(this.dom.select, this.config) 92 | ); 93 | 94 | this.renderer.update(this.state); 95 | 96 | this.dom.group.length = this.dom.option.length = this.dom.item.length = 0; 97 | 98 | Renderer.queryDomRefs(this.dom, ['group', 'option', 'item']); 99 | } 100 | 101 | public validate(): boolean { 102 | if (!this.state.isRequired || this.state.hasValue) { 103 | return true; 104 | } 105 | 106 | this.actions.invalidate(); 107 | 108 | return false; 109 | } 110 | 111 | public destroy(): void { 112 | this.timers.clear(); 113 | this.eventBindings.forEach(binding => binding.unbind()); 114 | this.renderer.destroy(); 115 | 116 | const cacheIndex = cache.indexOf(this); 117 | 118 | cache.splice(cacheIndex, 1); 119 | } 120 | 121 | private handleStateUpdate(state: State, key: keyof State): void { 122 | const {callbacks} = this.config; 123 | 124 | this.renderer.update(state, key); 125 | 126 | switch (key) { 127 | case 'bodyStatus': { 128 | let cb: ICallback; 129 | 130 | if (state.isOpen) { 131 | cb = callbacks.onOpen; 132 | } else { 133 | cb = callbacks.onClose; 134 | } 135 | 136 | if (typeof cb === 'function') cb(); 137 | 138 | break; 139 | } 140 | case 'selectedIndex': { 141 | const cb = callbacks.onSelect; 142 | 143 | if (typeof cb === 'function') cb(state.value); 144 | 145 | break; 146 | } 147 | case 'isClickSelecting': { 148 | const cb = callbacks.onOptionClick; 149 | 150 | if (state[key] === false) { 151 | const nextValue = state.getOptionFromIndex(state.focusedIndex).value; 152 | 153 | if (typeof cb === 'function') cb(nextValue); 154 | } 155 | } 156 | } 157 | } 158 | } 159 | 160 | export default Easydropdown; -------------------------------------------------------------------------------- /src/Easydropdown/EasydropdownFacade.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from 'chai'; 2 | import 'jsdom-global/register'; 3 | 4 | import factory from './factory'; 5 | 6 | describe('EasydropdownFacade', () => { 7 | afterEach(() => { 8 | document.body.innerHTML = ''; 9 | }); 10 | 11 | it('exposes a `value` accessor property', () => { 12 | const parent = document.createElement('div'); 13 | 14 | parent.innerHTML = (` 15 | 20 | `); 21 | 22 | const select = parent.firstElementChild as HTMLSelectElement; 23 | const facade = factory(select); 24 | 25 | assert.equal(facade.value, 'A'); 26 | 27 | facade.value = 'B'; 28 | 29 | assert.equal(select.value, 'B'); 30 | 31 | // @ts-ignore intentionally cuasing error 32 | assert.throws(() => facade.value = 5, TypeError); 33 | }); 34 | }); -------------------------------------------------------------------------------- /src/Easydropdown/EasydropdownFacade.ts: -------------------------------------------------------------------------------- 1 | import Easydropdown from './Easydropdown'; 2 | 3 | class EasydropdownFacade { 4 | /** 5 | * Programmatically opens the dropdown, closing any 6 | * other open instances. 7 | */ 8 | 9 | public open: () => void; 10 | 11 | /** 12 | * Programmatically closes the dropdown. 13 | */ 14 | 15 | public close: () => void; 16 | 17 | /** 18 | * Refreshes the instance and updates the DOM in 19 | * response to a change in the underlying `` element must exist within a document'); 60 | 61 | parent.replaceChild(this.dom.root, selectElement); 62 | 63 | tempSelect.parentElement.replaceChild(selectElement, tempSelect); 64 | selectElement.className = this.classNames.select; 65 | selectElement.setAttribute('aria-hidden', 'true'); 66 | 67 | this.dom.select = selectElement; 68 | } 69 | 70 | private syncSelectWithValue(value: string): void { 71 | if (this.dom.select.value === value) return; 72 | 73 | const event = new CustomEvent('change', { 74 | bubbles: true 75 | }); 76 | 77 | this.dom.select.value = value; 78 | 79 | this.dom.select.dispatchEvent(event); 80 | } 81 | 82 | public static queryDomRefs(dom: Dom, keys: string[] = Object.keys(dom)): Dom { 83 | return keys 84 | .reduce((localDom: Dom, ref: string) => { 85 | const selector = `[data-ref~="${ref}"]`; 86 | const elements = localDom.root.querySelectorAll(selector); 87 | 88 | if (elements.length < 1 || ref === 'root') return localDom; 89 | 90 | const element = elements[0]; 91 | const value = localDom[ref]; 92 | 93 | if (value === null) { 94 | localDom[ref] = element; 95 | } else if (Array.isArray(value)) { 96 | Array.prototype.push.apply(value, elements); 97 | } 98 | 99 | return localDom; 100 | }, dom); 101 | } 102 | } 103 | 104 | export default Renderer; -------------------------------------------------------------------------------- /src/Renderer/dom.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from 'chai'; 2 | import 'jsdom-global/register'; 3 | 4 | import Dom from './Dom'; 5 | 6 | const createMockItems = (): any[] => [ 7 | { 8 | offsetHeight: 10 9 | }, 10 | { 11 | offsetHeight: 43 12 | }, 13 | { 14 | offsetHeight: 5 15 | } 16 | ]; 17 | 18 | describe('Dom', () => { 19 | describe('.sumItemsHeight()', () => { 20 | it('sums the heights of all elements in the `items` array', () => { 21 | const dom = new Dom(); 22 | 23 | dom.item = createMockItems(); 24 | 25 | const sum = dom.item.reduce((localSum: number, item: HTMLElement) => { 26 | return localSum + item.offsetHeight; 27 | }, 0); 28 | 29 | assert.equal(dom.sumItemsHeight(), sum); 30 | }); 31 | 32 | it('breaks iteration at a provided maximum count', () => { 33 | const dom = new Dom(); 34 | 35 | dom.item = createMockItems(); 36 | 37 | assert.equal( 38 | dom.sumItemsHeight(2), 39 | dom.item[0].offsetHeight + dom.item[1].offsetHeight 40 | ); 41 | }); 42 | }); 43 | }); -------------------------------------------------------------------------------- /src/Renderer/domDiff.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from 'chai'; 2 | 3 | import DomChangeType from './Constants/DomChangeType'; 4 | import domDiff from './domDiff'; 5 | 6 | interface ITestCase { 7 | prev: () => Node; 8 | next: () => Node; 9 | type: DomChangeType; 10 | } 11 | 12 | const testCases: ITestCase[] = [ 13 | { 14 | prev: () => document.createTextNode(''), 15 | next: () => document.createTextNode(''), 16 | type: DomChangeType.NONE 17 | }, 18 | { 19 | prev: () => document.createTextNode('foo'), 20 | next: () => document.createTextNode('bar'), 21 | type: DomChangeType.INNER 22 | }, 23 | { 24 | prev: () => document.createElement('div'), 25 | next: () => document.createElement('div'), 26 | type: DomChangeType.NONE 27 | }, 28 | { 29 | prev: () => document.createElement('div'), 30 | next: () => document.createElement('span'), 31 | type: DomChangeType.REPLACE 32 | }, 33 | { 34 | prev: () => document.createElement('div'), 35 | next: () => document.createTextNode(''), 36 | type: DomChangeType.REPLACE 37 | }, 38 | { 39 | prev: () => document.createElement('div'), 40 | next: () => { 41 | const el = document.createElement('div'); 42 | 43 | el.classList.add('foo'); 44 | 45 | return el; 46 | }, 47 | type: DomChangeType.OUTER 48 | }, 49 | { 50 | prev: () => { 51 | const el = document.createElement('div'); 52 | 53 | el.textContent = 'foo'; 54 | 55 | return el; 56 | }, 57 | next: () => { 58 | const el = document.createElement('div'); 59 | 60 | el.textContent = 'bar'; 61 | 62 | return el; 63 | }, 64 | type: DomChangeType.INNER 65 | }, 66 | { 67 | prev: () => { 68 | const el = document.createElement('div'); 69 | 70 | el.innerHTML = '
'; 71 | 72 | return el; 73 | }, 74 | next: () => { 75 | const el = document.createElement('div'); 76 | 77 | el.innerHTML = '
'; 78 | 79 | return el; 80 | }, 81 | type: DomChangeType.INNER 82 | }, 83 | { 84 | prev: () => { 85 | const el = document.createElement('div'); 86 | 87 | el.innerHTML = 'foo'; 88 | 89 | return el; 90 | }, 91 | next: () => { 92 | const el = document.createElement('div'); 93 | 94 | el.classList.add('foo'); 95 | el.innerHTML = 'bar'; 96 | 97 | return el; 98 | }, 99 | type: DomChangeType.FULL 100 | } 101 | ]; 102 | 103 | describe('domDiff()', () => { 104 | it( 105 | 'diffs a previous and next version of an element, and ' + 106 | 'produces the appropriate change "type"', 107 | () => { 108 | testCases.forEach(testCase => { 109 | const prev = testCase.prev(); 110 | const next = testCase.next(); 111 | 112 | const patchCommand = domDiff(prev, next); 113 | 114 | assert.equal(patchCommand.type, testCase.type); 115 | }); 116 | } 117 | ); 118 | }); 119 | -------------------------------------------------------------------------------- /src/Renderer/domDiff.ts: -------------------------------------------------------------------------------- 1 | import merge from 'helpful-merge'; 2 | 3 | import AttributeChangeType from './Constants/AttributeChangeType'; 4 | import DomChangeType from './Constants/DomChangeType'; 5 | import IAttributeChange from './Interfaces/IAttributeChange'; 6 | import IPatchCommand from './Interfaces/IPatchCommand'; 7 | import PatchCommand from './PatchCommand'; 8 | 9 | function domDiff(prev: Node, next: Node): PatchCommand { 10 | let totalChildNodes = -1; 11 | 12 | const command = new PatchCommand(); 13 | 14 | if (prev instanceof HTMLSelectElement) { 15 | command.type = DomChangeType.NONE; 16 | 17 | return command; 18 | } 19 | 20 | if (prev instanceof Text && next instanceof Text) { 21 | if (prev.textContent === next.textContent) { 22 | command.type = DomChangeType.NONE; 23 | } else { 24 | command.type = DomChangeType.INNER; 25 | command.newTextContent = next.textContent; 26 | } 27 | } else if (prev instanceof HTMLElement && next instanceof HTMLElement) { 28 | if (prev.tagName !== next.tagName) { 29 | command.type = DomChangeType.REPLACE; 30 | command.newNode = next; 31 | } else if (prev.outerHTML === next.outerHTML) { 32 | command.type = DomChangeType.NONE; 33 | } else if (prev.innerHTML === next.innerHTML) { 34 | merge(command, diffAttributeChanges(prev, next)); 35 | } else { 36 | merge(command, diffAttributeChanges(prev, next)); 37 | 38 | if (command.attributeChanges.length > 0) { 39 | command.type = DomChangeType.FULL; 40 | } else { 41 | command.type = DomChangeType.INNER; 42 | } 43 | 44 | if ((totalChildNodes = prev.childNodes.length) > 0 && totalChildNodes === next.childNodes.length) { 45 | for (let i = 0, childNode; (childNode = prev.childNodes[i]); i++) { 46 | command.childCommands.push(domDiff(childNode, next.childNodes[i])); 47 | } 48 | } else { 49 | command.newInnerHtml = next.innerHTML; 50 | } 51 | } 52 | } else { 53 | command.type = DomChangeType.REPLACE; 54 | command.newNode = next; 55 | } 56 | 57 | return command; 58 | } 59 | 60 | function diffAttributeChanges(prev: HTMLElement, next: HTMLElement): IPatchCommand { 61 | const totalAttributes = Math.max(prev.attributes.length, next.attributes.length); 62 | const attributesMap = {}; 63 | const undef = void(0); 64 | const attributeChanges: IAttributeChange[] = []; 65 | 66 | for (let i = 0; i < totalAttributes; i++) { 67 | const attr1 = prev.attributes[i]; 68 | const attr2 = next.attributes[i]; 69 | 70 | if (attr1 && attributesMap[attr1.name] === undef) { 71 | attributesMap[attr1.name] = []; 72 | } 73 | 74 | if (attr2 && attributesMap[attr2.name] === undef) { 75 | attributesMap[attr2.name] = []; 76 | } 77 | 78 | if (attr1) attributesMap[attr1.name][0] = attr1.value; 79 | if (attr2) attributesMap[attr2.name][1] = attr2.value; 80 | } 81 | 82 | const keys = Object.keys(attributesMap); 83 | 84 | if (keys.length > 1) { 85 | keys.sort(); 86 | } 87 | 88 | for (let i = 0, key; (key = keys[i]); i++) { 89 | const attr = attributesMap[key]; 90 | 91 | const change: IAttributeChange = { 92 | type: null, 93 | name: key, 94 | value: null 95 | }; 96 | 97 | if (attr[0] === attr[1]) continue; 98 | 99 | if (attr[0] === undef) { 100 | change.type = AttributeChangeType.ADD; 101 | change.value = attr[1]; 102 | } else if (attr[1] === undef) { 103 | change.type = AttributeChangeType.REMOVE, 104 | change.value = ''; 105 | } else { 106 | change.type = AttributeChangeType.EDIT, 107 | change.value = attr[1]; 108 | } 109 | 110 | attributeChanges.push(change); 111 | } 112 | 113 | return { 114 | type: DomChangeType.OUTER, 115 | attributeChanges 116 | }; 117 | } 118 | 119 | export default domDiff; -------------------------------------------------------------------------------- /src/Renderer/domPatch.test.ts: -------------------------------------------------------------------------------- 1 | import merge from 'helpful-merge'; 2 | 3 | import {assert} from 'chai'; 4 | import {SinonStub, stub} from 'sinon'; 5 | 6 | import AttributeChangeType from './Constants/AttributeChangeType'; 7 | import DomChangeType from './Constants/DomChangeType'; 8 | import domPatch from './domPatch'; 9 | import IPatchCommand from './Interfaces/IPatchCommand'; 10 | import PatchCommand from './PatchCommand'; 11 | 12 | const coerceToPatchCommand = (raw): PatchCommand => { 13 | const command = merge(new PatchCommand(), raw); 14 | 15 | command.childCommands = command.childCommands.map(coerceToPatchCommand); 16 | 17 | return command; 18 | }; 19 | 20 | interface ITestCase { 21 | input: string; 22 | output: string; 23 | patchCommand: IPatchCommand; 24 | } 25 | 26 | const testCases: ITestCase[] = [ 27 | { 28 | input: 'foo', 29 | patchCommand: { 30 | type: DomChangeType.INNER, 31 | newTextContent: 'bar' 32 | }, 33 | output: 'bar' 34 | }, 35 | { 36 | input: '
', 37 | patchCommand: { 38 | type: DomChangeType.REPLACE, 39 | newNode: document.createElement('section') 40 | }, 41 | output: '
' 42 | }, 43 | { 44 | input: '
', 45 | patchCommand: { 46 | type: DomChangeType.INNER, 47 | childCommands: [ 48 | { 49 | type: DomChangeType.REPLACE, 50 | newNode: document.createElement('em') 51 | } 52 | ] 53 | }, 54 | output: '
' 55 | }, 56 | { 57 | input: '
', 58 | patchCommand: { 59 | type: DomChangeType.INNER, 60 | newInnerHtml: '' 61 | }, 62 | output: '
' 63 | }, 64 | { 65 | input: '
', 66 | patchCommand: { 67 | type: DomChangeType.FULL, 68 | attributeChanges: [ 69 | { 70 | name: 'class', 71 | value: 'foo', 72 | type: AttributeChangeType.ADD 73 | } 74 | ], 75 | childCommands: [ 76 | { 77 | type: DomChangeType.REPLACE, 78 | newNode: document.createElement('span') 79 | } 80 | ] 81 | }, 82 | output: '
' 83 | }, 84 | { 85 | input: '
', 86 | patchCommand: { 87 | type: DomChangeType.FULL, 88 | attributeChanges: [ 89 | { 90 | name: 'class', 91 | value: 'foo', 92 | type: AttributeChangeType.ADD 93 | } 94 | ], 95 | newInnerHtml: '' 96 | }, 97 | output: '
' 98 | }, 99 | { 100 | input: '
', 101 | patchCommand: { 102 | type: DomChangeType.OUTER, 103 | attributeChanges: [ 104 | { 105 | name: 'data-foo', 106 | value: '', 107 | type: AttributeChangeType.REMOVE 108 | } 109 | ] 110 | }, 111 | output: '
' 112 | } 113 | ]; 114 | 115 | describe('domPatch()', () => { 116 | let rafStub: SinonStub; 117 | 118 | before(() => { 119 | rafStub = stub(window, 'requestAnimationFrame') 120 | .callsFake(fn => void fn(0) || 0); 121 | }); 122 | 123 | after(() => rafStub.restore()); 124 | 125 | it( 126 | 'patches the provided element as per the provided "patch command"', 127 | () => { 128 | testCases.forEach(testCase => { 129 | const temp = document.createElement('div'); 130 | 131 | temp.innerHTML = testCase.input; 132 | 133 | const prev = temp.firstChild; 134 | const patchCommand = coerceToPatchCommand(testCase.patchCommand); 135 | 136 | domPatch(prev, patchCommand); 137 | 138 | const output = temp.innerHTML; 139 | 140 | assert.equal(output, testCase.output); 141 | }); 142 | } 143 | ); 144 | }); -------------------------------------------------------------------------------- /src/Renderer/domPatch.ts: -------------------------------------------------------------------------------- 1 | import AttributeChangeType from './Constants/AttributeChangeType'; 2 | import DomChangeType from './Constants/DomChangeType'; 3 | import IAttributeChange from './Interfaces/IAttributeChange'; 4 | import PatchCommand from './PatchCommand'; 5 | 6 | function domPatch(node: Node, command: PatchCommand): Node { 7 | switch (command.type) { 8 | case DomChangeType.NONE: 9 | return node; 10 | case DomChangeType.REPLACE: 11 | node.parentElement.replaceChild(command.newNode, node); 12 | 13 | return command.newNode; 14 | case DomChangeType.INNER: 15 | if (node instanceof Text) { 16 | node.textContent = command.newTextContent; 17 | } else if (command.childCommands.length > 0) { 18 | command.childCommands.forEach((childCommand, i) => domPatch(node.childNodes[i], childCommand)); 19 | } else { 20 | (node as HTMLElement).innerHTML = command.newInnerHtml; 21 | } 22 | 23 | return node; 24 | case DomChangeType.OUTER: 25 | patchAttributes(node as HTMLElement, command.attributeChanges); 26 | 27 | return node; 28 | case DomChangeType.FULL: 29 | if (command.childCommands.length > 0) { 30 | command.childCommands.forEach((childCommand, i) => domPatch(node.childNodes[i], childCommand)); 31 | } else { 32 | (node as HTMLElement).innerHTML = command.newInnerHtml; 33 | } 34 | 35 | patchAttributes(node as HTMLElement, command.attributeChanges); 36 | 37 | return node; 38 | } 39 | } 40 | 41 | function patchAttributes(el: HTMLElement, attributeChanges: IAttributeChange[]): void { 42 | const raf = window.requestAnimationFrame; 43 | 44 | attributeChanges.forEach(change => { 45 | if (raf && ['class', 'style'].indexOf(change.name) > -1) { 46 | raf(() => patchAttribute(el, change)); 47 | } else { 48 | patchAttribute(el, change); 49 | } 50 | }); 51 | } 52 | 53 | function patchAttribute(el: HTMLElement, change: IAttributeChange): void { 54 | switch (change.type) { 55 | case AttributeChangeType.ADD: 56 | case AttributeChangeType.EDIT: 57 | el.setAttribute(change.name, change.value); 58 | 59 | break; 60 | case AttributeChangeType.REMOVE: 61 | el.removeAttribute(change.name); 62 | 63 | break; 64 | } 65 | } 66 | 67 | export default domPatch; -------------------------------------------------------------------------------- /src/Shared/Polyfills/Element.matches.ts: -------------------------------------------------------------------------------- 1 | if (!Element.prototype.matches) { 2 | Element.prototype.matches = (Element.prototype as any).msMatchesSelector; 3 | } -------------------------------------------------------------------------------- /src/Shared/Util/Constants/CollisionType.ts: -------------------------------------------------------------------------------- 1 | enum CollisionType { 2 | NONE = 'NONE', 3 | TOP = 'TOP', 4 | BOTTOM = 'BOTTOM' 5 | } 6 | 7 | export default CollisionType; -------------------------------------------------------------------------------- /src/Shared/Util/Interfaces/ICollisionData.ts: -------------------------------------------------------------------------------- 1 | import CollisionType from '../Constants/CollisionType'; 2 | 3 | interface ICollisionData { 4 | type: CollisionType; 5 | maxVisibleItemsOverride: number; 6 | } 7 | 8 | export default ICollisionData; -------------------------------------------------------------------------------- /src/Shared/Util/Interfaces/IDispatchOpen.ts: -------------------------------------------------------------------------------- 1 | import Config from '../../../Config/Config'; 2 | import Dom from '../../../Renderer/Dom'; 3 | import IActions from '../../../State/Interfaces/IActions'; 4 | 5 | type IDispatchOpen = ( 6 | actions: IActions, 7 | config: Config, 8 | dom: Dom 9 | ) => void; 10 | 11 | export default IDispatchOpen; -------------------------------------------------------------------------------- /src/Shared/Util/closestParent.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from 'chai'; 2 | 3 | import closestParent from './closestParent'; 4 | 5 | describe('closestParent()', () => { 6 | it('returns the provided element if matching, and `includeSelf` set', () => { 7 | const el = document.createElement('div'); 8 | 9 | const output = closestParent(el, 'div', true); 10 | 11 | assert.equal(el, output); 12 | }); 13 | 14 | it('returns `null` if no parent matches', () => { 15 | const el = document.createElement('div'); 16 | const parent = document.createElement('section'); 17 | 18 | parent.appendChild(el); 19 | 20 | const output = closestParent(el, 'div'); 21 | 22 | assert.isNull(output); 23 | }); 24 | 25 | it('returns `null` if the parent is the ``', () => { 26 | const el = document.createElement('div'); 27 | 28 | document.body.appendChild(el); 29 | 30 | const output = closestParent(el, 'div'); 31 | 32 | assert.isNull(output); 33 | 34 | document.body.removeChild(el); 35 | }); 36 | 37 | it('traverses up the DOM until a matching parent is found', () => { 38 | const el = document.createElement('div'); 39 | const parent = document.createElement('section'); 40 | const grandParent = document.createElement('div'); 41 | 42 | grandParent.classList.add('foo'); 43 | 44 | parent.appendChild(el); 45 | grandParent.appendChild(parent); 46 | 47 | const output = closestParent(el, '.foo'); 48 | 49 | assert.equal(output, grandParent); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/Shared/Util/closestParent.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns the closest parent of a given element matching the 3 | * provided selector, optionally including the element itself. 4 | */ 5 | 6 | function closestParent( 7 | el: HTMLElement, 8 | selector: string, 9 | includeSelf: boolean = false 10 | ): HTMLElement { 11 | let parent = el.parentNode as HTMLElement; 12 | 13 | if (includeSelf && el.matches(selector)) { 14 | return el; 15 | } 16 | 17 | while (parent && parent !== document.body) { 18 | if (parent.matches && parent.matches(selector)) { 19 | return parent; 20 | } else if (parent.parentNode) { 21 | parent = parent.parentNode as HTMLElement; 22 | } else { 23 | return null; 24 | } 25 | } 26 | 27 | return null; 28 | } 29 | 30 | export default closestParent; -------------------------------------------------------------------------------- /src/Shared/Util/composeClassName.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from 'chai'; 2 | 3 | import composeClassName from './composeClassName'; 4 | 5 | describe('composeClassName()', () => { 6 | it('produces a compound classname based on the provided tokens', () => { 7 | const className = composeClassName([ 8 | 'foo', 9 | [true, 'bar'], 10 | [false, 'baz'] 11 | ]); 12 | 13 | assert.equal(className, 'foo bar'); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/Shared/Util/composeClassName.ts: -------------------------------------------------------------------------------- 1 | function composeClassName(tokens: Array): string { 2 | return tokens 3 | .reduce((classNames, token) => { 4 | if (typeof token === 'string') classNames.push(token); 5 | else { 6 | const [predicate, className] = token; 7 | 8 | if (predicate) classNames.push(className); 9 | } 10 | 11 | return classNames; 12 | }, []) 13 | .join(' '); 14 | } 15 | 16 | export default composeClassName; -------------------------------------------------------------------------------- /src/Shared/Util/createDomElementFromHtml.ts: -------------------------------------------------------------------------------- 1 | function createDomElementFromHtml(html: string): Element { 2 | const temp = document.createElement('div'); 3 | 4 | temp.innerHTML = html; 5 | 6 | return temp.firstElementChild; 7 | } 8 | 9 | export default createDomElementFromHtml; -------------------------------------------------------------------------------- /src/Shared/Util/detectBodyCollision.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from 'chai'; 2 | 3 | import createMockHandlerParams from '../../Events/Mock/createMockHandlerParams'; 4 | 5 | import CollisionType from './Constants/CollisionType'; 6 | import detectBodyCollision, { 7 | mapCollisionData 8 | } from './detectBodyCollision'; 9 | 10 | describe('detectBodyCollision()', () => { 11 | it('returns collision type `NONE` if the select has no options', () => { 12 | const params = createMockHandlerParams(); 13 | const collisionData = detectBodyCollision(params.dom, params.config); 14 | 15 | assert.equal(collisionData.type, CollisionType.NONE); 16 | }); 17 | 18 | it('returns collision data if at least option exists', () => { 19 | const params = createMockHandlerParams(); 20 | 21 | params.dom.option.push(document.createElement('div')); 22 | 23 | const collisionData = detectBodyCollision(params.dom, params.config); 24 | 25 | assert.notEqual(collisionData.type, CollisionType.NONE); 26 | }); 27 | 28 | describe('mapCollisionData()', () => { 29 | it('returns collision type `NONE` if no collision is occuring', () => { 30 | const collisionData = mapCollisionData(200, 200, 100, 30); 31 | 32 | assert.equal(collisionData.type, CollisionType.NONE); 33 | }); 34 | 35 | it('returns collision type `TOP` if the body height is greater than the clearspace above the head', () => { 36 | const collisionData = mapCollisionData(50, 200, 100, 30); 37 | 38 | assert.equal(collisionData.type, CollisionType.TOP); 39 | }); 40 | 41 | it('returns collision type `BOTTOM` if the body height is greater than the clearspace below the head', () => { 42 | const collisionData = mapCollisionData(200, 50, 100, 30); 43 | 44 | assert.equal(collisionData.type, CollisionType.BOTTOM); 45 | }); 46 | 47 | it( 48 | 'returns collision a visible options override if colliding at the ' + 49 | 'top and bottom, but closer to the top', 50 | () => { 51 | const collisionData = mapCollisionData(30, 50, 100, 30); 52 | 53 | assert.notEqual(collisionData.maxVisibleItemsOverride, -1); 54 | assert.equal(collisionData.type, CollisionType.TOP); 55 | } 56 | ); 57 | 58 | it( 59 | 'returns collision a visible options override if colliding at the ' + 60 | 'top and bottom, but closer to the bottom', 61 | () => { 62 | const collisionData = mapCollisionData(50, 30, 100, 30); 63 | 64 | assert.notEqual(collisionData.maxVisibleItemsOverride, -1); 65 | assert.equal(collisionData.type, CollisionType.BOTTOM); 66 | } 67 | ); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /src/Shared/Util/detectBodyCollision.ts: -------------------------------------------------------------------------------- 1 | import Config from '../../Config/Config'; 2 | import Dom from '../../Renderer/Dom'; 3 | 4 | import CollisionType from './Constants/CollisionType'; 5 | import ICollisionData from './Interfaces/ICollisionData'; 6 | 7 | const CLEARSPACE = 10; 8 | 9 | function mapCollisionData(deltaTop, deltaBottom, maxHeight, itemHeight): ICollisionData { 10 | let type = CollisionType.NONE; 11 | let maxVisibleItemsOverride = -1; 12 | 13 | if (deltaTop <= maxHeight && deltaBottom <= maxHeight) { 14 | const largestDelta = Math.max(deltaBottom, deltaTop); 15 | 16 | type = deltaTop < deltaBottom ? CollisionType.TOP : CollisionType.BOTTOM; 17 | maxVisibleItemsOverride = Math.floor(largestDelta / itemHeight); 18 | } else if (deltaTop <= maxHeight) { 19 | type = CollisionType.TOP; 20 | } else if (deltaBottom <= maxHeight) { 21 | type = CollisionType.BOTTOM; 22 | } 23 | 24 | return {type, maxVisibleItemsOverride}; 25 | } 26 | 27 | function detectBodyCollision(dom: Dom, config: Config): ICollisionData { 28 | const bbHead = dom.head.getBoundingClientRect(); 29 | const wh = window.innerHeight; 30 | const deltaTop = bbHead.top - CLEARSPACE; 31 | const deltaBottom = wh - bbHead.bottom - CLEARSPACE; 32 | 33 | if (dom.option.length < 1) return { 34 | type: CollisionType.NONE, 35 | maxVisibleItemsOverride: -1 36 | }; 37 | 38 | const maxVisibleItems = Math.min(config.behavior.maxVisibleItems, dom.item.length); 39 | const maxHeight = dom.sumItemsHeight(maxVisibleItems); 40 | const itemHeight = dom.sumItemsHeight(1); 41 | 42 | return mapCollisionData(deltaTop, deltaBottom, maxHeight, itemHeight); 43 | } 44 | 45 | export { 46 | detectBodyCollision as default, 47 | mapCollisionData 48 | }; -------------------------------------------------------------------------------- /src/Shared/Util/dispatchOpen.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from 'chai'; 2 | import {spy} from 'sinon'; 3 | 4 | import createMockHandlerParams from '../../Events/Mock/createMockHandlerParams'; 5 | 6 | import dispatchOpen, {dispatchOpen as unboundDispatchOpen} from './dispatchOpen'; 7 | 8 | describe('dispatchOpen()', () => { 9 | it('calls `actions.open()`', () => { 10 | const {actions, config, dom} = createMockHandlerParams(); 11 | const openSpy = spy(actions, 'open'); 12 | 13 | dispatchOpen(actions, config, dom); 14 | 15 | assert.isTrue(openSpy.called); 16 | }); 17 | 18 | it( 19 | 'calls `actions.open()` with `isScrollable = true` when there ' + 20 | 'are more items than the configured maxiumum', 21 | () => { 22 | const {actions, config, dom} = createMockHandlerParams(); 23 | const openSpy = spy(actions, 'open'); 24 | 25 | config.behavior.maxVisibleItems = 5; 26 | dom.item = [...Array(6)]; 27 | 28 | dispatchOpen(actions, config, dom); 29 | 30 | const isScrollable = openSpy.firstCall.args[2]; 31 | 32 | assert.isTrue(isScrollable); 33 | } 34 | ); 35 | 36 | it( 37 | 'overrides the configured maximum visible options if colliding', 38 | () => { 39 | const {actions, config, dom} = createMockHandlerParams(); 40 | const openSpy = spy(actions, 'open'); 41 | 42 | const mockDetectBodyCollision = () => ({ 43 | maxVisibleItemsOverride: 3 44 | }); 45 | 46 | config.behavior.maxVisibleItems = 6; 47 | dom.item = [...Array(6)]; 48 | 49 | unboundDispatchOpen(mockDetectBodyCollision, actions, config, dom); 50 | 51 | const isScrollable = openSpy.firstCall.args[2]; 52 | 53 | assert.isTrue(isScrollable); 54 | } 55 | ); 56 | }); 57 | -------------------------------------------------------------------------------- /src/Shared/Util/dispatchOpen.ts: -------------------------------------------------------------------------------- 1 | import Config from '../../Config/Config'; 2 | import Dom from '../../Renderer/Dom'; 3 | import IActions from '../../State/Interfaces/IActions'; 4 | 5 | import detectBodyCollision from './detectBodyCollision'; 6 | import IDispatchOpen from './Interfaces/IDispatchOpen'; 7 | 8 | function dispatchOpen( 9 | injectedDetectBodyCollision: typeof detectBodyCollision, 10 | actions: IActions, 11 | config: Config, 12 | dom: Dom 13 | ): void { 14 | const collisionData = injectedDetectBodyCollision(dom, config); 15 | 16 | const maxVisibleItems = collisionData.maxVisibleItemsOverride > -1 ? 17 | collisionData.maxVisibleItemsOverride : config.behavior.maxVisibleItems; 18 | 19 | const isScrollable = dom.item.length > maxVisibleItems; 20 | const maxBodyHeight = dom.sumItemsHeight(maxVisibleItems); 21 | 22 | actions.open(maxBodyHeight, collisionData.type, isScrollable); 23 | } 24 | 25 | const boundDispatchOpen: IDispatchOpen = dispatchOpen.bind(null, detectBodyCollision); 26 | 27 | export { 28 | boundDispatchOpen as default, 29 | dispatchOpen 30 | }; -------------------------------------------------------------------------------- /src/Shared/Util/getIsMobilePlatform.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from 'chai'; 2 | 3 | import getIsMobilePlatform from './getIsMobilePlatform'; 4 | 5 | describe('getIsMobilePlatform()', () => { 6 | it('returns `true` if the user agent matches a known mobile device', () => { 7 | assert.isTrue(getIsMobilePlatform('iphone')); 8 | assert.isTrue(getIsMobilePlatform('android')); 9 | assert.isTrue(getIsMobilePlatform('Windows Phone')); 10 | assert.isTrue(getIsMobilePlatform('opera mini')); 11 | }); 12 | }); -------------------------------------------------------------------------------- /src/Shared/Util/getIsMobilePlatform.ts: -------------------------------------------------------------------------------- 1 | function getIsMobilePlatform(userAgent: string): boolean { 2 | const isIos = /(ipad|iphone|ipod)/gi.test(userAgent); 3 | const isAndroid = /android/gi.test(userAgent); 4 | const isOperaMini = /opera mini/gi.test(userAgent); 5 | const isWindowsPhone = /windows phone/gi.test(userAgent); 6 | 7 | if (isIos || isAndroid || isOperaMini || isWindowsPhone) { 8 | return true; 9 | } 10 | 11 | return false; 12 | } 13 | 14 | export default getIsMobilePlatform; -------------------------------------------------------------------------------- /src/Shared/Util/killSelectReaction.ts: -------------------------------------------------------------------------------- 1 | import IHandlerParams from '../../Events/Interfaces/IHandlerParams'; 2 | 3 | const killSelectReaction = (select: HTMLSelectElement, {actions, timers}: IHandlerParams): void => { 4 | const keyingResetDuration = 100; 5 | 6 | window.clearTimeout(timers.keyingTimeoutId); 7 | 8 | actions.keying(); 9 | 10 | timers.keyingTimeoutId = window.setTimeout(() => actions.resetKeying(), keyingResetDuration); 11 | 12 | select.disabled = true; 13 | 14 | setTimeout(() => { 15 | select.disabled = false; 16 | select.focus(); 17 | }); 18 | }; 19 | 20 | export default killSelectReaction; -------------------------------------------------------------------------------- /src/Shared/Util/pollForSelectChange.ts: -------------------------------------------------------------------------------- 1 | import Config from '../../Config/Config'; 2 | import IActions from '../../State/Interfaces/IActions'; 3 | import State from '../../State/State'; 4 | 5 | const POLL_INTERVAL_DURATION = 100; 6 | 7 | function pollForSelectChange( 8 | selectElement: HTMLSelectElement, 9 | state: State, 10 | actions: IActions, 11 | config: Config 12 | ): number { 13 | let lastValue: string = selectElement.value; 14 | 15 | const pollIntervalId = window.setInterval(() => { 16 | if (selectElement.value !== lastValue) { 17 | const selectedIndex = state.getOptionIndexFromValue(selectElement.value); 18 | 19 | actions.selectOption(selectedIndex, config.behavior.closeOnSelect); 20 | actions.focusOption(selectedIndex, true); 21 | } 22 | 23 | lastValue = selectElement.value; 24 | }, POLL_INTERVAL_DURATION); 25 | 26 | return pollIntervalId; 27 | } 28 | 29 | export default pollForSelectChange; -------------------------------------------------------------------------------- /src/Shared/Util/pollForSelectMutation.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from 'chai'; 2 | import {spy} from 'sinon'; 3 | 4 | import State from '../../State/State'; 5 | 6 | import pollForSelectMutation from './pollForSelectMutation'; 7 | 8 | describe('pollForSelectMutation()', () => { 9 | it('calls the provided function when a mutation is detected', async () => { 10 | const select = document.createElement('select'); 11 | const handleMutationSpy = spy(); 12 | 13 | pollForSelectMutation(select, new State(), handleMutationSpy); 14 | 15 | select.classList.add('foo'); 16 | 17 | await new Promise(resolver => setTimeout(resolver, 300)); 18 | 19 | assert.isTrue(handleMutationSpy.called); 20 | }); 21 | 22 | it('does not calls the provided function when no mutation is detected', async () => { 23 | const select = document.createElement('select'); 24 | const handleMutationSpy = spy(); 25 | 26 | pollForSelectMutation(select, new State(), handleMutationSpy); 27 | 28 | await new Promise(resolver => setTimeout(resolver, 300)); 29 | 30 | assert.isFalse(handleMutationSpy.called); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/Shared/Util/pollForSelectMutation.ts: -------------------------------------------------------------------------------- 1 | import State from '../../State/State'; 2 | 3 | const POLL_INTERVAL_DURATION = 300; 4 | 5 | function pollForSelectMutation(selectElement: HTMLSelectElement, state: State, handleMutation: () => void): number { 6 | let lastOuterHtml: string = selectElement.outerHTML; 7 | 8 | const pollIntervalId = window.setInterval(() => { 9 | const {outerHTML} = selectElement; 10 | 11 | if (outerHTML !== lastOuterHtml && !state.isKeying) { 12 | handleMutation(); 13 | } 14 | 15 | lastOuterHtml = outerHTML; 16 | }, POLL_INTERVAL_DURATION); 17 | 18 | return pollIntervalId; 19 | } 20 | 21 | export default pollForSelectMutation; -------------------------------------------------------------------------------- /src/Shared/Util/throttle.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from 'chai'; 2 | import {spy} from 'sinon'; 3 | 4 | import throttle from './throttle'; 5 | 6 | describe('throttle()', () => { 7 | it('calls the provided function once immediately', () => { 8 | const fn = spy(); 9 | const throttled = throttle(fn, 100); 10 | 11 | throttled(); 12 | throttled(); 13 | throttled(); 14 | 15 | assert.isTrue(fn.calledOnce); 16 | }); 17 | 18 | it('calls the provided function once per the provided duration', async () => { 19 | const fn = spy(); 20 | const throttled = throttle(fn, 10); 21 | 22 | throttled('A'); 23 | throttled('B'); 24 | throttled('C'); 25 | 26 | assert.isTrue(fn.calledOnce); 27 | assert.isTrue(fn.calledWith('A')); 28 | 29 | await new Promise(resolver => setTimeout(resolver, 11)); 30 | 31 | assert.isTrue(fn.calledTwice); 32 | assert.isTrue(fn.calledWith('C')); 33 | }); 34 | }); -------------------------------------------------------------------------------- /src/Shared/Util/throttle.ts: -------------------------------------------------------------------------------- 1 | function throttle( 2 | handler: (...args: any[]) => void, 3 | delay: number 4 | ): (...args: any[]) => void { 5 | let timerId = null; 6 | let last: number = -Infinity; 7 | 8 | return function(...args): void { 9 | const now = Date.now(); 10 | 11 | const later = () => { 12 | timerId = null; 13 | 14 | handler.apply(this, args); 15 | 16 | last = now; 17 | }; 18 | 19 | const difference = now - last; 20 | 21 | if (difference >= delay) { 22 | later(); 23 | } else { 24 | clearTimeout(timerId); 25 | 26 | timerId = setTimeout(later, delay - difference); 27 | } 28 | }; 29 | } 30 | 31 | export default throttle; -------------------------------------------------------------------------------- /src/State/Constants/BodyStatus.ts: -------------------------------------------------------------------------------- 1 | enum BodyStatus { 2 | CLOSED = 'CLOSED', 3 | OPEN_ABOVE = 'OPEN_ABOVE', 4 | OPEN_BELOW = 'OPEN_BELOW' 5 | } 6 | 7 | export default BodyStatus; -------------------------------------------------------------------------------- /src/State/Constants/ScrollStatus.ts: -------------------------------------------------------------------------------- 1 | enum ScrollStatus { 2 | AT_TOP = 'AT_TOP', 3 | SCROLLED = 'SCROLLED', 4 | AT_BOTTOM = 'AT_BOTTOM' 5 | } 6 | 7 | export default ScrollStatus; -------------------------------------------------------------------------------- /src/State/Group.ts: -------------------------------------------------------------------------------- 1 | import Option from './Option'; 2 | 3 | class Group { 4 | public label: string = ''; 5 | public options: Option[] = []; 6 | public isDisabled: boolean = false; 7 | 8 | public get totalOptions(): number { 9 | return this.options.length; 10 | } 11 | 12 | public get hasLabel(): boolean { 13 | return this.label !== ''; 14 | } 15 | } 16 | 17 | export default Group; -------------------------------------------------------------------------------- /src/State/InjectedActions/closeOthers.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from 'chai'; 2 | import {spy} from 'sinon'; 3 | 4 | import Easydropdown from '../../Easydropdown/Easydropdown'; 5 | 6 | import closeOthers from './closeOthers'; 7 | 8 | describe('closeOthers()', () => { 9 | it('calls `close` on all instance in the provided cache, expect the caller instance', () => { 10 | const parent = document.createElement('div'); 11 | const select = document.createElement('select'); 12 | 13 | parent.appendChild(select); 14 | 15 | const thisInstance = new Easydropdown(select, {}); 16 | const otherInstance = new Easydropdown(select, {}); 17 | 18 | const cache = [ 19 | thisInstance, 20 | otherInstance 21 | ]; 22 | 23 | const thisCloseSpy = spy(thisInstance.actions, 'close'); 24 | const otherCloseSpy = spy(otherInstance.actions, 'close'); 25 | 26 | closeOthers(thisInstance, cache); 27 | 28 | assert.isTrue(otherCloseSpy.called); 29 | assert.isFalse(thisCloseSpy.called); 30 | }); 31 | }); -------------------------------------------------------------------------------- /src/State/InjectedActions/closeOthers.ts: -------------------------------------------------------------------------------- 1 | import Easydropdown from '../../Easydropdown/Easydropdown'; 2 | 3 | function closeOthers(thisInstance: Easydropdown, cache: Easydropdown[]): void { 4 | for (const instance of cache) { 5 | if (instance !== thisInstance) instance.actions.close(); 6 | } 7 | } 8 | 9 | export default closeOthers; -------------------------------------------------------------------------------- /src/State/InjectedActions/scrollToView.test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from 'chai'; 2 | 3 | import createMockHandlerParams from '../../Events/Mock/createMockHandlerParams'; 4 | 5 | import scrollToView, {getScrollTop} from './scrollToView'; 6 | 7 | describe('scrollToView()', () => { 8 | it('sets `itemsList.scrollTop` to the appropriate value', async () => { 9 | const params = createMockHandlerParams(); 10 | 11 | params.dom.option = [document.createElement('div')]; 12 | params.dom.itemsList.scrollTop = 100; 13 | 14 | scrollToView(params.dom, params.timers, params.state); 15 | 16 | assert.notEqual(params.dom.itemsList.scrollTop, 100); 17 | }); 18 | 19 | it('aborts if there are no options', async () => { 20 | const params = createMockHandlerParams(); 21 | 22 | params.dom.itemsList.scrollTop = 100; 23 | 24 | scrollToView(params.dom, params.timers, params.state); 25 | 26 | assert.equal(params.dom.itemsList.scrollTop, 100); 27 | }); 28 | 29 | describe('getScrollTop()', () => { 30 | it( 31 | 'scrolls to the top of the targeted option if less than the ' + 32 | 'current `scrollTop`', () => { 33 | const scrollTop = getScrollTop( 34 | 100, 35 | 50, 36 | 30, 37 | 200, 38 | 0 39 | ); 40 | 41 | assert.equal(scrollTop, 50); 42 | } 43 | ); 44 | 45 | it( 46 | 'scrolls to the top of the targeted option if greater than the ' + 47 | 'current `scrollTop` plus the body heigt', 48 | () => { 49 | const scrollTop = getScrollTop( 50 | 100, 51 | 250, 52 | 30, 53 | 100, 54 | 0 55 | ); 56 | 57 | assert.equal(scrollTop, 180); 58 | } 59 | ); 60 | 61 | it('returns the current scrollTop if the target option is visible', () => { 62 | const scrollTop = getScrollTop( 63 | 100, 64 | 150, 65 | 30, 66 | 100, 67 | 0 68 | ); 69 | 70 | assert.equal(scrollTop, 100); 71 | }); 72 | 73 | it('adds an offset to the targetted option if provided', () => { 74 | const scrollTop = getScrollTop( 75 | 100, 76 | 250, 77 | 30, 78 | 100, 79 | 35 80 | ); 81 | 82 | assert.equal(scrollTop, 215); 83 | }); 84 | 85 | it('subtracts an offset from the targetted option if provided', () => { 86 | const scrollTop = getScrollTop( 87 | 100, 88 | 50, 89 | 30, 90 | 200, 91 | 35 92 | ); 93 | 94 | assert.equal(scrollTop, 15); 95 | }); 96 | }); 97 | }); -------------------------------------------------------------------------------- /src/State/InjectedActions/scrollToView.ts: -------------------------------------------------------------------------------- 1 | import Timers from '../../Easydropdown/Timers'; 2 | import Dom from '../../Renderer/Dom'; 3 | import State from '../../State/State'; 4 | 5 | function getScrollTop( 6 | currentScrollTop: number, 7 | optionOffsetTop: number, 8 | optionHeight: number, 9 | bodyHeight: number, 10 | scrollOffset: number 11 | ): number { 12 | const max = currentScrollTop + bodyHeight; 13 | 14 | let remainder: number; 15 | 16 | if (optionOffsetTop < currentScrollTop) { 17 | return optionOffsetTop - scrollOffset; 18 | } else if ((remainder = (optionOffsetTop + optionHeight) - max) > 0) { 19 | return currentScrollTop + remainder + scrollOffset; 20 | } 21 | 22 | return currentScrollTop; 23 | } 24 | 25 | function scrollToView(dom: Dom, timers: Timers, state: State, scrollToMiddle: boolean = false): void { 26 | const index = Math.max(0, state.focusedIndex > -1 ? state.focusedIndex : state.selectedIndex); 27 | const option = dom.option[index]; 28 | 29 | if (!option) return; 30 | 31 | const offset = scrollToMiddle ? (state.maxBodyHeight / 2) - (option.offsetHeight / 2) : 0; 32 | 33 | const scrollTop = getScrollTop( 34 | dom.itemsList.scrollTop, 35 | option.offsetTop, 36 | option.offsetHeight, 37 | state.maxBodyHeight, 38 | offset 39 | ); 40 | 41 | if (scrollTop === dom.itemsList.scrollTop) return; 42 | 43 | dom.itemsList.scrollTop = scrollTop; 44 | } 45 | 46 | export { 47 | getScrollTop, 48 | scrollToView as default 49 | }; -------------------------------------------------------------------------------- /src/State/Interfaces/IActions.ts: -------------------------------------------------------------------------------- 1 | import CollisionType from '../../Shared/Util/Constants/CollisionType'; 2 | import State from '../State'; 3 | 4 | interface IActions { 5 | focus(): void; 6 | blur(): void; 7 | invalidate(): void; 8 | validate(): void; 9 | topOut(): void; 10 | bottomOut(): void; 11 | scroll(): void; 12 | open( 13 | maxBodyHeight: number, 14 | collisionType: CollisionType, 15 | isScrollable: boolean 16 | ): void; 17 | close(): void; 18 | makeScrollable(): void; 19 | makeUnscrollable(): void; 20 | startClickSelecting(): void; 21 | selectOption(index: number, close?: boolean): void; 22 | focusOption(index: number, shouldScrollToView?: boolean): void; 23 | search(): void; 24 | resetSearch(): void; 25 | keying(): void; 26 | resetKeying(): void; 27 | useNative(): void; 28 | closeOthers?(): void; 29 | scrollToView?(stateProxy: State, scrollToMiddle?: boolean): void; 30 | } 31 | 32 | export default IActions; -------------------------------------------------------------------------------- /src/State/Interfaces/IOnAction.ts: -------------------------------------------------------------------------------- 1 | import State from '../State'; 2 | 3 | type IOnAction = (state: State, updatedKey: string) => void; 4 | 5 | export default IOnAction; -------------------------------------------------------------------------------- /src/State/Interfaces/IPropertyDescriptor.ts: -------------------------------------------------------------------------------- 1 | interface IPropertyDescriptor { 2 | key: string; 3 | get: () => any; 4 | set: (value) => void; 5 | } 6 | 7 | export default IPropertyDescriptor; -------------------------------------------------------------------------------- /src/State/Option.ts: -------------------------------------------------------------------------------- 1 | class Option { 2 | public label: string = ''; 3 | public value: string = ''; 4 | public isDisabled: boolean = false; 5 | } 6 | 7 | export default Option; -------------------------------------------------------------------------------- /src/State/State.test.ts: -------------------------------------------------------------------------------- 1 | import * as chai from 'chai'; 2 | 3 | import BodyStatus from './Constants/BodyStatus'; 4 | import State from './State'; 5 | 6 | const {assert} = chai; 7 | 8 | const createMockState = () => ({ 9 | groups: [ 10 | {options: [{}, {}]}, 11 | {options: [{ 12 | value: 'foo' 13 | }, {}]} 14 | ] 15 | }); 16 | 17 | describe('State', () => { 18 | describe('get totalGroups()', () => { 19 | it('returns the total number of groups', () => { 20 | const state = new State(createMockState()); 21 | 22 | assert.equal(state.totalGroups, 2); 23 | }); 24 | }); 25 | 26 | describe('get totalOptions()', () => { 27 | it('returns the total number of options', () => { 28 | const state = new State(createMockState()); 29 | 30 | assert.equal(state.totalOptions, 4); 31 | }); 32 | }); 33 | 34 | describe('get label()', () => { 35 | it('returns an empty string if no selected option', () => { 36 | const state = new State(createMockState()); 37 | 38 | assert.equal(state.label, ''); 39 | }); 40 | }); 41 | 42 | describe('get value()', () => { 43 | it('returns an empty string if no selected index', () => { 44 | const state = new State(createMockState()); 45 | 46 | assert.equal(state.value, ''); 47 | }); 48 | 49 | it('returns a value based on the selected index for multiple groups', () => { 50 | const mockState = Object.assign(createMockState(), { 51 | selectedIndex: 2 52 | }); 53 | 54 | const state = new State(mockState); 55 | 56 | assert.equal(state.value, 'foo'); 57 | }); 58 | 59 | it('returns a value based on the selected index for a single group', () => { 60 | const mockState = Object.assign(createMockState(), { 61 | selectedIndex: 0 62 | }); 63 | 64 | mockState.groups.splice(0, 1); 65 | 66 | const state = new State(mockState); 67 | 68 | assert.equal(state.value, 'foo'); 69 | }); 70 | }); 71 | 72 | describe('get isGrouped()', () => { 73 | it('returns `false` if no groups have a label', () => { 74 | const state = new State(createMockState()); 75 | 76 | assert.isFalse(state.isGrouped); 77 | }); 78 | 79 | it('returns `true` if at least one group has a label', () => { 80 | const mockState = createMockState(); 81 | 82 | (mockState.groups[0] as any).label = 'foo'; 83 | 84 | const state = new State(mockState); 85 | 86 | assert.isTrue(state.isGrouped); 87 | }); 88 | }); 89 | 90 | describe('get humanReadableValue()', () => { 91 | it('returns `state.placeholder` if a placeholder exists', () => { 92 | const state = new State(createMockState()); 93 | 94 | state.placeholder = 'foo'; 95 | // @ts-ignore 96 | state.config.behavior.showPlaceholderWhenOpen = true; 97 | state.selectedIndex = 2; 98 | state.bodyStatus = BodyStatus.OPEN_ABOVE; 99 | 100 | assert.equal(state.humanReadableValue, state.placeholder); 101 | }); 102 | }); 103 | 104 | describe('getOptionIndexFromValue', () => { 105 | it('returns `-1` by default', () => { 106 | const state = new State(); 107 | 108 | assert.equal(state.getOptionIndexFromValue('foo'), -1); 109 | }); 110 | }); 111 | }); -------------------------------------------------------------------------------- /src/State/State.ts: -------------------------------------------------------------------------------- 1 | import merge from 'helpful-merge'; 2 | 3 | import Config from '../Config/Config'; 4 | 5 | import BodyStatus from './Constants/BodyStatus'; 6 | import ScrollStatus from './Constants/ScrollStatus'; 7 | import Group from './Group'; 8 | import Option from './Option'; 9 | 10 | class State { 11 | public groups: Group[] = []; 12 | public focusedIndex: number = -1; 13 | public selectedIndex: number = -1; 14 | public maxVisibleItemsOverride: number = -1; 15 | public maxBodyHeight: number = -1; 16 | public name: string = ''; 17 | public placeholder: string = ''; 18 | public scrollStatus: ScrollStatus = ScrollStatus.AT_TOP; 19 | public bodyStatus: BodyStatus = BodyStatus.CLOSED; 20 | public isDisabled: boolean = false; 21 | public isRequired: boolean = false; 22 | public isInvalid: boolean = false; 23 | public isFocused: boolean = false; 24 | public isUseNativeMode: boolean = false; 25 | public isScrollable: boolean = false; 26 | public isClickSelecting: boolean = false; 27 | public isSearching: boolean = false; 28 | public isKeying: boolean = false; 29 | 30 | private config: Config; 31 | 32 | constructor(stateRaw: any = null, config = new Config()) { 33 | this.config = config; 34 | 35 | if (!stateRaw) return; 36 | 37 | merge(this, stateRaw); 38 | 39 | this.groups = this.groups.map((groupRaw) => { 40 | const group = merge(new Group(), groupRaw); 41 | 42 | group.options = group.options.map(optionRaw => merge(new Option(), optionRaw)); 43 | 44 | return group; 45 | }); 46 | } 47 | 48 | public get totalGroups(): number { 49 | return this.groups.length; 50 | } 51 | 52 | public get lastGroup(): Group { 53 | return this.groups[this.groups.length - 1]; 54 | } 55 | 56 | public get totalOptions(): number { 57 | return this.groups.reduce((total: number, group: Group) => total + group.totalOptions, 0); 58 | } 59 | 60 | public get selectedOption(): Option { 61 | return this.getOptionFromIndex(this.selectedIndex); 62 | } 63 | 64 | public get focusedOption(): Option { 65 | return this.getOptionFromIndex(this.focusedIndex); 66 | } 67 | 68 | public get value(): string { 69 | return this.selectedOption ? this.selectedOption.value : ''; 70 | } 71 | 72 | public get humanReadableValue(): string { 73 | if ( 74 | (!this.hasValue && this.hasPlaceholder) || 75 | ( 76 | this.config.behavior.showPlaceholderWhenOpen && 77 | this.hasPlaceholder && 78 | this.isOpen 79 | ) 80 | ) { 81 | return this.placeholder; 82 | } 83 | 84 | return this.label; 85 | } 86 | 87 | public get label(): string { 88 | return this.selectedOption ? this.selectedOption.label : ''; 89 | } 90 | 91 | public get hasPlaceholder(): boolean { 92 | return this.placeholder !== ''; 93 | } 94 | 95 | public get isPlaceholderShown(): boolean { 96 | return this.hasPlaceholder && !this.hasValue; 97 | } 98 | 99 | public get hasValue(): boolean { 100 | return this.value !== ''; 101 | } 102 | 103 | public get isGrouped(): boolean { 104 | return Boolean(this.groups.find(group => group.hasLabel)); 105 | } 106 | 107 | public get isOpen(): boolean { 108 | return this.bodyStatus !== BodyStatus.CLOSED; 109 | } 110 | 111 | public get isClosed(): boolean { 112 | return this.bodyStatus === BodyStatus.CLOSED; 113 | } 114 | 115 | public get isOpenAbove(): boolean { 116 | return this.bodyStatus === BodyStatus.OPEN_ABOVE; 117 | } 118 | 119 | public get isOpenBelow(): boolean { 120 | return this.bodyStatus === BodyStatus.OPEN_BELOW; 121 | } 122 | 123 | public get isAtTop(): boolean { 124 | return this.scrollStatus === ScrollStatus.AT_TOP; 125 | } 126 | 127 | public get isAtBottom(): boolean { 128 | return this.scrollStatus === ScrollStatus.AT_BOTTOM; 129 | } 130 | 131 | public getOptionFromIndex(index: number): Option { 132 | let groupStartIndex = 0; 133 | 134 | for (const group of this.groups) { 135 | if (index < 0 ) break; 136 | 137 | const groupEndIndex = Math.max(0, groupStartIndex + group.totalOptions - 1); 138 | 139 | if (index <= groupEndIndex) { 140 | const option = group.options[index - groupStartIndex]; 141 | 142 | return option; 143 | } 144 | 145 | groupStartIndex += group.totalOptions; 146 | } 147 | 148 | return null; 149 | } 150 | 151 | public getOptionIndexFromValue(value: string): number { 152 | let index: number = -1; 153 | 154 | for (const group of this.groups) { 155 | for (const option of group.options) { 156 | index++; 157 | 158 | if (option.value === value) { 159 | return index; 160 | } 161 | } 162 | } 163 | 164 | return -1; 165 | } 166 | } 167 | 168 | export default State; -------------------------------------------------------------------------------- /src/State/StateManager.ts: -------------------------------------------------------------------------------- 1 | import merge from 'helpful-merge'; 2 | 3 | import IActions from './Interfaces/IActions'; 4 | import IOnAction from './Interfaces/IOnAction'; 5 | import IPropertyDescriptor from './Interfaces/IPropertyDescriptor'; 6 | import resolveActions from './resolveActions'; 7 | import State from './State'; 8 | 9 | class StateManager { 10 | public static proxyActions(state: State, injectedActions: any, onAction: IOnAction): IActions { 11 | const stateProxy = StateManager.createStateProxy(state, onAction); 12 | const actions = resolveActions(stateProxy); 13 | 14 | merge(actions, injectedActions); 15 | 16 | return actions; 17 | } 18 | 19 | private static createStateProxy(state: State, onAction: IOnAction): State { 20 | return Object.seal( 21 | StateManager 22 | .getPropertyDescriptorsFromValue(state, onAction) 23 | .reduce((proxy, {key, get, set}) => Object.defineProperty( 24 | proxy, 25 | key, 26 | { 27 | enumerable: true, 28 | get, 29 | set 30 | } 31 | ), {}) 32 | ); 33 | } 34 | 35 | private static getPropertyDescriptorsFromValue(state: State, onAction: IOnAction): IPropertyDescriptor[] { 36 | const prototype = Object.getPrototypeOf(state); 37 | const allKeys = Object.keys(state).concat(Object.keys(prototype)); 38 | 39 | return allKeys 40 | .reduce((localDescriptors, key) => { 41 | const propertyDescriptor = 42 | Object.getOwnPropertyDescriptor(state, key) || 43 | Object.getOwnPropertyDescriptor(prototype, key); 44 | 45 | const isAccessorProperty = typeof propertyDescriptor.get === 'function'; 46 | 47 | localDescriptors.push({ 48 | get: StateManager.readPropertyValue.bind(null, state, key), 49 | set: isAccessorProperty ? 50 | void 0 : StateManager.updatePropertyValue.bind(null, state, key, onAction), 51 | key 52 | }); 53 | 54 | return localDescriptors; 55 | }, []); 56 | } 57 | 58 | private static readPropertyValue(state: State, key: string): any { 59 | return state[key]; 60 | } 61 | 62 | private static updatePropertyValue(state: State, key: string, onAction: IOnAction, value: any): void { 63 | if (state[key] === value) return; 64 | 65 | state[key] = value; 66 | 67 | onAction(state, key); 68 | } 69 | } 70 | 71 | export default StateManager; -------------------------------------------------------------------------------- /src/State/StateMapper.ts: -------------------------------------------------------------------------------- 1 | import merge from 'helpful-merge'; 2 | 3 | import Config from '../Config/Config'; 4 | import getIsMobilePlatform from '../Shared/Util/getIsMobilePlatform'; 5 | 6 | import Group from './Group'; 7 | import Option from './Option'; 8 | import State from './State'; 9 | 10 | class StateMapper { 11 | public static mapFromSelect(selectElement: HTMLSelectElement, config: Config): State { 12 | const state = new State(null, config); 13 | 14 | let isWithinGroup = false; 15 | 16 | state.name = selectElement.name; 17 | state.isDisabled = selectElement.disabled; 18 | state.isRequired = selectElement.required; 19 | 20 | state.isUseNativeMode = ( 21 | config.behavior.useNativeUiOnMobile && 22 | getIsMobilePlatform(window.navigator.userAgent) 23 | ); 24 | 25 | for (let i = 0, child: Element; (child = selectElement.children[i]); i++) { 26 | if (i === 0 && child.getAttribute('data-placeholder') !== null) { 27 | state.placeholder = child.textContent; 28 | (child as HTMLOptionElement).value = ''; 29 | 30 | continue; 31 | } 32 | 33 | if (child instanceof HTMLOptionElement) { 34 | if (isWithinGroup === false) { 35 | state.groups.push(StateMapper.mapGroup()); 36 | 37 | isWithinGroup = true; 38 | } 39 | 40 | state.lastGroup.options.push(StateMapper.mapOption(child)); 41 | 42 | if (child.selected) state.selectedIndex = state.totalOptions - 1; 43 | } else if (child instanceof HTMLOptGroupElement) { 44 | isWithinGroup = true; 45 | 46 | state.groups.push(StateMapper.mapGroup(child)); 47 | 48 | for (let j = 0, groupChild: Element; (groupChild = child.children[j]); j++) { 49 | state.lastGroup.options.push( 50 | StateMapper.mapOption( 51 | groupChild as HTMLOptionElement, 52 | child as HTMLOptGroupElement 53 | ) 54 | ); 55 | 56 | if ((groupChild as HTMLOptionElement).selected) state.selectedIndex = state.totalOptions - 1; 57 | } 58 | 59 | isWithinGroup = false; 60 | } else { 61 | throw new TypeError( 62 | `[EasyDropDown] Invalid child tag "${child.tagName}" found in provided \`