├── .eslintignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── test.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .sasslintrc.json ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── accessibility-audit.pdf ├── docs ├── index.html └── themes │ ├── dark.css │ └── default.css ├── hydrate ├── package.json └── tests │ ├── .npmrc │ ├── __image_snapshots__ │ ├── test-js-hydrate-render-to-string-kitchen-sink-1-snap.png │ └── test-js-hydrate-render-to-string-kitchen-sink-2-snap.png │ ├── fixtures │ └── kitchen-sink.html │ ├── package.json │ ├── setup.js │ └── test.js ├── illustration.png ├── jest └── jest-setup.js ├── package.json ├── prerender.config.ts ├── src ├── components │ └── duet-date-picker │ │ ├── __image_snapshots__ │ │ ├── duet-date-picker-e-2-e-ts-duet-date-picker-min-max-support-supports-a-max-date-1-snap.png │ │ ├── duet-date-picker-e-2-e-ts-duet-date-picker-min-max-support-supports-a-min-date-1-snap.png │ │ └── duet-date-picker-e-2-e-ts-duet-date-picker-min-max-support-supports-min-and-max-dates-1-snap.png │ │ ├── date-adapter.ts │ │ ├── date-localization.ts │ │ ├── date-picker-day.tsx │ │ ├── date-picker-input.tsx │ │ ├── date-picker-month.tsx │ │ ├── date-utils.spec.ts │ │ ├── date-utils.ts │ │ ├── duet-date-picker.e2e.ts │ │ ├── duet-date-picker.scss │ │ ├── duet-date-picker.tsx │ │ └── readme.md ├── index.html ├── themes │ ├── dark.css │ └── default.css └── utils │ └── test-utils.ts ├── stencil.config.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | custom-element 3 | dist 4 | www 5 | 6 | hydrate/index.js 7 | hydrate/index.d.ts 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | name: Test on Node.js 12 running on macOS-latest 8 | runs-on: macOS-latest 9 | steps: 10 | - uses: actions/checkout@v1 11 | - uses: actions/setup-node@v1 12 | with: 13 | node-version: 12 14 | registry-url: https://registry.npmjs.org 15 | 16 | - name: Install dependencies 17 | run: npm install 18 | 19 | - name: Build project 20 | run: npm run build 21 | 22 | - name: Run tests 23 | run: npm test 24 | 25 | - name: Run hydrate tests 26 | run: cd hydrate/tests && npm install && npm test 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | **/node_modules/* 3 | lerna-debug.log 4 | npm-debug.log 5 | yarn-error.log 6 | .idea 7 | .eslintcache 8 | .DS_Store 9 | .vscode 10 | .stencil 11 | .stats 12 | package-lock.json 13 | www 14 | __diff_output__ 15 | src/components.d.ts 16 | dist/* 17 | hydrate/* 18 | custom-element/* 19 | 20 | !hydrate/tests/test.js 21 | !hydrate/tests/setup.js 22 | !hydrate/tests/package.js 23 | !hydrate/tests/.npmrc 24 | !hydrate/tests/fixtures 25 | !hydrate/tests/__image_snapshots__ 26 | !hydrate/tests/.gitignore 27 | !hydrate/package.json 28 | 29 | *.suo 30 | *.ntvs* 31 | *.njsproj 32 | *.sln 33 | *.vsix -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | custom-element 3 | dist 4 | www 5 | 6 | package.json 7 | package-lock.json 8 | 9 | *.md 10 | 11 | src/components.d.ts -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": false, 4 | "trailingComma": "es5", 5 | "bracketSpacing": true, 6 | "jsxBracketSameLine": false, 7 | "semi": false, 8 | "requirePragma": false, 9 | "insertPragma": false, 10 | "useTabs": false, 11 | "tabWidth": 2, 12 | "arrowParens": "avoid", 13 | "proseWrap": "preserve" 14 | } 15 | -------------------------------------------------------------------------------- /.sasslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": { 3 | "include": "./src/components/duet-date-picker/*.scss", 4 | "ignore": ["dist/**/*", "www/**/*", "node_modules/**"] 5 | }, 6 | "rules": { 7 | "property-sort-order": [ 8 | 1, 9 | { 10 | "order": "alphabetical" 11 | } 12 | ], 13 | "class-name-format": [ 14 | 1, 15 | { 16 | "convention": "strictbem" 17 | } 18 | ], 19 | "no-vendor-prefixes": 0, 20 | "no-color-literals": 0, 21 | "nesting-depth": [ 22 | 1, 23 | { 24 | "max-depth": 2 25 | } 26 | ], 27 | "no-qualifying-elements": [ 28 | 1, 29 | { 30 | "allow-element-with-attribute": true 31 | } 32 | ], 33 | "force-pseudo-nesting": 0, 34 | "mixins-before-declarations": 1, 35 | "leading-zero": 0, 36 | "quotes": [ 37 | 1, 38 | { 39 | "style": "double" 40 | } 41 | ] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at duetdesignsystem@lahitapiola.fi. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 LocalTapiola Services Ltd / Duet Design System 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /accessibility-audit.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duetds/date-picker/a89499198d6e5555073bb0dec3a3dab9a5b3648b/accessibility-audit.pdf -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Duet Date Picker 7 | 13 | 19 | 20 | 21 | 22 | 23 | 24 | 28 | 29 | 35 | 184 | 185 | 186 |
187 |

Duet Date Picker

188 |

189 | Duet Date Picker is an open source version of 190 | Duet Design System’s accessible date picker. It can be implemented and used 191 | across any JavaScript framework or no framework at all. 192 |

193 | 194 |

195 | 196 | For documentation, please see the 197 | GitHub repository. 198 | 199 |

200 | 201 | 202 | 218 | 219 |

Default

220 | 221 | 222 |
<label for="date">Choose a date</label>
223 | <duet-date-picker identifier="date"></duet-date-picker>
224 | 225 |

Using show() method

226 | 227 | 228 | 229 | 236 |
<label for="date">Choose a date</label>
237 | <duet-date-picker identifier="date"></duet-date-picker>
238 | <button type="button">Show date picker</button>
239 | 
240 | <script>
241 |   const button = document.querySelector("button")
242 | 
243 |   button.addEventListener("click", function() {
244 |     document.querySelector("duet-date-picker").show()
245 |   });
246 | </script>
247 | 248 |

Using setFocus() method

249 | 250 | 251 | 252 | 259 |
<label for="date">Choose a date</label>
260 | <duet-date-picker identifier="date"></duet-date-picker>
261 | <button type="button">Focus date picker</button>
262 | 
263 | <script>
264 |   const button = document.querySelector("button")
265 | 
266 |   button.addEventListener("click", function() {
267 |     document.querySelector("duet-date-picker").setFocus()
268 |   });
269 | </script>
270 | 271 |

Getting selected value

272 | 273 | 274 | undefined 275 | 283 |
<label for="date">Choose a date</label>
284 | <duet-date-picker identifier="date"></duet-date-picker>
285 | <output>undefined</output>
286 | 
287 | <script>
288 |   const picker = document.querySelector("duet-date-picker")
289 |   const output = document.querySelector("output")
290 | 
291 |   picker.addEventListener("duetChange", function(event) {
292 |     output.innerHTML = event.detail.valueAsDate
293 |   });
294 | </script>
295 | 296 |

Predefined value

297 | 298 | 299 |
<label for="date">Choose a date</label>
300 | <duet-date-picker identifier="date" value="2020-06-16">
301 | </duet-date-picker>
302 | 303 |

Minimum and maximum date

304 | 305 | 306 |
<label for="date">Choose a date</label>
307 | <duet-date-picker identifier="date" min="1990-06-10"
308 |   max="2020-07-18" value="2020-06-16">
309 | </duet-date-picker>
310 | 311 |

Localization

312 | 313 | 314 | 378 |
<label for="date">Valitse päivämäärä</label>
379 | <duet-date-picker identifier="date"></duet-date-picker>
380 | 
381 | <script>
382 |   const picker = document.querySelector("duet-date-picker")
383 |   const DATE_FORMAT = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/
384 | 
385 |   picker.dateAdapter = {
386 |     parse(value = "", createDate) {
387 |       const matches = value.match(DATE_FORMAT)
388 |       if (matches) {
389 |         return createDate(matches[3], matches[2], matches[1])
390 |       }
391 |     },
392 |     format(date) {
393 |       return `${date.getDate()}.${date.getMonth() + 1}.${date.getFullYear()}`
394 |     },
395 |   }
396 | 
397 |   picker.localization = {
398 |     buttonLabel: "Valitse päivämäärä",
399 |     placeholder: "pp.kk.vvvv",
400 |     selectedDateMessage: "Valittu päivämäärä on",
401 |     prevMonthLabel: "Edellinen kuukausi",
402 |     nextMonthLabel: "Seuraava kuukausi",
403 |     monthSelectLabel: "Kuukausi",
404 |     yearSelectLabel: "Vuosi",
405 |     closeLabel: "Sulje ikkuna",
406 |     calendarHeading: "Valitse päivämäärä",
407 |     dayNames: ["Sunnuntai", "Maanantai", "Tiistai", "Keskiviikko", "Torstai", "Perjantai", "Lauantai"],
408 |     monthNames: ["Tammikuu", "Helmikuu", "Maaliskuu", "Huhtikuu", "Toukokuu", "Kesäkuu", "Heinäkuu", "Elokuu", "Syyskuu", "Lokakuu", "Marraskuu", "Joulukuu"],
409 |     monthNamesShort: ["Tammi", "Helmi", "Maalis", "Huhti", "Touko", "Kesä", "Heinä", "Elo", "Syys", "Loka", "Marras", "Joulu"],
410 |     locale: "fi-FI",
411 |   }
412 | </script>
413 | 414 |

Changing first day of week and date format

415 | 416 | 417 | 468 |
<label for="date">Choose a date</label>
469 | <duet-date-picker first-day-of-week="0" identifier="date"></duet-date-picker>
470 | 
471 | <script>
472 |   const picker = document.querySelector("duet-date-picker")
473 |   const DATE_FORMAT_US = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/
474 | 
475 |   picker.dateAdapter = {
476 |     parse(value = "", createDate) {
477 |       const matches = value.match(DATE_FORMAT_US)
478 | 
479 |       if (matches) {
480 |         return createDate(matches[3], matches[1], matches[2])
481 |       }
482 |     },
483 |     format(date) {
484 |       return `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`
485 |     },
486 |   }
487 | 
488 |   picker.localization = {
489 |     buttonLabel: "Choose date",
490 |     placeholder: "mm/dd/yyyy",
491 |     selectedDateMessage: "Selected date is",
492 |     prevMonthLabel: "Previous month",
493 |     nextMonthLabel: "Next month",
494 |     monthSelectLabel: "Month",
495 |     yearSelectLabel: "Year",
496 |     closeLabel: "Close window",
497 |     calendarHeading: "Choose a date",
498 |     dayNames: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
499 |     monthNames: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
500 |     monthNamesShort: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
501 |     locale: "en-US",
502 |   }
503 | </script>
504 | 505 |

Required atrribute

506 | 507 |
508 | 509 | 510 |
511 | 518 |
<form class="form-picker-required">
519 |   <label for="date">Choose a date</label>
520 |   <duet-date-picker required identifier="date"></duet-date-picker>
521 | </form>
522 | 
523 | <script>
524 |   const form = document.querySelector(".form-picker-required")
525 |   form.addEventListener("submit", function(e) {
526 |     e.preventDefault()
527 |     alert("Submitted")
528 |   })
529 | </script>
530 | 531 |

Disable selectable days

532 | 533 | 534 | 548 |
<label for="date">Choose a date</label>
549 | <duet-date-picker identifier="date"></duet-date-picker>
550 | 
551 | <script>
552 |   function isWeekend(date) {
553 |     return date.getDay() === 0 || date.getDay() === 6
554 |   }
555 | 
556 |   const pickerDisableWeekend = document.querySelector("duet-date-picker")
557 |   pickerDisableWeekend.isDateDisabled = isWeekend
558 | 
559 |   pickerDisableWeekend.addEventListener("duetChange", function(e) {
560 |     if (isWeekend(e.detail.valueAsDate)) {
561 |       alert("Please select a weekday")
562 |     }
563 |   })
564 | </script>
565 |
566 |

567 | © 2020 LocalTapiola Services Ltd / 568 | Duet Design System.
Licensed under the MIT license. 569 |

