├── .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 | Switch theme
202 |
218 |
219 | Default
220 | Choose a date
221 |
222 | <label for="date">Choose a date</label>
223 | <duet-date-picker identifier="date"></duet-date-picker>
224 |
225 | Using show() method
226 | Choose a date
227 |
228 | Show date picker
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 | Choose a date
250 |
251 | Focus date picker
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 | Choose a date
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 | Choose a date
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 | Choose a date
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 | Valitse päivämäärä
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 | Choose a date
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 | Choose a date (required)
507 |
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 | Choose a date
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 | Choose a date
44 |
45 | Choose a date
46 |
47 | Choose a date
48 |
49 | Choose a date
50 |
51 | Valitse päivämäärä
52 |
53 |
117 | Choose a date
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 | {
56 | if (isFocused && el && focusedDayRef) {
57 | focusedDayRef(el)
58 | }
59 | }}
60 | >
61 | {day.getDate()}
62 | {dateFormatter.format(day)}
63 |
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 |
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 |
73 |
85 |
86 | ))}
87 |
88 | ))}
89 |
90 |
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 |
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 |
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 |
635 |
(this.dialogWrapperNode = element)}
639 | >
640 |
641 |
{this.localization.calendarHeading}
642 |
(this.firstFocusableElement = element)}
645 | onKeyDown={this.handleFirstFocusableKeydown}
646 | onClick={() => this.hide()}
647 | type="button"
648 | >
649 |
657 |
658 |
659 |
660 | {this.localization.closeLabel}
661 |
662 |
663 | {/* @ts-ignore */}
664 |
776 |
790 |
791 |
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 | Switch theme
191 |
207 |
208 | Default
209 | Choose a date
210 |
211 | <label for="date">Choose a date</label>
212 | <duet-date-picker identifier="date"></duet-date-picker>
213 |
214 | Using show() method
215 | Choose a date
216 |
217 | Show date picker
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 | Choose a date
239 |
240 | Focus date picker
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 | Choose a date
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 | Choose a date
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 | Choose a date
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 | Valitse päivämäärä
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 | Choose a date
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 | Choose a date (required)
496 |
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 | Choose a date
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 |
--------------------------------------------------------------------------------