570 |
571 |
Loading…
572 | 573 | 574 | -------------------------------------------------------------------------------- /docs/themes/dark.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --duet-color-primary: #005fcc; 3 | --duet-color-text: #fff; 4 | --duet-color-text-active: #fff; 5 | --duet-color-placeholder: #aaa; 6 | --duet-color-button: #444; 7 | --duet-color-surface: #222; 8 | --duet-color-overlay: rgba(0, 0, 0, 0.8); 9 | --duet-color-border: #fff; 10 | 11 | --duet-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 12 | --duet-font-normal: 400; 13 | --duet-font-bold: 600; 14 | 15 | --duet-radius: 4px; 16 | --duet-z-index: 600; 17 | } 18 | -------------------------------------------------------------------------------- /docs/themes/default.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --duet-color-primary: #005fcc; 3 | --duet-color-text: #333; 4 | --duet-color-text-active: #fff; 5 | --duet-color-placeholder: #666; 6 | --duet-color-button: #f5f5f5; 7 | --duet-color-surface: #fff; 8 | --duet-color-overlay: rgba(0, 0, 0, 0.8); 9 | --duet-color-border: #333; 10 | 11 | --duet-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 12 | --duet-font-normal: 400; 13 | --duet-font-bold: 600; 14 | 15 | --duet-radius: 4px; 16 | --duet-z-index: 600; 17 | } 18 | -------------------------------------------------------------------------------- /hydrate/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@duetds/date-picker/hydrate", 3 | "description": "duet component hydration app.", 4 | "main": "index.js", 5 | "types": "index.d.ts" 6 | } -------------------------------------------------------------------------------- /hydrate/tests/.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false -------------------------------------------------------------------------------- /hydrate/tests/__image_snapshots__/test-js-hydrate-render-to-string-kitchen-sink-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duetds/date-picker/a89499198d6e5555073bb0dec3a3dab9a5b3648b/hydrate/tests/__image_snapshots__/test-js-hydrate-render-to-string-kitchen-sink-1-snap.png -------------------------------------------------------------------------------- /hydrate/tests/__image_snapshots__/test-js-hydrate-render-to-string-kitchen-sink-2-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duetds/date-picker/a89499198d6e5555073bb0dec3a3dab9a5b3648b/hydrate/tests/__image_snapshots__/test-js-hydrate-render-to-string-kitchen-sink-2-snap.png -------------------------------------------------------------------------------- /hydrate/tests/fixtures/kitchen-sink.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Duet Date Picker 7 | 40 | 41 | 42 |
43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 117 | 118 | 119 | 170 |
171 | 172 | 173 | -------------------------------------------------------------------------------- /hydrate/tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@duetds/hydrate", 3 | "description": "Duet component hydration app.", 4 | "main": "index.js", 5 | "types": "index.d.ts", 6 | "scripts": { 7 | "test": "jest", 8 | "test:dev": "jest --watchAll --runInBand" 9 | }, 10 | "devDependencies": { 11 | "jest": "^26.1.0", 12 | "jest-image-snapshot": "^4.0.2", 13 | "jest-puppeteer": "^4.4.0", 14 | "puppeteer": "^5.0.0" 15 | }, 16 | "jest": { 17 | "setupFilesAfterEnv": [ 18 | "/setup.js" 19 | ], 20 | "preset": "jest-puppeteer" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /hydrate/tests/setup.js: -------------------------------------------------------------------------------- 1 | const { toMatchImageSnapshot } = require("jest-image-snapshot") 2 | expect.extend({ toMatchImageSnapshot }) 3 | jest.setTimeout(10000) 4 | -------------------------------------------------------------------------------- /hydrate/tests/test.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | const path = require("path") 3 | const { renderToString } = require("../index") 4 | 5 | describe("hydrate.renderToString", () => { 6 | const getScreenshot = async page => { 7 | // get the element's height, and set viewport to that height 8 | // this enables us to get full page, clipped screenshots 9 | const htmlElement = await page.$("html") 10 | const { width, height } = await htmlElement.boundingBox() 11 | await page.setViewport({ width: page.viewport().width, height: Math.round(height) }) 12 | 13 | return page.screenshot({ 14 | clip: { 15 | x: 0, 16 | y: 0, 17 | width: Math.round(width), 18 | height: Math.round(height), 19 | }, 20 | }) 21 | } 22 | 23 | const setFixture = async fixtureName => { 24 | const filePath = path.join(__dirname, "fixtures", fixtureName) 25 | 26 | if (!fs.existsSync(filePath)) { 27 | throw new Error(`hydrate test fixture not found: ${fixtureName}`) 28 | } 29 | 30 | const fixture = fs.readFileSync(filePath, "utf-8") 31 | const result = await renderToString(fixture) 32 | await page.setContent(result.html, { waitUntil: "networkidle0" }) 33 | await page.evaluateHandle(() => document.fonts.ready) 34 | } 35 | 36 | const injectDuetScript = async () => { 37 | await page.addScriptTag({ 38 | url: "https://cdn.jsdelivr.net/npm/@duetds/date-picker@latest/dist/duet/duet.esm.js", 39 | type: "module", 40 | }) 41 | // wait for all duet components etc to load 42 | await page.waitFor(2000) 43 | } 44 | 45 | beforeEach(async () => { 46 | await jestPuppeteer.resetBrowser() 47 | }) 48 | 49 | test("kitchen sink", async () => { 50 | expect.assertions(2) 51 | const tolerance = { 52 | failureThreshold: 0.02, 53 | failureThresholdType: "percent", 54 | } 55 | 56 | await setFixture("kitchen-sink.html") 57 | 58 | // before hydration 59 | let screenshot = await getScreenshot(page) 60 | expect(screenshot).toMatchImageSnapshot(tolerance) 61 | 62 | await injectDuetScript() 63 | 64 | // after hydration 65 | screenshot = await getScreenshot(page) 66 | expect(screenshot).toMatchImageSnapshot(tolerance) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /illustration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duetds/date-picker/a89499198d6e5555073bb0dec3a3dab9a5b3648b/illustration.png -------------------------------------------------------------------------------- /jest/jest-setup.js: -------------------------------------------------------------------------------- 1 | const { configureToMatchImageSnapshot } = require("jest-image-snapshot") 2 | 3 | const toMatchImageSnapshot = configureToMatchImageSnapshot({ 4 | failureThreshold: 300, 5 | customDiffConfig: { 6 | threshold: 0.2, 7 | }, 8 | }) 9 | 10 | expect.extend({ toMatchImageSnapshot }) 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@duetds/date-picker", 3 | "version": "1.4.0", 4 | "description": "Duet Date Picker is an open source version of Duet Design System’s accessible date picker.", 5 | "author": "LocalTapiola Services Ltd ", 6 | "license": "MIT", 7 | "module": "dist/index.js", 8 | "es2015": "dist/esm/index.js", 9 | "es2017": "dist/esm/index.js", 10 | "jsnext:main": "dist/esm/index.js", 11 | "main": "dist/index.cjs.js", 12 | "unpkg": "dist/duet/duet.js", 13 | "types": "dist/types/components.d.ts", 14 | "collection": "dist/collection/collection-manifest.json", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/duetds/date-picker.git" 18 | }, 19 | "publishConfig": { 20 | "access": "public" 21 | }, 22 | "files": [ 23 | "custom-element", 24 | "dist", 25 | "hydrate/index.js", 26 | "hydrate/index.d.ts" 27 | ], 28 | "scripts": { 29 | "start": "npm run dev", 30 | "dev": "stencil build --dev --es5 --watch --serve", 31 | "docs": "stencil build --docs-readme", 32 | "build": "stencil build --es5", 33 | "precommit": "stencil test --spec --silent", 34 | "test": "stencil test --spec --e2e --silent", 35 | "test:dev": "stencil test --spec --e2e --watchAll", 36 | "test:unit": "stencil test --spec --silent", 37 | "test:e2e": "stencil test --e2e --silent", 38 | "lint:js": "eslint \"**/*.{js,ts,tsx}\" --cache --quiet", 39 | "lint:sass": "sass-lint -c ./.sasslintrc.json", 40 | "lint:sass:fix": "sass-lint-auto-fix", 41 | "preversion": "npm run lint:js && npm run lint:sass && npm test", 42 | "version": "npm run build", 43 | "postversion": "npm publish", 44 | "postpublish": "git push origin master --tags" 45 | }, 46 | "husky": { 47 | "hooks": { 48 | "pre-commit": "pretty-quick --staged", 49 | "pre-push": "npm run lint:js && npm run lint:sass" 50 | } 51 | }, 52 | "engines": { 53 | "node": ">= 12.17.0", 54 | "npm": ">= 6.14.0" 55 | }, 56 | "bugs": { 57 | "email": "duetdesignsystem@lahitapiola.fi" 58 | }, 59 | "dependencies": { 60 | "@stencil/core": "^2.3.0" 61 | }, 62 | "devDependencies": { 63 | "@stencil/sass": "1.3.2", 64 | "@stencil/utils": "latest", 65 | "@types/jest": "26.0.10", 66 | "@types/jest-image-snapshot": "3.1.0", 67 | "@types/puppeteer": "3.0.1", 68 | "@typescript-eslint/eslint-plugin": "2.13.0", 69 | "@typescript-eslint/parser": "2.13.0", 70 | "eslint": "6.8.0", 71 | "eslint-config-prettier": "6.7.0", 72 | "eslint-plugin-prettier": "3.1.2", 73 | "husky": "4.2.5", 74 | "jest": "26.4.1", 75 | "jest-cli": "26.4.1", 76 | "jest-image-snapshot": "4.1.0", 77 | "prettier": "1.19.1", 78 | "prettier-stylelint": "0.4.2", 79 | "pretty-quick": "^2.0.1", 80 | "puppeteer": "5.2.1", 81 | "sass-lint": "1.13.1", 82 | "sass-lint-auto-fix": "0.21.2", 83 | "typescript": "3.9.7" 84 | }, 85 | "sasslintConfig": "./.sasslintrc.json", 86 | "eslintConfig": { 87 | "parser": "@typescript-eslint/parser", 88 | "parserOptions": { 89 | "sourceType": "module", 90 | "accessibility": "off", 91 | "ecmaVersion": 2018, 92 | "ecmaFeatures": { 93 | "jsx": true 94 | } 95 | }, 96 | "extends": [ 97 | "plugin:@typescript-eslint/recommended", 98 | "prettier/@typescript-eslint", 99 | "plugin:prettier/recommended" 100 | ], 101 | "plugins": [ 102 | "@typescript-eslint" 103 | ], 104 | "rules": { 105 | "comma-dangle": [ 106 | "error", 107 | "only-multiline" 108 | ], 109 | "curly": [ 110 | "error", 111 | "all" 112 | ], 113 | "no-console": "off", 114 | "no-undef": "off", 115 | "no-var": "off", 116 | "prefer-rest-params": "off", 117 | "@typescript-eslint/no-var-requires": "off", 118 | "@typescript-eslint/no-use-before-define": "off", 119 | "@typescript-eslint/explicit-member-accessibility": "off", 120 | "@typescript-eslint/explicit-function-return-type": "off", 121 | "@typescript-eslint/no-explicit-any": "off", 122 | "@typescript-eslint/no-unused-vars": "off", 123 | "@typescript-eslint/no-inferrable-types": "off", 124 | "@typescript-eslint/no-this-alias": "off", 125 | "@typescript-eslint/ban-ts-ignore": "off" 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /prerender.config.ts: -------------------------------------------------------------------------------- 1 | import { PrerenderConfig } from "@stencil/core" 2 | 3 | export const config: PrerenderConfig = { 4 | hydrateOptions(url) { 5 | return { 6 | prettyHtml: false, 7 | clientHydrateAnnotations: true, 8 | removeScripts: false, 9 | removeUnusedStyles: false, 10 | } 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /src/components/duet-date-picker/__image_snapshots__/duet-date-picker-e-2-e-ts-duet-date-picker-min-max-support-supports-a-max-date-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duetds/date-picker/a89499198d6e5555073bb0dec3a3dab9a5b3648b/src/components/duet-date-picker/__image_snapshots__/duet-date-picker-e-2-e-ts-duet-date-picker-min-max-support-supports-a-max-date-1-snap.png -------------------------------------------------------------------------------- /src/components/duet-date-picker/__image_snapshots__/duet-date-picker-e-2-e-ts-duet-date-picker-min-max-support-supports-a-min-date-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duetds/date-picker/a89499198d6e5555073bb0dec3a3dab9a5b3648b/src/components/duet-date-picker/__image_snapshots__/duet-date-picker-e-2-e-ts-duet-date-picker-min-max-support-supports-a-min-date-1-snap.png -------------------------------------------------------------------------------- /src/components/duet-date-picker/__image_snapshots__/duet-date-picker-e-2-e-ts-duet-date-picker-min-max-support-supports-min-and-max-dates-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duetds/date-picker/a89499198d6e5555073bb0dec3a3dab9a5b3648b/src/components/duet-date-picker/__image_snapshots__/duet-date-picker-e-2-e-ts-duet-date-picker-min-max-support-supports-min-and-max-dates-1-snap.png -------------------------------------------------------------------------------- /src/components/duet-date-picker/date-adapter.ts: -------------------------------------------------------------------------------- 1 | import { parseISODate, printISODate, createDate } from "./date-utils" 2 | 3 | type CreateDate = typeof createDate 4 | export type DuetDateParser = (input: string, createDate: CreateDate) => Date | undefined 5 | export type DuetDateFormatter = (date: Date) => string 6 | 7 | export interface DuetDateAdapter { 8 | parse: DuetDateParser 9 | format: DuetDateFormatter 10 | } 11 | 12 | const isoAdapter: DuetDateAdapter = { parse: parseISODate, format: printISODate } 13 | export default isoAdapter 14 | -------------------------------------------------------------------------------- /src/components/duet-date-picker/date-localization.ts: -------------------------------------------------------------------------------- 1 | type MonthsNames = [string, string, string, string, string, string, string, string, string, string, string, string] 2 | type DayNames = [string, string, string, string, string, string, string] 3 | 4 | export type DuetLocalizedText = { 5 | buttonLabel: string 6 | placeholder: string 7 | selectedDateMessage: string 8 | prevMonthLabel: string 9 | nextMonthLabel: string 10 | monthSelectLabel: string 11 | yearSelectLabel: string 12 | closeLabel: string 13 | calendarHeading: string 14 | dayNames: DayNames 15 | monthNames: MonthsNames 16 | monthNamesShort: MonthsNames 17 | locale: string | string[] 18 | } 19 | 20 | const localization: DuetLocalizedText = { 21 | buttonLabel: "Choose date", 22 | placeholder: "YYYY-MM-DD", 23 | selectedDateMessage: "Selected date is", 24 | prevMonthLabel: "Previous month", 25 | nextMonthLabel: "Next month", 26 | monthSelectLabel: "Month", 27 | yearSelectLabel: "Year", 28 | closeLabel: "Close window", 29 | calendarHeading: "Choose a date", 30 | dayNames: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"], 31 | monthNames: [ 32 | "January", 33 | "February", 34 | "March", 35 | "April", 36 | "May", 37 | "June", 38 | "July", 39 | "August", 40 | "September", 41 | "October", 42 | "November", 43 | "December", 44 | ], 45 | monthNamesShort: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], 46 | locale: "en-GB", 47 | } 48 | 49 | export default localization 50 | -------------------------------------------------------------------------------- /src/components/duet-date-picker/date-picker-day.tsx: -------------------------------------------------------------------------------- 1 | import { h, FunctionalComponent } from "@stencil/core" 2 | import { isEqual, isEqualMonth } from "./date-utils" 3 | 4 | export type DatePickerDayProps = { 5 | focusedDay: Date 6 | today: Date 7 | day: Date 8 | disabled: boolean 9 | inRange: boolean 10 | isSelected: boolean 11 | dateFormatter: Intl.DateTimeFormat 12 | onDaySelect: (event: MouseEvent, day: Date) => void 13 | onKeyboardNavigation: (event: KeyboardEvent) => void 14 | focusedDayRef?: (element: HTMLElement) => void 15 | } 16 | 17 | export const DatePickerDay: FunctionalComponent = ({ 18 | focusedDay, 19 | today, 20 | day, 21 | onDaySelect, 22 | onKeyboardNavigation, 23 | focusedDayRef, 24 | disabled, 25 | inRange, 26 | isSelected, 27 | dateFormatter, 28 | }) => { 29 | const isToday = isEqual(day, today) 30 | const isMonth = isEqualMonth(day, focusedDay) 31 | const isFocused = isEqual(day, focusedDay) 32 | const isOutsideRange = !inRange 33 | 34 | function handleClick(e) { 35 | onDaySelect(e, day) 36 | } 37 | 38 | return ( 39 | 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /src/components/duet-date-picker/date-picker-input.tsx: -------------------------------------------------------------------------------- 1 | import { h, FunctionalComponent } from "@stencil/core" 2 | import { DuetLocalizedText } from "./date-localization" 3 | 4 | type DatePickerInputProps = { 5 | value: string 6 | formattedValue: string 7 | valueAsDate: Date 8 | localization: DuetLocalizedText 9 | name: string 10 | identifier: string 11 | disabled: boolean 12 | required: boolean 13 | role: string 14 | dateFormatter: Intl.DateTimeFormat 15 | onClick: (event: MouseEvent) => void 16 | onInput: (event: InputEvent) => void 17 | onBlur: (event: FocusEvent) => void 18 | onFocus: (event: FocusEvent) => void 19 | buttonRef: (element: HTMLButtonElement) => void 20 | inputRef: (element: HTMLInputElement) => void 21 | } 22 | 23 | export const DatePickerInput: FunctionalComponent = ({ 24 | onClick, 25 | dateFormatter, 26 | localization, 27 | name, 28 | formattedValue, 29 | valueAsDate, 30 | value, 31 | identifier, 32 | disabled, 33 | required, 34 | role, 35 | buttonRef, 36 | inputRef, 37 | onInput, 38 | onBlur, 39 | onFocus, 40 | }) => { 41 | return ( 42 |
43 | 58 | 59 | 90 |
91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /src/components/duet-date-picker/date-picker-month.tsx: -------------------------------------------------------------------------------- 1 | import { h, FunctionalComponent } from "@stencil/core" 2 | import { DuetLocalizedText } from "./date-localization" 3 | import { DatePickerDay, DatePickerDayProps } from "./date-picker-day" 4 | import { getViewOfMonth, inRange, DaysOfWeek, isEqual } from "./date-utils" 5 | import { DateDisabledPredicate } from "./duet-date-picker" 6 | 7 | function chunk(array: T[], chunkSize: number): T[][] { 8 | const result = [] 9 | 10 | for (let i = 0; i < array.length; i += chunkSize) { 11 | result.push(array.slice(i, i + chunkSize)) 12 | } 13 | 14 | return result 15 | } 16 | 17 | function mapWithOffset(array: T[], startingOffset: number, mapFn: (item: T) => U): U[] { 18 | return array.map((_, i) => { 19 | const adjustedIndex = (i + startingOffset) % array.length 20 | return mapFn(array[adjustedIndex]) 21 | }) 22 | } 23 | 24 | type DatePickerMonthProps = { 25 | selectedDate: Date 26 | focusedDate: Date 27 | labelledById: string 28 | localization: DuetLocalizedText 29 | firstDayOfWeek: DaysOfWeek 30 | min?: Date 31 | max?: Date 32 | dateFormatter: Intl.DateTimeFormat 33 | isDateDisabled: DateDisabledPredicate 34 | onDateSelect: DatePickerDayProps["onDaySelect"] 35 | onKeyboardNavigation: DatePickerDayProps["onKeyboardNavigation"] 36 | focusedDayRef: (element: HTMLElement) => void 37 | } 38 | 39 | export const DatePickerMonth: FunctionalComponent = ({ 40 | selectedDate, 41 | focusedDate, 42 | labelledById, 43 | localization, 44 | firstDayOfWeek, 45 | min, 46 | max, 47 | dateFormatter, 48 | isDateDisabled, 49 | onDateSelect, 50 | onKeyboardNavigation, 51 | focusedDayRef, 52 | }) => { 53 | const today = new Date() 54 | const days = getViewOfMonth(focusedDate, firstDayOfWeek) 55 | 56 | return ( 57 | 58 | 59 | 60 | {mapWithOffset(localization.dayNames, firstDayOfWeek, dayName => ( 61 | 65 | ))} 66 | 67 | 68 | 69 | {chunk(days, 7).map(week => ( 70 | 71 | {week.map(day => ( 72 | 86 | ))} 87 | 88 | ))} 89 | 90 |
62 | 63 | {dayName} 64 |
73 | 85 |
91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /src/components/duet-date-picker/date-utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isEqual, 3 | isEqualMonth, 4 | addDays, 5 | addMonths, 6 | addYears, 7 | startOfWeek, 8 | endOfWeek, 9 | setMonth, 10 | setYear, 11 | inRange, 12 | clamp, 13 | startOfMonth, 14 | endOfMonth, 15 | getViewOfMonth, 16 | parseISODate, 17 | printISODate, 18 | DaysOfWeek, 19 | } from "./date-utils" 20 | 21 | describe("duet-date-picker/date-utils", () => { 22 | describe("parseISODate", () => { 23 | it("handles falsy values", () => { 24 | // @ts-ignore 25 | expect(parseISODate()).toBeUndefined() 26 | // @ts-ignore 27 | expect(parseISODate(false)).toBeUndefined() 28 | // @ts-ignore 29 | expect(parseISODate("")).toBeUndefined() 30 | // @ts-ignore 31 | expect(parseISODate(null)).toBeUndefined() 32 | // @ts-ignore 33 | expect(parseISODate(0)).toBeUndefined() 34 | }) 35 | 36 | it("returns undefined for invalid strings", () => { 37 | // invalid format 38 | expect(parseISODate("hello world")).toBeUndefined() 39 | expect(parseISODate("01/01/2020")).toBeUndefined() 40 | expect(parseISODate("01.01.2020")).toBeUndefined() 41 | expect(parseISODate("01-01-2020")).toBeUndefined() 42 | expect(parseISODate("2020/01/01")).toBeUndefined() 43 | // expect(parseISODate("2020-01-01")).toBeUndefined() 44 | expect(parseISODate("2020--01--01")).toBeUndefined() 45 | expect(parseISODate("19-01-01")).toBeUndefined() 46 | expect(parseISODate("190-01-01")).toBeUndefined() 47 | expect(parseISODate("2020-000001-000001")).toBeUndefined() 48 | expect(parseISODate("0xAA-01-01")).toBeUndefined() 49 | 50 | // correct format, but invalid dates 51 | expect(parseISODate("2020-12-32")).toBeUndefined() 52 | expect(parseISODate("2020-13-01")).toBeUndefined() 53 | }) 54 | 55 | it("returns a date for valid strings", () => { 56 | expect(parseISODate("2020-01-01")).toEqual(new Date(2020, 0, 1)) 57 | }) 58 | }) 59 | 60 | describe("isEqual", () => { 61 | it("compares dates", () => { 62 | expect(isEqual(new Date(2020, 0, 1), new Date(2020, 0, 1))).toBe(true) 63 | expect(isEqual(new Date(2020, 0, 1), new Date(2020, 0, 2))).toBe(false) 64 | 65 | expect(isEqual(null, new Date(2020, 0, 1))).toBe(false) 66 | expect(isEqual(new Date(2020, 0, 1), null)).toBe(false) 67 | expect(isEqual(null, null)).toBe(false) 68 | }) 69 | }) 70 | 71 | describe("isEqualMonth", () => { 72 | it("compares dates", () => { 73 | expect(isEqualMonth(new Date(2020, 0, 1), new Date(2020, 0, 1))).toBe(true) 74 | expect(isEqualMonth(new Date(2020, 0, 1), new Date(2020, 0, 31))).toBe(true) 75 | 76 | expect(isEqualMonth(new Date(2020, 0, 1), new Date(2020, 1, 1))).toBe(false) 77 | expect(isEqualMonth(new Date(2020, 0, 1), new Date(2021, 0, 1))).toBe(false) 78 | 79 | expect(isEqualMonth(null, new Date(2020, 0, 1))).toBe(false) 80 | expect(isEqualMonth(new Date(2020, 0, 1), null)).toBe(false) 81 | expect(isEqualMonth(null, null)).toBe(false) 82 | }) 83 | }) 84 | 85 | describe("printISODate", () => { 86 | it("should print in format dd.mm.yyyy", () => { 87 | expect(printISODate(new Date(2020, 0, 1))).toBe("2020-01-01") 88 | expect(printISODate(new Date(2020, 8, 9))).toBe("2020-09-09") 89 | expect(printISODate(new Date(2020, 9, 10))).toBe("2020-10-10") 90 | }) 91 | 92 | it("returns empty string for undefined dates", () => { 93 | expect(printISODate(undefined)).toBe("") 94 | }) 95 | }) 96 | 97 | describe("addDays", () => { 98 | it("can add days", () => { 99 | const date = new Date(2020, 0, 30) 100 | expect(addDays(date, 1)).toEqual(new Date(2020, 0, 31)) 101 | expect(addDays(date, 7)).toEqual(new Date(2020, 1, 6)) 102 | expect(addDays(date, 366)).toEqual(new Date(2021, 0, 30)) 103 | }) 104 | 105 | it("can subtract days", () => { 106 | const date = new Date(2020, 0, 31) 107 | expect(addDays(date, -1)).toEqual(new Date(2020, 0, 30)) 108 | expect(addDays(date, -2)).toEqual(new Date(2020, 0, 29)) 109 | expect(addDays(date, -7)).toEqual(new Date(2020, 0, 24)) 110 | }) 111 | }) 112 | 113 | describe("addMonths", () => { 114 | it("can add months", () => { 115 | const date = new Date(2020, 0, 1) 116 | expect(addMonths(date, 1)).toEqual(new Date(2020, 1, 1)) 117 | expect(addMonths(date, 2)).toEqual(new Date(2020, 2, 1)) 118 | expect(addMonths(date, 12)).toEqual(new Date(2021, 0, 1)) 119 | }) 120 | 121 | it("can subtract months", () => { 122 | const date = new Date(2020, 2, 1) 123 | expect(addMonths(date, -1)).toEqual(new Date(2020, 1, 1)) 124 | expect(addMonths(date, -2)).toEqual(new Date(2020, 0, 1)) 125 | expect(addMonths(date, -12)).toEqual(new Date(2019, 2, 1)) 126 | }) 127 | }) 128 | 129 | describe("addYears", () => { 130 | it("can add years", () => { 131 | const date = new Date(2020, 0, 1) 132 | expect(addYears(date, 1)).toEqual(new Date(2021, 0, 1)) 133 | expect(addYears(date, 10)).toEqual(new Date(2030, 0, 1)) 134 | }) 135 | 136 | it("can subtract years", () => { 137 | const date = new Date(2020, 0, 1) 138 | expect(addYears(date, -1)).toEqual(new Date(2019, 0, 1)) 139 | expect(addYears(date, -10)).toEqual(new Date(2010, 0, 1)) 140 | }) 141 | }) 142 | 143 | describe("startOfWeek", () => { 144 | it("returns the first day of the week", () => { 145 | expect(startOfWeek(new Date(2020, 0, 1))).toEqual(new Date(2019, 11, 30)) 146 | }) 147 | 148 | it("returns the same date if already start of the week", () => { 149 | const start = startOfWeek(new Date(2020, 0, 1)) 150 | expect(startOfWeek(start)).toEqual(start) 151 | }) 152 | 153 | it("supports changing the first day of the week", () => { 154 | expect(startOfWeek(new Date(2020, 0, 1), DaysOfWeek.Sunday)).toEqual(new Date(2019, 11, 29)) 155 | }) 156 | }) 157 | 158 | describe("endOfWeek", () => { 159 | it("returns the first day of the week", () => { 160 | expect(endOfWeek(new Date(2020, 0, 1))).toEqual(new Date(2020, 0, 5)) 161 | }) 162 | 163 | it("returns the same date if already start of the week", () => { 164 | const end = endOfWeek(new Date(2020, 0, 1)) 165 | expect(endOfWeek(end)).toEqual(end) 166 | }) 167 | 168 | it("supports changing the first day of the week", () => { 169 | expect(endOfWeek(new Date(2020, 0, 1), DaysOfWeek.Sunday)).toEqual(new Date(2020, 0, 4)) 170 | }) 171 | }) 172 | 173 | describe("setMonths", () => { 174 | it("sets the month and returns a new date", () => { 175 | const date = new Date(2020, 0, 1) 176 | const result = setMonth(date, 1) 177 | 178 | expect(result).not.toBe(date) 179 | expect(result).toEqual(new Date(2020, 1, 1)) 180 | }) 181 | }) 182 | 183 | describe("setYears", () => { 184 | it("sets the year and returns a new date", () => { 185 | const date = new Date(2020, 0, 1) 186 | const result = setYear(date, 2021) 187 | 188 | expect(result).not.toBe(date) 189 | expect(result).toEqual(new Date(2021, 0, 1)) 190 | }) 191 | }) 192 | 193 | describe("inRange", () => { 194 | it("returns false for dates below min", () => { 195 | const min = new Date(2020, 0, 1) 196 | const max = new Date(2020, 11, 31) 197 | const date = new Date(2019, 1, 1) 198 | 199 | expect(inRange(date, min, max)).toBe(false) 200 | }) 201 | 202 | it("returns false for dates above max", () => { 203 | const min = new Date(2020, 0, 1) 204 | const max = new Date(2020, 11, 31) 205 | const date = new Date(2021, 1, 1) 206 | 207 | expect(inRange(date, min, max)).toBe(false) 208 | }) 209 | 210 | it("returns true for dates in range", () => { 211 | const min = new Date(2020, 0, 1) 212 | const max = new Date(2020, 11, 31) 213 | const date = new Date(2020, 1, 1) 214 | 215 | expect(inRange(date, min, max)).toBe(true) 216 | expect(inRange(min, min, max)).toBe(true) 217 | expect(inRange(max, min, max)).toBe(true) 218 | }) 219 | 220 | it("supports only specifying a minimum", () => { 221 | const min = new Date(2020, 0, 1) 222 | 223 | expect(inRange(new Date(2020, 1, 1), min)).toBe(true) 224 | expect(inRange(min, min)).toBe(true) 225 | expect(inRange(new Date(2019, 0, 1), min)).toBe(false) 226 | }) 227 | 228 | it("supports only specifying a maximum", () => { 229 | const max = new Date(2020, 1, 1) 230 | 231 | expect(inRange(new Date(2020, 0, 1), undefined, max)).toBe(true) 232 | expect(inRange(max, undefined, max)).toBe(true) 233 | expect(inRange(new Date(2021, 0, 1), undefined, max)).toBe(false) 234 | }) 235 | 236 | it("handles undefined min and max", () => { 237 | expect(inRange(new Date(2020, 0, 1))).toBe(true) 238 | }) 239 | }) 240 | 241 | describe("clamp", () => { 242 | it("returns min date for dates below min", () => { 243 | const min = new Date(2020, 0, 1) 244 | const max = new Date(2020, 11, 31) 245 | const date = new Date(2019, 11, 31) 246 | 247 | expect(clamp(date, min, max)).toBe(min) 248 | }) 249 | 250 | it("returns max date for dates above max", () => { 251 | const min = new Date(2020, 0, 1) 252 | const max = new Date(2020, 11, 31) 253 | const date = new Date(2021, 0, 1) 254 | 255 | expect(clamp(date, min, max)).toBe(max) 256 | }) 257 | 258 | it("returns date if in range", () => { 259 | const min = new Date(2020, 0, 1) 260 | const max = new Date(2020, 11, 31) 261 | const date = new Date(2020, 5, 1) 262 | 263 | expect(clamp(date, min, max)).toBe(date) 264 | expect(clamp(min, min, max)).toBe(min) 265 | expect(clamp(max, min, max)).toBe(max) 266 | }) 267 | 268 | it("supports only specifying a minimum", () => { 269 | const min = new Date(2020, 0, 1) 270 | const date = new Date(2020, 1, 1) 271 | 272 | expect(clamp(date, min)).toBe(date) 273 | expect(clamp(min, min)).toBe(min) 274 | }) 275 | 276 | it("supports only specifying a maximum", () => { 277 | const max = new Date(2020, 1, 1) 278 | const date = new Date(2020, 0, 1) 279 | 280 | expect(clamp(date, undefined, max)).toBe(date) 281 | expect(clamp(max, undefined, max)).toBe(max) 282 | }) 283 | 284 | it("handles undefined min and max", () => { 285 | const date = new Date(2020, 0, 1) 286 | expect(clamp(date)).toBe(date) 287 | }) 288 | }) 289 | 290 | describe("startOfMonth", () => { 291 | it("returns the first day of the month", () => { 292 | for (var i = 0; i < 12; i++) { 293 | var date = new Date(2020, i, 10) // arbitrary day in middle of month 294 | expect(startOfMonth(date)).toEqual(new Date(2020, i, 1)) 295 | } 296 | }) 297 | }) 298 | 299 | describe("endOfMonth", () => { 300 | it("returns the last day of the month", () => { 301 | expect(endOfMonth(new Date(2020, 0, 10))).toEqual(new Date(2020, 0, 31)) // jan 302 | expect(endOfMonth(new Date(2020, 1, 10))).toEqual(new Date(2020, 1, 29)) // feb (leap year) 303 | expect(endOfMonth(new Date(2019, 1, 10))).toEqual(new Date(2019, 1, 28)) // feb (regular year) 304 | expect(endOfMonth(new Date(2020, 2, 10))).toEqual(new Date(2020, 2, 31)) // march 305 | expect(endOfMonth(new Date(2020, 3, 10))).toEqual(new Date(2020, 3, 30)) // april 306 | expect(endOfMonth(new Date(2020, 4, 10))).toEqual(new Date(2020, 4, 31)) // may 307 | expect(endOfMonth(new Date(2020, 5, 10))).toEqual(new Date(2020, 5, 30)) // june 308 | expect(endOfMonth(new Date(2020, 6, 10))).toEqual(new Date(2020, 6, 31)) // july 309 | expect(endOfMonth(new Date(2020, 7, 10))).toEqual(new Date(2020, 7, 31)) // august 310 | expect(endOfMonth(new Date(2020, 8, 10))).toEqual(new Date(2020, 8, 30)) // september 311 | expect(endOfMonth(new Date(2020, 9, 10))).toEqual(new Date(2020, 9, 31)) // october 312 | expect(endOfMonth(new Date(2020, 10, 10))).toEqual(new Date(2020, 10, 30)) // november 313 | expect(endOfMonth(new Date(2020, 11, 10))).toEqual(new Date(2020, 11, 31)) // december 314 | }) 315 | }) 316 | 317 | describe("getViewOfMonth", () => { 318 | function range(from: number, to: number) { 319 | var result = [] 320 | for (var i = 0; i <= to - from; i++) { 321 | result.push(from + i) 322 | } 323 | return result 324 | } 325 | 326 | function assertMonth(days: Date[], expected) { 327 | expect(days.map(d => d.getDate())).toEqual(expected) 328 | } 329 | 330 | it("gives a correct view of the month", () => { 331 | // jan 332 | assertMonth(getViewOfMonth(new Date(2020, 0, 10)), [30, 31, ...range(1, 31), 1, 2]) 333 | // feb (leap year) 334 | assertMonth(getViewOfMonth(new Date(2020, 1, 10)), [...range(27, 31), ...range(1, 29), 1]) 335 | // feb (regular year) 336 | assertMonth(getViewOfMonth(new Date(2019, 1, 10)), [...range(28, 31), ...range(1, 28), ...range(1, 3)]) 337 | //march 338 | assertMonth(getViewOfMonth(new Date(2020, 2, 10)), [...range(24, 29), ...range(1, 31), ...range(1, 5)]) 339 | // april 340 | assertMonth(getViewOfMonth(new Date(2020, 3, 10)), [30, 31, ...range(1, 30), ...range(1, 3)]) 341 | // may 342 | assertMonth(getViewOfMonth(new Date(2020, 4, 10)), [...range(27, 30), ...range(1, 31)]) 343 | // june 344 | assertMonth(getViewOfMonth(new Date(2020, 5, 10)), [...range(1, 30), ...range(1, 5)]) 345 | // july 346 | assertMonth(getViewOfMonth(new Date(2020, 6, 10)), [29, 30, ...range(1, 31), 1, 2]) 347 | // august 348 | assertMonth(getViewOfMonth(new Date(2020, 7, 10)), [...range(27, 31), ...range(1, 31), ...range(1, 6)]) 349 | // september 350 | assertMonth(getViewOfMonth(new Date(2020, 8, 10)), [31, ...range(1, 30), ...range(1, 4)]) 351 | // october 352 | assertMonth(getViewOfMonth(new Date(2020, 9, 10)), [...range(28, 30), ...range(1, 31), 1]) 353 | // november 354 | assertMonth(getViewOfMonth(new Date(2020, 10, 10)), [...range(26, 31), ...range(1, 30), ...range(1, 6)]) 355 | // december 356 | assertMonth(getViewOfMonth(new Date(2020, 11, 10)), [30, ...range(1, 31), ...range(1, 3)]) 357 | }) 358 | }) 359 | }) 360 | -------------------------------------------------------------------------------- /src/components/duet-date-picker/date-utils.ts: -------------------------------------------------------------------------------- 1 | const ISO_DATE_FORMAT = /^(\d{4})-(\d{2})-(\d{2})$/ 2 | 3 | export enum DaysOfWeek { 4 | Sunday = 0, 5 | Monday = 1, 6 | Tuesday = 2, 7 | Wednesday = 3, 8 | Thursday = 4, 9 | Friday = 5, 10 | Saturday = 6, 11 | } 12 | 13 | export function createDate(year: string, month: string, day: string): Date { 14 | var dayInt = parseInt(day, 10) 15 | var monthInt = parseInt(month, 10) 16 | var yearInt = parseInt(year, 10) 17 | 18 | const isValid = 19 | Number.isInteger(yearInt) && // all parts should be integers 20 | Number.isInteger(monthInt) && 21 | Number.isInteger(dayInt) && 22 | monthInt > 0 && // month must be 1-12 23 | monthInt <= 12 && 24 | dayInt > 0 && // day must be 1-31 25 | dayInt <= 31 && 26 | yearInt > 0 27 | 28 | if (isValid) { 29 | return new Date(yearInt, monthInt - 1, dayInt) 30 | } 31 | } 32 | 33 | /** 34 | * @param value date string in ISO format YYYY-MM-DD 35 | */ 36 | export function parseISODate(value: string): Date { 37 | if (!value) { 38 | return 39 | } 40 | 41 | const matches = value.match(ISO_DATE_FORMAT) 42 | 43 | if (matches) { 44 | return createDate(matches[1], matches[2], matches[3]) 45 | } 46 | } 47 | 48 | /** 49 | * print date in format YYYY-MM-DD 50 | * @param date 51 | */ 52 | export function printISODate(date: Date): string { 53 | if (!date) { 54 | return "" 55 | } 56 | 57 | var d = date.getDate().toString(10) 58 | var m = (date.getMonth() + 1).toString(10) 59 | var y = date.getFullYear().toString(10) 60 | 61 | // days are not zero-indexed, so pad if less than 10 62 | if (date.getDate() < 10) { 63 | d = `0${d}` 64 | } 65 | 66 | // months *are* zero-indexed, pad if less than 9! 67 | if (date.getMonth() < 9) { 68 | m = `0${m}` 69 | } 70 | 71 | return `${y}-${m}-${d}` 72 | } 73 | 74 | /** 75 | * Compare if two dates are equal in terms of day, month, and year 76 | */ 77 | export function isEqual(a: Date, b: Date): boolean { 78 | if (a == null || b == null) { 79 | return false 80 | } 81 | 82 | return isEqualMonth(a, b) && a.getDate() === b.getDate() 83 | } 84 | 85 | /** 86 | * Compare if two dates are in the same month of the same year. 87 | */ 88 | export function isEqualMonth(a: Date, b: Date): boolean { 89 | if (a == null || b == null) { 90 | return false 91 | } 92 | 93 | return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() 94 | } 95 | 96 | export function addDays(date: Date, days: number): Date { 97 | var d = new Date(date) 98 | d.setDate(d.getDate() + days) 99 | return d 100 | } 101 | 102 | export function addMonths(date: Date, months: number): Date { 103 | const d = new Date(date) 104 | d.setMonth(date.getMonth() + months) 105 | return d 106 | } 107 | 108 | export function addYears(date: Date, years: number): Date { 109 | const d = new Date(date) 110 | d.setFullYear(date.getFullYear() + years) 111 | return d 112 | } 113 | 114 | export function startOfWeek(date: Date, firstDayOfWeek: DaysOfWeek = DaysOfWeek.Monday): Date { 115 | var d = new Date(date) 116 | var day = d.getDay() 117 | var diff = (day < firstDayOfWeek ? 7 : 0) + day - firstDayOfWeek 118 | 119 | d.setDate(d.getDate() - diff) 120 | return d 121 | } 122 | 123 | export function endOfWeek(date: Date, firstDayOfWeek: DaysOfWeek = DaysOfWeek.Monday): Date { 124 | var d = new Date(date) 125 | var day = d.getDay() 126 | var diff = (day < firstDayOfWeek ? -7 : 0) + 6 - (day - firstDayOfWeek) 127 | 128 | d.setDate(d.getDate() + diff) 129 | return d 130 | } 131 | 132 | export function startOfMonth(date: Date): Date { 133 | return new Date(date.getFullYear(), date.getMonth(), 1) 134 | } 135 | 136 | export function endOfMonth(date: Date): Date { 137 | return new Date(date.getFullYear(), date.getMonth() + 1, 0) 138 | } 139 | 140 | export function setMonth(date: Date, month: number): Date { 141 | const d = new Date(date) 142 | d.setMonth(month) 143 | return d 144 | } 145 | 146 | export function setYear(date: Date, year: number): Date { 147 | const d = new Date(date) 148 | d.setFullYear(year) 149 | return d 150 | } 151 | 152 | /** 153 | * Check if date is within a min and max 154 | */ 155 | export function inRange(date: Date, min?: Date, max?: Date): boolean { 156 | return clamp(date, min, max) === date 157 | } 158 | 159 | /** 160 | * Ensures date is within range, returns min or max if out of bounds 161 | */ 162 | export function clamp(date: Date, min?: Date, max?: Date): Date { 163 | const time = date.getTime() 164 | 165 | if (min && min instanceof Date && time < min.getTime()) { 166 | return min 167 | } 168 | 169 | if (max && max instanceof Date && time > max.getTime()) { 170 | return max 171 | } 172 | 173 | return date 174 | } 175 | 176 | /** 177 | * given start and end date, return an (inclusive) array of all dates in between 178 | * @param start 179 | * @param end 180 | */ 181 | function getDaysInRange(start: Date, end: Date): Date[] { 182 | const days: Date[] = [] 183 | let current = start 184 | 185 | while (!isEqual(current, end)) { 186 | days.push(current) 187 | current = addDays(current, 1) 188 | } 189 | 190 | days.push(current) 191 | 192 | return days 193 | } 194 | 195 | /** 196 | * given a date, return an array of dates from a calendar perspective 197 | * @param date 198 | * @param firstDayOfWeek 199 | */ 200 | export function getViewOfMonth(date: Date, firstDayOfWeek: DaysOfWeek = DaysOfWeek.Monday): Date[] { 201 | const start = startOfWeek(startOfMonth(date), firstDayOfWeek) 202 | const end = endOfWeek(endOfMonth(date), firstDayOfWeek) 203 | 204 | return getDaysInRange(start, end) 205 | } 206 | 207 | /** 208 | * Form random hash 209 | */ 210 | export function chr4() { 211 | return Math.random() 212 | .toString(16) 213 | .slice(-4) 214 | } 215 | 216 | /** 217 | * Create random identifier with a prefix 218 | * @param prefix 219 | */ 220 | export function createIdentifier(prefix) { 221 | return `${prefix}-${chr4()}${chr4()}-${chr4()}-${chr4()}-${chr4()}-${chr4()}${chr4()}${chr4()}` 222 | } 223 | -------------------------------------------------------------------------------- /src/components/duet-date-picker/duet-date-picker.e2e.ts: -------------------------------------------------------------------------------- 1 | import { createPage } from "../../utils/test-utils" 2 | import { E2EElement, E2EPage } from "@stencil/core/testing" 3 | import localization from "./date-localization" 4 | 5 | async function getFocusedElement(page: E2EPage) { 6 | return page.evaluateHandle(() => document.activeElement) 7 | } 8 | 9 | async function getChooseDateButton(page: E2EPage) { 10 | return page.find(".duet-date__toggle") 11 | } 12 | 13 | async function getInput(page: E2EPage) { 14 | return page.find(".duet-date__input") 15 | } 16 | 17 | async function getDialog(page: E2EPage) { 18 | return page.find(`[role="dialog"]`) 19 | } 20 | 21 | async function getGrid(page: E2EPage) { 22 | const dialog = await getDialog(page) 23 | return dialog.find("table") 24 | } 25 | 26 | async function getPicker(page: E2EPage) { 27 | return page.find("duet-date-picker") 28 | } 29 | 30 | async function setMonthDropdown(page: E2EPage, month: string) { 31 | await page.select(".duet-date__select--month", month) 32 | await page.waitForChanges() 33 | } 34 | 35 | async function setYearDropdown(page: E2EPage, year: string) { 36 | await page.select(".duet-date__select--year", year) 37 | await page.waitForChanges() 38 | } 39 | 40 | async function getPrevMonthButton(page: E2EPage) { 41 | const dialog = await getDialog(page) 42 | return dialog.find(`.duet-date__prev`) 43 | } 44 | 45 | async function getNextMonthButton(page: E2EPage) { 46 | const dialog = await getDialog(page) 47 | return dialog.find(`.duet-date__next`) 48 | } 49 | 50 | async function findByText(context: E2EPage | E2EElement, selector: string, text: string) { 51 | const elements = await context.findAll(selector) 52 | return elements.find(element => element.innerText.includes(text)) 53 | } 54 | 55 | async function clickDay(page: E2EPage, date: string) { 56 | const grid = await getGrid(page) 57 | const button = await findByText(grid, "button", date) 58 | await button.click() 59 | await page.waitForChanges() 60 | } 61 | 62 | async function openCalendar(page: E2EPage) { 63 | const button = await getChooseDateButton(page) 64 | await button.click() 65 | await page.waitForChanges() 66 | const dialog = await getDialog(page) 67 | await dialog.waitForVisible() 68 | } 69 | 70 | async function clickOutside(page: E2EPage) { 71 | const input = await getInput(page) 72 | await input.click() 73 | await page.waitForChanges() 74 | const dialog = await getDialog(page) 75 | await dialog.waitForNotVisible() 76 | } 77 | 78 | async function isCalendarOpen(page: E2EPage): Promise { 79 | const dialog = await getDialog(page) 80 | return dialog.isVisible() 81 | } 82 | 83 | async function getYearOptions(page: E2EPage) { 84 | return page.$eval(".duet-date__select--year", (select: HTMLSelectElement) => { 85 | return Array.from(select.options).map(option => option.value) 86 | }) 87 | } 88 | 89 | const generatePage = (props: Partial = {}) => { 90 | const attrs = Object.entries(props) 91 | .map(([attr, value]) => `${attr}="${value}"`) 92 | .join(" ") 93 | 94 | return createPage(` 95 | 96 | 112 | 113 | 114 | `) 115 | } 116 | 117 | const ANIMATION_DELAY = 600 118 | 119 | describe("duet-date-picker", () => { 120 | it("should render a date picker", async () => { 121 | const page = await generatePage() 122 | const component = await getPicker(page) 123 | expect(component).not.toBeNull() 124 | }) 125 | 126 | describe("mouse interaction", () => { 127 | it("should open on button click", async () => { 128 | const page = await generatePage() 129 | 130 | expect(await isCalendarOpen(page)).toBe(false) 131 | await openCalendar(page) 132 | expect(await isCalendarOpen(page)).toBe(true) 133 | }) 134 | 135 | it("should close on click outside", async () => { 136 | const page = await generatePage() 137 | 138 | await openCalendar(page) 139 | expect(await isCalendarOpen(page)).toBe(true) 140 | 141 | await clickOutside(page) 142 | expect(await isCalendarOpen(page)).toBe(false) 143 | }) 144 | 145 | it("supports selecting a date in the future", async () => { 146 | const page = await generatePage({ value: "2020-01-01" }) 147 | await openCalendar(page) 148 | 149 | const picker = await getPicker(page) 150 | const nextMonth = await getNextMonthButton(page) 151 | const spy = await picker.spyOnEvent("duetChange") 152 | 153 | await nextMonth.click() 154 | await nextMonth.click() 155 | await nextMonth.click() 156 | await clickDay(page, "19 April") 157 | 158 | expect(spy).toHaveReceivedEventTimes(1) 159 | expect(spy.lastEvent.detail).toEqual({ 160 | component: "duet-date-picker", 161 | value: "2020-04-19", 162 | valueAsDate: new Date(2020, 3, 19).toISOString(), 163 | }) 164 | }) 165 | 166 | it("supports selecting a date in the past", async () => { 167 | const page = await generatePage({ value: "2020-01-01" }) 168 | await openCalendar(page) 169 | 170 | const picker = await getPicker(page) 171 | const spy = await picker.spyOnEvent("duetChange") 172 | 173 | await setMonthDropdown(page, "3") 174 | await setYearDropdown(page, "2019") 175 | await clickDay(page, "19 April") 176 | 177 | expect(spy).toHaveReceivedEventTimes(1) 178 | expect(spy.lastEvent.detail).toEqual({ 179 | component: "duet-date-picker", 180 | value: "2019-04-19", 181 | valueAsDate: new Date(2019, 3, 19).toISOString(), 182 | }) 183 | }) 184 | }) 185 | 186 | // see: https://www.w3.org/TR/wai-aria-practices/examples/dialog-modal/datepicker-dialog.html 187 | describe("a11y/ARIA requirements", () => { 188 | describe("button", () => { 189 | it("has an accessible label", async () => { 190 | const page = await generatePage() 191 | const button = await getChooseDateButton(page) 192 | const element = await button.find(".duet-date__vhidden") 193 | expect(element).toEqualText(localization.buttonLabel) 194 | }) 195 | }) 196 | 197 | describe("dialog", () => { 198 | it("meets a11y requirements", async () => { 199 | const page = await generatePage() 200 | const dialog = await getDialog(page) 201 | 202 | // has aria-modal attr 203 | expect(dialog).toBeDefined() 204 | expect(dialog).toEqualAttribute("aria-modal", "true") 205 | 206 | // has accessible label 207 | const labelledById = dialog.getAttribute("aria-labelledby") 208 | const title = await page.find(`#${labelledById}`) 209 | expect(title).toBeDefined() 210 | }) 211 | }) 212 | 213 | describe("grid", () => { 214 | it("meets a11y requirements", async () => { 215 | const page = await generatePage({ value: "2020-01-01" }) 216 | const grid = await getGrid(page) 217 | 218 | // has accessible label 219 | const labelledById = await grid.getAttribute("aria-labelledby") 220 | const title = await page.find(`#${labelledById}`) 221 | expect(title).toBeDefined() 222 | 223 | await openCalendar(page) 224 | 225 | // should be single selected element 226 | const selected = await grid.findAll(`[aria-pressed="true"]`) 227 | expect(selected.length).toBe(1) 228 | 229 | // only one button is in focus order, has accessible label, and correct text content 230 | expect(selected[0]).toEqualAttribute("tabindex", "0") 231 | expect(selected[0].innerText).toContain("1 January") 232 | }) 233 | 234 | it.todo("correctly abbreviates the shortened day names") 235 | }) 236 | 237 | describe("controls", () => { 238 | it.todo("has a label for next month button") 239 | it.todo("has a label for previous month button") 240 | it.todo("has a label for the month select dropdown") 241 | it.todo("has a label for the year select dropdown") 242 | }) 243 | }) 244 | 245 | describe("keyboard a11y", () => { 246 | it("closes on ESC press", async () => { 247 | const page = await generatePage() 248 | await openCalendar(page) 249 | 250 | expect(await isCalendarOpen(page)).toBe(true) 251 | 252 | await page.waitFor(ANIMATION_DELAY) 253 | await page.keyboard.press("Escape") 254 | await page.waitFor(ANIMATION_DELAY) 255 | 256 | expect(await isCalendarOpen(page)).toBe(false) 257 | }) 258 | 259 | it("supports selecting a date in the future", async () => { 260 | const page = await generatePage({ value: "2020-01-01" }) 261 | const picker = await getPicker(page) 262 | const spy = await picker.spyOnEvent("duetChange") 263 | 264 | // open calendar 265 | await page.keyboard.press("Tab") 266 | await page.waitForChanges() 267 | await page.keyboard.press("Tab") 268 | await page.waitForChanges() 269 | await page.keyboard.press("Enter") 270 | await page.waitForChanges() 271 | 272 | // wait for calendar to open 273 | await page.waitFor(ANIMATION_DELAY) 274 | 275 | // set month to april 276 | await setMonthDropdown(page, "3") 277 | 278 | // tab to grid 279 | await page.keyboard.press("Tab") 280 | await page.waitForChanges() 281 | await page.keyboard.press("Tab") 282 | await page.waitForChanges() 283 | await page.keyboard.press("Tab") 284 | await page.waitForChanges() 285 | await page.keyboard.press("Tab") 286 | await page.waitForChanges() 287 | 288 | // tab to grid, select 19th of month 289 | await page.keyboard.press("ArrowDown") 290 | await page.waitForChanges() 291 | await page.keyboard.press("ArrowDown") 292 | await page.waitForChanges() 293 | await page.keyboard.press("ArrowRight") 294 | await page.waitForChanges() 295 | await page.keyboard.press("ArrowRight") 296 | await page.waitForChanges() 297 | await page.keyboard.press("ArrowRight") 298 | await page.waitForChanges() 299 | await page.keyboard.press("ArrowRight") 300 | await page.waitForChanges() 301 | await page.keyboard.press("Enter") 302 | await page.waitForChanges() 303 | 304 | expect(spy).toHaveReceivedEventTimes(1) 305 | expect(spy.lastEvent.detail).toEqual({ 306 | component: "duet-date-picker", 307 | value: "2020-04-19", 308 | valueAsDate: new Date(2020, 3, 19).toISOString(), 309 | }) 310 | }) 311 | 312 | it("supports selecting a date in the past", async () => { 313 | const page = await generatePage({ value: "2020-01-01" }) 314 | const picker = await getPicker(page) 315 | const spy = await picker.spyOnEvent("duetChange") 316 | 317 | // open calendar 318 | await page.keyboard.press("Tab") 319 | await page.waitForChanges() 320 | await page.keyboard.press("Tab") 321 | await page.waitForChanges() 322 | await page.keyboard.press("Enter") 323 | await page.waitForChanges() 324 | 325 | // wait for calendar to open 326 | await page.waitFor(ANIMATION_DELAY) 327 | 328 | // select april from month dropdown 329 | await setMonthDropdown(page, "3") 330 | 331 | // tab to year dropdown, select 2019 332 | await page.keyboard.press("Tab") 333 | await setYearDropdown(page, "2019") 334 | 335 | // tab to grid 336 | await page.keyboard.press("Tab") 337 | await page.keyboard.press("Tab") 338 | await page.keyboard.press("Tab") 339 | 340 | // select date 19th of month 341 | await page.keyboard.press("ArrowDown") 342 | await page.waitForChanges() 343 | await page.keyboard.press("ArrowDown") 344 | await page.waitForChanges() 345 | await page.keyboard.press("ArrowRight") 346 | await page.waitForChanges() 347 | await page.keyboard.press("ArrowRight") 348 | await page.waitForChanges() 349 | await page.keyboard.press("ArrowRight") 350 | await page.waitForChanges() 351 | await page.keyboard.press("ArrowRight") 352 | await page.waitForChanges() 353 | await page.keyboard.press("Enter") 354 | await page.waitForChanges() 355 | 356 | expect(spy).toHaveReceivedEventTimes(1) 357 | expect(spy.lastEvent.detail).toEqual({ 358 | component: "duet-date-picker", 359 | value: "2019-04-19", 360 | valueAsDate: new Date(2019, 3, 19).toISOString(), 361 | }) 362 | }) 363 | 364 | it("supports navigating to disabled dates", async () => { 365 | const page = await generatePage({ value: "2020-01-01" }) 366 | 367 | // disable weekends 368 | await page.$eval("duet-date-picker", async (picker: HTMLDuetDatePickerElement) => { 369 | picker.isDateDisabled = function isWeekend(date) { 370 | return date.getDay() === 0 || date.getDay() === 6 371 | } 372 | }) 373 | 374 | const picker = await getPicker(page) 375 | const spy = await picker.spyOnEvent("duetChange") 376 | 377 | // open calendar 378 | await page.keyboard.press("Tab") 379 | await page.waitForChanges() 380 | await page.keyboard.press("Tab") 381 | await page.waitForChanges() 382 | await page.keyboard.press("Enter") 383 | await page.waitForChanges() 384 | 385 | // wait for calendar to open 386 | await page.waitFor(ANIMATION_DELAY) 387 | 388 | // set month to april 389 | await setMonthDropdown(page, "3") 390 | 391 | // tab to grid 392 | await page.keyboard.press("Tab") 393 | await page.waitForChanges() 394 | await page.keyboard.press("Tab") 395 | await page.waitForChanges() 396 | await page.keyboard.press("Tab") 397 | await page.waitForChanges() 398 | await page.keyboard.press("Tab") 399 | await page.waitForChanges() 400 | 401 | // navigate to 2. april thursday 402 | await page.keyboard.press("ArrowRight") 403 | await page.waitForChanges() 404 | // navigate to 3. april friday 405 | await page.keyboard.press("ArrowRight") 406 | await page.waitForChanges() 407 | // navigate to 4. april saturday 408 | await page.keyboard.press("ArrowRight") 409 | await page.waitForChanges() 410 | 411 | await page.keyboard.press("Enter") 412 | await page.waitForChanges() 413 | expect(spy).toHaveReceivedEventTimes(0) 414 | 415 | // navigate to 5. april sunday 416 | await page.keyboard.press("ArrowRight") 417 | await page.waitForChanges() 418 | 419 | await page.keyboard.press("Enter") 420 | await page.waitForChanges() 421 | expect(spy).toHaveReceivedEventTimes(0) 422 | 423 | // navigate to 6. april monday 424 | await page.keyboard.press("ArrowRight") 425 | await page.waitForChanges() 426 | 427 | await page.keyboard.press("Enter") 428 | await page.waitForChanges() 429 | 430 | expect(spy).toHaveReceivedEventTimes(1) 431 | expect(spy.lastEvent.detail).toEqual({ 432 | component: "duet-date-picker", 433 | value: "2020-04-06", 434 | valueAsDate: new Date(2020, 3, 6).toISOString(), 435 | }) 436 | }) 437 | 438 | it.todo("moves focus to start of week on home press") 439 | it.todo("moves focus to end of week end press") 440 | 441 | it.todo("moves focus to previous month on page up press") 442 | it.todo("moves focus to next month on page down press") 443 | 444 | it.todo("moves focus to previous year on shift + page down press") 445 | it.todo("moves focus to next year on shift + page down press") 446 | 447 | it("maintains curosor position when typing disallowed characters", async () => { 448 | const page = await generatePage() 449 | const element = await getPicker(page) 450 | const input = await getInput(page) 451 | const DATE = "2020-03-19" 452 | 453 | // tab to input 454 | await page.keyboard.press("Tab") 455 | 456 | // type some _allowed_ chars 457 | await page.keyboard.type(DATE, { delay: 50 }) 458 | 459 | // move cursor so we can test maintaining position 460 | await page.keyboard.press("ArrowLeft") 461 | 462 | // store cursor position 463 | const cursorBefore = await input.getProperty("selectionStart") 464 | expect(cursorBefore).toBe(DATE.length - 1) 465 | 466 | // attempt to enter _disallowed_ character 467 | await page.keyboard.press("a") 468 | 469 | const cursorAfter = await input.getProperty("selectionStart") 470 | const value = await element.getProperty("value") 471 | 472 | // we should see cursor hasn't changed 473 | expect(cursorAfter).toBe(cursorBefore) 474 | 475 | // and value contains no disallowed chars 476 | expect(value).toBe(DATE) 477 | }) 478 | }) 479 | 480 | describe("events", () => { 481 | it("raises a duetBlur event when the input is blurred", async () => { 482 | const page = await generatePage() 483 | const picker = await getPicker(page) 484 | const spy = await picker.spyOnEvent("duetBlur") 485 | 486 | await page.keyboard.press("Tab") 487 | await page.keyboard.press("Tab") 488 | expect(spy).toHaveReceivedEventTimes(1) 489 | }) 490 | 491 | it("raises a duetFocus event when the input is focused", async () => { 492 | const page = await generatePage() 493 | const picker = await getPicker(page) 494 | const spy = await picker.spyOnEvent("duetFocus") 495 | 496 | await page.keyboard.press("Tab") 497 | 498 | expect(spy).toHaveReceivedEventTimes(1) 499 | }) 500 | 501 | it("raises a duetOpen event on open", async () => { 502 | const page = await generatePage() 503 | const picker = await getPicker(page) 504 | const spy = await picker.spyOnEvent("duetOpen") 505 | 506 | await picker.callMethod("show") 507 | expect(spy).toHaveReceivedEventTimes(1) 508 | }) 509 | 510 | it("raises a duetClose event on close", async () => { 511 | const page = await generatePage() 512 | const picker = await getPicker(page) 513 | const spy = await picker.spyOnEvent("duetClose") 514 | 515 | await picker.callMethod("hide") 516 | expect(spy).toHaveReceivedEventTimes(1) 517 | }) 518 | }) 519 | 520 | describe("focus management", () => { 521 | it("traps focus in calendar", async () => { 522 | const page = await generatePage() 523 | 524 | await openCalendar(page) 525 | 526 | // wait for calendar to open 527 | await page.waitFor(ANIMATION_DELAY) 528 | 529 | // month dropdown 530 | let focused = await getFocusedElement(page) 531 | let id = await page.evaluate(element => element.id, focused) 532 | let label = await page.find(`label[for="${id}"]`) 533 | expect(label).toEqualText(localization.monthSelectLabel) 534 | 535 | // year dropdown 536 | await page.keyboard.press("Tab") 537 | focused = await getFocusedElement(page) 538 | id = await page.evaluate(element => element.id, focused) 539 | label = await page.find(`label[for="${id}"]`) 540 | expect(label).toEqualText(localization.yearSelectLabel) 541 | 542 | // prev month 543 | await page.keyboard.press("Tab") 544 | focused = await getFocusedElement(page) 545 | let ariaLabel = await page.evaluate(element => element.innerText, focused) 546 | expect(ariaLabel).toEqual(localization.prevMonthLabel) 547 | 548 | // next month 549 | await page.keyboard.press("Tab") 550 | focused = await getFocusedElement(page) 551 | ariaLabel = await page.evaluate(element => element.innerText, focused) 552 | expect(ariaLabel).toBe(localization.nextMonthLabel) 553 | 554 | // day 555 | await page.keyboard.press("Tab") 556 | focused = await getFocusedElement(page) 557 | const tabIndex = await page.evaluate(element => element.tabIndex, focused) 558 | expect(tabIndex).toBe(0) 559 | 560 | // close button 561 | await page.keyboard.press("Tab") 562 | focused = await getFocusedElement(page) 563 | ariaLabel = await page.evaluate(element => element.innerText, focused) 564 | expect(ariaLabel).toBe(localization.closeLabel) 565 | 566 | // back to month 567 | await page.keyboard.press("Tab") 568 | focused = await getFocusedElement(page) 569 | id = await page.evaluate(element => element.id, focused) 570 | label = await page.find(`label[for="${id}"]`) 571 | expect(label).toEqualText(localization.monthSelectLabel) 572 | }) 573 | 574 | it.todo("doesn't shift focus when interacting with calendar navigation controls") 575 | it.todo("shifts focus back to button on date select") 576 | it.todo("shifts focus back to button on ESC press") 577 | it.todo("doesn't shift focus to button on click outside") 578 | }) 579 | 580 | describe("min/max support", () => { 581 | it("supports a min date", async () => { 582 | const page = await generatePage({ value: "2020-01-15", min: "2020-01-02" }) 583 | const picker = await getPicker(page) 584 | const spy = await picker.spyOnEvent("duetChange") 585 | 586 | await openCalendar(page) 587 | 588 | // wait for calendar to open 589 | await page.waitFor(ANIMATION_DELAY) 590 | 591 | // make sure it's rendered correctly 592 | // We use a slightly higher threshold here since the CSS transition 593 | // makes certain parts move slightly depending on how the browser converts 594 | // the percentage based units into pixels. 595 | const screenshot = await page.screenshot() 596 | expect(screenshot).toMatchImageSnapshot({ 597 | failureThreshold: 0.001, 598 | failureThresholdType: "percent", 599 | }) 600 | 601 | // try clicking a day outside the range 602 | await clickDay(page, "1 January") 603 | expect(spy).toHaveReceivedEventTimes(0) 604 | 605 | // click a day inside the range 606 | await clickDay(page, "2 January") 607 | 608 | expect(spy).toHaveReceivedEventTimes(1) 609 | expect(spy.lastEvent.detail).toEqual({ 610 | component: "duet-date-picker", 611 | value: "2020-01-02", 612 | valueAsDate: new Date(2020, 0, 2).toISOString(), 613 | }) 614 | }) 615 | 616 | it("supports a max date", async () => { 617 | const page = await generatePage({ value: "2020-01-15", max: "2020-01-30" }) 618 | const picker = await getPicker(page) 619 | const spy = await picker.spyOnEvent("duetChange") 620 | 621 | await openCalendar(page) 622 | 623 | // wait for calendar to open 624 | await page.waitFor(ANIMATION_DELAY) 625 | 626 | // make sure it's rendered correctly 627 | // We use a slightly higher threshold here since the CSS transition 628 | // makes certain parts move slightly depending on how the browser converts 629 | // the percentage based units into pixels. 630 | const screenshot = await page.screenshot() 631 | expect(screenshot).toMatchImageSnapshot({ 632 | failureThreshold: 0.001, 633 | failureThresholdType: "percent", 634 | }) 635 | 636 | // try clicking a day outside the range 637 | await clickDay(page, "31 January") 638 | expect(spy).toHaveReceivedEventTimes(0) 639 | 640 | // click a day inside the range 641 | await clickDay(page, "30 January") 642 | 643 | expect(spy).toHaveReceivedEventTimes(1) 644 | expect(spy.lastEvent.detail).toEqual({ 645 | component: "duet-date-picker", 646 | value: "2020-01-30", 647 | valueAsDate: new Date(2020, 0, 30).toISOString(), 648 | }) 649 | }) 650 | 651 | it("supports min and max dates", async () => { 652 | const page = await generatePage({ value: "2020-01-15", min: "2020-01-02", max: "2020-01-30" }) 653 | const picker = await getPicker(page) 654 | const spy = await picker.spyOnEvent("duetChange") 655 | 656 | await openCalendar(page) 657 | 658 | // wait for calendar to open 659 | await page.waitFor(ANIMATION_DELAY) 660 | 661 | // make sure it's rendered correctly. 662 | // We use a slightly higher threshold here since the CSS transition 663 | // makes certain parts move slightly depending on how the browser converts 664 | // the percentage based units into pixels. 665 | const screenshot = await page.screenshot() 666 | expect(screenshot).toMatchImageSnapshot({ 667 | failureThreshold: 0.001, 668 | failureThresholdType: "percent", 669 | }) 670 | 671 | // try clicking a day less than min 672 | await clickDay(page, "1 January") 673 | expect(spy).toHaveReceivedEventTimes(0) 674 | 675 | // try clicking a day greater than max 676 | await clickDay(page, "31 January") 677 | expect(spy).toHaveReceivedEventTimes(0) 678 | 679 | // click a day inside the range 680 | await clickDay(page, "30 January") 681 | 682 | expect(spy).toHaveReceivedEventTimes(1) 683 | expect(spy.lastEvent.detail).toEqual({ 684 | component: "duet-date-picker", 685 | value: "2020-01-30", 686 | valueAsDate: new Date(2020, 0, 30).toISOString(), 687 | }) 688 | }) 689 | 690 | it("disables prev month button if same month and year as min", async () => { 691 | const page = await generatePage({ value: "2020-04-19", min: "2020-04-01" }) 692 | 693 | await openCalendar(page) 694 | 695 | const prevMonthButton = await getPrevMonthButton(page) 696 | expect(prevMonthButton).toHaveAttribute("disabled") 697 | }) 698 | 699 | it("disables next month button if same month and year as max", async () => { 700 | const page = await generatePage({ value: "2020-04-19", max: "2020-04-30" }) 701 | 702 | await openCalendar(page) 703 | 704 | const nextMonthButton = await getNextMonthButton(page) 705 | expect(nextMonthButton).toHaveAttribute("disabled") 706 | }) 707 | 708 | it("does not disable prev/next buttons when only month value (but not year) is same as min and max", async () => { 709 | // there was a bug whereby both buttons would be disabled if the min/max/selected date 710 | // had the same month (here: 4), but different years. this tests ensures no regression 711 | const page = await generatePage({ value: "2020-04-19", min: "2019-04-19", max: "2021-04-19" }) 712 | 713 | await openCalendar(page) 714 | 715 | const prevMonthButton = await getPrevMonthButton(page) 716 | const nextMonthButton = await getNextMonthButton(page) 717 | 718 | expect(prevMonthButton).not.toHaveAttribute("disabled") 719 | expect(nextMonthButton).not.toHaveAttribute("disabled") 720 | }) 721 | 722 | it("respects min/max dates when generating year dropdown", async () => { 723 | const page = await generatePage({ value: "2020-04-19", min: "2019-04-19", max: "2021-04-19" }) 724 | const picker = await getPicker(page) 725 | 726 | // range smaller than default 40 year range 727 | let options = await getYearOptions(page) 728 | expect(options).toEqual(["2019", "2020", "2021"]) 729 | 730 | // range larger than default 40 year range 731 | const minYear = 1990 732 | const maxYear = 2050 733 | picker.setAttribute("min", `${minYear}-01-02`) 734 | picker.setAttribute("max", `${maxYear}-01-30`) 735 | await page.waitForChanges() 736 | 737 | options = await getYearOptions(page) 738 | 739 | expect(options.length).toBe(maxYear - minYear + 1) 740 | expect(options[0]).toBe(minYear.toString()) 741 | expect(options[options.length - 1]).toBe(maxYear.toString()) 742 | }) 743 | 744 | it("respects min/max dates when generating month dropdown", async () => { 745 | const page = await generatePage({ value: "2020-04-19", min: "2019-04-01", max: "2020-05-31" }) 746 | 747 | await openCalendar(page) 748 | 749 | function getAllowedMonths() { 750 | return page.$eval(".duet-date__select--month", (select: HTMLSelectElement) => { 751 | return Array.from(select.options) 752 | .filter(option => !option.disabled) 753 | .map(option => option.value) 754 | }) 755 | } 756 | 757 | // in 2020, January - May is allowed 758 | let allowedMonths = await getAllowedMonths() 759 | expect(allowedMonths).toEqual(["0", "1", "2", "3", "4"]) 760 | 761 | await setYearDropdown(page, "2019") 762 | 763 | // in 2019, April - December is allowed 764 | allowedMonths = await getAllowedMonths() 765 | expect(allowedMonths).toEqual(["3", "4", "5", "6", "7", "8", "9", "10", "11"]) 766 | }) 767 | }) 768 | 769 | describe("methods", () => { 770 | it("should open calendar on show()", async () => { 771 | const page = await generatePage() 772 | const picker = await getPicker(page) 773 | 774 | expect(await isCalendarOpen(page)).toBe(false) 775 | 776 | await picker.callMethod("show") 777 | await page.waitForChanges() 778 | 779 | expect(await isCalendarOpen(page)).toBe(true) 780 | }) 781 | 782 | it("should close calendar on hide()", async () => { 783 | const page = await generatePage() 784 | const picker = await getPicker(page) 785 | 786 | await picker.callMethod("show") 787 | await page.waitForChanges() 788 | expect(await isCalendarOpen(page)).toBe(true) 789 | 790 | await picker.callMethod("hide") 791 | await page.waitForChanges() 792 | 793 | const dialog = await getDialog(page) 794 | await dialog.waitForNotVisible() 795 | 796 | expect(await isCalendarOpen(page)).toBe(false) 797 | }) 798 | 799 | it("should focus input on setFocus()", async () => { 800 | const page = await generatePage() 801 | const picker = await getPicker(page) 802 | 803 | await picker.callMethod("setFocus") 804 | await page.waitForChanges() 805 | 806 | const focused = await getFocusedElement(page) 807 | const tagName = await page.evaluate(element => element.tagName, focused) 808 | 809 | expect(tagName.toLowerCase()).toEqualText("input") 810 | }) 811 | }) 812 | 813 | describe("form interaction", () => { 814 | it("supports required attribute", async () => { 815 | const page = await createPage(` 816 |
817 | 818 | 819 |
820 | `) 821 | 822 | const picker = await getPicker(page) 823 | const form = await page.find("form") 824 | const button = await page.find("button[type='submit']") 825 | const spy = await form.spyOnEvent("submit") 826 | 827 | await button.click() 828 | await page.waitForChanges() 829 | 830 | expect(spy).toHaveReceivedEventTimes(0) 831 | 832 | picker.setProperty("value", "2020-01-01") 833 | await page.waitForChanges() 834 | await button.click() 835 | 836 | expect(spy).toHaveReceivedEventTimes(1) 837 | }) 838 | 839 | it("always submits value as ISO date", async () => { 840 | const page = await createPage(` 841 |
842 | 843 | 844 |
845 | `) 846 | 847 | const picker = await getPicker(page) 848 | const input = await getInput(page) 849 | 850 | // use non-ISO date format 851 | await page.$eval("duet-date-picker", async (picker: HTMLDuetDatePickerElement) => { 852 | var DATE_FORMAT = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/ 853 | 854 | picker.dateAdapter = { 855 | parse(value = "", createDate) { 856 | const matches = value.match(DATE_FORMAT) 857 | if (matches) { 858 | return createDate(matches[3], matches[2], matches[1]) 859 | } 860 | }, 861 | format(date) { 862 | return `${date.getDate()}.${date.getMonth() + 1}.${date.getFullYear()}` 863 | }, 864 | } 865 | }) 866 | 867 | picker.setProperty("value", "2020-01-01") 868 | await page.waitForChanges() 869 | 870 | // submitted value should be ISO format 871 | const submittedValue = await page.$eval("form", (form: HTMLFormElement) => new FormData(form).get("test")) 872 | expect(submittedValue).toEqual("2020-01-01") 873 | 874 | // whilst the displayed value should be Finnish format 875 | expect(await input.getProperty("value")).toEqual("1.1.2020") 876 | }) 877 | }) 878 | }) 879 | -------------------------------------------------------------------------------- /src/components/duet-date-picker/duet-date-picker.scss: -------------------------------------------------------------------------------- 1 | // --------------------------------------------- 2 | // DUET DATE PICKER 3 | // --------------------------------------------- 4 | 5 | .duet-date *, 6 | .duet-date *::before, 7 | .duet-date *::after { 8 | box-sizing: border-box; 9 | margin: 0; 10 | width: auto; 11 | } 12 | 13 | .duet-date { 14 | box-sizing: border-box; 15 | color: var(--duet-color-text); 16 | display: block; 17 | font-family: var(--duet-font); 18 | margin: 0; 19 | position: relative; 20 | text-align: left; 21 | width: 100%; 22 | } 23 | 24 | // --------------------------------------------- 25 | // DUET DATE PICKER __ INPUT 26 | // --------------------------------------------- 27 | 28 | .duet-date__input { 29 | -webkit-appearance: none; 30 | appearance: none; 31 | background: var(--duet-color-surface); 32 | border: 1px solid var(--duet-color-border, var(--duet-color-text)); // for backwards compatibility, fallback to old value 33 | border-radius: var(--duet-radius); 34 | color: var(--duet-color-text); 35 | float: none; 36 | font-family: var(--duet-font); 37 | font-size: 100%; 38 | line-height: normal; 39 | padding: 14px 60px 14px 14px; 40 | width: 100%; 41 | 42 | &:focus { 43 | border-color: var(--duet-color-primary); 44 | box-shadow: 0 0 0 1px var(--duet-color-primary); 45 | outline: 0; 46 | } 47 | 48 | &::-webkit-input-placeholder { 49 | color: var(--duet-color-placeholder); 50 | opacity: 1; 51 | } 52 | 53 | &:-moz-placeholder { 54 | color: var(--duet-color-placeholder); 55 | opacity: 1; 56 | } 57 | 58 | &:-ms-input-placeholder { 59 | color: var(--duet-color-placeholder); 60 | } 61 | } 62 | 63 | .duet-date__input-wrapper { 64 | position: relative; 65 | width: 100%; 66 | } 67 | 68 | // --------------------------------------------- 69 | // DUET DATE PICKER __ TOGGLE 70 | // --------------------------------------------- 71 | 72 | .duet-date__toggle { 73 | -moz-appearance: none; 74 | -webkit-appearance: none; 75 | -webkit-user-select: none; 76 | align-items: center; 77 | appearance: none; 78 | background: var(--duet-color-button); 79 | border: 0; 80 | border-radius: 0; 81 | border-bottom-right-radius: var(--duet-radius); 82 | border-top-right-radius: var(--duet-radius); 83 | box-shadow: inset 1px 0 0 rgba(0, 0, 0, 0.1); 84 | color: var(--duet-color-text); 85 | cursor: pointer; 86 | display: flex; 87 | height: calc(100% - 2px); 88 | justify-content: center; 89 | padding: 0; 90 | position: absolute; 91 | right: 1px; 92 | top: 1px; 93 | user-select: none; 94 | width: 48px; 95 | z-index: 2; 96 | 97 | &:focus { 98 | box-shadow: 0 0 0 2px var(--duet-color-primary); 99 | outline: 0; 100 | } 101 | } 102 | 103 | .duet-date__toggle-icon { 104 | display: flex; 105 | flex-basis: 100%; 106 | justify-content: center; 107 | align-items: center; 108 | } 109 | 110 | // --------------------------------------------- 111 | // DUET DATE PICKER __ DIALOG 112 | // --------------------------------------------- 113 | 114 | .duet-date__dialog { 115 | display: flex; 116 | left: 0; 117 | min-width: 320px; 118 | opacity: 0; 119 | position: absolute; 120 | top: 100%; 121 | transform: scale(0.96) translateZ(0) translateY(-20px); 122 | transform-origin: top right; 123 | transition: transform 300ms ease, opacity 300ms ease, visibility 300ms ease; 124 | visibility: hidden; 125 | width: 100%; 126 | will-change: transform, opacity, visibility; 127 | z-index: var(--duet-z-index); 128 | 129 | @media (max-width: 35.9375em) { 130 | background: var(--duet-color-overlay); 131 | bottom: 0; 132 | position: fixed; 133 | right: 0; 134 | top: 0; 135 | transform: translateZ(0); 136 | transform-origin: bottom center; 137 | } 138 | 139 | &.is-left { 140 | left: auto; 141 | right: 0; 142 | width: auto; 143 | } 144 | 145 | &.is-active { 146 | opacity: 1; 147 | // The value of 1.0001 fixes a Chrome glitch with scaling 148 | transform: scale(1.0001) translateZ(0) translateY(0); 149 | visibility: visible; 150 | } 151 | } 152 | 153 | .duet-date__dialog-content { 154 | background: var(--duet-color-surface); 155 | border: 1px solid rgba(0, 0, 0, 0.1); 156 | border-radius: var(--duet-radius); 157 | box-shadow: 0 4px 10px 0 rgba(0, 0, 0, 0.1); 158 | margin-left: auto; 159 | margin-top: 8px; 160 | max-width: 310px; 161 | min-width: 290px; 162 | padding: 16px 16px 20px; 163 | position: relative; 164 | transform: none; 165 | width: 100%; 166 | z-index: var(--duet-z-index); 167 | 168 | @media (max-width: 35.9375em) { 169 | border: 0; 170 | border-radius: 0; 171 | border-top-left-radius: var(--duet-radius); 172 | border-top-right-radius: var(--duet-radius); 173 | bottom: 0; 174 | left: 0; 175 | margin: 0; 176 | max-width: none; 177 | min-height: 26em; 178 | opacity: 0; 179 | padding: 0 8% 20px; 180 | position: absolute; 181 | transform: translateZ(0) translateY(100%); 182 | transition: transform 400ms ease, opacity 400ms ease, visibility 400ms ease; 183 | visibility: hidden; 184 | will-change: transform, opacity, visibility; 185 | 186 | .is-active & { 187 | opacity: 1; 188 | transform: translateZ(0) translateY(0); 189 | visibility: visible; 190 | } 191 | } 192 | } 193 | 194 | // --------------------------------------------- 195 | // DUET DATE PICKER __ TABLE 196 | // --------------------------------------------- 197 | 198 | .duet-date__table { 199 | border-collapse: collapse; 200 | border-spacing: 0; 201 | color: var(--duet-color-text); 202 | font-size: 1rem; 203 | font-weight: var(--duet-font-normal); 204 | line-height: 1.25; 205 | text-align: center; 206 | width: 100%; 207 | } 208 | 209 | .duet-date__table-header { 210 | font-size: 0.75rem; 211 | font-weight: var(--duet-font-bold); 212 | letter-spacing: 1px; 213 | line-height: 1.25; 214 | padding-bottom: 8px; 215 | text-decoration: none; 216 | text-transform: uppercase; 217 | } 218 | 219 | .duet-date__cell { 220 | text-align: center; 221 | } 222 | 223 | .duet-date__day { 224 | -moz-appearance: none; 225 | -webkit-appearance: none; 226 | appearance: none; 227 | background: transparent; 228 | border: 0; 229 | border-radius: 50%; 230 | color: var(--duet-color-text); 231 | cursor: pointer; 232 | display: inline-block; 233 | font-family: var(--duet-font); 234 | font-size: 0.875rem; 235 | font-variant-numeric: tabular-nums; 236 | font-weight: var(--duet-font-normal); 237 | height: 36px; 238 | line-height: 1.25; 239 | padding: 0 0 1px; 240 | position: relative; 241 | text-align: center; 242 | vertical-align: middle; 243 | width: 36px; 244 | z-index: 1; 245 | 246 | &.is-today { 247 | box-shadow: 0 0 0 1px var(--duet-color-primary); 248 | position: relative; 249 | z-index: 200; 250 | } 251 | 252 | &:hover::before, 253 | &.is-today::before { 254 | background: var(--duet-color-primary); 255 | border-radius: 50%; 256 | bottom: 0; 257 | content: ""; 258 | left: 0; 259 | opacity: 0.06; 260 | position: absolute; 261 | right: 0; 262 | top: 0; 263 | } 264 | 265 | &[aria-pressed="true"], 266 | &:focus { 267 | background: var(--duet-color-primary); 268 | box-shadow: none; 269 | color: var(--duet-color-text-active); 270 | outline: 0; 271 | } 272 | 273 | &:active { 274 | background: var(--duet-color-primary); 275 | box-shadow: 0 0 5px var(--duet-color-primary); 276 | color: var(--duet-color-text-active); 277 | z-index: 200; 278 | } 279 | 280 | &:focus { 281 | box-shadow: 0 0 5px var(--duet-color-primary); 282 | z-index: 200; 283 | } 284 | 285 | &:not(.is-month) { 286 | box-shadow: none; 287 | } 288 | 289 | &:not(.is-month), 290 | &[aria-disabled="true"] { 291 | background: transparent; 292 | color: var(--duet-color-text); 293 | cursor: default; 294 | opacity: 0.5; 295 | } 296 | 297 | &[aria-disabled="true"] { 298 | &.is-today { 299 | box-shadow: 0 0 0 1px var(--duet-color-primary); 300 | 301 | &:focus { 302 | box-shadow: 0 0 5px var(--duet-color-primary); 303 | background: var(--duet-color-primary); 304 | color: var(--duet-color-text-active); 305 | } 306 | } 307 | 308 | &:not(.is-today)::before { 309 | display: none; 310 | } 311 | } 312 | 313 | &.is-outside { 314 | background: var(--duet-color-button); 315 | box-shadow: none; 316 | color: var(--duet-color-text); 317 | cursor: default; 318 | opacity: 0.6; 319 | pointer-events: none; 320 | 321 | &::before { 322 | display: none; 323 | } 324 | } 325 | } 326 | 327 | // --------------------------------------------- 328 | // DUET DATE PICKER __ HEADER 329 | // --------------------------------------------- 330 | 331 | .duet-date__header { 332 | align-items: center; 333 | display: flex; 334 | justify-content: space-between; 335 | margin-bottom: 16px; 336 | width: 100%; 337 | } 338 | 339 | // --------------------------------------------- 340 | // DUET DATE PICKER __ NAVIGATION 341 | // --------------------------------------------- 342 | 343 | .duet-date__nav { 344 | white-space: nowrap; 345 | } 346 | 347 | .duet-date__prev, 348 | .duet-date__next { 349 | -moz-appearance: none; 350 | -webkit-appearance: none; 351 | align-items: center; 352 | appearance: none; 353 | background: var(--duet-color-button); 354 | border: 0; 355 | border-radius: 50%; 356 | color: var(--duet-color-text); 357 | cursor: pointer; 358 | display: inline-flex; 359 | height: 32px; 360 | justify-content: center; 361 | margin-left: 8px; 362 | padding: 0; 363 | transition: background-color 300ms ease; 364 | width: 32px; 365 | 366 | @media (max-width: 35.9375em) { 367 | height: 40px; 368 | width: 40px; 369 | } 370 | 371 | &:focus { 372 | box-shadow: 0 0 0 2px var(--duet-color-primary); 373 | outline: 0; 374 | } 375 | 376 | &:active:focus { 377 | box-shadow: none; 378 | } 379 | 380 | &:disabled { 381 | cursor: default; 382 | opacity: 0.5; 383 | } 384 | 385 | svg { 386 | margin: 0 auto; 387 | } 388 | } 389 | 390 | // --------------------------------------------- 391 | // DUET DATE PICKER __ SELECT 392 | // --------------------------------------------- 393 | 394 | .duet-date__select { 395 | display: inline-flex; 396 | margin-top: 4px; 397 | position: relative; 398 | 399 | span { 400 | margin-right: 4px; 401 | } 402 | 403 | select { 404 | cursor: pointer; 405 | font-size: 1rem; 406 | height: 100%; 407 | left: 0; 408 | opacity: 0; 409 | position: absolute; 410 | top: 0; 411 | width: 100%; 412 | z-index: 2; 413 | 414 | &:focus + .duet-date__select-label { 415 | box-shadow: 0 0 0 2px var(--duet-color-primary); 416 | } 417 | } 418 | } 419 | 420 | .duet-date__select-label { 421 | align-items: center; 422 | border-radius: var(--duet-radius); 423 | color: var(--duet-color-text); 424 | display: flex; 425 | font-size: 1.25rem; 426 | font-weight: var(--duet-font-bold); 427 | line-height: 1.25; 428 | padding: 0 4px 0 8px; 429 | pointer-events: none; 430 | position: relative; 431 | width: 100%; 432 | z-index: 1; 433 | 434 | svg { 435 | width: 16px; 436 | height: 16px; 437 | } 438 | } 439 | 440 | // --------------------------------------------- 441 | // DUET DATE PICKER __ MOBILE 442 | // --------------------------------------------- 443 | 444 | .duet-date__mobile { 445 | align-items: center; 446 | border-bottom: 1px solid rgba(0, 0, 0, 0.12); 447 | display: flex; 448 | justify-content: space-between; 449 | margin-bottom: 20px; 450 | margin-left: -10%; 451 | overflow: hidden; 452 | padding: 12px 20px; 453 | position: relative; 454 | text-overflow: ellipsis; 455 | white-space: nowrap; 456 | width: 120%; 457 | 458 | @media (min-width: 36em) { 459 | border: 0; 460 | margin: 0; 461 | overflow: visible; 462 | padding: 0; 463 | position: absolute; 464 | right: -8px; 465 | top: -8px; 466 | width: auto; 467 | } 468 | } 469 | 470 | .duet-date__mobile-heading { 471 | display: inline-block; 472 | font-weight: var(--duet-font-bold); 473 | max-width: 84%; 474 | overflow: hidden; 475 | text-overflow: ellipsis; 476 | white-space: nowrap; 477 | 478 | @media (min-width: 36em) { 479 | display: none; 480 | } 481 | } 482 | 483 | // --------------------------------------------- 484 | // DUET DATE PICKER __ CLOSE 485 | // --------------------------------------------- 486 | 487 | .duet-date__close { 488 | -webkit-appearance: none; 489 | align-items: center; 490 | appearance: none; 491 | background: var(--duet-color-button); 492 | border: 0; 493 | border-radius: 50%; 494 | color: var(--duet-color-text); 495 | cursor: pointer; 496 | display: flex; 497 | height: 24px; 498 | justify-content: center; 499 | padding: 0; 500 | width: 24px; 501 | 502 | @media (min-width: 36em) { 503 | opacity: 0; 504 | } 505 | 506 | &:focus { 507 | box-shadow: 0 0 0 2px var(--duet-color-primary); 508 | outline: none; 509 | 510 | @media (min-width: 36em) { 511 | opacity: 1; 512 | } 513 | } 514 | 515 | svg { 516 | margin: 0 auto; 517 | } 518 | } 519 | 520 | // --------------------------------------------- 521 | // DUET DATE PICKER __ VISUALLY HIDDEN 522 | // --------------------------------------------- 523 | 524 | .duet-date__vhidden { 525 | border: 0; 526 | clip: rect(1px, 1px, 1px, 1px); 527 | height: 1px; 528 | overflow: hidden; 529 | padding: 0; 530 | position: absolute; 531 | top: 0; 532 | width: 1px; 533 | } 534 | -------------------------------------------------------------------------------- /src/components/duet-date-picker/duet-date-picker.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | ComponentInterface, 4 | Host, 5 | Prop, 6 | Element, 7 | h, 8 | Event, 9 | EventEmitter, 10 | State, 11 | Listen, 12 | Method, 13 | Watch, 14 | } from "@stencil/core" 15 | import { 16 | addDays, 17 | startOfWeek, 18 | endOfWeek, 19 | setMonth, 20 | setYear, 21 | clamp, 22 | inRange, 23 | endOfMonth, 24 | startOfMonth, 25 | printISODate, 26 | parseISODate, 27 | createIdentifier, 28 | DaysOfWeek, 29 | createDate, 30 | } from "./date-utils" 31 | import { DatePickerInput } from "./date-picker-input" 32 | import { DatePickerMonth } from "./date-picker-month" 33 | import defaultLocalization, { DuetLocalizedText } from "./date-localization" 34 | import isoAdapter, { DuetDateAdapter } from "./date-adapter" 35 | 36 | function range(from: number, to: number) { 37 | var result: number[] = [] 38 | for (var i = from; i <= to; i++) { 39 | result.push(i) 40 | } 41 | return result 42 | } 43 | 44 | const keyCode = { 45 | TAB: 9, 46 | ESC: 27, 47 | SPACE: 32, 48 | PAGE_UP: 33, 49 | PAGE_DOWN: 34, 50 | END: 35, 51 | HOME: 36, 52 | LEFT: 37, 53 | UP: 38, 54 | RIGHT: 39, 55 | DOWN: 40, 56 | } 57 | 58 | function cleanValue(input: HTMLInputElement, regex: RegExp): string { 59 | const value = input.value 60 | const cursor = input.selectionStart 61 | 62 | const beforeCursor = value.slice(0, cursor) 63 | const afterCursor = value.slice(cursor, value.length) 64 | 65 | const filteredBeforeCursor = beforeCursor.replace(regex, "") 66 | const filterAfterCursor = afterCursor.replace(regex, "") 67 | 68 | const newValue = filteredBeforeCursor + filterAfterCursor 69 | const newCursor = filteredBeforeCursor.length 70 | 71 | input.value = newValue 72 | input.selectionStart = input.selectionEnd = newCursor 73 | 74 | return newValue 75 | } 76 | 77 | export type DuetDatePickerChangeEvent = { 78 | component: "duet-date-picker" 79 | valueAsDate: Date 80 | value: string 81 | } 82 | export type DuetDatePickerFocusEvent = { 83 | component: "duet-date-picker" 84 | } 85 | export type DuetDatePickerOpenEvent = { 86 | component: "duet-date-picker" 87 | } 88 | export type DuetDatePickerCloseEvent = { 89 | component: "duet-date-picker" 90 | } 91 | export type DuetDatePickerDirection = "left" | "right" 92 | 93 | const DISALLOWED_CHARACTERS = /[^0-9\.\/\-]+/g 94 | const TRANSITION_MS = 300 95 | 96 | export type DateDisabledPredicate = (date: Date) => boolean 97 | 98 | @Component({ 99 | tag: "duet-date-picker", 100 | styleUrl: "duet-date-picker.scss", 101 | shadow: false, 102 | scoped: false, 103 | }) 104 | export class DuetDatePicker implements ComponentInterface { 105 | /** 106 | * Own Properties 107 | */ 108 | private monthSelectId = createIdentifier("DuetDateMonth") 109 | private yearSelectId = createIdentifier("DuetDateYear") 110 | private dialogLabelId = createIdentifier("DuetDateLabel") 111 | 112 | private datePickerButton: HTMLButtonElement 113 | private datePickerInput: HTMLInputElement 114 | private firstFocusableElement: HTMLElement 115 | private monthSelectNode: HTMLElement 116 | private dialogWrapperNode: HTMLElement 117 | private focusedDayNode: HTMLButtonElement 118 | 119 | private focusTimeoutId: ReturnType 120 | 121 | private initialTouchX: number = null 122 | private initialTouchY: number = null 123 | 124 | /** 125 | * Whilst dateAdapter is used for handling the formatting/parsing dates in the input, 126 | * these are used to format dates exclusively for the benefit of screen readers. 127 | * 128 | * We prefer DateTimeFormat over date.toLocaleDateString, as the former has 129 | * better performance when formatting large number of dates. See: 130 | * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleDateString#Performance 131 | */ 132 | private dateFormatShort: Intl.DateTimeFormat 133 | private dateFormatLong: Intl.DateTimeFormat 134 | 135 | /** 136 | * Reference to host HTML element. 137 | */ 138 | @Element() element: HTMLElement 139 | 140 | /** 141 | * State() variables 142 | */ 143 | @State() activeFocus = false 144 | @State() focusedDay = new Date() 145 | @State() open = false 146 | 147 | /** 148 | * Public Property API 149 | */ 150 | 151 | /** 152 | * Name of the date picker input. 153 | */ 154 | @Prop() name: string = "date" 155 | 156 | /** 157 | * Adds a unique identifier for the date picker input. Use this instead of html `id` attribute. 158 | */ 159 | @Prop() identifier: string = "" 160 | 161 | /** 162 | * Makes the date picker input component disabled. This prevents users from being able to 163 | * interact with the input, and conveys its inactive state to assistive technologies. 164 | */ 165 | @Prop({ reflect: true }) disabled: boolean = false 166 | 167 | /** 168 | * Defines a specific role attribute for the date picker input. 169 | */ 170 | @Prop() role: string 171 | 172 | /** 173 | * Forces the opening direction of the calendar modal to be always left or right. 174 | * This setting can be useful when the input is smaller than the opening date picker 175 | * would be as by default the picker always opens towards right. 176 | */ 177 | @Prop() direction: DuetDatePickerDirection = "right" 178 | 179 | /** 180 | * Should the input be marked as required? 181 | */ 182 | @Prop() required: boolean = false 183 | 184 | /** 185 | * Date value. Must be in IS0-8601 format: YYYY-MM-DD. 186 | */ 187 | @Prop({ reflect: true, mutable: true }) value: string = "" 188 | 189 | /** 190 | * Minimum date allowed to be picked. Must be in IS0-8601 format: YYYY-MM-DD. 191 | * This setting can be used alone or together with the max property. 192 | */ 193 | @Prop() min: string = "" 194 | 195 | /** 196 | * Maximum date allowed to be picked. Must be in IS0-8601 format: YYYY-MM-DD. 197 | * This setting can be used alone or together with the min property. 198 | */ 199 | @Prop() max: string = "" 200 | 201 | /** 202 | * Which day is considered first day of the week? `0` for Sunday, `1` for Monday, etc. 203 | * Default is Monday. 204 | */ 205 | @Prop() firstDayOfWeek: DaysOfWeek = DaysOfWeek.Monday 206 | 207 | /** 208 | * Button labels, day names, month names, etc, used for localization. 209 | * Default is English. 210 | */ 211 | @Prop() localization: DuetLocalizedText = defaultLocalization 212 | 213 | /** 214 | * Date adapter, for custom parsing/formatting. 215 | * Must be object with a `parse` function which accepts a `string` and returns a `Date`, 216 | * and a `format` function which accepts a `Date` and returns a `string`. 217 | * Default is IS0-8601 parsing and formatting. 218 | */ 219 | @Prop() dateAdapter: DuetDateAdapter = isoAdapter 220 | 221 | /** 222 | * Controls which days are disabled and therefore disallowed. 223 | * For example, this can be used to disallow selection of weekends. 224 | */ 225 | @Prop() isDateDisabled: DateDisabledPredicate = () => false 226 | 227 | /** 228 | * Events section. 229 | */ 230 | 231 | /** 232 | * Event emitted when a date is selected. 233 | */ 234 | @Event() duetChange: EventEmitter 235 | 236 | /** 237 | * Event emitted the date picker input is blurred. 238 | */ 239 | @Event() duetBlur: EventEmitter 240 | 241 | /** 242 | * Event emitted the date picker input is focused. 243 | */ 244 | @Event() duetFocus: EventEmitter 245 | 246 | /** 247 | * Event emitted the date picker modal is opened. 248 | */ 249 | @Event() duetOpen: EventEmitter 250 | 251 | /** 252 | * Event emitted the date picker modal is closed. 253 | */ 254 | @Event() duetClose: EventEmitter 255 | 256 | connectedCallback() { 257 | this.createDateFormatters() 258 | } 259 | 260 | @Watch("localization") 261 | createDateFormatters() { 262 | this.dateFormatShort = new Intl.DateTimeFormat(this.localization.locale, { day: "numeric", month: "long" }) 263 | this.dateFormatLong = new Intl.DateTimeFormat(this.localization.locale, { 264 | day: "numeric", 265 | month: "long", 266 | year: "numeric", 267 | }) 268 | } 269 | 270 | /** 271 | * Component event handling. 272 | */ 273 | @Listen("click", { target: "document", capture: true }) 274 | handleDocumentClick(e: MouseEvent) { 275 | if (!this.open) { 276 | return 277 | } 278 | 279 | // the dialog and the button aren't considered clicks outside. 280 | // dialog for obvious reasons, but the button needs to be skipped 281 | // so that two things are possible: 282 | // 283 | // a) clicking again on the button when dialog is open should close the modal. 284 | // without skipping the button here, we would see a click outside 285 | // _and_ a click on the button, so the `open` state goes 286 | // open -> close (click outside) -> open (click button) 287 | // 288 | // b) clicking another date picker's button should close the current calendar 289 | // and open the new one. this means we can't stopPropagation() on the button itself 290 | // 291 | // this was the only satisfactory combination of things to get the above to work 292 | 293 | const isClickOutside = e 294 | .composedPath() 295 | .every(node => node !== this.dialogWrapperNode && node !== this.datePickerButton) 296 | 297 | if (isClickOutside) { 298 | this.hide(false) 299 | } 300 | } 301 | 302 | /** 303 | * Public methods API 304 | */ 305 | 306 | /** 307 | * Sets focus on the date picker's input. Use this method instead of the global `focus()`. 308 | */ 309 | @Method() async setFocus() { 310 | return this.datePickerInput.focus() 311 | } 312 | 313 | /** 314 | * Show the calendar modal, moving focus to the calendar inside. 315 | */ 316 | @Method() async show() { 317 | this.open = true 318 | this.duetOpen.emit({ 319 | component: "duet-date-picker", 320 | }) 321 | this.setFocusedDay(parseISODate(this.value) || new Date()) 322 | 323 | clearTimeout(this.focusTimeoutId) 324 | this.focusTimeoutId = setTimeout(() => this.monthSelectNode.focus(), TRANSITION_MS) 325 | } 326 | 327 | /** 328 | * Hide the calendar modal. Set `moveFocusToButton` to false to prevent focus 329 | * returning to the date picker's button. Default is true. 330 | */ 331 | @Method() async hide(moveFocusToButton = true) { 332 | this.open = false 333 | this.duetClose.emit({ 334 | component: "duet-date-picker", 335 | }) 336 | 337 | // in cases where calendar is quickly shown and hidden 338 | // we should avoid moving focus to the button 339 | clearTimeout(this.focusTimeoutId) 340 | 341 | if (moveFocusToButton) { 342 | // iOS VoiceOver needs to wait for all transitions to finish. 343 | setTimeout(() => this.datePickerButton.focus(), TRANSITION_MS + 200) 344 | } 345 | } 346 | 347 | /** 348 | * Local methods. 349 | */ 350 | private enableActiveFocus = () => { 351 | this.activeFocus = true 352 | } 353 | 354 | private disableActiveFocus = () => { 355 | this.activeFocus = false 356 | } 357 | 358 | private addDays(days: number) { 359 | this.setFocusedDay(addDays(this.focusedDay, days)) 360 | } 361 | 362 | private addMonths(months: number) { 363 | this.setMonth(this.focusedDay.getMonth() + months) 364 | } 365 | 366 | private addYears(years: number) { 367 | this.setYear(this.focusedDay.getFullYear() + years) 368 | } 369 | 370 | private startOfWeek() { 371 | this.setFocusedDay(startOfWeek(this.focusedDay, this.firstDayOfWeek)) 372 | } 373 | 374 | private endOfWeek() { 375 | this.setFocusedDay(endOfWeek(this.focusedDay, this.firstDayOfWeek)) 376 | } 377 | 378 | private setMonth(month: number) { 379 | const min = setMonth(startOfMonth(this.focusedDay), month) 380 | const max = endOfMonth(min) 381 | const date = setMonth(this.focusedDay, month) 382 | 383 | this.setFocusedDay(clamp(date, min, max)) 384 | } 385 | 386 | private setYear(year: number) { 387 | const min = setYear(startOfMonth(this.focusedDay), year) 388 | const max = endOfMonth(min) 389 | const date = setYear(this.focusedDay, year) 390 | 391 | this.setFocusedDay(clamp(date, min, max)) 392 | } 393 | 394 | private setFocusedDay(day: Date) { 395 | this.focusedDay = clamp(day, parseISODate(this.min), parseISODate(this.max)) 396 | } 397 | 398 | private toggleOpen = (e: Event) => { 399 | e.preventDefault() 400 | this.open ? this.hide(false) : this.show() 401 | } 402 | 403 | private handleEscKey = (event: KeyboardEvent) => { 404 | if (event.keyCode === keyCode.ESC) { 405 | this.hide() 406 | } 407 | } 408 | 409 | private handleBlur = (event: Event) => { 410 | event.stopPropagation() 411 | 412 | this.duetBlur.emit({ 413 | component: "duet-date-picker", 414 | }) 415 | } 416 | 417 | private handleFocus = (event: Event) => { 418 | event.stopPropagation() 419 | 420 | this.duetFocus.emit({ 421 | component: "duet-date-picker", 422 | }) 423 | } 424 | 425 | private handleTouchStart = (event: TouchEvent) => { 426 | const touch = event.changedTouches[0] 427 | this.initialTouchX = touch.pageX 428 | this.initialTouchY = touch.pageY 429 | } 430 | 431 | private handleTouchMove = (event: TouchEvent) => { 432 | event.preventDefault() 433 | } 434 | 435 | private handleTouchEnd = (event: TouchEvent) => { 436 | const touch = event.changedTouches[0] 437 | const distX = touch.pageX - this.initialTouchX // get horizontal dist traveled 438 | const distY = touch.pageY - this.initialTouchY // get vertical dist traveled 439 | const threshold = 70 440 | 441 | const isHorizontalSwipe = Math.abs(distX) >= threshold && Math.abs(distY) <= threshold 442 | const isDownwardsSwipe = Math.abs(distY) >= threshold && Math.abs(distX) <= threshold && distY > 0 443 | 444 | if (isHorizontalSwipe) { 445 | this.addMonths(distX < 0 ? 1 : -1) 446 | } else if (isDownwardsSwipe) { 447 | this.hide(false) 448 | event.preventDefault() 449 | } 450 | 451 | this.initialTouchY = null 452 | this.initialTouchX = null 453 | } 454 | 455 | private handleNextMonthClick = (event: MouseEvent) => { 456 | event.preventDefault() 457 | this.addMonths(1) 458 | } 459 | 460 | private handlePreviousMonthClick = (event: MouseEvent) => { 461 | event.preventDefault() 462 | this.addMonths(-1) 463 | } 464 | 465 | private handleFirstFocusableKeydown = (event: KeyboardEvent) => { 466 | // this ensures focus is trapped inside the dialog 467 | if (event.keyCode === keyCode.TAB && event.shiftKey) { 468 | this.focusedDayNode.focus() 469 | event.preventDefault() 470 | } 471 | } 472 | 473 | private handleKeyboardNavigation = (event: KeyboardEvent) => { 474 | // handle tab separately, since it needs to be treated 475 | // differently to other keyboard interactions 476 | if (event.keyCode === keyCode.TAB && !event.shiftKey) { 477 | event.preventDefault() 478 | this.firstFocusableElement.focus() 479 | return 480 | } 481 | 482 | var handled = true 483 | 484 | switch (event.keyCode) { 485 | case keyCode.RIGHT: 486 | this.addDays(1) 487 | break 488 | case keyCode.LEFT: 489 | this.addDays(-1) 490 | break 491 | case keyCode.DOWN: 492 | this.addDays(7) 493 | break 494 | case keyCode.UP: 495 | this.addDays(-7) 496 | break 497 | case keyCode.PAGE_UP: 498 | if (event.shiftKey) { 499 | this.addYears(-1) 500 | } else { 501 | this.addMonths(-1) 502 | } 503 | break 504 | case keyCode.PAGE_DOWN: 505 | if (event.shiftKey) { 506 | this.addYears(1) 507 | } else { 508 | this.addMonths(1) 509 | } 510 | break 511 | case keyCode.HOME: 512 | this.startOfWeek() 513 | break 514 | case keyCode.END: 515 | this.endOfWeek() 516 | break 517 | default: 518 | handled = false 519 | } 520 | 521 | if (handled) { 522 | event.preventDefault() 523 | this.enableActiveFocus() 524 | } 525 | } 526 | 527 | private handleDaySelect = (_event: MouseEvent, day: Date) => { 528 | const isInRange = inRange(day, parseISODate(this.min), parseISODate(this.max)) 529 | const isAllowed = !this.isDateDisabled(day) 530 | 531 | if (isInRange && isAllowed) { 532 | this.setValue(day) 533 | this.hide() 534 | } else { 535 | // for consistency we should set the focused day in cases where 536 | // user has selected a day that has been specifically disallowed 537 | this.setFocusedDay(day) 538 | } 539 | } 540 | 541 | private handleMonthSelect = e => { 542 | this.setMonth(parseInt(e.target.value, 10)) 543 | } 544 | 545 | private handleYearSelect = e => { 546 | this.setYear(parseInt(e.target.value, 10)) 547 | } 548 | 549 | private handleInputChange = () => { 550 | const target = this.datePickerInput 551 | 552 | // clean up any invalid characters 553 | cleanValue(target, DISALLOWED_CHARACTERS) 554 | 555 | const parsed = this.dateAdapter.parse(target.value, createDate) 556 | if (parsed || target.value === "") { 557 | this.setValue(parsed) 558 | } 559 | } 560 | 561 | private setValue(date: Date) { 562 | this.value = printISODate(date) 563 | this.duetChange.emit({ 564 | component: "duet-date-picker", 565 | value: this.value, 566 | valueAsDate: date, 567 | }) 568 | } 569 | 570 | private processFocusedDayNode = (element: HTMLButtonElement) => { 571 | this.focusedDayNode = element 572 | 573 | if (this.activeFocus && this.open) { 574 | setTimeout(() => element.focus(), 0) 575 | } 576 | } 577 | 578 | /** 579 | * render() function 580 | * Always the last one in the class. 581 | */ 582 | render() { 583 | const valueAsDate = parseISODate(this.value) 584 | const formattedDate = valueAsDate && this.dateAdapter.format(valueAsDate) 585 | const selectedYear = (valueAsDate || this.focusedDay).getFullYear() 586 | const focusedMonth = this.focusedDay.getMonth() 587 | const focusedYear = this.focusedDay.getFullYear() 588 | 589 | const minDate = parseISODate(this.min) 590 | const maxDate = parseISODate(this.max) 591 | const prevMonthDisabled = 592 | minDate != null && minDate.getMonth() === focusedMonth && minDate.getFullYear() === focusedYear 593 | const nextMonthDisabled = 594 | maxDate != null && maxDate.getMonth() === focusedMonth && maxDate.getFullYear() === focusedYear 595 | 596 | const minYear = minDate ? minDate.getFullYear() : selectedYear - 10 597 | const maxYear = maxDate ? maxDate.getFullYear() : selectedYear + 10 598 | 599 | return ( 600 | 601 |
602 | (this.datePickerButton = element)} 618 | inputRef={element => (this.datePickerInput = element)} 619 | /> 620 | 621 | 792 |
793 |
794 | ) 795 | } 796 | } 797 | -------------------------------------------------------------------------------- /src/components/duet-date-picker/readme.md: -------------------------------------------------------------------------------- 1 | # duet-date-picker 2 | 3 | 4 | 5 | 6 | 7 | 8 | ## Properties 9 | 10 | | Property | Attribute | Description | Type | Default | 11 | | ---------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | 12 | | `dateAdapter` | -- | Date adapter, for custom parsing/formatting. Must be object with a `parse` function which accepts a `string` and returns a `Date`, and a `format` function which accepts a `Date` and returns a `string`. Default is IS0-8601 parsing and formatting. | `DuetDateAdapter` | `isoAdapter` | 13 | | `direction` | `direction` | Forces the opening direction of the calendar modal to be always left or right. This setting can be useful when the input is smaller than the opening date picker would be as by default the picker always opens towards right. | `"left" \| "right"` | `"right"` | 14 | | `disabled` | `disabled` | Makes the date picker input component disabled. This prevents users from being able to interact with the input, and conveys its inactive state to assistive technologies. | `boolean` | `false` | 15 | | `firstDayOfWeek` | `first-day-of-week` | Which day is considered first day of the week? `0` for Sunday, `1` for Monday, etc. Default is Monday. | `DaysOfWeek.Friday \| DaysOfWeek.Monday \| DaysOfWeek.Saturday \| DaysOfWeek.Sunday \| DaysOfWeek.Thursday \| DaysOfWeek.Tuesday \| DaysOfWeek.Wednesday` | `DaysOfWeek.Monday` | 16 | | `identifier` | `identifier` | Adds a unique identifier for the date picker input. Use this instead of html `id` attribute. | `string` | `""` | 17 | | `isDateDisabled` | -- | Controls which days are disabled and therefore disallowed. For example, this can be used to disallow selection of weekends. | `(date: Date) => boolean` | `() => false` | 18 | | `localization` | -- | Button labels, day names, month names, etc, used for localization. Default is English. | `{ buttonLabel: string; placeholder: string; selectedDateMessage: string; prevMonthLabel: string; nextMonthLabel: string; monthSelectLabel: string; yearSelectLabel: string; closeLabel: string; calendarHeading: string; dayNames: DayNames; monthNames: MonthsNames; monthNamesShort: MonthsNames; locale: string \| string[]; }` | `defaultLocalization` | 19 | | `max` | `max` | Maximum date allowed to be picked. Must be in IS0-8601 format: YYYY-MM-DD. This setting can be used alone or together with the min property. | `string` | `""` | 20 | | `min` | `min` | Minimum date allowed to be picked. Must be in IS0-8601 format: YYYY-MM-DD. This setting can be used alone or together with the max property. | `string` | `""` | 21 | | `name` | `name` | Name of the date picker input. | `string` | `"date"` | 22 | | `required` | `required` | Should the input be marked as required? | `boolean` | `false` | 23 | | `role` | `role` | Defines a specific role attribute for the date picker input. | `string` | `undefined` | 24 | | `value` | `value` | Date value. Must be in IS0-8601 format: YYYY-MM-DD. | `string` | `""` | 25 | 26 | 27 | ## Events 28 | 29 | | Event | Description | Type | 30 | | ------------ | ----------------------------------------------- | ----------------------------------------------------------------------------------- | 31 | | `duetBlur` | Event emitted the date picker input is blurred. | `CustomEvent<{ component: "duet-date-picker"; }>` | 32 | | `duetChange` | Event emitted when a date is selected. | `CustomEvent<{ component: "duet-date-picker"; valueAsDate: Date; value: string; }>` | 33 | | `duetClose` | Event emitted the date picker modal is closed. | `CustomEvent<{ component: "duet-date-picker"; }>` | 34 | | `duetFocus` | Event emitted the date picker input is focused. | `CustomEvent<{ component: "duet-date-picker"; }>` | 35 | | `duetOpen` | Event emitted the date picker modal is opened. | `CustomEvent<{ component: "duet-date-picker"; }>` | 36 | 37 | 38 | ## Methods 39 | 40 | ### `hide(moveFocusToButton?: boolean) => Promise` 41 | 42 | Hide the calendar modal. Set `moveFocusToButton` to false to prevent focus 43 | returning to the date picker's button. Default is true. 44 | 45 | #### Returns 46 | 47 | Type: `Promise` 48 | 49 | 50 | 51 | ### `setFocus() => Promise` 52 | 53 | Sets focus on the date picker's input. Use this method instead of the global `focus()`. 54 | 55 | #### Returns 56 | 57 | Type: `Promise` 58 | 59 | 60 | 61 | ### `show() => Promise` 62 | 63 | Show the calendar modal, moving focus to the calendar inside. 64 | 65 | #### Returns 66 | 67 | Type: `Promise` 68 | 69 | 70 | 71 | 72 | ---------------------------------------------- 73 | 74 | *Built with [StencilJS](https://stenciljs.com/)* 75 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Duet Date Picker examples 7 | 8 | 9 | 10 | 11 | 12 | 13 | 17 | 18 | 24 | 173 | 174 | 175 |
176 |

Duet Date Picker examples

177 |

178 | Duet Date Picker is an open source version of 179 | Duet Design System’s accessible date picker. It can be implemented and used 180 | across any JavaScript framework or no framework at all. 181 |

182 | 183 |

184 | 185 | For documentation, please see the 186 | GitHub repository. 187 | 188 |

189 | 190 | 191 | 207 | 208 |

Default

209 | 210 | 211 |
<label for="date">Choose a date</label>
212 | <duet-date-picker identifier="date"></duet-date-picker>
213 | 214 |

Using show() method

215 | 216 | 217 | 218 | 225 |
<label for="date">Choose a date</label>
226 | <duet-date-picker identifier="date"></duet-date-picker>
227 | <button type="button">Show date picker</button>
228 | 
229 | <script>
230 |   const button = document.querySelector("button")
231 | 
232 |   button.addEventListener("click", function() {
233 |     document.querySelector("duet-date-picker").show()
234 |   });
235 | </script>
236 | 237 |

Using setFocus() method

238 | 239 | 240 | 241 | 248 |
<label for="date">Choose a date</label>
249 | <duet-date-picker identifier="date"></duet-date-picker>
250 | <button type="button">Focus date picker</button>
251 | 
252 | <script>
253 |   const button = document.querySelector("button")
254 | 
255 |   button.addEventListener("click", function() {
256 |     document.querySelector("duet-date-picker").setFocus()
257 |   });
258 | </script>
259 | 260 |

Getting selected value

261 | 262 | 263 | undefined 264 | 272 |
<label for="date">Choose a date</label>
273 | <duet-date-picker identifier="date"></duet-date-picker>
274 | <output>undefined</output>
275 | 
276 | <script>
277 |   const picker = document.querySelector("duet-date-picker")
278 |   const output = document.querySelector("output")
279 | 
280 |   picker.addEventListener("duetChange", function(event) {
281 |     output.innerHTML = event.detail.valueAsDate
282 |   });
283 | </script>
284 | 285 |

Predefined value

286 | 287 | 288 |
<label for="date">Choose a date</label>
289 | <duet-date-picker identifier="date" value="2020-06-16">
290 | </duet-date-picker>
291 | 292 |

Minimum and maximum date

293 | 294 | 295 |
<label for="date">Choose a date</label>
296 | <duet-date-picker identifier="date" min="1990-06-10"
297 |   max="2020-07-18" value="2020-06-16">
298 | </duet-date-picker>
299 | 300 |

Localization

301 | 302 | 303 | 367 |
<label for="date">Valitse päivämäärä</label>
368 | <duet-date-picker identifier="date"></duet-date-picker>
369 | 
370 | <script>
371 |   const picker = document.querySelector("duet-date-picker")
372 |   const DATE_FORMAT = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/
373 | 
374 |   picker.dateAdapter = {
375 |     parse(value = "", createDate) {
376 |       const matches = value.match(DATE_FORMAT)
377 |       if (matches) {
378 |         return createDate(matches[3], matches[2], matches[1])
379 |       }
380 |     },
381 |     format(date) {
382 |       return `${date.getDate()}.${date.getMonth() + 1}.${date.getFullYear()}`
383 |     },
384 |   }
385 | 
386 |   picker.localization = {
387 |     buttonLabel: "Valitse päivämäärä",
388 |     placeholder: "pp.kk.vvvv",
389 |     selectedDateMessage: "Valittu päivämäärä on",
390 |     prevMonthLabel: "Edellinen kuukausi",
391 |     nextMonthLabel: "Seuraava kuukausi",
392 |     monthSelectLabel: "Kuukausi",
393 |     yearSelectLabel: "Vuosi",
394 |     closeLabel: "Sulje ikkuna",
395 |     calendarHeading: "Valitse päivämäärä",
396 |     dayNames: ["Sunnuntai", "Maanantai", "Tiistai", "Keskiviikko", "Torstai", "Perjantai", "Lauantai"],
397 |     monthNames: ["Tammikuu", "Helmikuu", "Maaliskuu", "Huhtikuu", "Toukokuu", "Kesäkuu", "Heinäkuu", "Elokuu", "Syyskuu", "Lokakuu", "Marraskuu", "Joulukuu"],
398 |     monthNamesShort: ["Tammi", "Helmi", "Maalis", "Huhti", "Touko", "Kesä", "Heinä", "Elo", "Syys", "Loka", "Marras", "Joulu"],
399 |     locale: "fi-FI",
400 |   }
401 | </script>
402 | 403 |

Changing first day of week and date format

404 | 405 | 406 | 457 |
<label for="date">Choose a date</label>
458 | <duet-date-picker first-day-of-week="0" identifier="date"></duet-date-picker>
459 | 
460 | <script>
461 |   const picker = document.querySelector("duet-date-picker")
462 |   const DATE_FORMAT_US = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/
463 | 
464 |   picker.dateAdapter = {
465 |     parse(value = "", createDate) {
466 |       const matches = value.match(DATE_FORMAT_US)
467 | 
468 |       if (matches) {
469 |         return createDate(matches[3], matches[1], matches[2])
470 |       }
471 |     },
472 |     format(date) {
473 |       return `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`
474 |     },
475 |   }
476 | 
477 |   picker.localization = {
478 |     buttonLabel: "Choose date",
479 |     placeholder: "mm/dd/yyyy",
480 |     selectedDateMessage: "Selected date is",
481 |     prevMonthLabel: "Previous month",
482 |     nextMonthLabel: "Next month",
483 |     monthSelectLabel: "Month",
484 |     yearSelectLabel: "Year",
485 |     closeLabel: "Close window",
486 |     calendarHeading: "Choose a date",
487 |     dayNames: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
488 |     monthNames: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
489 |     monthNamesShort: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
490 |     locale: "en-US",
491 |   }
492 | </script>
493 | 494 |

Required atrribute

495 | 496 |
497 | 498 | 499 |
500 | 507 |
<label for="date">Choose a date</label>
508 | <duet-date-picker required identifier="date"></duet-date-picker>
509 | 
510 | <script>
511 |   const form = document.querySelector(".form-picker-required")
512 |   form.addEventListener("submit", function(e) {
513 |     e.preventDefault()
514 |     alert("Submitted")
515 |   })
516 | </script>
517 | 518 |

Disable selectable days

519 |

520 | This only disables selection of dates in the popup calendar. You must still handle the case where a user enters 521 | a disallowed date into the input. 522 |

523 | 524 | 525 | 539 |
<label for="date">Choose a date</label>
540 | <duet-date-picker identifier="date"></duet-date-picker>
541 | 
542 | <script>
543 |   function isWeekend(date) {
544 |     return date.getDay() === 0 || date.getDay() === 6
545 |   }
546 | 
547 |   const pickerDisableWeekend = document.querySelector(".picker-disabled-weekend")
548 |   pickerDisableWeekend.isDateDisabled = isWeekend
549 | 
550 |   pickerDisableWeekend.addEventListener("duetChange", function(e) {
551 |     if (isWeekend(e.detail.valueAsDate)) {
552 |       alert("Please select a weekday")
553 |     }
554 |   })
555 | </script>
556 |
557 |

558 | © 2020 LocalTapiola Services Ltd / 559 | Duet Design System.
Licensed under the MIT license. 560 |

561 |
562 |
Loading…
563 | 564 | 565 | -------------------------------------------------------------------------------- /src/themes/dark.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --duet-color-primary: #005fcc; 3 | --duet-color-text: #fff; 4 | --duet-color-text-active: #fff; 5 | --duet-color-placeholder: #aaa; 6 | --duet-color-button: #444; 7 | --duet-color-surface: #222; 8 | --duet-color-overlay: rgba(0, 0, 0, 0.8); 9 | --duet-color-border: #fff; 10 | 11 | --duet-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 12 | --duet-font-normal: 400; 13 | --duet-font-bold: 600; 14 | 15 | --duet-radius: 4px; 16 | --duet-z-index: 600; 17 | } 18 | -------------------------------------------------------------------------------- /src/themes/default.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --duet-color-primary: #005fcc; 3 | --duet-color-text: #333; 4 | --duet-color-text-active: #fff; 5 | --duet-color-placeholder: #666; 6 | --duet-color-button: #f5f5f5; 7 | --duet-color-surface: #fff; 8 | --duet-color-overlay: rgba(0, 0, 0, 0.8); 9 | --duet-color-border: #333; 10 | 11 | --duet-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 12 | --duet-font-normal: 400; 13 | --duet-font-bold: 600; 14 | 15 | --duet-radius: 4px; 16 | --duet-z-index: 600; 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/test-utils.ts: -------------------------------------------------------------------------------- 1 | import { newE2EPage, E2EPage } from "@stencil/core/testing" 2 | import { Page as PuppeteerPage } from "puppeteer" 3 | 4 | export type DuetE2EPage = E2EPage & Pick 5 | 6 | type DuetE2EPageOptions = { html: string; viewportWidth: number } 7 | 8 | export async function createPage(optionsOrHtml?: string | DuetE2EPageOptions) { 9 | const options: DuetE2EPageOptions = 10 | typeof optionsOrHtml === "string" ? { html: optionsOrHtml, viewportWidth: 600 } : optionsOrHtml 11 | 12 | const page = (await newE2EPage()) as DuetE2EPage 13 | const viewport = Object.assign({ height: page.viewport().height }, { width: options.viewportWidth }) 14 | await page.setViewport(viewport) 15 | await page.setContent(options.html, { waitUntil: "networkidle0" }) 16 | await page.evaluateHandle(() => (document as any).fonts.ready) 17 | 18 | // monkey patch screenshot function to add some extra features 19 | const screenshot = page.screenshot 20 | page.screenshot = async function() { 21 | // get the element's height, and set viewport to that height 22 | // this enables us to get full page, clipped screenshots 23 | const htmlElement = await page.$("html") 24 | const { width, height } = await htmlElement.boundingBox() 25 | await page.setViewport({ width: page.viewport().width, height: Math.round(height) }) 26 | 27 | return screenshot.call(page, { 28 | clip: { 29 | x: 0, 30 | y: 0, 31 | width: Math.round(width), 32 | height: Math.round(height), 33 | }, 34 | }) 35 | } 36 | 37 | return page 38 | } 39 | -------------------------------------------------------------------------------- /stencil.config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from "@stencil/core" 2 | import { sass } from "@stencil/sass" 3 | 4 | export const config: Config = { 5 | // See https://github.com/ionic-team/stencil/blob/master/src/declarations/config.ts for config 6 | namespace: "duet", 7 | enableCache: true, 8 | hashFileNames: false, 9 | autoprefixCss: false, 10 | minifyCss: true, 11 | buildEs5: true, 12 | taskQueue: "immediate", 13 | preamble: "Built with Duet Design System", 14 | hashedFileNameLength: 8, 15 | commonjs: { include: /node_modules|(..\/.+)/ } as any, 16 | bundles: [{ components: ["duet-date-picker"] }], 17 | devServer: { 18 | openBrowser: true, 19 | port: 3333, 20 | reloadStrategy: "pageReload", 21 | }, 22 | extras: { 23 | // We need the following for IE11 and old Edge: 24 | cssVarsShim: true, 25 | dynamicImportShim: true, 26 | // We don’t use shadow DOM so this is not needed: 27 | shadowDomShim: false, 28 | // Setting the below option to “true” will actually break Safari 10 support: 29 | safari10: false, 30 | // This is to tackle an Angular specific performance issue: 31 | initializeNextTick: true, 32 | // Don’t need any of these so setting them to “false”: 33 | scriptDataOpts: false, 34 | appendChildSlotFix: false, 35 | cloneNodeFix: false, 36 | slotChildNodesFix: false, 37 | }, 38 | outputTargets: [ 39 | { 40 | type: "dist-hydrate-script", 41 | dir: "hydrate", 42 | empty: false, 43 | }, 44 | { 45 | type: "dist-custom-elements-bundle", 46 | dir: "custom-element", 47 | empty: true, 48 | }, 49 | { 50 | type: "dist", 51 | dir: "dist", 52 | empty: true, 53 | copy: [{ src: "themes", warn: true }], 54 | }, 55 | { 56 | type: "docs-readme", 57 | }, 58 | { 59 | type: "www", 60 | dir: "www", 61 | serviceWorker: null, 62 | empty: true, 63 | baseUrl: "https://duetds.github.io/", 64 | prerenderConfig: "./prerender.config.ts", 65 | copy: [{ src: "themes", dest: "themes", warn: true }], 66 | }, 67 | ], 68 | plugins: [sass()], 69 | testing: { 70 | browserHeadless: process.env.TEST_HEADLESS !== "false", 71 | setupFilesAfterEnv: ["/jest/jest-setup.js"], 72 | testPathIgnorePatterns: ["/hydrate/", "/dist/"], 73 | }, 74 | } 75 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "allowUnreachableCode": false, 5 | "declaration": false, 6 | "experimentalDecorators": true, 7 | "lib": ["dom", "es2017"], 8 | "moduleResolution": "node", 9 | "module": "esnext", 10 | "target": "es2017", 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "jsx": "react", 14 | "jsxFactory": "h" 15 | }, 16 | "include": ["src", "types/jsx.d.ts"], 17 | "exclude": ["node_modules"] 18 | } 19 | --------------------------------------------------------------------------------