├── .husky └── pre-push ├── test └── unit │ ├── setup.ts │ └── specs │ ├── utils │ ├── helpers.spec.ts │ └── PropsValidator.spec.ts │ └── components │ ├── SwitchButton │ └── SwitchButton.spec.ts │ ├── CalendarDialog │ ├── switchButtonImplementation.spec.ts │ ├── calendarInputDateImplementation.spec.ts │ ├── helperButtons.spec.ts │ ├── resetButtonImplementation.spec.ts │ ├── calendarInputTimeImplementation.spec.ts │ ├── calendarImplementation.spec.ts │ ├── CalendarDialog.spec.ts │ └── inlineImplementation.spec.ts │ ├── CalendarInputDate │ └── CalendarInputDate.spec.ts │ ├── CalendarInputTime │ └── CalendarInputTime.spec.ts │ ├── DateInput │ └── DateInput.spec.ts │ ├── DatePicker │ └── DatePicker.spec.ts │ └── Calendar │ └── Calendar.spec.ts ├── src ├── vite-env.d.ts ├── styles │ ├── DatePicker.scss │ ├── _mixins.scss │ ├── InputDate.scss │ ├── InputTime.scss │ ├── Switch.scss │ ├── CalendarDialog.scss │ ├── Button.scss │ └── Calendar.scss ├── global.d.ts ├── components │ ├── commonTypes.ts │ ├── SwitchButton │ │ ├── types.ts │ │ └── SwitchButton.vue │ ├── CalendarInputDate │ │ ├── types.ts │ │ └── CalendarInputDate.vue │ ├── CalendarInputTime │ │ ├── types.ts │ │ └── CalendarInputTime.vue │ ├── Calendar │ │ ├── types.ts │ │ └── Calendar.vue │ ├── DateInput │ │ ├── types.ts │ │ └── DateInput.vue │ ├── DatePicker │ │ ├── types.ts │ │ └── DatePicker.vue │ └── CalendarDialog │ │ ├── types.ts │ │ └── CalendarDialog.vue ├── globalSideEffect.ts ├── index.ts ├── utils │ ├── propsValidator.ts │ ├── helpers.ts │ └── DateUtil.ts └── composables │ ├── useSelectedDates.ts │ └── useCalendarDateUtil.ts ├── .vscode └── extensions.json ├── serve ├── style.css ├── main.ts ├── ExampleIsMondayFirst.vue ├── ExampleDateInputConfig.vue ├── ExampleDateFormat.vue ├── ExampleTimeInputConfig.vue ├── ExampleInitialDates.vue ├── ExampleInline.vue ├── ExampleLanguage.vue ├── StatefullDatepicker.vue ├── ExampleHelperButtons.vue ├── App.vue ├── ExampleDisabledDates.vue ├── ExampleAvailableDates.vue └── ExampleEvents.vue ├── vite.config.dev.ts ├── Dockerfile.dev ├── babel.config.cjs ├── tsconfig.json ├── tsconfig.test.json ├── tsconfig.serve.json ├── .gitignore ├── index.html ├── .github ├── workflows │ ├── ci.yml │ ├── tag-release.yml │ └── npm-publish-npm-packages.yml └── FUNDING.yml ├── tsconfig.node.json ├── eslint.config.js ├── LICENSE.txt ├── tsconfig.app.json ├── .devcontainer └── devcontainer.json ├── vite.config.ts ├── package.json ├── jest.config.ts └── README.md /.husky/pre-push: -------------------------------------------------------------------------------- 1 | npm run lint && npm test -------------------------------------------------------------------------------- /test/unit/setup.ts: -------------------------------------------------------------------------------- 1 | import "./../../src/globalSideEffect" -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar"] 3 | } 4 | -------------------------------------------------------------------------------- /src/styles/DatePicker.scss: -------------------------------------------------------------------------------- 1 | .vdpr-datepicker { 2 | position: relative; 3 | } -------------------------------------------------------------------------------- /serve/style.css: -------------------------------------------------------------------------------- 1 | #app { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | } 5 | -------------------------------------------------------------------------------- /vite.config.dev.ts: -------------------------------------------------------------------------------- 1 | import viteBaseConfig from "./vite.config"; 2 | 3 | export default viteBaseConfig; 4 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM node:20.18-bullseye-slim 2 | RUN apt-get update && \ 3 | apt-get install gitk -y 4 | USER node -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | import { defineEmitOptions } from "./globalSideEffect"; 2 | 3 | declare global { 4 | const defineEmitOptions: defineEmitOptions; 5 | } 6 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ["@babel/preset-env", { targets: { node: "current" } }], 4 | "@babel/preset-typescript", 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /src/styles/_mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin flex-c-c { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | } 6 | 7 | @mixin flex-sb-c { 8 | display: flex; 9 | justify-content: space-between; 10 | align-items: center; 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" }, 6 | { "path": "./tsconfig.test.json" }, 7 | { "path": "./tsconfig.serve.json" } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /serve/main.ts: -------------------------------------------------------------------------------- 1 | import "moment/dist/locale/fr"; 2 | import "moment/dist//locale/id"; 3 | import "./style.css"; 4 | import "./../src/globalSideEffect"; 5 | 6 | import { createApp } from "vue"; 7 | import App from "./App.vue"; 8 | 9 | createApp(App).mount("#app"); 10 | -------------------------------------------------------------------------------- /src/components/commonTypes.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | export type FromToRange = { 4 | from: F; 5 | to: T; 6 | }; 7 | 8 | export type ClassValue = 9 | | string 10 | | Array 11 | | Record 12 | | null 13 | | undefined; 14 | -------------------------------------------------------------------------------- /src/styles/InputDate.scss: -------------------------------------------------------------------------------- 1 | .vdpr-datepicker__calendar-input-date { 2 | height: 30px; 3 | 4 | &-elem { 5 | box-sizing: border-box; 6 | border: none; 7 | width: 100%; 8 | height: 100%; 9 | padding-left: 5px; 10 | background-color: #eee; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.app.json", 3 | "include": ["**/*.spec.ts", "src/**/*"], 4 | "exclude": ["serve/**/*"], 5 | "compilerOptions": { 6 | "composite": true, 7 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.test.tsbuildinfo" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.serve.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.app.json", 3 | "include": ["src/**/*", "serve/**/*.ts", "serve/**/*.tsx", "serve/**/*.vue"], 4 | "exclude": ["**/*.spec.ts"], 5 | "compilerOptions": { 6 | "composite": true, 7 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.serve.tsbuildinfo" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /serve/ExampleIsMondayFirst.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | dist-zip 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | **/coverage/** 28 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + Vue + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/components/SwitchButton/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { ExtractPropTypes } from "vue"; 3 | 4 | export const switchButtonProps = { 5 | checked: { 6 | type: Boolean, 7 | }, 8 | }; 9 | 10 | export type SwitchButtonProps = ExtractPropTypes; 11 | 12 | export const switchButtonEmits = defineEmitOptions({ 13 | change: (_e: Event) => true, 14 | }); 15 | -------------------------------------------------------------------------------- /src/globalSideEffect.ts: -------------------------------------------------------------------------------- 1 | import { InferRecord } from "@utils/helpers"; 2 | import { ObjectEmitsOptions } from "vue"; 3 | 4 | type defineEmitOptions = typeof _defineEmitOptions; 5 | 6 | const _defineEmitOptions = (emitOptions: T) => { 7 | return emitOptions as InferRecord; 8 | }; 9 | 10 | Object.assign(globalThis, { 11 | defineEmitOptions: _defineEmitOptions, 12 | }); 13 | 14 | export type { defineEmitOptions }; 15 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: 'ci' 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | - dev-version-2 10 | 11 | jobs: 12 | test: 13 | name: CI Test ${{github.ref_name}} 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: 20 20 | - run: npm ci 21 | - run: npm run build 22 | - run: npm run lint 23 | - run: npm test -------------------------------------------------------------------------------- /src/components/SwitchButton/SwitchButton.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | 18 | 25 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | "rootDir": ".", 8 | "outDir": "dist", 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | 23 | "composite": true, 24 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo" 25 | }, 26 | "include": ["vite.config.ts"] 27 | } 28 | -------------------------------------------------------------------------------- /serve/ExampleDateInputConfig.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 34 | -------------------------------------------------------------------------------- /serve/ExampleDateFormat.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 34 | -------------------------------------------------------------------------------- /src/styles/InputTime.scss: -------------------------------------------------------------------------------- 1 | @import "mixins"; 2 | 3 | .vdpr-datepicker__calendar-input-time { 4 | position: relative; 5 | height: 30px; 6 | background-color: #eee; 7 | 8 | &-elem { 9 | border: none; 10 | height: 100%; 11 | width: 100%; 12 | background-color: transparent; 13 | box-sizing: border-box; 14 | padding-left: 5px; 15 | } 16 | 17 | &-control { 18 | position: absolute; 19 | top: 0; 20 | right: 0; 21 | height: 100%; 22 | width: 20%; 23 | flex-direction: column; 24 | user-select: none; 25 | @include flex-c-c; 26 | 27 | &-up, 28 | &-down { 29 | cursor: pointer; 30 | color: #999; 31 | @include flex-c-c; 32 | 33 | &:hover { 34 | color: black; 35 | } 36 | } 37 | 38 | &-up { 39 | margin-bottom: -5px; 40 | margin-top: 2px; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/components/CalendarInputDate/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { ClassValue } from "@components/commonTypes"; 3 | import { ExtractPropTypes, PropType } from "vue"; 4 | 5 | export const calendarInputDateProps = { 6 | inputClass: [String, Object, Array] as PropType, 7 | timestamp: { 8 | type: Number as PropType, 9 | default: 0, 10 | }, 11 | format: { 12 | type: String as PropType, 13 | default: "DD/MM/YYYY", 14 | }, 15 | language: { 16 | type: String as PropType, 17 | default: "en", 18 | }, 19 | }; 20 | 21 | export type CalendarInputDateProps = ExtractPropTypes< 22 | typeof calendarInputDateProps 23 | >; 24 | 25 | export const calendarInputDateEmits = defineEmitOptions({ 26 | change: (_d: Date) => true, 27 | }); 28 | 29 | export type CalendarInputDateEmits = typeof calendarInputDateEmits; 30 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [limbara] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: limbara 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /src/components/CalendarInputTime/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { ClassValue } from "@components/commonTypes"; 3 | import { ExtractPropTypes, PropType } from "vue"; 4 | 5 | export const calendarInputTimeProps = { 6 | inputClass: [String, Object, Array] as PropType, 7 | readonly: { 8 | type: Boolean as PropType, 9 | default: false, 10 | }, 11 | timestamp: { 12 | type: Number as PropType, 13 | default: 0, 14 | }, 15 | language: { 16 | type: String as PropType, 17 | default: "en", 18 | }, 19 | step: { 20 | type: Number as PropType, 21 | default: 60, // in minutes 22 | }, 23 | }; 24 | 25 | export type CalendarInputTimeProps = ExtractPropTypes< 26 | typeof calendarInputTimeProps 27 | >; 28 | 29 | export const calendarInputTimeEmits = defineEmitOptions({ 30 | change: (_d: Date) => true, 31 | }); 32 | 33 | export type CalendarInputTimeEmits = typeof calendarInputTimeEmits; 34 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import "./globalSideEffect.ts"; 2 | 3 | export type { DatesAvailabilityConfig } from "@composables/useCalendarDateUtil.ts"; 4 | export type { InitialDate } from "@composables/useSelectedDates.ts"; 5 | export type { FromToRange } from "@components/commonTypes.ts"; 6 | export type { SameDateFormatConfig } from "@components/DateInput/types.ts"; 7 | 8 | export { default as DatePicker } from "@components/DatePicker/DatePicker.vue"; 9 | export type { 10 | DatePickerModelValue, 11 | DatePickerDateInputProps, 12 | DatePickerProps, 13 | datePickerProps, 14 | DatePickerEmits, 15 | datePickerEmits, 16 | } from "@components/DatePicker/types.ts"; 17 | 18 | export { default as CalendarDialog } from "@components/CalendarDialog/CalendarDialog.vue"; 19 | export type { 20 | CalendarDialogInputTimeProps, 21 | CalendarDialogInputDateProps, 22 | HelperButtonShape, 23 | CalendarDialogProps, 24 | calendarDialogProps, 25 | CalendarDialogEmits, 26 | calendarDialogEmits, 27 | } from "@components/CalendarDialog/types.ts"; 28 | -------------------------------------------------------------------------------- /serve/ExampleTimeInputConfig.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 37 | 38 | 43 | -------------------------------------------------------------------------------- /serve/ExampleInitialDates.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 40 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | import pluginVue from "eslint-plugin-vue"; 5 | import jesteslint from "eslint-plugin-jest"; 6 | 7 | export default [ 8 | { files: ["**/*.{js,mjs,cjs,ts,vue}"] }, 9 | { 10 | languageOptions: { 11 | globals: globals.browser 12 | } 13 | }, 14 | pluginJs.configs.recommended, 15 | ...tseslint.configs.recommended, 16 | ...pluginVue.configs["flat/essential"], 17 | { rules: { 'vue/multi-word-component-names': 'off' } }, 18 | { files: ["**/*.vue"], languageOptions: { parserOptions: { parser: tseslint.parser } } }, 19 | { 20 | files: ['test/**'], 21 | ...jesteslint.configs['flat/recommended'], 22 | rules: { 23 | ...jesteslint.configs['flat/recommended'].rules, 24 | 'jest/prefer-expect-assertions': 'off', 25 | }, 26 | }, 27 | { ignores: ["**/babel.config.cjs", "**/vite.config.*.ts", "**/jest.config.ts", "dist/**", "node_modules/**", "test/unit/coverage/**"] }, 28 | { 29 | rules: { 30 | '@typescript-eslint/no-explicit-any': 'off' 31 | } 32 | } 33 | ]; -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Nico Limbara 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | "rootDir": ".", 9 | "outDir": "dist", 10 | "paths": { 11 | "@components/*": ["./src/components/*"], 12 | "@composables/*": ["./src/composables/*"], 13 | "@utils/*": ["./src/utils/*"] 14 | }, 15 | 16 | /* Bundler mode */ 17 | "moduleResolution": "bundler", 18 | "allowImportingTsExtensions": true, 19 | "isolatedModules": true, 20 | "moduleDetection": "force", 21 | "noEmit": true, 22 | "jsx": "preserve", 23 | 24 | /* Linting */ 25 | "strict": true, 26 | "noUnusedLocals": true, 27 | "noUnusedParameters": true, 28 | "noFallthroughCasesInSwitch": true, 29 | "typeRoots": ["./node_modules/@types", "./**/*.d.ts"], 30 | 31 | "composite": true, 32 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo" 33 | }, 34 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], 35 | "exclude": ["**/*.spec.ts", "serve/**/*"] 36 | } 37 | -------------------------------------------------------------------------------- /serve/ExampleInline.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 46 | -------------------------------------------------------------------------------- /.github/workflows/tag-release.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to https://registry.npmjs.org 2 | 3 | name: Tag Release 4 | 5 | on: 6 | push: 7 | tags: 8 | - "v*.*.*" 9 | workflow_dispatch: 10 | 11 | jobs: 12 | test: 13 | name: Test on ${{github.ref_name}} 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 20 20 | - run: npm ci 21 | - run: npm test 22 | 23 | tag-release: 24 | name: Tag release on ${{github.ref_name}} 25 | runs-on: ubuntu-latest 26 | permissions: 27 | contents: write 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: actions/setup-node@v4 31 | with: 32 | node-version: 20 33 | - name: Install dependencies and build 🔧 34 | run: npm ci && npm run build 35 | - name: Release on ${{github.ref_name}} 36 | uses: softprops/action-gh-release@v2 37 | with: 38 | generate_release_notes: true 39 | fail_on_unmatched_files: true 40 | prerelease: false 41 | draft: true 42 | token: ${{ secrets.GITHUB_TOKEN }} 43 | files: | 44 | dist-zip/dist.zip 45 | -------------------------------------------------------------------------------- /serve/ExampleLanguage.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 51 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish-npm-packages.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to https://registry.npmjs.org 2 | 3 | name: NPM Publish 4 | 5 | on: 6 | release: 7 | types: [published] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | test-and-coverage: 12 | name: Test And Coverage on ${{github.ref_name}} 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: 20 19 | - run: npm ci 20 | - run: npm test 21 | - uses: codecov/codecov-action@v3.1.4 22 | with: 23 | token: ${{secrets.CODECOV_TOKEN}} 24 | 25 | publish: 26 | name: Publish on ${{github.ref_name}} 27 | if: github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') 28 | needs: test-and-coverage 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v3 32 | - uses: actions/setup-node@v3 33 | with: 34 | node-version: 20 35 | registry-url: https://registry.npmjs.org 36 | - name: Install dependencies and build 🔧 37 | run: npm ci && npm run build 38 | - name: Publish package on NPM 📦 39 | run: npm publish 40 | env: 41 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 42 | -------------------------------------------------------------------------------- /src/components/Calendar/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { ExtractPropTypes, PropType } from "vue"; 3 | 4 | export type ComputedDay = { 5 | date: Date; 6 | timestamp: number; 7 | dateNumber: number; 8 | isHighlighted: boolean; 9 | isDisabled: boolean; 10 | isFaded: boolean; 11 | }; 12 | 13 | export const calendarProps = { 14 | pageDate: { 15 | type: Date as PropType, 16 | required: true, 17 | }, 18 | days: { 19 | type: Array as PropType>, 20 | default: () => [] as Array, 21 | }, 22 | dayNames: { 23 | type: Array as PropType>, 24 | default: () => [] as Array, 25 | }, 26 | isPrevPageDisabled: { 27 | type: Boolean as PropType, 28 | default: false, 29 | }, 30 | isNextPageDisabled: { 31 | type: Boolean as PropType, 32 | default: false, 33 | }, 34 | language: { 35 | type: String as PropType, 36 | default: "en", 37 | }, 38 | }; 39 | 40 | export type CalendarProps = ExtractPropTypes; 41 | 42 | export const calendarEmits = defineEmitOptions({ 43 | "select-disabled-date": (_d: Date) => true, 44 | "select-date": (_d: Date) => true, 45 | "on-prev-calendar": (_e: Event) => true, 46 | "on-next-calendar": (_e: Event) => true, 47 | }); 48 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-dockerfile 3 | { 4 | "name": "Vue Time Date Range Picker", 5 | "containerUser": "node", 6 | "build": { 7 | // Sets the run context to one level up instead of the .devcontainer folder. 8 | "context": "..", 9 | // Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename. 10 | "dockerfile": "../Dockerfile.dev" 11 | }, 12 | "features": { 13 | "ghcr.io/devcontainers/features/git:1": {} 14 | }, 15 | // Features to add to the dev container. More info: https://containers.dev/features. 16 | // "features": {}, 17 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 18 | // "forwardPorts": [], 19 | // Uncomment the next line to run commands after the container is created. 20 | "postCreateCommand": "npm install", 21 | // Configure tool-specific properties. 22 | "customizations": { 23 | "vscode": { 24 | "extensions": [ 25 | "esbenp.prettier-vscode", 26 | "Vue.volar", 27 | "dbaeumer.vscode-eslint" 28 | ] 29 | } 30 | } 31 | // Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root. 32 | // "remoteUser": "devcontainer" 33 | } -------------------------------------------------------------------------------- /src/styles/Switch.scss: -------------------------------------------------------------------------------- 1 | $bg-switch-check: #3a86ff; 2 | $bg-switch-uncheck: #ccc; 3 | $color-switch-button: white; 4 | $color-switch-text: white; 5 | 6 | .vdpr-datepicker__switch { 7 | position: relative; 8 | display: inline-block; 9 | width: 60px; 10 | height: 30px; 11 | 12 | > input { 13 | display: none; 14 | 15 | &:checked { 16 | + .vdpr-datepicker__switch-slider { 17 | background-color: $bg-switch-check; 18 | } 19 | 20 | + .vdpr-datepicker__switch-slider::before { 21 | transform: translateX(26px); 22 | } 23 | 24 | + .vdpr-datepicker__switch-slider::after { 25 | content: "ON"; 26 | left: 25%; 27 | } 28 | } 29 | } 30 | 31 | &-slider { 32 | cursor: pointer; 33 | position: absolute; 34 | top: 0; 35 | left: 0; 36 | right: 0; 37 | bottom: 0; 38 | background-color: $bg-switch-uncheck; 39 | transition: 0.4s; 40 | border-radius: 34px; 41 | 42 | &:before { 43 | content: ""; 44 | height: 24px; 45 | width: 24px; 46 | position: absolute; 47 | left: 5px; 48 | bottom: 3px; 49 | background-color: $color-switch-button; 50 | transition: 0.4s; 51 | border-radius: 50%; 52 | } 53 | 54 | &:after { 55 | content: "OFF"; 56 | position: absolute; 57 | top: 50%; 58 | left: 75%; 59 | transform: translate(-50%, -50%); 60 | color: $color-switch-text; 61 | font-size: 11px; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /serve/StatefullDatepicker.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 33 | 34 | 45 | -------------------------------------------------------------------------------- /src/styles/CalendarDialog.scss: -------------------------------------------------------------------------------- 1 | @import "./mixins"; 2 | @import "./Calendar.scss"; 3 | @import "./Button.scss"; 4 | @import "./InputDate.scss"; 5 | @import "./InputTime.scss"; 6 | @import "./Switch.scss"; 7 | 8 | .vdpr-datepicker { 9 | &__calendar-dialog { 10 | box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); 11 | position: absolute; 12 | margin-top: 2px; 13 | padding: 5px; 14 | display: flex; 15 | flex-direction: row; 16 | z-index: 1000; 17 | background-color: white; 18 | 19 | &--inline { 20 | display: inline-flex; 21 | position: unset; 22 | z-index: 0; 23 | box-shadow: none; 24 | border: 1px solid rgba(0, 0, 0, 0.175); 25 | } 26 | } 27 | 28 | &__calendar { 29 | width: 300px; 30 | } 31 | 32 | &__calendar-button-helper { 33 | width: 140px; 34 | padding: 5px 10px; 35 | 36 | > :not(:first-child) { 37 | margin-top: 5px; 38 | } 39 | } 40 | 41 | &__calendar-actions { 42 | width: 160px; 43 | padding: 5px 10px; 44 | font-size: 13px; 45 | 46 | > :not(:first-child) { 47 | margin-top: 5px; 48 | } 49 | } 50 | 51 | &__calendar-input-wrapper { 52 | @include flex-sb-c; 53 | width: 100%; 54 | 55 | &--end { 56 | justify-content: flex-end; 57 | } 58 | 59 | &:not(:first-child) { 60 | margin-top: 10px; 61 | } 62 | 63 | > span { 64 | flex-basis: 35%; 65 | } 66 | 67 | > .vdpr-datepicker__calendar-input-date, 68 | .vdpr-datepicker__calendar-input-time { 69 | flex-basis: 65%; 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/components/DateInput/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { ClassValue, FromToRange } from "@components/commonTypes"; 3 | import { Nullable } from "@utils/helpers"; 4 | import { isValidSameDateFormat } from "@utils/propsValidator"; 5 | import { ExtractPropTypes, PropType } from "vue"; 6 | 7 | export type SameDateFormatConfig = FromToRange; 8 | 9 | export const dateInputProps = { 10 | inputClass: [String, Object, Array] as PropType, 11 | refName: String as PropType, 12 | name: String as PropType, 13 | type: String as PropType, 14 | placeholder: String as PropType, 15 | id: String as PropType, 16 | required: Boolean as PropType, 17 | format: { 18 | type: String as PropType, 19 | default: "DD/MM/YYYY HH:mm", 20 | }, 21 | sameDateFormat: { 22 | type: Object as PropType, 23 | validator: isValidSameDateFormat, 24 | default: () => 25 | ({ 26 | from: "DD/MM/YYYY, HH:mm", 27 | to: "HH:mm", 28 | } as SameDateFormatConfig), 29 | }, 30 | language: { 31 | type: String as PropType, 32 | default: "en", 33 | }, 34 | selectedStartDate: Date as PropType>, 35 | selectedEndDate: Date as PropType>, 36 | }; 37 | 38 | export type DateInputProps = ExtractPropTypes; 39 | 40 | export const dateInputEmits = defineEmitOptions({ 41 | click: (_e: Event) => true, 42 | }); 43 | 44 | export type DateInputEmits = typeof dateInputEmits; 45 | -------------------------------------------------------------------------------- /serve/ExampleHelperButtons.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 60 | -------------------------------------------------------------------------------- /test/unit/specs/utils/helpers.spec.ts: -------------------------------------------------------------------------------- 1 | import { isEmptyLiteralObject, isObjectDate, omit } from "@utils/helpers"; 2 | 3 | describe("helpers", () => { 4 | describe("isObjectDate", () => { 5 | it("should return true if value is Date object", () => { 6 | const value = new Date("2024 11 2"); 7 | 8 | expect(isObjectDate(value)).toEqual(true); 9 | }); 10 | 11 | it("should return false if value is not a Date object", () => { 12 | const value: Array = [ 13 | Date.now(), 14 | "2024 11 02", 15 | "2024 11 02 12:00:00", 16 | null, 17 | undefined, 18 | JSON.stringify(new Date()), 19 | JSON.parse(JSON.stringify(new Date())), 20 | [new Date()], 21 | ]; 22 | 23 | value.forEach((v) => { 24 | expect(isObjectDate(v)).toEqual(false); 25 | }); 26 | }); 27 | }); 28 | 29 | describe("isEmptyLiteralObject", () => { 30 | it("should return true if literal object is empty", () => { 31 | expect(isEmptyLiteralObject({})).toBe(true); 32 | }); 33 | 34 | it("should return false if literal object is not empty", () => { 35 | expect(isEmptyLiteralObject({ value: true })).toBe(false); 36 | }); 37 | }); 38 | 39 | describe("omit", () => { 40 | it("should omit certain exclude keys from object", () => { 41 | expect( 42 | omit( 43 | { 44 | a: 1, 45 | b: 2, 46 | c: 3, 47 | }, 48 | ["a"] 49 | ) 50 | ).toEqual({ 51 | b: 2, 52 | c: 3, 53 | }); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from "node:url"; 2 | import { defineConfig } from "vite"; 3 | import vue from "@vitejs/plugin-vue"; 4 | import { resolve } from "node:path"; 5 | import postcssPresetEnv from "postcss-preset-env"; 6 | import dtsPlugin from "vite-plugin-dts"; 7 | import zipPack from "vite-plugin-zip-pack" 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig((env) => ({ 11 | plugins: [vue(), dtsPlugin({ tsconfigPath: "./tsconfig.app.json" }), zipPack()], 12 | resolve: { 13 | alias: { 14 | "@components": fileURLToPath( 15 | new URL("./src/components", import.meta.url) 16 | ), 17 | "@composables": fileURLToPath( 18 | new URL("./src/composables", import.meta.url) 19 | ), 20 | "@utils": fileURLToPath(new URL("./src/utils", import.meta.url)), 21 | }, 22 | }, 23 | server: { 24 | host: true, 25 | }, 26 | css: { 27 | postcss: { 28 | plugins: [postcssPresetEnv()], 29 | }, 30 | preprocessorOptions: { 31 | scss: { 32 | api: "modern-compiler", 33 | }, 34 | }, 35 | devSourcemap: env.mode === "development", 36 | }, 37 | build: { 38 | lib: { 39 | formats: ["es", "umd", "iife"], 40 | entry: resolve(__dirname, "src", "index.ts"), 41 | name: "vdprDatePicker", 42 | fileName: "vdprDatePicker", 43 | }, 44 | emptyOutDir: false, 45 | sourcemap: true, 46 | rollupOptions: { 47 | external: ["vue", "moment"], 48 | output: { 49 | globals: { 50 | vue: "Vue", 51 | moment: "moment", 52 | }, 53 | }, 54 | }, 55 | }, 56 | })); 57 | -------------------------------------------------------------------------------- /src/components/CalendarInputDate/CalendarInputDate.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 17 | 18 | 60 | -------------------------------------------------------------------------------- /src/components/DateInput/DateInput.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 23 | 24 | 67 | -------------------------------------------------------------------------------- /src/styles/Button.scss: -------------------------------------------------------------------------------- 1 | @use "sass:color"; 2 | 3 | $bg-button-default: white; 4 | $color-button-default: #333; 5 | $bg-button-submit: #3a86ff; 6 | $color-button-submit: white; 7 | $bg-button-reset: #ef233c; 8 | $color-button-reset: white; 9 | 10 | $button-border-color: color.adjust($bg-button-default, 11 | $lightness: -10%, 12 | $space: hsl); 13 | $button-submit-border-color: color.adjust($bg-button-submit, 14 | $lightness: -10%, 15 | $space: hsl); 16 | $button-reset-border-color: color.adjust($bg-button-reset, 17 | $lightness: -10%, 18 | $space: hsl); 19 | 20 | .vdpr-datepicker__button { 21 | cursor: pointer; 22 | user-select: none; 23 | font-size: 14px; 24 | font-weight: 400; 25 | text-align: center; 26 | text-transform: capitalize; 27 | padding: 6px 12px; 28 | line-height: 1.5; 29 | white-space: nowrap; 30 | border: 1px solid transparent; 31 | border-radius: 4px; 32 | 33 | &--block { 34 | width: 100%; 35 | display: block; 36 | } 37 | 38 | &-default { 39 | color: $color-button-default; 40 | background-color: $bg-button-default; 41 | border-color: $button-border-color; 42 | 43 | &:hover { 44 | background-color: $button-border-color; 45 | } 46 | } 47 | 48 | &-submit { 49 | color: $color-button-submit; 50 | background-color: $bg-button-submit; 51 | border-color: $button-submit-border-color; 52 | 53 | &:hover { 54 | background-color: $button-submit-border-color; 55 | } 56 | } 57 | 58 | &-reset { 59 | color: $color-button-reset; 60 | background-color: $bg-button-reset; 61 | border-color: $button-reset-border-color; 62 | 63 | &:hover { 64 | background-color: $button-reset-border-color; 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /test/unit/specs/components/SwitchButton/SwitchButton.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from "@vue/test-utils"; 2 | import SwitchButton from "@components/SwitchButton/SwitchButton.vue"; 3 | import "regenerator-runtime"; 4 | 5 | describe("Switch Button", () => { 6 | it("should render correct contents", () => { 7 | const wrapper = shallowMount(SwitchButton, { 8 | attachTo: document.body, 9 | }); 10 | 11 | const label = wrapper.find('.vdpr-datepicker__switch') 12 | 13 | expect(label).toBeDefined() 14 | 15 | const input = label.find('input') 16 | expect(input).toBeDefined() 17 | expect(input.attributes().type).toBe("checkbox") 18 | 19 | const slider = label.find('.vdpr-datepicker__switch-slider') 20 | 21 | expect(slider).toBeDefined() 22 | }) 23 | 24 | it("should render checked state", () => { 25 | const wrapper = shallowMount(SwitchButton, { 26 | attachTo: document.body, 27 | props: { 28 | checked: true, 29 | }, 30 | }); 31 | 32 | const input = wrapper.get('input') 33 | 34 | expect(input.element.checked).toEqual(true) 35 | }) 36 | 37 | it("should render unchecked state", () => { 38 | const wrapper = shallowMount(SwitchButton, { 39 | attachTo: document.body, 40 | props: { 41 | checked: false, 42 | }, 43 | }); 44 | 45 | const input = wrapper.get('input') 46 | 47 | expect(input.element.checked).toEqual(false) 48 | }) 49 | 50 | it("should emit event change", async () => { 51 | const wrapper = shallowMount(SwitchButton, { 52 | attachTo: document.body, 53 | props: { 54 | checked: false, 55 | }, 56 | }); 57 | 58 | await wrapper.trigger("click"); 59 | 60 | expect(wrapper.emitted("change")).toBeDefined(); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /test/unit/specs/components/CalendarDialog/switchButtonImplementation.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from "@vue/test-utils"; 2 | import moment from "moment"; 3 | import CalendarDialog from "@components/CalendarDialog/CalendarDialog.vue"; 4 | import SwitchButton from "@components/SwitchButton/SwitchButton.vue"; 5 | 6 | describe("Calendar Dialog : Switch Button Implementation", () => { 7 | const startDate = new Date("2020 07 01"); 8 | const endDate = new Date("2020 07 15"); 9 | 10 | it("change date to start of day & end of day if check true", () => { 11 | const wrapper = shallowMount(CalendarDialog, { 12 | props: { 13 | initialDates: [startDate, endDate], 14 | switchButtonInitial: false, 15 | }, 16 | }); 17 | 18 | const switchButton = wrapper.findComponent(SwitchButton); 19 | switchButton.vm.$emit("change", { target: { checked: true } }); 20 | 21 | expect(wrapper.vm.isAllDayChecked).toEqual(true); 22 | expect(wrapper.vm.selectedStartDate).toEqual( 23 | moment(startDate).startOf("day").toDate() 24 | ); 25 | expect(wrapper.vm.selectedEndDate).toEqual( 26 | moment(endDate).endOf("day").toDate() 27 | ); 28 | }); 29 | 30 | it("change date to start of day & start of day if check false", () => { 31 | const wrapper = shallowMount(CalendarDialog, { 32 | props: { 33 | initialDates: [startDate, endDate], 34 | isAllDay: true, 35 | }, 36 | }); 37 | 38 | const switchButton = wrapper.findComponent(SwitchButton); 39 | switchButton.vm.$emit("change", { target: { checked: false } }); 40 | 41 | expect(wrapper.vm.isAllDayChecked).toEqual(false); 42 | expect(wrapper.vm.selectedStartDate).toEqual( 43 | moment(startDate).startOf("day").toDate() 44 | ); 45 | expect(wrapper.vm.selectedEndDate).toEqual( 46 | moment(endDate).startOf("day").toDate() 47 | ); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /serve/App.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 53 | -------------------------------------------------------------------------------- /test/unit/specs/components/CalendarInputDate/CalendarInputDate.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from "@vue/test-utils"; 2 | import CalendarInputDate from "@components/CalendarInputDate/CalendarInputDate.vue"; 3 | import "regenerator-runtime"; 4 | 5 | describe("Calendar Input Date", () => { 6 | let wrapper: ReturnType>; 7 | 8 | const date = "2020 08 10"; 9 | 10 | beforeEach(() => { 11 | wrapper = shallowMount(CalendarInputDate, { 12 | props: { 13 | inputClass: "date_input_class", 14 | timestamp: new Date(date).getTime() / 1000, 15 | format: "DD/MM/YYYY", 16 | }, 17 | }); 18 | }); 19 | 20 | it("should render correct contents", () => { 21 | expect(wrapper.findAll("input")).toHaveLength(1); 22 | 23 | const attrs = wrapper.find("input").attributes(); 24 | 25 | expect(attrs.class).toContain("date_input_class"); 26 | }); 27 | 28 | it("format date", () => { 29 | expect(wrapper.find('input').element.value).toEqual("10/08/2020"); 30 | }); 31 | 32 | it("doesn't format date if timestamp is zero", () => { 33 | wrapper = shallowMount(CalendarInputDate, { 34 | props: { 35 | timestamp: 0, 36 | }, 37 | }); 38 | 39 | expect(wrapper.find("input").element.value).toEqual(""); 40 | }); 41 | 42 | it("change language", async () => { 43 | await wrapper.setProps({ 44 | language: "id", 45 | format: "MMMM", 46 | }); 47 | 48 | expect(wrapper.find("input").element.value).toEqual("Agustus"); 49 | 50 | await wrapper.setProps({ 51 | language: "ms-my", 52 | format: "MMMM", 53 | }); 54 | 55 | expect(wrapper.find("input").element.value).toEqual("Ogos"); 56 | }); 57 | 58 | it("emit change when input change", async () => { 59 | wrapper.find("input").element.value = "24/10/2020"; 60 | 61 | await wrapper.find("input").trigger("change"); 62 | 63 | expect(wrapper.emitted("change")?.[0]).toEqual([new Date("2020 10 24")]); 64 | }); 65 | 66 | it("doesn't emit change if input invalid", async () => { 67 | wrapper.find("input").element.value = "ww/08/2020"; 68 | 69 | await wrapper.find("input").trigger("change"); 70 | 71 | expect(wrapper.emitted("change")).toBeFalsy(); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /serve/ExampleDisabledDates.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 89 | -------------------------------------------------------------------------------- /serve/ExampleAvailableDates.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 93 | -------------------------------------------------------------------------------- /test/unit/specs/components/CalendarDialog/calendarInputDateImplementation.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from "@vue/test-utils"; 2 | import CalendarDialog from "@components/CalendarDialog/CalendarDialog.vue"; 3 | import CalendarInputDate from "@components/CalendarInputDate/CalendarInputDate.vue"; 4 | 5 | describe("Calendar Dialog : Calendar Input Date Implementation", () => { 6 | type MountCalendarDialogFN = typeof shallowMount; 7 | const mountCalendarDialog = ( 8 | options: Parameters[1] 9 | ) => { 10 | const wrapper = shallowMount(CalendarDialog, options); 11 | 12 | const inputDates = wrapper.findAllComponents(CalendarInputDate); 13 | 14 | const inputDateFrom = inputDates.at(0)!; 15 | const inputDateTo = inputDates.at(1)!; 16 | 17 | return { 18 | wrapper, 19 | inputDateFrom, 20 | inputDateTo, 21 | }; 22 | }; 23 | 24 | it("set date when input from date change", () => { 25 | const from = new Date("2024 07 01"); 26 | const to = new Date("2024 07 15"); 27 | 28 | const { wrapper, inputDateFrom } = mountCalendarDialog({ 29 | props: { 30 | initialDates: [from, to], 31 | }, 32 | }); 33 | 34 | inputDateFrom.vm.$emit("change", new Date("2024 07 02")); 35 | 36 | expect(wrapper.vm.selectedStartDate).toEqual(new Date("2024 07 02")); 37 | expect(wrapper.vm.selectedEndDate).toEqual(to); 38 | 39 | inputDateFrom.vm.$emit("change", new Date("2024 07 16")); 40 | 41 | expect(wrapper.vm.selectedStartDate).toEqual(to); 42 | expect(wrapper.vm.selectedEndDate).toEqual(new Date("2024 07 16")); 43 | }); 44 | 45 | it("set date when input to date change", () => { 46 | const from = new Date("2024 07 01"); 47 | const to = new Date("2024 07 15"); 48 | 49 | const { wrapper, inputDateTo } = mountCalendarDialog({ 50 | props: { 51 | initialDates: [from, to], 52 | }, 53 | }); 54 | 55 | inputDateTo.vm.$emit("change", new Date("2024 07 02")); 56 | 57 | expect(wrapper.vm.selectedStartDate).toEqual(from); 58 | expect(wrapper.vm.selectedEndDate).toEqual(new Date("2024 07 02")); 59 | 60 | inputDateTo.vm.$emit("change", new Date("2024 07 16")); 61 | 62 | expect(wrapper.vm.selectedStartDate).toEqual(from); 63 | expect(wrapper.vm.selectedEndDate).toEqual(new Date("2024 07 16")); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/utils/propsValidator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HelperButtonShape, 3 | } from "@components/CalendarDialog/types"; 4 | 5 | import { isEmptyLiteralObject, isObjectDate } from "./helpers"; 6 | import { SameDateFormatConfig } from "@components/DateInput/types"; 7 | import { DatesAvailabilityConfig } from "@composables/useCalendarDateUtil"; 8 | import { InitialDate } from "@composables/useSelectedDates"; 9 | 10 | export const isValidInitialDate = (value: InitialDate | undefined | null) => { 11 | if (!value || (value as Array).length === 0) return true; 12 | 13 | const [from, to] = value; 14 | 15 | if (from && to) { 16 | return isObjectDate(from) && isObjectDate(to) && to.getTime() >= from.getTime() 17 | } 18 | 19 | if (from) { 20 | return isObjectDate(from) 21 | } 22 | 23 | if (to) { 24 | return isObjectDate(to) 25 | } 26 | 27 | return true 28 | }; 29 | 30 | export const isValidHelperButtons = ( 31 | value: Array | undefined | null 32 | ) => { 33 | if (!value || value.length === 0) return true; 34 | 35 | return value.every((v) => { 36 | const isButtonNameValid = typeof v.name === "string" && v.name !== ""; 37 | const isButtonFromDateValid = isObjectDate(v.from); 38 | const isButtonToDateValid = isObjectDate(v.to); 39 | 40 | return isButtonNameValid && isButtonFromDateValid && isButtonToDateValid; 41 | }); 42 | }; 43 | 44 | export const isValidDateAvailabilityConfig = ( 45 | value: DatesAvailabilityConfig | undefined | null 46 | ) => { 47 | if (!value || isEmptyLiteralObject(value)) return true; 48 | 49 | const { dates, from, to, ranges, custom } = value; 50 | 51 | if (Array.isArray(dates) && dates.some((v) => !isObjectDate(v))) return false; 52 | 53 | if (from && !isObjectDate(from)) return false; 54 | 55 | if (to && !isObjectDate(to)) return false; 56 | 57 | if ( 58 | Array.isArray(ranges) && 59 | ranges.some((r) => !isObjectDate(r.from) || !isObjectDate(r.to)) 60 | ) 61 | return false; 62 | 63 | if (custom && typeof custom !== "function") return false; 64 | 65 | return true; 66 | }; 67 | 68 | export const isValidSameDateFormat = ( 69 | value: SameDateFormatConfig | undefined | null 70 | ) => { 71 | if (!value) return true; 72 | 73 | if (isEmptyLiteralObject(value)) return false; 74 | 75 | const { from, to } = value; 76 | 77 | return ( 78 | typeof from === "string" && 79 | from !== "" && 80 | typeof to === "string" && 81 | to !== "" 82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /src/components/CalendarInputTime/CalendarInputTime.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 31 | 32 | 88 | -------------------------------------------------------------------------------- /src/components/DatePicker/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { calendarDialogProps } from "@components/CalendarDialog/types"; 3 | import { DateInputProps, dateInputProps } from "@components/DateInput/types"; 4 | import { InitialDate } from "@composables/useSelectedDates"; 5 | import { Nullable } from "@utils/helpers"; 6 | import { ExtractPropTypes, PropType } from "vue"; 7 | 8 | export type DatePickerDateInputProps = Partial< 9 | Pick< 10 | DateInputProps, 11 | "inputClass" | "refName" | "name" | "placeholder" | "id" | "required" 12 | > 13 | >; 14 | 15 | export type DatePickerModelValue = InitialDate | null; 16 | 17 | export const datePickerProps = { 18 | modelValue: { 19 | type: Array as unknown as PropType, 20 | default: () => null, 21 | }, 22 | initialDates: calendarDialogProps.initialDates, 23 | inline: calendarDialogProps.inline, 24 | language: calendarDialogProps.language, 25 | format: dateInputProps.format, 26 | sameDateFormat: dateInputProps.sameDateFormat, 27 | dateInput: { 28 | type: Object as PropType, 29 | default: () => ({} as DatePickerDateInputProps), 30 | }, 31 | disabledDates: calendarDialogProps.disabledDates, 32 | availableDates: calendarDialogProps.availableDates, 33 | showHelperButtons: calendarDialogProps.showHelperButtons, 34 | helperButtons: calendarDialogProps.helperButtons, 35 | calendarDateInput: calendarDialogProps.dateInput, 36 | calendarTimeInput: calendarDialogProps.timeInput, 37 | switchButtonLabel: calendarDialogProps.switchButtonLabel, 38 | switchButtonInitial: calendarDialogProps.switchButtonInitial, 39 | applyButtonLabel: calendarDialogProps.applyButtonLabel, 40 | resetButtonLabel: calendarDialogProps.resetButtonLabel, 41 | isMondayFirst: calendarDialogProps.isMondayFirst, 42 | }; 43 | 44 | export type DatePickerProps = ExtractPropTypes; 45 | 46 | export const datePickerEmits = defineEmitOptions({ 47 | "update:model-value": (_modelValue: DatePickerModelValue) => true, 48 | "date-applied": (_startDate: Date, _endDate: Date) => true, 49 | "datepicker-opened": () => true, 50 | "datepicker-closed": () => true, 51 | "on-prev-calendar": (_e: Event) => true, 52 | "on-next-calendar": (_e: Event) => true, 53 | "select-date": (_startDate: Nullable, _endDate: Nullable) => true, 54 | "select-disabled-date": (_date: Date) => true, 55 | "on-reset": (_e: Event) => true, 56 | }); 57 | 58 | export type DatePickerEmits = typeof datePickerEmits; -------------------------------------------------------------------------------- /src/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | import { ToRefs, UnwrapRef } from "vue"; 2 | 3 | export type InferRecord = { 4 | [K in keyof T]: T[K]; 5 | }; 6 | 7 | export type UnwrapRefs = { 8 | [K in keyof T]: UnwrapRef; 9 | }; 10 | 11 | export type Nullable = T | null; 12 | 13 | export type MappedRecord< 14 | Type extends object, 15 | Mapping extends Partial> 16 | > = { 17 | [Property in keyof Type as Property extends keyof Mapping 18 | ? Mapping[Property] extends string 19 | ? Mapping[Property] 20 | : Property 21 | : Property]: Type[Property]; 22 | }; 23 | 24 | /** 25 | * check if value is an instance of Date 26 | * @param value 27 | * @returns 28 | */ 29 | export const isObjectDate = (value: any): value is Date => { 30 | return ( 31 | typeof value === "object" && 32 | Object.prototype.toString.call(value) === "[object Date]" 33 | ); 34 | }; 35 | 36 | /** 37 | * check if a literal object value's keys is empty 38 | * @param value 39 | * @returns 40 | */ 41 | export const isEmptyLiteralObject = (value: T): boolean => { 42 | return isPlainObject(value) && Object.keys(value).length === 0; 43 | }; 44 | 45 | /** 46 | * exclude listed property from object 47 | * @param obj 48 | * @param exclude 49 | * @returns 50 | */ 51 | export const omit = >( 52 | obj: T, 53 | exclude: U[] 54 | ): Omit => { 55 | const clone = { ...obj }; 56 | 57 | exclude.forEach((prop) => delete clone[prop]); 58 | 59 | return clone; 60 | }; 61 | 62 | /*! 63 | * is-plain-object 64 | * 65 | * Copyright (c) 2014-2017, Jon Schlinkert. 66 | * Released under the MIT License. 67 | */ 68 | /* istanbul ignore next */ 69 | function hasObjectPrototype(o: any): boolean { 70 | return Object.prototype.toString.call(o) === "[object Object]"; 71 | } 72 | 73 | /* istanbul ignore next */ 74 | export function isPlainObject(o: any): o is object { 75 | if (hasObjectPrototype(o) === false) return false; 76 | 77 | // If has modified constructor 78 | const ctor = o.constructor; 79 | if (ctor === undefined) return true; 80 | 81 | // If has modified prototype 82 | const prot = ctor.prototype; 83 | if (hasObjectPrototype(prot) === false) return false; 84 | 85 | // If constructor does not have an Object-specific method 86 | if (prot.hasOwnProperty!("isPrototypeOf") === false) { 87 | return false; 88 | } 89 | 90 | // Most likely a plain Object 91 | return true; 92 | } 93 | -------------------------------------------------------------------------------- /test/unit/specs/components/CalendarInputTime/CalendarInputTime.spec.ts: -------------------------------------------------------------------------------- 1 | 2 | import { shallowMount } from '@vue/test-utils'; 3 | import CalendarInputTime from '@components/CalendarInputTime/CalendarInputTime.vue'; 4 | import 'regenerator-runtime'; 5 | 6 | describe('Calendar Input Time', () => { 7 | const upButtonClass = '.vdpr-datepicker__calendar-input-time-control-up'; 8 | const downButtonClass = '.vdpr-datepicker__calendar-input-time-control-down'; 9 | 10 | let wrapper: ReturnType>; 11 | 12 | beforeEach(() => { 13 | wrapper = shallowMount(CalendarInputTime, { 14 | props: { 15 | inputClass: 'time_input_class', 16 | timestamp: new Date('2020 08 10 15:00:00').getTime() / 1000, 17 | step: 60, 18 | }, 19 | }); 20 | }); 21 | 22 | it('should render correct contents', () => { 23 | expect(wrapper.find('input').exists()).toBe(true); 24 | 25 | expect(wrapper.find(upButtonClass).exists()).toBe(true); 26 | 27 | expect(wrapper.find(downButtonClass).exists()).toBe(true); 28 | 29 | const attrs = wrapper.find('input').attributes(); 30 | 31 | expect(attrs.class).toContain('time_input_class'); 32 | }); 33 | 34 | it('format time', () => { 35 | expect(wrapper.find('input').element.value).toEqual('15:00'); 36 | }); 37 | 38 | it("doesn't format time if timestamp is zero", () => { 39 | wrapper = shallowMount(CalendarInputTime, { 40 | props: { 41 | timestamp: 0, 42 | }, 43 | }); 44 | 45 | expect(wrapper.find('input').element.value).toEqual(''); 46 | }); 47 | 48 | it('emit change button up', async () => { 49 | await wrapper.find(upButtonClass).trigger('click'); 50 | 51 | expect(wrapper.emitted('change')?.[0]).toEqual([new Date('2020 08 10 16:00:00')]); 52 | }); 53 | 54 | it('emit change button-down', async () => { 55 | await wrapper.find(downButtonClass).trigger('click'); 56 | 57 | expect(wrapper.emitted('change')?.[0]).toEqual([new Date('2020 08 10 14:00:00')]); 58 | }); 59 | 60 | it('emit change when input change', async () => { 61 | const input = wrapper.find('input'); 62 | 63 | input.element.value = '20:00'; 64 | 65 | await input.trigger('change'); 66 | 67 | expect(wrapper.emitted('change')?.[0]).toEqual([new Date('2020 08 10 20:00:00')]); 68 | }); 69 | 70 | it("doesn't emit change if input invalid", async () => { 71 | const input = wrapper.find('input'); 72 | 73 | input.element.value = 'ww:00'; 74 | 75 | await input.trigger('change'); 76 | 77 | expect(wrapper.emitted('on-change')).toBeFalsy(); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/composables/useSelectedDates.ts: -------------------------------------------------------------------------------- 1 | import DateUtil from "@utils/DateUtil"; 2 | import { isObjectDate, Nullable } from "@utils/helpers"; 3 | import { ToRefs } from "vue"; 4 | import { computed, ref } from "vue"; 5 | 6 | export type InitialDate = [Nullable, Nullable]; 7 | 8 | type UseDatePickerProps = ToRefs<{ 9 | language: string; 10 | initialDates: InitialDate; 11 | }>; 12 | 13 | export const useSelectedDates = (props: UseDatePickerProps) => { 14 | const dateUtil = computed(() => { 15 | return new DateUtil(props.language.value); 16 | }); 17 | 18 | const selectedStartDate = ref>( 19 | props.initialDates.value?.[0] ?? null 20 | ); 21 | const selectedEndDate = ref>( 22 | props.initialDates.value?.[1] ?? null 23 | ); 24 | 25 | const isAllDay = computed(() => { 26 | if (selectedStartDate.value && selectedEndDate.value) { 27 | return dateUtil.value.isAllDay( 28 | selectedStartDate.value, 29 | selectedEndDate.value 30 | ); 31 | } 32 | 33 | return false; 34 | }); 35 | 36 | const isDateHighlighted = computed(() => (date: Date) => { 37 | const hasStartDate = isObjectDate(selectedStartDate.value); 38 | const hasEndDate = isObjectDate(selectedEndDate.value); 39 | 40 | if (hasStartDate && hasEndDate) 41 | return dateUtil.value.isSameOrBetween( 42 | date, 43 | dateUtil.value.startOf(selectedStartDate.value!, "d"), 44 | dateUtil.value.startOf(selectedEndDate.value!, "d") 45 | ); 46 | 47 | if (hasStartDate) 48 | return dateUtil.value.isSameDate(date, selectedStartDate.value!); 49 | 50 | if (hasEndDate) 51 | return dateUtil.value.isSameDate(date, selectedEndDate.value!); 52 | 53 | return false; 54 | }); 55 | 56 | const setDates = ( 57 | startDate: Nullable, 58 | endDate: Nullable 59 | ): { startDate: Nullable; endDate: Nullable } => { 60 | const selected = [ 61 | dateUtil.value.isValidDate(startDate) ? startDate : null, 62 | dateUtil.value.isValidDate(endDate) ? endDate : null, 63 | ]; 64 | 65 | if ( 66 | selected[0] && 67 | selected[1] && 68 | dateUtil.value.isAfter(selected[0], selected[1]) 69 | ) { 70 | [selected[0], selected[1]] = [selected[1], selected[0]]; 71 | } 72 | 73 | [selectedStartDate.value, selectedEndDate.value] = selected; 74 | 75 | return { 76 | startDate: selectedStartDate.value, 77 | endDate: selectedEndDate.value, 78 | }; 79 | }; 80 | 81 | return { 82 | selectedStartDate, 83 | selectedEndDate, 84 | isAllDay, 85 | isDateHighlighted, 86 | setDates, 87 | }; 88 | }; 89 | -------------------------------------------------------------------------------- /test/unit/specs/components/CalendarDialog/helperButtons.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from "@vue/test-utils"; 2 | import CalendarDialog from "@components/CalendarDialog/CalendarDialog.vue"; 3 | 4 | describe("Calendar Dialog : helper buttons", () => { 5 | const buttonHelpersClass = ".vdpr-datepicker__calendar-button-helper"; 6 | let wrapper; 7 | 8 | it("should render defaults helper buttons", () => { 9 | wrapper = shallowMount(CalendarDialog, { 10 | props: { 11 | showHelperButtons: true, 12 | }, 13 | }); 14 | 15 | expect(wrapper.find(buttonHelpersClass).exists()).toBe(true); 16 | 17 | const helperButtons = wrapper.find(buttonHelpersClass).findAll("button"); 18 | 19 | const defaultHelpersButtons = wrapper.vm.getDefaultHelpers(); 20 | 21 | expect(helperButtons.length).toEqual(defaultHelpersButtons.length); 22 | 23 | helperButtons.forEach((b, i) => { 24 | expect(b.text()).toBe(defaultHelpersButtons[i].name); 25 | }); 26 | }); 27 | 28 | it("should not render show helper button", () => { 29 | wrapper = shallowMount(CalendarDialog, { 30 | props: { 31 | showHelperButtons: false, 32 | }, 33 | }); 34 | 35 | expect(wrapper.find(buttonHelpersClass).exists()).toBe(false); 36 | }); 37 | 38 | it("should render custom helper buttons", () => { 39 | wrapper = shallowMount(CalendarDialog, { 40 | props: { 41 | helperButtons: [ 42 | { 43 | name: "Custom Button", 44 | from: new Date("2020 09 20"), 45 | to: new Date("2020 09 15"), 46 | }, 47 | ], 48 | }, 49 | }); 50 | 51 | expect(wrapper.find(buttonHelpersClass).element.children.length).toEqual(1); 52 | }); 53 | 54 | it("should emit event select-date when clicked", async () => { 55 | const name = "Custom Button"; 56 | const from = new Date("2020 08 01"); 57 | const to = new Date("2020 08 20"); 58 | 59 | wrapper = shallowMount(CalendarDialog, { 60 | props: { 61 | helperButtons: [ 62 | { 63 | name, 64 | from, 65 | to, 66 | }, 67 | ], 68 | }, 69 | }); 70 | 71 | const helperButtons = wrapper.find(buttonHelpersClass).findAll("button"); 72 | 73 | const customButton = helperButtons.find((e) => e.text() === name); 74 | 75 | expect(customButton).toBeDefined(); 76 | 77 | await customButton?.trigger("click"); 78 | 79 | const selectDateEvent = wrapper.emitted("select-date"); 80 | 81 | expect(selectDateEvent).toBeDefined(); 82 | expect(selectDateEvent).toHaveLength(1); 83 | expect(selectDateEvent?.[0]).toEqual([from, to]); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /test/unit/specs/components/DateInput/DateInput.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from "@vue/test-utils"; 2 | import DateInput from "@components/DateInput/DateInput.vue"; 3 | import "regenerator-runtime"; 4 | 5 | describe("Date Input", () => { 6 | let wrapper: ReturnType>; 7 | 8 | beforeEach(() => { 9 | wrapper = shallowMount(DateInput, { 10 | props: { 11 | placeholder: "Select A Date", 12 | id: "input_date", 13 | name: "input_date", 14 | inputClass: "input_class", 15 | required: true, 16 | }, 17 | }); 18 | }); 19 | 20 | it("should render correct contents", () => { 21 | expect(wrapper.findAll("input")).toHaveLength(1); 22 | 23 | const attrs = wrapper.find("input").attributes(); 24 | 25 | expect(attrs.placeholder).toBe("Select A Date"); 26 | expect(attrs.id).toBe("input_date"); 27 | expect(attrs.name).toBe("input_date"); 28 | expect(attrs.class).toContain("input_class"); 29 | expect(attrs.required).toBe(""); 30 | }); 31 | 32 | it("format dates", async () => { 33 | await wrapper.setProps({ 34 | format: "DD/MM/YYYY HH:mm", 35 | sameDateFormat: { 36 | from: "DD/MM/YYYY, HH:mm", 37 | to: "HH:mm", 38 | }, 39 | selectedStartDate: new Date("2020 08 01 00:00"), 40 | selectedEndDate: new Date("2020 08 15 23:59"), 41 | }); 42 | 43 | expect(wrapper.find("input").element.value).toEqual( 44 | "01/08/2020 00:00 - 15/08/2020 23:59" 45 | ); 46 | 47 | await wrapper.setProps({ 48 | selectedStartDate: new Date("2020 08 02 00:00"), 49 | selectedEndDate: new Date("2020 08 02 23:59"), 50 | }); 51 | 52 | expect(wrapper.find("input").element.value).toEqual( 53 | "02/08/2020, 00:00 - 23:59" 54 | ); 55 | }); 56 | 57 | it("emits on click", async () => { 58 | await wrapper.find("input").trigger("click"); 59 | 60 | expect(wrapper.emitted("click")).toBeTruthy(); 61 | }); 62 | 63 | it("change language", async () => { 64 | await wrapper.setProps({ 65 | format: "MMMM", 66 | language: "id", 67 | selectedStartDate: new Date("2020 07 01 00:00"), 68 | selectedEndDate: new Date("2020 08 15 23:59"), 69 | }); 70 | 71 | expect(wrapper.find("input").element.value).toContain("Juli"); 72 | expect(wrapper.find("input").element.value).toContain("Agustus"); 73 | 74 | await wrapper.setProps({ 75 | format: "MMMM", 76 | language: "ms-my", 77 | selectedStartDate: new Date("2020 07 01 00:00"), 78 | selectedEndDate: new Date("2020 08 15 23:59"), 79 | }); 80 | 81 | expect(wrapper.find("input").element.value).toContain("Julai"); 82 | expect(wrapper.find("input").element.value).toContain("Ogos"); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-time-date-range-picker", 3 | "version": "2.2.1", 4 | "description": "a vue time date range picker", 5 | "keywords": [ 6 | "vue", 7 | "vuejs", 8 | "range-datetime-picker", 9 | "date-time-range-picker" 10 | ], 11 | "author": "Nico Limbara ", 12 | "license": "MIT", 13 | "private": false, 14 | "bugs": { 15 | "url": "https://github.com/limbara/vue-time-date-range-picker/issues" 16 | }, 17 | "homepage": "https://github.com/limbara/vue-time-date-range-picker#readme", 18 | "type": "module", 19 | "files": [ 20 | "dist", 21 | "!**/*.tsbuildinfo", 22 | "LICENSE", 23 | "README.md" 24 | ], 25 | "main": "dist/vdprDatePicker.umd.cjs", 26 | "module": "dist/vdprDatePicker.js", 27 | "browser": "dist/vdprDatePicker.js", 28 | "unpkg": "dist/vdprDatePicker.iife.js", 29 | "types": "dist/src/index.d.ts", 30 | "style": "dist/style.css", 31 | "exports": { 32 | ".": { 33 | "import": "./dist/vdprDatePicker.js", 34 | "require": "./dist/vdprDatePicker.umd.cjs", 35 | "types": "./dist/src/index.d.ts" 36 | }, 37 | "./dist/style.css": { 38 | "import": "./dist/style.css", 39 | "require": "./dist/style.css", 40 | "default": "./dist/style.css" 41 | } 42 | }, 43 | "scripts": { 44 | "dev": "vite -c vite.config.dev.ts", 45 | "clean": "rimraf ./dist ./dist-zip", 46 | "prebuild": "npm run clean", 47 | "build": "vue-tsc -b && vite build", 48 | "lint": "eslint", 49 | "test": "jest", 50 | "prepare": "husky" 51 | }, 52 | "engines": { 53 | "node": ">=20.18.0" 54 | }, 55 | "devDependencies": { 56 | "@babel/core": "^7.25.8", 57 | "@babel/preset-env": "^7.25.8", 58 | "@babel/preset-typescript": "^7.25.7", 59 | "@eslint/js": "^9.12.0", 60 | "@testing-library/jest-dom": "^6.5.0", 61 | "@types/eslint__js": "^8.42.3", 62 | "@types/jest": "^29.5.13", 63 | "@vitejs/plugin-vue": "^5.1.4", 64 | "@vue/test-utils": "^2.4.6", 65 | "@vue/vue3-jest": "^29.2.6", 66 | "babel-jest": "^29.7.0", 67 | "eslint": "^9.12.0", 68 | "eslint-plugin-jest": "^28.8.3", 69 | "eslint-plugin-vue": "^9.29.0", 70 | "globals": "^15.11.0", 71 | "husky": "^9.1.6", 72 | "jest": "^29.7.0", 73 | "jest-environment-jsdom": "^29.7.0", 74 | "moment": "2.27.0", 75 | "postcss-preset-env": "^10.0.7", 76 | "rimraf": "^6.0.1", 77 | "sass-embedded": "^1.79.5", 78 | "ts-node": "^10.9.2", 79 | "typescript": "^5.5.3", 80 | "typescript-eslint": "^8.8.1", 81 | "vite": "^5.4.8", 82 | "vite-plugin-dts": "^4.3.0", 83 | "vite-plugin-zip-pack": "^1.2.4", 84 | "vue": "3.2.25", 85 | "vue-tsc": "^2.1.6" 86 | }, 87 | "peerDependencies": { 88 | "moment": "^2.27.0", 89 | "vue": "^3.2.25" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/components/Calendar/Calendar.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 51 | 52 | 100 | -------------------------------------------------------------------------------- /src/styles/Calendar.scss: -------------------------------------------------------------------------------- 1 | @use "sass:color"; 2 | 3 | $bg-calendar-highlighted: #3a86ff; 4 | $bg-calendar-not-selected: #eee; 5 | $bg-calendar-disabled: #a6a6a6; 6 | $color-calendar-highligted: white; 7 | $color-calendar-not-selected: #333; 8 | $color-calendar-disabled: #ccc; 9 | $color-calendar-faded: #ccc; 10 | $color-calendar-header: #333; 11 | 12 | .vdpr-datepicker__calendar { 13 | box-sizing: border-box; 14 | background-color: white; 15 | 16 | &-month-year { 17 | font-size: 24px; 18 | text-transform: uppercase; 19 | text-align: center; 20 | flex-basis: 70%; 21 | } 22 | 23 | &-control { 24 | @include flex-sb-c; 25 | padding: 10px; 26 | 27 | &-prev, 28 | &-next { 29 | cursor: pointer; 30 | position: relative; 31 | border-radius: 50%; 32 | border: 1px solid #333; 33 | color: #333; 34 | padding: 12px; 35 | 36 | &:before { 37 | position: absolute; 38 | top: 50%; 39 | left: 50%; 40 | transform: translate(-50%, -50%); 41 | } 42 | } 43 | 44 | &-prev:before { 45 | content: "\003c"; 46 | } 47 | 48 | &-next:before { 49 | content: "\003e"; 50 | } 51 | 52 | &-disabled { 53 | cursor: not-allowed; 54 | background-color: #ccc; 55 | } 56 | } 57 | 58 | &-table { 59 | width: 100%; 60 | table-layout: fixed; 61 | border-collapse: separate; 62 | border-spacing: 1px; 63 | 64 | >* { 65 | margin: 0; 66 | padding: 0; 67 | } 68 | 69 | thead { 70 | text-transform: uppercase; 71 | text-align: center; 72 | font-size: 12px; 73 | } 74 | 75 | th { 76 | white-space: nowrap; 77 | overflow: hidden; 78 | padding: 4px; 79 | line-height: 28px; 80 | color: $color-calendar-header; 81 | } 82 | 83 | td { 84 | white-space: nowrap; 85 | overflow: hidden; 86 | line-height: 35px; 87 | text-align: center; 88 | background-color: $bg-calendar-not-selected; 89 | color: $color-calendar-not-selected; 90 | font-size: 14px; 91 | 92 | &:hover { 93 | cursor: pointer; 94 | background-color: color.adjust($bg-calendar-not-selected, $lightness: -5%, $space: hsl); 95 | } 96 | } 97 | 98 | .faded { 99 | color: $color-calendar-faded; 100 | } 101 | 102 | .highlighted { 103 | background-color: $bg-calendar-highlighted; 104 | color: $color-calendar-highligted; 105 | 106 | &:hover { 107 | background-color: color.adjust($bg-calendar-highlighted, $lightness: -5%, $space: hsl); 108 | } 109 | } 110 | 111 | .disabled { 112 | cursor: not-allowed; 113 | background-color: $bg-calendar-disabled; 114 | color: $color-calendar-disabled; 115 | 116 | &:hover { 117 | cursor: not-allowed; 118 | background-color: $bg-calendar-disabled; 119 | } 120 | } 121 | } 122 | } -------------------------------------------------------------------------------- /serve/ExampleEvents.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 96 | 97 | 106 | -------------------------------------------------------------------------------- /test/unit/specs/components/CalendarDialog/resetButtonImplementation.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount, shallowMount } from "@vue/test-utils"; 2 | import CalendarDialog from "@components/CalendarDialog/CalendarDialog.vue"; 3 | import CalendarInputDate from "@components/CalendarInputDate/CalendarInputDate.vue"; 4 | import CalendarInputTime from "@components/CalendarInputTime/CalendarInputTime.vue"; 5 | import Calendar from "@components/Calendar/Calendar.vue"; 6 | import "regenerator-runtime"; 7 | 8 | describe("Calendar Dialog : Reset Button Implementation", () => { 9 | const datePickerButtonReset = ".vdpr-datepicker__button-reset"; 10 | const startDate = new Date("2021 06 01"); 11 | const endDate = new Date("2021 06 30"); 12 | 13 | type MountCalendarDialogFN = typeof shallowMount; 14 | 15 | const mountCalendarDialog = ( 16 | options?: Parameters[1] 17 | ) => { 18 | const wrapper = mount(CalendarDialog, { 19 | ...options, 20 | props: { 21 | initialDates: [startDate, endDate], 22 | switchButtonInitial: false, 23 | ...options?.props, 24 | }, 25 | }); 26 | 27 | const resetButton = wrapper.find(datePickerButtonReset); 28 | 29 | return { 30 | wrapper, 31 | resetButton, 32 | }; 33 | }; 34 | 35 | it("reset calendar dialog data", async () => { 36 | const { wrapper, resetButton } = mountCalendarDialog(); 37 | 38 | await resetButton.trigger("click"); 39 | 40 | expect(wrapper.vm.selectedStartDate).toBeNull(); 41 | expect(wrapper.vm.selectedEndDate).toBeNull(); 42 | expect(wrapper.vm.isAllDayChecked).toBe(false); 43 | }); 44 | 45 | it("emit on-reset event", async () => { 46 | const { wrapper, resetButton } = mountCalendarDialog(); 47 | 48 | await resetButton.trigger("click"); 49 | 50 | expect(wrapper.emitted("on-reset")).toHaveLength(1); 51 | }); 52 | 53 | it("reset input date value", async () => { 54 | const { wrapper, resetButton } = mountCalendarDialog(); 55 | 56 | const inputDates = wrapper.findAllComponents(CalendarInputDate); 57 | const inputStartDate = inputDates.at(0); 58 | const inputEndDate = inputDates.at(1); 59 | 60 | await resetButton.trigger("click"); 61 | 62 | expect(inputStartDate?.find("input").element.value).toBe(""); 63 | expect(inputEndDate?.find("input").element.value).toBe(""); 64 | }); 65 | 66 | it("reset input time value", async () => { 67 | const { wrapper, resetButton } = mountCalendarDialog(); 68 | 69 | const inputTimes = wrapper.findAllComponents(CalendarInputTime); 70 | const inputStartTime = inputTimes.at(0); 71 | const inputEndTime = inputTimes.at(1); 72 | 73 | await resetButton.trigger("click"); 74 | 75 | expect(inputStartTime?.find("input").element.value).toBe(""); 76 | expect(inputEndTime?.find("input").element.value).toBe(""); 77 | }); 78 | 79 | it("reset calendar highlighted day", async () => { 80 | const { wrapper, resetButton } = mountCalendarDialog(); 81 | 82 | const calendar = wrapper.findComponent(Calendar); 83 | 84 | await resetButton.trigger("click"); 85 | 86 | expect(calendar.findAll(".highlighted").length).toBe(0); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /src/components/DatePicker/DatePicker.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 49 | 50 | 129 | 130 | 133 | -------------------------------------------------------------------------------- /src/components/CalendarDialog/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { CalendarInputDateProps } from "@components/CalendarInputDate/types"; 3 | import { CalendarInputTimeProps } from "@components/CalendarInputTime/types"; 4 | import { FromToRange } from "@components/commonTypes"; 5 | import { calendarProps } from "@components/Calendar/types"; 6 | import { ExtractPropTypes, PropType } from "vue"; 7 | import { 8 | isValidDateAvailabilityConfig, 9 | isValidHelperButtons, 10 | isValidInitialDate, 11 | } from "@utils/propsValidator"; 12 | import { InitialDate } from "@composables/useSelectedDates"; 13 | import { Nullable } from "@utils/helpers"; 14 | import { DatesAvailabilityConfig } from "@composables/useCalendarDateUtil"; 15 | 16 | export type HelperButtonShape = Readonly< 17 | { 18 | name: string; 19 | } & FromToRange 20 | >; 21 | 22 | export type CalendarDialogInputTimeProps = Partial< 23 | Pick 24 | >; 25 | 26 | export type CalendarDialogInputDateProps = Partial< 27 | Pick & { 28 | labelStarts: string; 29 | labelEnds: string; 30 | } 31 | >; 32 | 33 | export const calendarDialogProps = { 34 | language: calendarProps.language, 35 | disabledDates: { 36 | type: Object as PropType, 37 | validator: isValidDateAvailabilityConfig, 38 | default: () => ({} as DatesAvailabilityConfig), 39 | }, 40 | availableDates: { 41 | type: Object as PropType, 42 | validator: isValidDateAvailabilityConfig, 43 | default: () => ({} as DatesAvailabilityConfig), 44 | }, 45 | isMondayFirst: { 46 | type: Boolean as PropType, 47 | default: false, 48 | }, 49 | initialDates: { 50 | type: Array as unknown as PropType, 51 | validator: isValidInitialDate, 52 | default: () => [] as unknown as InitialDate, 53 | }, 54 | inline: { 55 | type: Boolean as PropType, 56 | default: false, 57 | }, 58 | showHelperButtons: { 59 | type: Boolean as PropType, 60 | default: true, 61 | }, 62 | helperButtons: { 63 | type: Array as unknown as PropType>, 64 | validator: isValidHelperButtons, 65 | default: () => [] as unknown as Array, 66 | }, 67 | timeInput: { 68 | type: Object as PropType, 69 | default: () => 70 | ({ 71 | inputClass: null, 72 | readonly: false, 73 | step: 60, 74 | } as unknown as CalendarDialogInputTimeProps), 75 | }, 76 | dateInput: { 77 | type: Object as PropType, 78 | default: () => 79 | ({ 80 | inputClass: null, 81 | labelStarts: "Starts", 82 | labelEnds: "Ends", 83 | format: "DD/MM/YYYY", 84 | } as unknown as CalendarDialogInputDateProps), 85 | }, 86 | switchButtonLabel: { 87 | type: String as PropType, 88 | default: "All Days", 89 | }, 90 | switchButtonInitial: { 91 | type: Boolean as PropType, 92 | default: false, 93 | }, 94 | applyButtonLabel: { 95 | type: String as PropType, 96 | default: "Apply", 97 | }, 98 | resetButtonLabel: { 99 | type: String as PropType, 100 | default: "Reset", 101 | }, 102 | }; 103 | 104 | export type CalendarDialogProps = ExtractPropTypes; 105 | 106 | export const calendarDialogEmits = defineEmitOptions({ 107 | "on-apply": (_startDate: Date, _endDate: Date) => true, 108 | "on-reset": (_e: Event) => true, 109 | "select-date": (_startDate: Nullable, _endDate: Nullable) => true, 110 | "select-disabled-date": (_date: Date) => true, 111 | "on-prev-calendar": (_e: Event) => true, 112 | "on-next-calendar": (_e: Event) => true, 113 | }); 114 | 115 | export type CalendarDialogEmits = typeof calendarDialogEmits; 116 | -------------------------------------------------------------------------------- /test/unit/specs/components/CalendarDialog/calendarInputTimeImplementation.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from "@vue/test-utils"; 2 | import CalendarDialog from "@components/CalendarDialog/CalendarDialog.vue"; 3 | import CalendarInputTime from "@components/CalendarInputTime/CalendarInputTime.vue"; 4 | 5 | describe("Calendar Dialog : Calendar Input Time Implementation", () => { 6 | type MountCalendarDialogFN = typeof shallowMount; 7 | 8 | const mountCalendarDialog = ( 9 | options: Parameters[1] 10 | ) => { 11 | const wrapper = shallowMount(CalendarDialog, options); 12 | const inputs = wrapper.findAllComponents(CalendarInputTime); 13 | 14 | const inputTimeStart = inputs.at(0)!; 15 | const inputTimeTo = inputs.at(1)!; 16 | 17 | return { 18 | wrapper, 19 | inputTimeStart, 20 | inputTimeTo, 21 | }; 22 | }; 23 | 24 | it("set date when input time start submitted", () => { 25 | const from = new Date("2024 07 01 01:00:00"); 26 | const to = new Date("2024 07 01 15:00:00"); 27 | 28 | const { wrapper, inputTimeStart } = mountCalendarDialog({ 29 | props: { 30 | initialDates: [from, to], 31 | }, 32 | }); 33 | 34 | inputTimeStart.vm.$emit("change", new Date("2024 07 01 02:00:00")); 35 | 36 | expect(wrapper.vm.selectedStartDate).toEqual( 37 | new Date("2024 07 01 02:00:00") 38 | ); 39 | expect(wrapper.vm.selectedEndDate).toEqual(to); 40 | 41 | inputTimeStart.vm.$emit("change", new Date("2024 07 01 24:00:00")); 42 | 43 | expect(wrapper.vm.selectedStartDate).toEqual(to); 44 | expect(wrapper.vm.selectedEndDate).toEqual(new Date("2024 07 02 00:00:00")); 45 | }); 46 | 47 | it("set date when input time end submitted", () => { 48 | const from = new Date("2024 07 01 01:00:00"); 49 | const to = new Date("2024 07 01 15:00:00"); 50 | 51 | const { wrapper, inputTimeTo } = mountCalendarDialog({ 52 | props: { 53 | initialDates: [from, to], 54 | }, 55 | }); 56 | 57 | inputTimeTo.vm.$emit("change", new Date("2024 07 01 20:00:00")); 58 | 59 | expect(wrapper.vm.selectedStartDate).toEqual(from); 60 | expect(wrapper.vm.selectedEndDate).toEqual(new Date("2024 07 01 20:00:00")); 61 | 62 | inputTimeTo.vm.$emit("change", new Date("2024 07 01 24:00:00")); 63 | 64 | expect(wrapper.vm.selectedStartDate).toEqual(from); 65 | expect(wrapper.vm.selectedEndDate).toEqual(new Date("2024 07 02 00:00:00")); 66 | }); 67 | 68 | it("set date when input time start change", () => { 69 | const from = new Date("2024 07 01 01:00:00"); 70 | const to = new Date("2024 07 01 15:00:00"); 71 | 72 | const { wrapper, inputTimeStart } = mountCalendarDialog({ 73 | props: { 74 | initialDates: [from, to], 75 | }, 76 | }); 77 | 78 | inputTimeStart.vm.$emit("change", new Date("2024 07 01 00:00:00")); 79 | 80 | expect(wrapper.vm.selectedStartDate).toEqual( 81 | new Date("2024 07 01 00:00:00") 82 | ); 83 | expect(wrapper.vm.selectedEndDate).toEqual(to); 84 | 85 | inputTimeStart.vm.$emit("change", new Date("2024 07 01 18:00:00")); 86 | 87 | expect(wrapper.vm.selectedStartDate).toEqual(to); 88 | expect(wrapper.vm.selectedEndDate).toEqual(new Date("2024 07 01 18:00:00")); 89 | }); 90 | 91 | it("set date when input time end change", () => { 92 | const from = new Date("2024 07 01 01:00:00"); 93 | const to = new Date("2024 07 01 15:00:00"); 94 | 95 | const { wrapper, inputTimeTo } = mountCalendarDialog({ 96 | props: { 97 | initialDates: [from, to], 98 | }, 99 | }); 100 | 101 | inputTimeTo.vm.$emit("change", new Date("2024 07 01 16:00:00")); 102 | 103 | expect(wrapper.vm.selectedStartDate).toEqual(from); 104 | expect(wrapper.vm.selectedEndDate).toEqual(new Date("2024 07 01 16:00:00")); 105 | 106 | inputTimeTo.vm.$emit("change", new Date("2024 07 01 00:00:00")); 107 | expect(wrapper.vm.selectedStartDate).toEqual( 108 | new Date("2024 07 01 00:00:00") 109 | ); 110 | expect(wrapper.vm.selectedEndDate).toEqual(from); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /src/utils/DateUtil.ts: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | import { isObjectDate } from "./helpers"; 3 | 4 | export default class { 5 | private lang: string; 6 | private localMoment: moment.Moment; 7 | 8 | constructor(lang: string = "") { 9 | this.lang = lang; 10 | this.localMoment = moment().locale(lang); 11 | } 12 | 13 | createDate(...param: Parameters) { 14 | return moment(...param) 15 | .locale(this.lang) 16 | .toDate(); 17 | } 18 | 19 | now(): Date { 20 | return moment().locale(this.lang).toDate(); 21 | } 22 | 23 | getDayNames() { 24 | return this.localMoment.localeData().weekdays(); 25 | } 26 | 27 | /** 28 | * Get Abbreviated Day Names 29 | */ 30 | getAbbrDayNames() { 31 | return this.localMoment.localeData().weekdaysShort(); 32 | } 33 | 34 | getMonthNames() { 35 | return this.localMoment.localeData().months(); 36 | } 37 | 38 | /** 39 | * Get Abbreviated Month Names 40 | */ 41 | getAbbrMonthNames() { 42 | return this.localMoment.localeData().monthsShort(); 43 | } 44 | 45 | formatDate(date: Date, format: string) { 46 | return moment(date).locale(this.lang).format(format); 47 | } 48 | 49 | /** 50 | * Check if date is the same on DD MM YYYY level 51 | * @param date1 52 | * @param date2 53 | * @returns 54 | */ 55 | isSameDate(date1: Date, date2: Date) { 56 | return ( 57 | moment(date1).format("DD MM YYYY") === moment(date2).format("DD MM YYYY") 58 | ); 59 | } 60 | 61 | isAllDay(fromDate: Date, toDate: Date) { 62 | const startFromDate = moment(fromDate).startOf("day"); 63 | const endToDate = moment(toDate).endOf("day"); 64 | 65 | return ( 66 | moment(fromDate).format("DD MM YYYY HH:mm:ss") === 67 | startFromDate.format("DD MM YYYY HH:mm:ss") && 68 | moment(toDate).format("DD MM YYYY HH:mm:ss") === 69 | endToDate.format("DD MM YYYY HH:mm:ss") 70 | ); 71 | } 72 | 73 | isValidDate(date: any): date is Date { 74 | return isObjectDate(date) && moment(date).isValid(); 75 | } 76 | 77 | toUnix(date: Date) { 78 | return moment(date).unix(); 79 | } 80 | 81 | fromUnix(unixTimestamp: number) { 82 | return moment.unix(unixTimestamp).toDate(); 83 | } 84 | 85 | startOf(date: Date, of: moment.unitOfTime.StartOf) { 86 | return moment(date).locale(this.lang).startOf(of).toDate(); 87 | } 88 | 89 | endOf(date: Date, of: moment.unitOfTime.StartOf) { 90 | return moment(date).locale(this.lang).endOf(of).toDate(); 91 | } 92 | 93 | /** 94 | * Check if date is the same as comparing date 95 | * @param date 96 | * @param comparingDate 97 | * @returns 98 | */ 99 | isSame(date: Date, comparingDate: Date) { 100 | return moment(date).isSame(comparingDate) 101 | } 102 | 103 | /** 104 | * Check if date is before a comparingDate 105 | */ 106 | isBefore(date: Date, comparingDate: Date) { 107 | return moment(date).isBefore(comparingDate); 108 | } 109 | 110 | /** 111 | * Check if date is same or before a comparingDate 112 | */ 113 | isSameOrBefore(date: Date, comparingDate: Date) { 114 | return moment(date).isSameOrBefore(comparingDate); 115 | } 116 | 117 | /** 118 | * Check if date is after a comparingDate 119 | */ 120 | isAfter(date: Date, comparingDate: Date) { 121 | return moment(date).isAfter(comparingDate); 122 | } 123 | 124 | /** 125 | * Check if date is same or after a comparingDate 126 | */ 127 | isSameOrAfter(date: Date, comparingDate: Date) { 128 | return moment(date).isSameOrAfter(comparingDate); 129 | } 130 | 131 | /** 132 | * Check if a date is between fromDate and toDate 133 | */ 134 | isBetween(date: Date, fromDate: Date, toDate: Date) { 135 | return moment(date).isBetween(fromDate, toDate); 136 | } 137 | 138 | /** 139 | * Check if a date is same or between as fromDate and toDate 140 | */ 141 | isSameOrBetween(date: Date, fromDate: Date, toDate: Date) { 142 | const theDate = moment(date); 143 | 144 | return theDate.isSameOrAfter(fromDate) && theDate.isSameOrBefore(toDate); 145 | } 146 | 147 | /** 148 | * Add number of timeKey to date 149 | */ 150 | add( 151 | date: Date, 152 | number: moment.DurationInputArg1, 153 | timeKey: moment.DurationInputArg2 154 | ) { 155 | return moment(date).locale(this.lang).add(number, timeKey).toDate(); 156 | } 157 | 158 | /** 159 | * Subtract number of timeKey to date 160 | */ 161 | subtract( 162 | date: Date, 163 | number: moment.DurationInputArg1, 164 | timeKey: moment.DurationInputArg2 165 | ) { 166 | return moment(date).locale(this.lang).subtract(number, timeKey).toDate(); 167 | } 168 | 169 | /** 170 | * Get Number of Day in A month from A Date 171 | */ 172 | daysInMonth(date: Date) { 173 | return moment(date).daysInMonth(); 174 | } 175 | 176 | /** 177 | * Get Day 0 - 6 from A Date 178 | */ 179 | day(date: Date) { 180 | return moment(date).day(); 181 | } 182 | 183 | /** 184 | * Get Month 0 - 11 from A Date 185 | */ 186 | month(date: Date) { 187 | return moment(date).month(); 188 | } 189 | 190 | /** 191 | * Get Year from A Date 192 | */ 193 | year(date: Date) { 194 | return moment(date).year(); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /test/unit/specs/components/CalendarDialog/calendarImplementation.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from "@vue/test-utils"; 2 | import moment from "moment"; 3 | import CalendarDialog from "@components/CalendarDialog/CalendarDialog.vue"; 4 | import Calendar from "@components/Calendar/Calendar.vue"; 5 | import { InitialDate } from "@composables/useSelectedDates"; 6 | 7 | describe("Calendar Dialog : Calendar Implementation", () => { 8 | describe("emit event select-date correctly", () => { 9 | it("when no initial dates, select start & end date with the same date", () => { 10 | const date = new Date("2024 07 23"); 11 | 12 | const wrapper = shallowMount(CalendarDialog); 13 | const calendar = wrapper.findComponent(Calendar); 14 | 15 | calendar.vm.$emit("select-date", date); 16 | 17 | const selectDateEvent = wrapper.emitted("select-date"); 18 | 19 | expect(selectDateEvent).toHaveLength(1); 20 | expect(selectDateEvent?.[0]).toEqual([date, date]); 21 | }); 22 | 23 | it("when only initial end date supplied", () => { 24 | const from = new Date("2024 07 23"); 25 | const to = new Date("2024 07 26"); 26 | const initialDates: InitialDate = [null, to]; 27 | 28 | const wrapper = shallowMount(CalendarDialog, { 29 | props: { 30 | initialDates, 31 | }, 32 | }); 33 | const calendar = wrapper.findComponent(Calendar); 34 | 35 | calendar.vm.$emit("select-date", from); 36 | 37 | const selectDateEvent = wrapper.emitted("select-date"); 38 | 39 | expect(selectDateEvent).toHaveLength(1); 40 | expect(selectDateEvent?.[0]).toEqual([from, to]); 41 | }); 42 | 43 | it("when only initial start date supplied", () => { 44 | const from = new Date("2024 07 23"); 45 | const to = new Date("2024 07 26"); 46 | const initialDates: InitialDate = [from, null]; 47 | 48 | const wrapper = shallowMount(CalendarDialog, { 49 | props: { 50 | initialDates, 51 | }, 52 | }); 53 | const calendar = wrapper.findComponent(Calendar); 54 | 55 | calendar.vm.$emit("select-date", to); 56 | 57 | const selectDateEvent = wrapper.emitted("select-date"); 58 | 59 | expect(selectDateEvent).toHaveLength(1); 60 | expect(selectDateEvent?.[0]).toEqual([from, to]); 61 | }); 62 | 63 | it("when from date is less than to date", () => { 64 | const from = new Date("2024 07 23"); 65 | const to = new Date("2024 07 26"); 66 | 67 | const wrapper = shallowMount(CalendarDialog); 68 | const calendar = wrapper.findComponent(Calendar); 69 | 70 | calendar.vm.$emit("select-date", from); 71 | 72 | const selectDateEvent = wrapper.emitted("select-date"); 73 | 74 | expect(selectDateEvent).toHaveLength(1); 75 | expect(selectDateEvent?.[0]).toEqual([from, from]); 76 | 77 | calendar.vm.$emit("select-date", to); 78 | 79 | expect(selectDateEvent).toHaveLength(2); 80 | expect(selectDateEvent?.[1]).toEqual([from, to]); 81 | }); 82 | 83 | it("when to date less than from date", () => { 84 | const from = new Date("2024 07 26"); 85 | const to = new Date("2024 07 23"); 86 | 87 | const wrapper = shallowMount(CalendarDialog); 88 | const calendar = wrapper.findComponent(Calendar); 89 | 90 | calendar.vm.$emit("select-date", from); 91 | 92 | const selectDateEvent = wrapper.emitted("select-date"); 93 | 94 | expect(selectDateEvent).toHaveLength(1); 95 | expect(selectDateEvent?.[0]).toEqual([from, from]); 96 | 97 | calendar.vm.$emit("select-date", to); 98 | 99 | expect(selectDateEvent).toHaveLength(2); 100 | expect(selectDateEvent?.[1]).toEqual([to, from]); 101 | }); 102 | 103 | it("when all days is checked", () => { 104 | const date = new Date("2024 07 26"); 105 | 106 | const wrapper = shallowMount(CalendarDialog, { 107 | attachTo: document.body, 108 | props: { 109 | switchButtonInitial: true, 110 | }, 111 | }); 112 | 113 | const calendar = wrapper.findComponent(Calendar); 114 | 115 | calendar.vm.$emit("select-date", date); 116 | 117 | const selectDateEvent = wrapper.emitted("select-date"); 118 | 119 | expect(selectDateEvent).toHaveLength(1); 120 | expect(selectDateEvent?.[0]).toEqual([ 121 | moment(date).startOf("day").toDate(), 122 | moment(date).endOf("day").toDate(), 123 | ]); 124 | }); 125 | }); 126 | 127 | it("emit select-disabled-date event", () => { 128 | const date = new Date("2024 08 01"); 129 | 130 | const wrapper = shallowMount(CalendarDialog); 131 | const calendar = wrapper.findComponent(Calendar); 132 | 133 | calendar.vm.$emit("select-disabled-date", date); 134 | 135 | const selectDisabledDateEvent = wrapper.emitted("select-disabled-date"); 136 | 137 | expect(selectDisabledDateEvent).toHaveLength(1); 138 | expect(selectDisabledDateEvent?.[0]).toEqual([date]); 139 | }); 140 | 141 | it("emit on-prev-calendar event", () => { 142 | const wrapper = shallowMount(CalendarDialog); 143 | const calendar = wrapper.findComponent(Calendar); 144 | 145 | calendar.vm.$emit("on-prev-calendar"); 146 | 147 | expect(wrapper.emitted("on-prev-calendar")).toHaveLength(1); 148 | }); 149 | 150 | it("emit on-next-calendar event", () => { 151 | const wrapper = shallowMount(CalendarDialog); 152 | const calendar = wrapper.findComponent(Calendar); 153 | 154 | calendar.vm.$emit("on-next-calendar"); 155 | 156 | expect(wrapper.emitted("on-next-calendar")).toHaveLength(1); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /test/unit/specs/components/CalendarDialog/CalendarDialog.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount, mount } from "@vue/test-utils"; 2 | import CalendarDialog from "@components/CalendarDialog/CalendarDialog.vue"; 3 | import Calendar from "@components/Calendar/Calendar.vue"; 4 | import SwitchButton from "@components/SwitchButton/SwitchButton.vue"; 5 | import CalendarInputDate from "@components/CalendarInputDate/CalendarInputDate.vue"; 6 | import CalendarInputTime from "@components/CalendarInputTime/CalendarInputTime.vue"; 7 | import "regenerator-runtime"; 8 | import "@testing-library/jest-dom"; 9 | 10 | describe("Calendar Dialog", () => { 11 | const datePickerDialogClass = ".vdpr-datepicker__calendar-dialog"; 12 | const datePickerActionsClass = ".vdpr-datepicker__calendar-actions"; 13 | const datePickerHelperButtons = ".vdpr-datepicker__calendar-button-helper"; 14 | const datePickerButtonSubmit = ".vdpr-datepicker__button-submit"; 15 | const datePickerButtonReset = ".vdpr-datepicker__button-reset"; 16 | 17 | it("should have correct default data", () => { 18 | const wrapper = shallowMount(CalendarDialog); 19 | 20 | expect(wrapper.vm.selectedStartDate).toEqual(null); 21 | expect(wrapper.vm.selectedEndDate).toEqual(null); 22 | expect(wrapper.vm.isAllDayChecked).toEqual(false); 23 | }); 24 | 25 | it("should set correct data if initialized dates", () => { 26 | const from = new Date("2020 08 01 15:00:00"); 27 | const to = new Date("2020 08 02 00:00:00"); 28 | 29 | const wrapper = shallowMount(CalendarDialog, { 30 | props: { 31 | initialDates: [from, to], 32 | }, 33 | }); 34 | 35 | expect(wrapper.vm.selectedStartDate).toEqual(from); 36 | expect(wrapper.vm.selectedEndDate).toEqual(to); 37 | expect(wrapper.vm.isAllDayChecked).toEqual(false); 38 | }); 39 | 40 | it("should set correct data if initialDates is all days", () => { 41 | const from = new Date("2020 08 01 00:00:00"); 42 | const to = new Date("2020 08 02 23:59:59"); 43 | 44 | const wrapper = shallowMount(CalendarDialog, { 45 | props: { 46 | initialDates: [from, to], 47 | }, 48 | }); 49 | 50 | expect(wrapper.vm.selectedStartDate).toEqual(from); 51 | expect(wrapper.vm.selectedEndDate).toEqual(to); 52 | expect(wrapper.vm.isAllDayChecked).toEqual(true); 53 | }); 54 | 55 | it("should render correct contents", () => { 56 | const wrapper = mount(CalendarDialog, { 57 | props: { 58 | initialDates: [ 59 | new Date("2020 08 01 00:00:00"), 60 | new Date("2020 08 02 23:59:59"), 61 | ], 62 | dateInput: { 63 | format: "DD/MM/YYYY", 64 | }, 65 | }, 66 | }); 67 | 68 | expect(wrapper.find(datePickerDialogClass).exists()).toBe(true); 69 | expect(wrapper.find(datePickerHelperButtons).exists()).toBe(true); 70 | expect(wrapper.find(datePickerActionsClass).exists()).toBe(true); 71 | expect(wrapper.find(datePickerButtonSubmit).exists()).toBe(true); 72 | expect(wrapper.find(datePickerButtonReset).exists()).toBe(true); 73 | 74 | const comCalendar = wrapper.findComponent(Calendar); 75 | const inputDates = wrapper.findAllComponents(CalendarInputDate); 76 | const comSwitchButton = wrapper.findComponent(SwitchButton); 77 | const inputTimes = wrapper.findAllComponents(CalendarInputTime); 78 | 79 | expect(comCalendar.exists()).toBe(true); 80 | expect(comSwitchButton.exists()).toBe(true); 81 | expect(inputDates).toHaveLength(2); 82 | expect(inputTimes).toHaveLength(2); 83 | 84 | const inputDateStart = inputDates?.at(0)?.find("input"); 85 | const inputDateEnd = inputDates?.at(1)?.find("input"); 86 | const inputTimeStart = inputTimes?.at(0)?.find("input"); 87 | const inputTimeEnd = inputTimes?.at(1)?.find("input"); 88 | 89 | expect(inputDateStart?.element.value).toEqual("01/08/2020"); 90 | expect(inputDateEnd?.element.value).toEqual("02/08/2020"); 91 | expect(inputTimeStart?.element.value).toEqual("00:00"); 92 | expect(inputTimeEnd?.element.value).toEqual("23:59"); 93 | }); 94 | 95 | it("should change switch button checked with switchButtonInitial", async () => { 96 | const wrapper = mount(CalendarDialog, { 97 | attachTo: document.body, 98 | props: { 99 | switchButtonInitial: true, 100 | }, 101 | }); 102 | 103 | const comSwitchButton = wrapper.findComponent(SwitchButton); 104 | const inputCheckbox = comSwitchButton.find("input"); 105 | 106 | expect(inputCheckbox.element).toBeChecked(); 107 | 108 | await inputCheckbox.trigger('click') 109 | 110 | expect(inputCheckbox.element).not.toBeChecked(); 111 | }); 112 | 113 | it("should change switch button label", () => { 114 | const wrapper = shallowMount(CalendarDialog, { 115 | props: { 116 | switchButtonLabel: "Seharian", 117 | }, 118 | }); 119 | 120 | expect(wrapper.find(datePickerActionsClass).html()).toContain("Seharian"); 121 | }); 122 | 123 | it("should change apply button label", () => { 124 | const wrapper = shallowMount(CalendarDialog, { 125 | props: { 126 | applyButtonLabel: "Use", 127 | }, 128 | }); 129 | 130 | expect(wrapper.find(datePickerButtonSubmit).html()).toContain("Use"); 131 | }); 132 | 133 | it("should change reset button label", () => { 134 | const wrapper = shallowMount(CalendarDialog, { 135 | props: { 136 | resetButtonLabel: "Restart", 137 | }, 138 | }); 139 | 140 | expect(wrapper.find(datePickerButtonReset).html()).toContain("Restart"); 141 | }); 142 | 143 | it("emit on-apply when button apply clicked", async () => { 144 | const from = new Date("2024 07 26"); 145 | const to = new Date("2024 07 26"); 146 | 147 | const wrapper = shallowMount(CalendarDialog, { 148 | props: { 149 | initialDates: [from, to], 150 | }, 151 | }); 152 | const button = wrapper.find(datePickerButtonSubmit); 153 | 154 | await button.trigger("click"); 155 | 156 | const onApplyEvent = wrapper.emitted("on-apply"); 157 | 158 | expect(onApplyEvent).toHaveLength(1); 159 | expect(onApplyEvent?.[0]).toEqual([from, to]); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /test/unit/specs/components/DatePicker/DatePicker.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from "@vue/test-utils"; 2 | import DatePicker from "@components/DatePicker/DatePicker.vue"; 3 | import CalendarDialog from "@components/CalendarDialog/CalendarDialog.vue"; 4 | import DateInput from "@components/DateInput/DateInput.vue"; 5 | import "regenerator-runtime"; 6 | import "@testing-library/jest-dom"; 7 | 8 | describe("Date Picker", () => { 9 | const datePickerClass = ".vdpr-datepicker"; 10 | 11 | type MountDatePickerFN = typeof shallowMount; 12 | 13 | const mountDatePicker = (options?: Parameters[1]) => { 14 | const wrapper = shallowMount(DatePicker, options); 15 | 16 | const calendarDialog = wrapper.findComponent(CalendarDialog); 17 | const dateInput = wrapper.findComponent(DateInput); 18 | 19 | return { 20 | wrapper, 21 | calendarDialog, 22 | dateInput, 23 | }; 24 | }; 25 | 26 | it("should render correct contents", () => { 27 | const { wrapper, calendarDialog, dateInput } = mountDatePicker(); 28 | 29 | expect(wrapper.find(datePickerClass).exists()).toBe(true); 30 | expect(calendarDialog.exists()).toBe(true); 31 | expect(dateInput.exists()).toBe(true); 32 | }); 33 | 34 | it("toggle calendar dialog", async () => { 35 | const { wrapper, calendarDialog, dateInput } = mountDatePicker(); 36 | 37 | await dateInput.trigger("click"); 38 | 39 | expect(calendarDialog.isVisible()).toBe(true); 40 | 41 | await dateInput.trigger("click"); 42 | 43 | await wrapper.vm.$nextTick(); 44 | 45 | expect(calendarDialog.isVisible()).toBe(false); 46 | }); 47 | 48 | it("should close calendar dialog when applied", async () => { 49 | const fromDate = new Date("2024 08 01"); 50 | const endDate = new Date("2024 08 02"); 51 | 52 | const { wrapper, calendarDialog, dateInput } = mountDatePicker(); 53 | 54 | await dateInput.trigger("click"); 55 | 56 | expect(calendarDialog.isVisible()).toBe(true); 57 | 58 | calendarDialog.vm.$emit("on-apply", fromDate, endDate); 59 | 60 | await wrapper.vm.$nextTick(); 61 | 62 | expect(calendarDialog.isVisible()).toBe(false); 63 | }); 64 | 65 | it("emit date-applied event", () => { 66 | const fromDate = new Date("2024 08 01"); 67 | const endDate = new Date("2024 08 02"); 68 | 69 | const { wrapper, calendarDialog } = mountDatePicker(); 70 | 71 | calendarDialog.vm.$emit("on-apply", fromDate, endDate); 72 | 73 | const dateAppliedEvent = wrapper.emitted("date-applied"); 74 | 75 | expect(dateAppliedEvent).toHaveLength(1); 76 | expect(dateAppliedEvent?.[0]).toEqual([fromDate, endDate]); 77 | }); 78 | 79 | it("emit event datepicker-opened & datepicker-closed", async () => { 80 | const { wrapper, dateInput } = mountDatePicker(); 81 | 82 | await dateInput.trigger("click"); 83 | expect(wrapper.emitted("datepicker-opened")).toBeTruthy(); 84 | 85 | await dateInput.trigger("click"); 86 | expect(wrapper.emitted("datepicker-closed")).toBeTruthy(); 87 | }); 88 | 89 | it("emit on-prev-calendar event", () => { 90 | const { wrapper, calendarDialog } = mountDatePicker(); 91 | 92 | calendarDialog.vm.$emit("on-prev-calendar"); 93 | expect(wrapper.emitted("on-prev-calendar")).toHaveLength(1); 94 | }); 95 | 96 | it("emit on-next-calendar event", () => { 97 | const { wrapper, calendarDialog } = mountDatePicker(); 98 | 99 | calendarDialog.vm.$emit("on-next-calendar"); 100 | expect(wrapper.emitted("on-next-calendar")).toHaveLength(1); 101 | }); 102 | 103 | it("emit select-date event", () => { 104 | const { wrapper, calendarDialog } = mountDatePicker(); 105 | const fromDate = new Date("2024 08 01"); 106 | const endDate = new Date("2024 08 03"); 107 | 108 | calendarDialog.vm.$emit("select-date", fromDate, endDate); 109 | 110 | const selectDateEvent = wrapper.emitted("select-date"); 111 | expect(selectDateEvent).toHaveLength(1); 112 | expect(selectDateEvent?.[0]).toEqual([fromDate, endDate]); 113 | }); 114 | 115 | it("emit select-disabled-date event", () => { 116 | const { wrapper, calendarDialog } = mountDatePicker(); 117 | 118 | const date = new Date("2024 09 01"); 119 | 120 | calendarDialog.vm.$emit("select-disabled-date", date); 121 | 122 | const selectDisabledDateEvent = wrapper.emitted("select-disabled-date"); 123 | expect(selectDisabledDateEvent).toHaveLength(1); 124 | expect(selectDisabledDateEvent?.[0]).toEqual([date]); 125 | }); 126 | 127 | it("emit on-reset", () => { 128 | const { wrapper, calendarDialog } = mountDatePicker(); 129 | 130 | calendarDialog.vm.$emit("on-reset"); 131 | expect(wrapper.emitted("on-reset")).toBeTruthy(); 132 | }); 133 | 134 | describe("update v-model:modelValue correctly", () => { 135 | it("emit event update:model-value, when select date is triggered", () => { 136 | const { wrapper, calendarDialog } = mountDatePicker(); 137 | 138 | const from = new Date("2024 11 01"); 139 | const to = new Date("2024 11 10"); 140 | 141 | calendarDialog.vm.$emit("select-date", from, to); 142 | 143 | const updateModelValueEvent = wrapper.emitted("update:model-value"); 144 | 145 | expect(updateModelValueEvent).toHaveLength(1); 146 | expect(updateModelValueEvent?.[0]).toEqual([[from, to]]); 147 | }); 148 | 149 | it("emit event update:model-value, when event date apply triggered", () => { 150 | const { wrapper, calendarDialog } = mountDatePicker(); 151 | 152 | const from = new Date("2024 11 01"); 153 | const to = new Date("2024 11 10"); 154 | 155 | calendarDialog.vm.$emit("on-apply", from, to); 156 | 157 | const updateModelValueEvent = wrapper.emitted("update:model-value"); 158 | 159 | expect(updateModelValueEvent).toHaveLength(1); 160 | expect(updateModelValueEvent?.[0]).toEqual([[from, to]]); 161 | }); 162 | 163 | it("emit event update:model-value, when event reset triggered", () => { 164 | const { wrapper, calendarDialog } = mountDatePicker(); 165 | 166 | calendarDialog.vm.$emit("on-reset"); 167 | 168 | const updateModelValueEvent = wrapper.emitted("update:model-value"); 169 | 170 | expect(updateModelValueEvent).toHaveLength(1); 171 | expect(updateModelValueEvent?.[0]).toEqual([null]); 172 | }); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /test/unit/specs/components/CalendarDialog/inlineImplementation.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from "@vue/test-utils"; 2 | import CalendarDialog from "@components/CalendarDialog/CalendarDialog.vue"; 3 | import "regenerator-runtime"; 4 | import "@testing-library/jest-dom"; 5 | import CalendarInputDate from "@components/CalendarInputDate/CalendarInputDate.vue"; 6 | import CalendarInputTime from "@components/CalendarInputTime/CalendarInputTime.vue"; 7 | import SwitchButton from "@components/SwitchButton/SwitchButton.vue"; 8 | import moment from "moment"; 9 | 10 | describe("Calendar Dialog : inline Implementation", () => { 11 | const datePickerButtonSubmit = ".vdpr-datepicker__button-submit"; 12 | const inlineClass = ".vdpr-datepicker__calendar-dialog--inline"; 13 | 14 | it("should render correct contents", () => { 15 | const wrapper = shallowMount(CalendarDialog, { 16 | props: { 17 | inline: true, 18 | }, 19 | }); 20 | 21 | expect(wrapper.find(inlineClass).exists()).toBe(true); 22 | expect(wrapper.find(datePickerButtonSubmit).element).not.toBeVisible(); 23 | }); 24 | 25 | describe("doesn't emit on-apply if", () => { 26 | it("only start input date time changes", () => { 27 | const from = new Date("2024 07 12"); 28 | 29 | const wrapper = shallowMount(CalendarDialog, { 30 | props: { 31 | inline: true, 32 | }, 33 | }); 34 | 35 | const inputDates = wrapper.findAllComponents(CalendarInputDate); 36 | const [startInput] = inputDates; 37 | const inputTimes = wrapper.findAllComponents(CalendarInputTime); 38 | const [startTimeInput] = inputTimes; 39 | 40 | const onApplyEvent = wrapper.emitted("on-apply"); 41 | 42 | startInput.vm.$emit("change", from); 43 | expect(onApplyEvent).toBeUndefined(); 44 | 45 | from.setHours(10, 0); 46 | startTimeInput.vm.$emit("change", from); 47 | expect(onApplyEvent).toBeUndefined(); 48 | }); 49 | 50 | it("only end input date time changes", async () => { 51 | const to = new Date("2024 07 12"); 52 | 53 | const wrapper = shallowMount(CalendarDialog, { 54 | props: { 55 | inline: true, 56 | }, 57 | }); 58 | 59 | const inputDates = wrapper.findAllComponents(CalendarInputDate); 60 | const [, endInput] = inputDates; 61 | const inputTimes = wrapper.findAllComponents(CalendarInputTime); 62 | const [, endTimeInput] = inputTimes; 63 | 64 | endInput.vm.$emit("change", to); 65 | expect(wrapper.emitted("on-apply")).toBeUndefined(); 66 | 67 | to.setHours(12, 0); 68 | endTimeInput.vm.$emit("change", to); 69 | expect(wrapper.emitted("on-apply")).toBeUndefined(); 70 | }); 71 | }); 72 | 73 | describe("emit on-apply if inline", () => { 74 | it("when start & end input date changes", async () => { 75 | const from = new Date("2024 07 12"); 76 | const to = new Date("2024 07 14"); 77 | 78 | const wrapper = shallowMount(CalendarDialog, { 79 | props: { 80 | inline: true, 81 | }, 82 | }); 83 | 84 | const inputDates = wrapper.findAllComponents(CalendarInputDate); 85 | const [startInput, endInput] = inputDates; 86 | 87 | startInput.vm.$emit("change", from); 88 | endInput.vm.$emit("change", to); 89 | 90 | const onApplyEvent = wrapper.emitted("on-apply"); 91 | 92 | expect(onApplyEvent).toHaveLength(1); 93 | expect(onApplyEvent?.[0]).toEqual([from, to]); 94 | }); 95 | 96 | it("when start & end input time changes", () => { 97 | const from = new Date("2024 07 12"); 98 | const to = new Date("2024 07 14"); 99 | 100 | const wrapper = shallowMount(CalendarDialog, { 101 | props: { 102 | inline: true, 103 | }, 104 | }); 105 | 106 | const inputDates = wrapper.findAllComponents(CalendarInputDate); 107 | const [startInput, endInput] = inputDates; 108 | const inputTimes = wrapper.findAllComponents(CalendarInputTime); 109 | const [startTimeInput, endTimeInput] = inputTimes; 110 | 111 | startInput.vm.$emit("change", from); 112 | endInput.vm.$emit("change", to); 113 | 114 | const onApplyEvent = wrapper.emitted("on-apply"); 115 | 116 | expect(onApplyEvent).toHaveLength(1); 117 | expect(onApplyEvent?.[0]).toEqual([from, to]); 118 | 119 | from.setHours(10, 0); 120 | startTimeInput.vm.$emit("change", from); 121 | expect(onApplyEvent).toHaveLength(2); 122 | expect(onApplyEvent?.[1]).toEqual([from, to]); 123 | 124 | to.setHours(12, 0); 125 | endTimeInput.vm.$emit("change", to); 126 | expect(onApplyEvent).toHaveLength(3); 127 | expect(onApplyEvent?.[2]).toEqual([from, to]); 128 | }); 129 | 130 | it("when all day check changes", () => { 131 | const from = new Date("2024 07 12"); 132 | const to = new Date("2024 07 14"); 133 | 134 | const wrapper = shallowMount(CalendarDialog, { 135 | attachTo: document.body, 136 | props: { 137 | inline: true, 138 | switchButtonInitial: false, 139 | }, 140 | }); 141 | 142 | const inputDates = wrapper.findAllComponents(CalendarInputDate); 143 | const [startInput, endInput] = inputDates; 144 | const switchButton = wrapper.findComponent(SwitchButton); 145 | 146 | startInput.vm.$emit("change", from); 147 | endInput.vm.$emit("change", to); 148 | 149 | const onApplyEvent = wrapper.emitted("on-apply"); 150 | 151 | expect(onApplyEvent).toHaveLength(1); 152 | 153 | switchButton.vm.$emit("change", { target: { checked: true } }); 154 | expect(onApplyEvent).toHaveLength(2); 155 | expect(onApplyEvent?.[1]).toEqual([ 156 | moment(from).startOf("day").toDate(), 157 | moment(to).endOf("day").toDate(), 158 | ]); 159 | 160 | switchButton.vm.$emit("change", { target: { checked: false } }); 161 | expect(onApplyEvent).toHaveLength(3); 162 | expect(onApplyEvent?.[2]).toEqual([ 163 | moment(from).startOf("day").toDate(), 164 | moment(to).startOf("day").toDate(), 165 | ]); 166 | }); 167 | 168 | it("when helper button clicked", async () => { 169 | const name = "Custom Button"; 170 | const from = new Date("2020 07 01 00:00:00"); 171 | const to = new Date("2020 07 31 23:59:59"); 172 | 173 | const wrapper = shallowMount(CalendarDialog, { 174 | props: { 175 | inline: true, 176 | helperButtons: [ 177 | { 178 | name, 179 | from, 180 | to, 181 | }, 182 | ], 183 | }, 184 | }); 185 | 186 | const helpersButton = wrapper 187 | .find(".vdpr-datepicker__calendar-button-helper") 188 | .findAll("button"); 189 | 190 | const customHelperButton = helpersButton.find((b) => b.text() === name); 191 | 192 | await customHelperButton?.trigger("click"); 193 | 194 | const onApplyEvent = wrapper.emitted("on-apply"); 195 | 196 | expect(onApplyEvent).toBeDefined(); 197 | expect(onApplyEvent).toHaveLength(1); 198 | expect(onApplyEvent?.[0]).toEqual([from, to]); 199 | }); 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /test/unit/specs/utils/PropsValidator.spec.ts: -------------------------------------------------------------------------------- 1 | import { SameDateFormatConfig } from "@components/DateInput/types"; 2 | import { DatesAvailabilityConfig } from "@composables/useCalendarDateUtil"; 3 | import { InitialDate } from "@composables/useSelectedDates"; 4 | import * as PropsValidator from "@utils/propsValidator"; 5 | 6 | describe("Props Validator", () => { 7 | describe("isValidInitialDates", () => { 8 | it("should return true if value is undefined or null", () => { 9 | expect(PropsValidator.isValidInitialDate(undefined)).toBe(true); 10 | 11 | expect(PropsValidator.isValidInitialDate(null)).toBe(true); 12 | }); 13 | 14 | it("should return true if value is empty array", () => { 15 | expect( 16 | PropsValidator.isValidInitialDate([] as unknown as InitialDate) 17 | ).toBe(true); 18 | }); 19 | 20 | it("should return true if value contain only startDate", () => { 21 | expect( 22 | PropsValidator.isValidInitialDate([new Date("2024-12-12"), null]) 23 | ).toBe(true); 24 | }); 25 | 26 | it("should return true if value contain only endDate", () => { 27 | expect( 28 | PropsValidator.isValidInitialDate([null, new Date("2024-12-12")]) 29 | ).toBe(true); 30 | }); 31 | 32 | it("should return false if value is not a Date", () => { 33 | const isValid = PropsValidator.isValidInitialDate([ 34 | "2024-12-12", 35 | "2024-12-12", 36 | ] as unknown as InitialDate); 37 | 38 | expect(isValid).toBe(false); 39 | }); 40 | 41 | it("should return false if from date greater than to date", () => { 42 | const isValid = PropsValidator.isValidInitialDate([ 43 | new Date("2024-10-02"), 44 | new Date("2024-10-01"), 45 | ]); 46 | 47 | expect(isValid).toBe(false); 48 | }); 49 | }); 50 | 51 | describe("isValidHelperButtons", () => { 52 | it("should return true if value is undefined or null", () => { 53 | expect(PropsValidator.isValidHelperButtons(undefined)).toBe(true); 54 | 55 | expect(PropsValidator.isValidHelperButtons(null)).toBe(true); 56 | }); 57 | 58 | it("should return true if array is empty", () => { 59 | expect(PropsValidator.isValidHelperButtons([])).toBe(true); 60 | }); 61 | 62 | it("should return false if button name empty", () => { 63 | const isValid = PropsValidator.isValidHelperButtons([ 64 | { 65 | name: "", 66 | from: new Date("2020-10-01"), 67 | to: new Date("2020-10-02"), 68 | }, 69 | ]); 70 | 71 | expect(isValid).toBe(false); 72 | }); 73 | 74 | it("should return false if button from not date", () => { 75 | const isValid = PropsValidator.isValidHelperButtons([ 76 | { 77 | name: "This Day", 78 | from: "2020-10-02" as unknown as Date, 79 | to: new Date("2020-10-02"), 80 | }, 81 | ]); 82 | 83 | expect(isValid).toBe(false); 84 | }); 85 | 86 | it("should return false if button to not date", () => { 87 | const isValid = PropsValidator.isValidHelperButtons([ 88 | { 89 | name: "This Day", 90 | from: new Date("2020-10-02"), 91 | to: "2020-10-02" as unknown as Date, 92 | }, 93 | ]); 94 | 95 | expect(isValid).toBe(false); 96 | }); 97 | }); 98 | 99 | describe("isValidDateAvailabilityConfig", () => { 100 | it("should return true if value is undefined or null", () => { 101 | expect(PropsValidator.isValidDateAvailabilityConfig(undefined)).toBe( 102 | true 103 | ); 104 | 105 | expect(PropsValidator.isValidDateAvailabilityConfig(null)).toBe(true); 106 | }); 107 | 108 | it("should return true if value is empty object", () => { 109 | expect(PropsValidator.isValidDateAvailabilityConfig({})).toBe(true); 110 | }); 111 | 112 | it("should return false if dates items is not date", () => { 113 | const isValid = PropsValidator.isValidDateAvailabilityConfig({ 114 | dates: ["2020-10-15"] as unknown as Array, 115 | }); 116 | 117 | expect(isValid).toBe(false); 118 | }); 119 | 120 | it("should return false if from is not date", () => { 121 | const isValid = PropsValidator.isValidDateAvailabilityConfig({ 122 | from: "2020-10-15" as unknown as Date, 123 | }); 124 | 125 | expect(isValid).toBe(false); 126 | }); 127 | 128 | it("should return false if to is not date", () => { 129 | const isValid = PropsValidator.isValidDateAvailabilityConfig({ 130 | to: "2020-10-15" as unknown as Date, 131 | }); 132 | 133 | expect(isValid).toBe(false); 134 | }); 135 | 136 | it("should return false if ranges is not valid", () => { 137 | let isValid = PropsValidator.isValidDateAvailabilityConfig({ 138 | ranges: [ 139 | { 140 | from: "2020-10-15" as unknown as Date, 141 | to: new Date("2020-10-20"), 142 | }, 143 | ], 144 | }); 145 | 146 | expect(isValid).toBe(false); 147 | 148 | isValid = PropsValidator.isValidDateAvailabilityConfig({ 149 | ranges: [ 150 | { 151 | from: new Date("2020-10-15"), 152 | to: "2020-10-20" as unknown as Date, 153 | }, 154 | ], 155 | }); 156 | 157 | expect(isValid).toBe(false); 158 | }); 159 | 160 | it("should return false if custom is not function", () => { 161 | const isValid = PropsValidator.isValidDateAvailabilityConfig({ 162 | custom: new Date( 163 | "2020-10-15" 164 | ) as unknown as DatesAvailabilityConfig["custom"], 165 | }); 166 | 167 | expect(isValid).toBe(false); 168 | }); 169 | 170 | it("should return true if all props is valid", () => { 171 | const isValid = PropsValidator.isValidDateAvailabilityConfig({ 172 | dates: [new Date("2020-10-15")], 173 | from: new Date("2020-12-01"), 174 | to: new Date("2020-07-30"), 175 | ranges: [ 176 | { 177 | from: new Date("2020-08-01"), 178 | to: new Date("2020-08-10"), 179 | }, 180 | ], 181 | custom() { 182 | return true; 183 | }, 184 | }); 185 | 186 | expect(isValid).toBe(true); 187 | }); 188 | }); 189 | 190 | describe("isValidSameDateFormat", () => { 191 | it("shoud return true if value is undefined or null", () => { 192 | expect(PropsValidator.isValidSameDateFormat(undefined)).toBe(true); 193 | 194 | expect(PropsValidator.isValidSameDateFormat(null)).toBe(true); 195 | }); 196 | 197 | it("shoud return false if is empty object", () => { 198 | expect( 199 | PropsValidator.isValidSameDateFormat({} as SameDateFormatConfig) 200 | ).toBe(false); 201 | }); 202 | 203 | it("should return false if from or to is not string", () => { 204 | let isValid = PropsValidator.isValidSameDateFormat({ 205 | from: 1 as unknown as string, 206 | to: "DD/MM/YYYY HH:mm", 207 | }); 208 | 209 | expect(isValid).toBe(false); 210 | 211 | isValid = PropsValidator.isValidSameDateFormat({ 212 | from: "DD/MM/YYYY HH:mm", 213 | to: 1 as unknown as string, 214 | }); 215 | 216 | expect(isValid).toBe(false); 217 | }); 218 | }); 219 | }); 220 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * For a detailed explanation regarding each configuration property, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | import type { Config } from "jest"; 7 | 8 | const config: Config = { 9 | // All imported modules in your tests should be mocked automatically 10 | // automock: false, 11 | 12 | // Stop running tests after `n` failures 13 | // bail: 0, 14 | 15 | // The directory where Jest should store its cached dependency information 16 | // cacheDirectory: "/tmp/jest_rs", 17 | 18 | // Automatically clear mock calls, instances, contexts and results before every test 19 | clearMocks: true, 20 | 21 | // Indicates whether the coverage information should be collected while executing the test 22 | collectCoverage: true, 23 | 24 | // An array of glob patterns indicating a set of files for which coverage information should be collected 25 | collectCoverageFrom: ["src/components/**/*.{js,ts,vue}", "src/utils/**/*.{js,ts,vue}"], 26 | 27 | // The directory where Jest should output its coverage files 28 | coverageDirectory: "/test/unit/coverage", 29 | 30 | // An array of regexp pattern strings used to skip coverage collection 31 | coveragePathIgnorePatterns: ["/node_modules/"], 32 | 33 | // Indicates which provider should be used to instrument code for coverage 34 | // coverageProvider: "babel", 35 | 36 | // A list of reporter names that Jest uses when writing coverage reports 37 | // coverageReporters: [ 38 | // "json", 39 | // "text", 40 | // "lcov", 41 | // "clover" 42 | // ], 43 | 44 | // An object that configures minimum threshold enforcement for coverage results 45 | // coverageThreshold: undefined, 46 | 47 | // A path to a custom dependency extractor 48 | // dependencyExtractor: undefined, 49 | 50 | // Make calling deprecated APIs throw helpful error messages 51 | // errorOnDeprecated: false, 52 | 53 | // The default configuration for fake timers 54 | // fakeTimers: { 55 | // "enableGlobally": false 56 | // }, 57 | 58 | // Force coverage collection from ignored files using an array of glob patterns 59 | // forceCoverageMatch: [], 60 | 61 | // A path to a module which exports an async function that is triggered once before all test suites 62 | // globalSetup: undefined, 63 | 64 | // A path to a module which exports an async function that is triggered once after all test suites 65 | // globalTeardown: undefined, 66 | 67 | // A set of global variables that need to be available in all test environments 68 | // globals: {}, 69 | 70 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 71 | // maxWorkers: "50%", 72 | 73 | // An array of directory names to be searched recursively up from the requiring module's location 74 | // moduleDirectories: [ 75 | // "node_modules" 76 | // ], 77 | 78 | // An array of file extensions your modules use 79 | moduleFileExtensions: ["js", "jsx", "ts", "tsx", "vue"], 80 | 81 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 82 | moduleNameMapper: { 83 | "^@components/(.*)$": "/src/components/$1", 84 | "^@composables/(.*)$": "/src/composables/$1", 85 | "^@utils/(.*)$": "/src/utils/$1", 86 | }, 87 | 88 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 89 | // modulePathIgnorePatterns: [], 90 | 91 | // Activates notifications for test results 92 | // notify: false, 93 | 94 | // An enum that specifies notification mode. Requires { notify: true } 95 | // notifyMode: "failure-change", 96 | 97 | // A preset that is used as a base for Jest's configuration 98 | // preset: undefined, 99 | 100 | // Run tests from one or more projects 101 | // projects: undefined, 102 | 103 | // Use this configuration option to add custom reporters to Jest 104 | // reporters: undefined, 105 | 106 | // Automatically reset mock state before every test 107 | // resetMocks: false, 108 | 109 | // Reset the module registry before running each individual test 110 | // resetModules: false, 111 | 112 | // A path to a custom resolver 113 | // resolver: undefined, 114 | 115 | // Automatically restore mock state and implementation before every test 116 | // restoreMocks: false, 117 | 118 | // The root directory that Jest should scan for tests and modules within 119 | // rootDir: 'package.json', 120 | 121 | // A list of paths to directories that Jest should use to search for files in 122 | // roots: [ 123 | // "" 124 | // ], 125 | 126 | // Allows you to use a custom runner instead of Jest's default test runner 127 | // runner: "jest-runner", 128 | 129 | // The paths to modules that run some code to configure or set up the testing environment before each test 130 | setupFiles: ["./test/unit/setup.ts"], 131 | 132 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 133 | // setupFilesAfterEnv: [], 134 | 135 | // The number of seconds after which a test is considered as slow and reported as such in the results. 136 | // slowTestThreshold: 5, 137 | 138 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 139 | // snapshotSerializers: [], 140 | 141 | // The test environment that will be used for testing 142 | testEnvironment: "jsdom", 143 | 144 | // Options that will be passed to the testEnvironment 145 | testEnvironmentOptions: { 146 | customExportConditions: ["node", "node-addons"], 147 | }, 148 | 149 | // Adds a location field to test results 150 | // testLocationInResults: false, 151 | 152 | // The glob patterns Jest uses to detect test files 153 | // testMatch: [ 154 | // "**/__tests__/**/*.[jt]s?(x)", 155 | // "**/?(*.)+(spec|test).[tj]s?(x)" 156 | // ], 157 | 158 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 159 | // testPathIgnorePatterns: [ 160 | // "/node_modules/" 161 | // ], 162 | 163 | // The regexp pattern or array of patterns that Jest uses to detect test files 164 | // testRegex: [], 165 | 166 | // This option allows the use of a custom results processor 167 | // testResultsProcessor: undefined, 168 | 169 | // This option allows use of a custom test runner 170 | // testRunner: "jest-circus/runner", 171 | 172 | // A map from regular expressions to paths to transformers 173 | transform: { 174 | "^.+\\.js$": "babel-jest", 175 | "^.+\\.ts$": "babel-jest", 176 | "^.+\\.vue$": "@vue/vue3-jest", 177 | }, 178 | 179 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 180 | // transformIgnorePatterns: [ 181 | // "/node_modules/", 182 | // "\\.pnp\\.[^\\/]+$" 183 | // ], 184 | 185 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 186 | // unmockedModulePathPatterns: undefined, 187 | 188 | // Indicates whether each individual test should be reported during the run 189 | // verbose: undefined, 190 | 191 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 192 | // watchPathIgnorePatterns: [], 193 | 194 | // Whether to use watchman for file crawling 195 | // watchman: true, 196 | }; 197 | 198 | export default config; 199 | -------------------------------------------------------------------------------- /src/composables/useCalendarDateUtil.ts: -------------------------------------------------------------------------------- 1 | import { FromToRange } from "@components/commonTypes"; 2 | import DateUtil from "@utils/DateUtil"; 3 | import { isEmptyLiteralObject } from "@utils/helpers"; 4 | import { computed, ref, ToRefs } from "vue"; 5 | 6 | export type Day = { 7 | date: Date; 8 | timestamp: number; 9 | dateNumber: number; 10 | isFaded: boolean; 11 | }; 12 | 13 | export type DisableDateCheckFunction = (date: Date) => boolean; 14 | 15 | export type DatesAvailabilityConfig = Partial< 16 | { 17 | dates: Array; 18 | ranges: Array>; 19 | custom: DisableDateCheckFunction; 20 | } & FromToRange 21 | >; 22 | 23 | type UseCalendarProps = ToRefs<{ 24 | pageDate: Date; 25 | language: string; 26 | isMondayFirst: boolean; 27 | disabledDates?: DatesAvailabilityConfig; 28 | availableDates?: DatesAvailabilityConfig; 29 | }>; 30 | 31 | export const useCalendar = (props: UseCalendarProps) => { 32 | const dateUtil = computed(() => { 33 | return new DateUtil(props.language.value); 34 | }); 35 | 36 | const pageDate = ref(props.pageDate?.value ?? dateUtil.value.now()); 37 | 38 | const disableDateCheckFunction = computed(() => { 39 | if ( 40 | !props?.disabledDates?.value || 41 | isEmptyLiteralObject(props?.disabledDates?.value) 42 | ) 43 | return null; 44 | 45 | return createDisableDateCheckFunction( 46 | props?.disabledDates?.value, 47 | dateUtil.value 48 | ); 49 | }); 50 | 51 | const enableDateCheckFunction = computed(() => { 52 | if ( 53 | !props?.availableDates?.value || 54 | isEmptyLiteralObject(props?.availableDates?.value) 55 | ) 56 | return null; 57 | 58 | return createEnableDateCheckFunction( 59 | props?.availableDates?.value, 60 | dateUtil.value 61 | ); 62 | }); 63 | 64 | const isDisabledDate = computed(() => (date: Date) => { 65 | // Disable date takes precedence 66 | 67 | if (disableDateCheckFunction.value) { 68 | return disableDateCheckFunction.value(date); 69 | } 70 | 71 | if (enableDateCheckFunction.value) { 72 | return !enableDateCheckFunction.value(date); 73 | } 74 | 75 | return false; 76 | }); 77 | 78 | const days = computed(() => { 79 | return createDays( 80 | pageDate.value, 81 | props.isMondayFirst.value, 82 | dateUtil.value 83 | ); 84 | }); 85 | 86 | const isNextPageDisabled = computed(() => { 87 | const disabledDatesConfig = props?.disabledDates?.value ?? {}; 88 | const availableDatesConfig = props?.availableDates?.value ?? {}; 89 | 90 | if (!isEmptyLiteralObject(disabledDatesConfig)) { 91 | const { from, to } = disabledDatesConfig; 92 | 93 | if (!from) { 94 | return false; 95 | } 96 | 97 | // next is always available if there's 'to' date intersecting 'from' date 98 | if (to && dateUtil.value.isAfter(to, from)) { 99 | return false; 100 | } 101 | 102 | return ( 103 | (dateUtil.value.month(from) <= dateUtil.value.month(pageDate.value) && 104 | dateUtil.value.year(from) <= dateUtil.value.year(pageDate.value)) || 105 | dateUtil.value.year(from) < dateUtil.value.year(pageDate.value) 106 | ); 107 | } 108 | // availableDates cannot interfere disabledDates 109 | if ( 110 | isEmptyLiteralObject(disabledDatesConfig) && 111 | !isEmptyLiteralObject(availableDatesConfig) 112 | ) { 113 | const { from, to } = availableDatesConfig; 114 | 115 | if (!to) { 116 | return false; 117 | } 118 | 119 | // next is always available if there's 'from' date intersecting 'to' date 120 | if (from && dateUtil.value.isAfter(from, to)) { 121 | return false; 122 | } 123 | 124 | return ( 125 | (dateUtil.value.month(to) <= dateUtil.value.month(pageDate.value) && 126 | dateUtil.value.year(to) <= dateUtil.value.year(pageDate.value)) || 127 | dateUtil.value.year(to) < dateUtil.value.year(pageDate.value) 128 | ); 129 | } 130 | 131 | return false; 132 | }); 133 | 134 | const isPrevPageDisabled = computed(() => { 135 | const disabledDatesConfig = props?.disabledDates?.value ?? {}; 136 | const availableDatesConfig = props?.availableDates?.value ?? {}; 137 | 138 | if (!isEmptyLiteralObject(disabledDatesConfig)) { 139 | const { from, to } = disabledDatesConfig; 140 | 141 | if (!to) { 142 | return false; 143 | } 144 | 145 | // prev is always available if there's 'from' date intersecting 'to' date 146 | if (from && dateUtil.value.isBefore(from, to)) { 147 | return false; 148 | } 149 | 150 | return ( 151 | (dateUtil.value.month(to) >= dateUtil.value.month(pageDate.value) && 152 | dateUtil.value.year(to) >= dateUtil.value.year(pageDate.value)) || 153 | dateUtil.value.year(to) > dateUtil.value.year(pageDate.value) 154 | ); 155 | } 156 | // availableDates cannot interfere disabledDates 157 | if ( 158 | isEmptyLiteralObject(disabledDatesConfig) && 159 | !isEmptyLiteralObject(availableDatesConfig) 160 | ) { 161 | const { from, to } = availableDatesConfig; 162 | 163 | if (!from) { 164 | return false; 165 | } 166 | 167 | // prev is always available if there's 'to' date intersecting 'from' date 168 | if (to && dateUtil.value.isBefore(to, from)) { 169 | return false; 170 | } 171 | 172 | return ( 173 | (dateUtil.value.month(from) >= dateUtil.value.month(pageDate.value) && 174 | dateUtil.value.year(from) >= dateUtil.value.year(pageDate.value)) || 175 | dateUtil.value.year(from) > dateUtil.value.year(pageDate.value) 176 | ); 177 | } 178 | return false; 179 | }); 180 | 181 | const dayNames = computed(() => { 182 | const dayNames = dateUtil.value.getAbbrDayNames(); 183 | 184 | if (props.isMondayFirst.value) { 185 | const [sunday, ...restOfDays] = dayNames; 186 | 187 | return [...restOfDays, sunday]; 188 | } 189 | 190 | return dayNames; 191 | }); 192 | 193 | const prevPage = (): boolean => { 194 | if (isPrevPageDisabled.value) return false; 195 | 196 | pageDate.value = dateUtil.value.subtract(pageDate.value, 1, "month"); 197 | 198 | return true; 199 | }; 200 | 201 | const nextPage = (): boolean => { 202 | if (isNextPageDisabled.value) return false; 203 | 204 | pageDate.value = dateUtil.value.add(pageDate.value, 1, "month"); 205 | 206 | return true; 207 | }; 208 | 209 | return { 210 | pageDate, 211 | dayNames, 212 | days, 213 | isNextPageDisabled, 214 | isPrevPageDisabled, 215 | nextPage, 216 | prevPage, 217 | isDisabledDate, 218 | }; 219 | }; 220 | 221 | export const createDay = (date: Date, isFaded: boolean): Day => { 222 | return { 223 | date, 224 | timestamp: date.getTime(), 225 | dateNumber: date.getDate(), 226 | isFaded, 227 | }; 228 | }; 229 | 230 | /** 231 | * returns full 42 days (1 row = 7 days) in specified pageDate's month, followed by the pre-days and post-days of the month 232 | * @param pageDate 233 | * @param isMondayFirst 234 | * @param dateUtil 235 | * @returns 236 | */ 237 | export const createDays = ( 238 | pageDate: Date, 239 | isMondayFirst: boolean, 240 | dateUtil: DateUtil 241 | ) => { 242 | let pointer = dateUtil.startOf(pageDate, "month"); 243 | const daysInMonth = dateUtil.daysInMonth(pageDate); 244 | 245 | const days: Array = []; 246 | const preDays: Array = []; 247 | const postDays: Array = []; 248 | 249 | for (let i = 0; i < daysInMonth; i += 1) { 250 | days.push(createDay(pointer, false)); 251 | pointer = dateUtil.add(pointer, 1, "day"); 252 | } 253 | 254 | let firstDay = days[0].date; 255 | const SUNDAY = 0; 256 | const MONDAY = 1; 257 | const threshold = isMondayFirst ? MONDAY : SUNDAY; 258 | 259 | while (firstDay.getDay() !== threshold) { 260 | firstDay = dateUtil.subtract(firstDay, 1, "day"); 261 | preDays.unshift(createDay(firstDay, true)); 262 | } 263 | 264 | let lastDay = days[days.length - 1].date; 265 | 266 | for (let k = preDays.length + days.length; k < 42; k += 1) { 267 | lastDay = dateUtil.add(lastDay, 1, "day"); 268 | postDays.push(createDay(lastDay, true)); 269 | } 270 | 271 | return [...preDays, ...days, ...postDays]; 272 | }; 273 | 274 | export const createDisableDateCheckFunction = ( 275 | datesAvailabilityConfig: DatesAvailabilityConfig, 276 | dateUtil: DateUtil 277 | ): DisableDateCheckFunction => { 278 | const functions = createDisableDateCheckFuctions( 279 | datesAvailabilityConfig, 280 | dateUtil 281 | ); 282 | 283 | return (date) => 284 | functions.length === 0 ? false : functions.some((f) => f(date)); 285 | }; 286 | 287 | export const createEnableDateCheckFunction = ( 288 | datesAvailabilityConfig: DatesAvailabilityConfig, 289 | dateUtil: DateUtil 290 | ): DisableDateCheckFunction => { 291 | return (date) => 292 | !createDisableDateCheckFunction(datesAvailabilityConfig, dateUtil)(date); 293 | }; 294 | 295 | const createDisableDateCheckFuctions = ( 296 | datesAvailabilityConfig: DatesAvailabilityConfig, 297 | dateUtil: DateUtil 298 | ): DisableDateCheckFunction[] => { 299 | const result: DisableDateCheckFunction[] = []; 300 | const { dates, from, to, ranges, custom } = datesAvailabilityConfig; 301 | 302 | if (Array.isArray(dates)) { 303 | dates.forEach((d) => { 304 | result.push((date) => dateUtil.isSameDate(date, d)); 305 | }); 306 | } 307 | 308 | if (Array.isArray(ranges)) { 309 | ranges.forEach((r) => { 310 | result.push((date) => dateUtil.isSameOrBetween(date, r.from, r.to)); 311 | }); 312 | } 313 | 314 | // 'from' date smaller than 'to' date, 315 | // disabling dates only happens between 'from' & 'to' 316 | if (from && to && dateUtil.isBefore(from, to)) { 317 | result.push((date) => dateUtil.isSameOrBetween(date, from, to)); 318 | } else { 319 | if (from) { 320 | result.push((date) => dateUtil.isSameOrAfter(date, from)); 321 | } 322 | if (to) { 323 | result.push((date) => dateUtil.isSameOrBefore(date, to)); 324 | } 325 | } 326 | 327 | if (custom && typeof custom === "function") { 328 | result.push((date) => Boolean(custom(date))); 329 | } 330 | 331 | return result; 332 | }; 333 | -------------------------------------------------------------------------------- /test/unit/specs/components/Calendar/Calendar.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from "@vue/test-utils"; 2 | import Calendar from "@components/Calendar/Calendar.vue"; 3 | import "regenerator-runtime"; 4 | import { createDays } from "@composables/useCalendarDateUtil"; 5 | import { ComputedDay } from "@components/Calendar/types"; 6 | import DateUtil from "@utils/DateUtil"; 7 | import moment from "moment"; 8 | 9 | const createDaysFunction = (...params: Parameters) => { 10 | return ( 11 | disabledDatesNumber: Set = new Set(), 12 | highlightedDatesNumber: Set = new Set() 13 | ) => { 14 | return createDays(...params).map((d) => ({ 15 | ...d, 16 | isDisabled: disabledDatesNumber.has(d.dateNumber), 17 | isHighlighted: highlightedDatesNumber.has(d.dateNumber), 18 | })); 19 | }; 20 | }; 21 | 22 | describe("Calendar", () => { 23 | const calendarMonthYearClass = ".vdpr-datepicker__calendar-month-year"; 24 | const calendarPrevButtonClass = ".vdpr-datepicker__calendar-control-prev"; 25 | const calendarNextButtonClass = ".vdpr-datepicker__calendar-control-next"; 26 | const calendarTableClass = ".vdpr-datepicker__calendar-table"; 27 | 28 | const now = new Date("2024 11 01"); 29 | const dateUtil = new DateUtil("en"); 30 | const dayNames = dateUtil.getDayNames(); 31 | 32 | it("Should render days and dayNames correctly", () => { 33 | const days = createDaysFunction(now, false, dateUtil)(); 34 | 35 | const wrapper = shallowMount(Calendar, { 36 | props: { 37 | pageDate: now, 38 | days: days, 39 | dayNames: dayNames, 40 | }, 41 | }); 42 | 43 | const calendarTable = wrapper.find(calendarTableClass); 44 | const calendarHeader = calendarTable.get("thead"); 45 | 46 | const calendarDayNamesTh = calendarHeader.findAll("th"); 47 | 48 | calendarDayNamesTh.forEach((th, i) => { 49 | expect(th.text()).toEqual(dayNames[i]); 50 | }); 51 | 52 | const calendarBody = calendarTable.get("tbody"); 53 | const calendarDayTr = calendarBody.findAll("tr"); 54 | 55 | expect(calendarDayTr).toBeDefined(); 56 | 57 | calendarDayTr.forEach((tr, rowIndex) => { 58 | const calendarDayTh = tr.findAll("td"); 59 | 60 | calendarDayTh.forEach((th, index) => { 61 | const data = days[rowIndex * 7 + index]; 62 | 63 | expect(th.text()).toEqual( 64 | days[rowIndex * 7 + index].dateNumber.toString() 65 | ); 66 | 67 | if (data.isFaded) { 68 | // eslint-disable-next-line jest/no-conditional-expect 69 | expect(th.classes()).toContain("faded"); 70 | } else { 71 | // eslint-disable-next-line jest/no-conditional-expect 72 | expect(th.classes()).not.toContain("faded"); 73 | } 74 | }); 75 | }); 76 | }); 77 | 78 | it("should render disabled days correctly", () => { 79 | const disabledDateSet = new Set([1, 2, 3, 8, 25]); 80 | const days = createDaysFunction(now, false, dateUtil)(disabledDateSet); 81 | 82 | const wrapper = shallowMount(Calendar, { 83 | props: { 84 | pageDate: now, 85 | days: days, 86 | dayNames: dayNames, 87 | }, 88 | }); 89 | 90 | const calendarTable = wrapper.find(calendarTableClass); 91 | const calendarBody = calendarTable.get("tbody"); 92 | const calendarDayTr = calendarBody.findAll("tr"); 93 | 94 | expect(calendarDayTr).toBeDefined(); 95 | 96 | calendarDayTr.forEach((tr) => { 97 | const calendarDayTh = tr.findAll("td"); 98 | 99 | calendarDayTh.forEach((th) => { 100 | const dayNumber = parseInt(th.text()); 101 | 102 | if (disabledDateSet.has(dayNumber)) { 103 | // eslint-disable-next-line jest/no-conditional-expect 104 | expect(th.classes()).toContain("disabled"); 105 | } else { 106 | // eslint-disable-next-line jest/no-conditional-expect 107 | expect(th.classes()).not.toContain("disabled"); 108 | } 109 | }); 110 | }); 111 | }); 112 | 113 | it("should render hightlighed days correctly", () => { 114 | const highlightedDatesNumber = new Set([1, 2, 3, 4, 5]); 115 | const days = createDaysFunction( 116 | now, 117 | false, 118 | dateUtil 119 | )(undefined, highlightedDatesNumber); 120 | 121 | const wrapper = shallowMount(Calendar, { 122 | props: { 123 | pageDate: now, 124 | days: days, 125 | dayNames: dayNames, 126 | }, 127 | }); 128 | 129 | const calendarTable = wrapper.find(calendarTableClass); 130 | const calendarBody = calendarTable.get("tbody"); 131 | const calendarDayTr = calendarBody.findAll("tr"); 132 | 133 | expect(calendarDayTr).toBeDefined(); 134 | 135 | calendarDayTr.forEach((tr) => { 136 | const calendarDayTh = tr.findAll("td"); 137 | 138 | calendarDayTh.forEach((th) => { 139 | const dayNumber = parseInt(th.text()); 140 | 141 | if (highlightedDatesNumber.has(dayNumber)) { 142 | // eslint-disable-next-line jest/no-conditional-expect 143 | expect(th.classes()).toContain("highlighted"); 144 | } else { 145 | // eslint-disable-next-line jest/no-conditional-expect 146 | expect(th.classes()).not.toContain("highlighted"); 147 | } 148 | }); 149 | }); 150 | }); 151 | 152 | it("should render prev & next button default state correctly", () => { 153 | const wrapper = shallowMount(Calendar, { 154 | props: { 155 | pageDate: now, 156 | }, 157 | }); 158 | 159 | const prevButton = wrapper.find(calendarPrevButtonClass); 160 | const nextButton = wrapper.find(calendarNextButtonClass); 161 | 162 | expect(prevButton.classes()).not.toContain( 163 | "vdpr-datepicker__calendar-control-disabled" 164 | ); 165 | expect(nextButton.classes()).not.toContain( 166 | "vdpr-datepicker__calendar-control-disabled" 167 | ); 168 | }); 169 | 170 | it("should render disabled prev & next button state correctly", () => { 171 | const wrapper = shallowMount(Calendar, { 172 | props: { 173 | pageDate: now, 174 | isNextPageDisabled: true, 175 | isPrevPageDisabled: true, 176 | }, 177 | }); 178 | 179 | const prevButton = wrapper.find(calendarPrevButtonClass); 180 | const nextButton = wrapper.find(calendarNextButtonClass); 181 | 182 | expect(prevButton.classes()).toContain( 183 | "vdpr-datepicker__calendar-control-disabled" 184 | ); 185 | expect(nextButton.classes()).toContain( 186 | "vdpr-datepicker__calendar-control-disabled" 187 | ); 188 | }); 189 | 190 | it("should render current month year", () => { 191 | const wrapper = shallowMount(Calendar, { 192 | props: { 193 | pageDate: now, 194 | }, 195 | }); 196 | 197 | const currentMonthYear = moment(now).format("MMM YYYY"); 198 | 199 | const calendarMonthYear = wrapper.find(calendarMonthYearClass); 200 | 201 | expect(calendarMonthYear.text()).toEqual(currentMonthYear); 202 | }); 203 | 204 | it("should emit event on-prev-calendar", async () => { 205 | const wrapper = shallowMount(Calendar, { 206 | props: { 207 | pageDate: now, 208 | }, 209 | }); 210 | 211 | const prevButton = wrapper.find(calendarPrevButtonClass); 212 | await prevButton.trigger("click"); 213 | 214 | expect(wrapper.emitted("on-prev-calendar")).toBeTruthy(); 215 | }); 216 | 217 | it("should not emit event on-prev-calendar when disabled", async () => { 218 | const wrapper = shallowMount(Calendar, { 219 | props: { 220 | pageDate: now, 221 | isPrevPageDisabled: true, 222 | }, 223 | }); 224 | 225 | const prevButton = wrapper.find(calendarPrevButtonClass); 226 | await prevButton.trigger("click"); 227 | 228 | expect(wrapper.emitted("on-prev-calendar")).toBeFalsy(); 229 | }); 230 | 231 | it("should emit event on-next-calendar", async () => { 232 | const wrapper = shallowMount(Calendar, { 233 | props: { 234 | pageDate: now, 235 | }, 236 | }); 237 | 238 | const nextButton = wrapper.find(calendarNextButtonClass); 239 | await nextButton.trigger("click"); 240 | 241 | expect(wrapper.emitted("on-next-calendar")).toBeTruthy(); 242 | }); 243 | 244 | it("should not emit event on-next-calendar when disabled", async () => { 245 | const wrapper = shallowMount(Calendar, { 246 | props: { 247 | pageDate: now, 248 | isNextPageDisabled: true, 249 | }, 250 | }); 251 | 252 | const nextButton = wrapper.find(calendarNextButtonClass); 253 | await nextButton.trigger("click"); 254 | 255 | expect(wrapper.emitted("on-next-calendar")).toBeFalsy(); 256 | }); 257 | 258 | it("should emit event select-date when selected", async () => { 259 | const days = createDaysFunction(now, false, dateUtil)(); 260 | 261 | const wrapper = shallowMount(Calendar, { 262 | props: { 263 | pageDate: now, 264 | days: days, 265 | dayNames: dayNames, 266 | }, 267 | }); 268 | 269 | const calendarTable = wrapper.find(calendarTableClass); 270 | 271 | const calendarBody = calendarTable.get("tbody"); 272 | const calendarDayTr = calendarBody 273 | .findAll("tr") 274 | .flatMap((tr) => tr.findAll("td")); 275 | 276 | const date7 = calendarDayTr.find((tr) => tr.text() === "7"); 277 | expect(date7).toBeDefined(); 278 | await date7?.trigger("click"); 279 | 280 | expect(wrapper.emitted("select-date")?.at(0)?.toString()).toEqual( 281 | expect.stringContaining("Thu Nov 07 2024 00:00:00") 282 | ); 283 | }); 284 | 285 | it("should emit event select-date when selected disabled dates", async () => { 286 | const days = createDaysFunction(now, false, dateUtil)(new Set([7])); 287 | 288 | const wrapper = shallowMount(Calendar, { 289 | props: { 290 | pageDate: now, 291 | days: days, 292 | dayNames: dayNames, 293 | }, 294 | }); 295 | 296 | const calendarTable = wrapper.find(calendarTableClass); 297 | 298 | const calendarBody = calendarTable.get("tbody"); 299 | const calendarDayTr = calendarBody 300 | .findAll("tr") 301 | .flatMap((tr) => tr.findAll("td")); 302 | 303 | const date7 = calendarDayTr.find((tr) => tr.text() === "7"); 304 | expect(date7).toBeDefined(); 305 | await date7?.trigger("click"); 306 | 307 | expect(wrapper.emitted("select-date")).not.toBeDefined(); 308 | }); 309 | }); 310 | -------------------------------------------------------------------------------- /src/components/CalendarDialog/CalendarDialog.vue: -------------------------------------------------------------------------------- 1 | 111 | 112 | 117 | 118 | 427 | 428 | 431 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![codecov](https://codecov.io/gh/limbara/vue-time-date-range-picker/branch/master/graph/badge.svg)](https://codecov.io/gh/limbara/vue-time-date-range-picker) 2 | 3 | # vue-time-date-range-picker 4 | 5 | A Vue Component to pick a ranged datetime in calendar. Built alongside Vue 3.x . 6 | This datepicker utilize **moment** for translations. 7 | 8 | ### Version matrix 9 | 10 | | Vue.js version | Package version | Branch | 11 | | :--- |:---------------:| ---: | 12 | | 2.x | 1.x | [version-1](https://github.com/limbara/vue-time-date-range-picker/tree/version-1) | 13 | | 3.x | 2.x | `master` | 14 | 15 | - [Demo](#demo) 16 | - [Install](#install) 17 | - [Usage](#usage) 18 | - [Props](#available-props) 19 | - [Events](#events) 20 | - [Translations](#translations) 21 | 22 | ## Demo 23 | 24 | codepen demo : https://codepen.io/limbara/pen/ZEQxoZZ 25 | sandbox demo : https://codesandbox.io/s/example-vue-time-date-range-picker-byw7g 26 | 27 | Clone the repo and run 'npm install && npm run serve' for local demo 28 | 29 | ## Install 30 | 31 | ```bash 32 | npm i vue-time-date-range-picker moment 33 | ``` 34 | 35 | ## Usage 36 | 37 | Usage within JS project 38 | 39 | ```javascript 40 | import { DatePicker, CalendarDialog } from 'vue-time-date-range-picker' 41 | import 'vue-time-date-range-picker/dist/style.css' 42 | 43 | export default { 44 | //... 45 | components: { 46 | DatePicker, 47 | CalendarDialog 48 | } 49 | //... 50 | } 51 | ``` 52 | You can use CalendarDialog Component if you want to implement your own input element 53 | 54 | Usage from CDN 55 | ```html 56 | 57 | 58 | ` 59 | 60 |
61 |
62 |
v-model value is: {{ initialDates?.map(v => v.toString()) ?? 'empty' }}
63 | 64 |
65 |
66 | 67 | 68 | 69 | 70 | 88 | 89 | ``` 90 | 91 | ## Available props 92 | 93 | Below is props that're available in **DatePicker** Component 94 | 95 | ** date format refer to [moment](https://momentjs.com/) ** 96 | 97 | | Prop | Type | Default | Description | 98 | |---------------------------------------|---------------|-------------------|---------------------------------------------------------| 99 | | v-model | [Date,Date] / null| | v-model binding | 100 | | initial-dates | [Date, Date] | | Initial value for the datepicker | 101 | | inline | Boolean | false | Use datepicker inline style | 102 | | language | String | en | Languange | 103 | | format | String | DD/MM/YYYY HH:mm | Format for display date input | 104 | | [same-date-format](#same-date-format) | Object | refer below | Format for display date input if start & end date same | 105 | | [disabled-dates](#disabled-dates) | Object | refer below | Disable certain dates | 106 | | [available-dates](#available-dates) | Object | refer below | Allow only certain dates | 107 | | [date-input](#date-input) | Object | | Input configuration | 108 | | show-helper-buttons | Boolean | | Show helper buttons | 109 | | [helper-buttons](#helper-buttons) | [ ]Object | | Custom helper button | 110 | | [calendar-date-input](#c-date-input) | Object | refer below | Calendar input date configuration | 111 | | [calendar-time-input](#c-time-input) | Object | refer below | Calendar input time configuration | 112 | | switch-button-label | String | | Switch Button label | 113 | | switch-button-initial | Boolean | | Switch Button initial value | 114 | | apply-button-label | String | | Apply Button Label | 115 | | reset-button-label | String | | Reset Button Label | 116 | | is-monday-first | Boolean | | Calendar start from Monday instead of Sunday | 117 | 118 | Below is props that're available in **Calendar Dialog** Component 119 | 120 | | Prop | Type | Default | Description | 121 | |---------------------------------------|-----------------|-------------|---------------------------------------------| 122 | | initial-dates | [Date, Date] | | Initial value for the datepicker | 123 | | inline | Boolean | false | Use datepicker inline style | 124 | | language | String | en | Languange | 125 | | [disabled-dates](#disabled-dates) | Object | refer below | Disable certain dates | 126 | | [available-dates](#available-dates) | Object | refer below | Allow only certain dates | 127 | | show-helper-buttons | Boolean | true | Show helper buttons | 128 | | [helper-buttons](#helper-buttons) | [ ]Object | [ ] | Custom helper button | 129 | | [date-input](#c-date-input) | Object | | Calendar input date configuration | 130 | | [time-input](#c-time-input) | Object | | Calendar input time configuration | 131 | | switch-button-label | String | All Days | Switch Button label | 132 | | switch-button-initial | Boolean | false | Switch Button initial value | 133 | | apply-button-label | String | Apply | Apply Button Label | 134 | | reset-button-label | String | Reset | Reset Button Label | 135 | | is-monday-first | Boolean | false | Calendar start from Monday (default Sunday) | 136 | 137 | #### Same Date Format 138 | Below is values that're available for props "same-date-format" 139 | 140 | | Key | Type | Default | Description | 141 | |-------------|----------------|---------------------|------------------------------------------| 142 | | from | String | DD/MM/YYYY, HH:mm | format selected start date | 143 | | to | String | HH:mm | format selected end date | 144 | 145 | #### Date Input 146 | Below is values that're available for props "date-input" 147 | 148 | | Key | Type | Default | Description | 149 | |-------------|-----------------------|---------------------|-------------------------------------------| 150 | | inputClass | String\|Array\|Object | | class for input element | 151 | | refName | String | | ref name for input element | 152 | | name | String | | attribute name | 153 | | placeholder | String | | attribute placeholder | 154 | | id | String | | atttibute id | 155 | | required | Boolean | | attirbute required | 156 | 157 | #### Disabled Dates 158 | Below is values that're available for props "disabled-dates" 159 | 160 | | Key | Type | Default | Description | 161 | |-------------|-----------------|------------|---------------------------------------------------------| 162 | | dates | [ ]Date | | disable dates matching array of Date object | 163 | | from | Date | | disable dates from this date | 164 | | to | Date | | disable dates until this date | 165 | | ranges | Object | | disable dates matching object of date "from" & "to" | 166 | | custom | Function | | disable dates with function | 167 | 168 | If accidentially both disabled dates and available dates are provided, disabled dates take priority. 169 | 170 | #### Available Dates 171 | Below is values that're available for props "available-dates" 172 | 173 | | Key | Type | Default | Description | 174 | |-------------|-----------------|------------|---------------------------------------------------------| 175 | | dates | [ ]Date | | allow dates matching array of Date object | 176 | | from | Date | | allow dates from this date | 177 | | to | Date | | allow dates until this date | 178 | | ranges | Object | | allow dates matching object of date "from" & "to" | 179 | | custom | Function | | allow dates with function | 180 | 181 | If accidentially both disabled dates and available dates are provided, disabled dates take priority. 182 | 183 | #### Helper Buttons 184 | Below is values that're available for props "helper-buttons" 185 | 186 | | Key | Type | Default | Description | 187 | |-------------|----------------|--------------|------------------------------------------| 188 | | name | String | | button name | 189 | | from | String | | format selected start date | 190 | | to | String | | format selected end date | 191 | 192 | #### C Date Input 193 | Below is values that're available for props "calendar-date-input" or "date-input" for Calendar Dialog component 194 | 195 | | Key | Type | Default | Description | 196 | |-------------|-----------------------|--------------|------------------------------------------| 197 | | labelStarts | String | Starts | start date label | 198 | | labelEnds | String | Ends | ends date label | 199 | | inputClass | String\|Array\|Object | | class for input element | 200 | | format | String | DD/MM/YYYY | date format | 201 | 202 | #### C Time Input 203 | Below is values that're available for props "calendar-time-input" or "time-input" for Calendar Dialog component 204 | 205 | | Key | Type | Default | Description | 206 | |-------------|-----------------------|--------------|------------------------------------------| 207 | | inputClass | String\|Array\|Object | | class for input element | 208 | | readonly | Boolean | false | attribute readonly | 209 | | step | Number | 60 | step value in minutes | 210 | 211 | ## Events 212 | 213 | Below is events that're available in **DatePicker** Component 214 | 215 | | Event | Output | Description | 216 | |-----------------------|---------------|-------------------------------------------------------------| 217 | | date-applied | Date, Date | Dates is applied to date input. Output start & end date | 218 | | on-prev-calendar | | On calendar page previous | 219 | | on-next-calendar | | On calendar page next | 220 | | datepicker-opened | | Datepicker is opened | 221 | | datepicker-closed | | Datepicker is closed | 222 | | select-date | Date, Date | A date is selected in calendar. Output start & end date | 223 | | select-disabled-date | Date | A disabled date is selected in calendar | 224 | | on-reset | | On reset when button reset clicked | 225 | 226 | Below is events that're available in **Calendar Dialog** Component 227 | 228 | | Event | Output | Description | 229 | |-----------------------|---------------|-------------------------------------------------------------| 230 | | on-apply | Date, Date | Dates is applied to date input. Output start & end date | 231 | | on-prev-calendar | | On calendar page previous | 232 | | on-next-calendar | | On calendar page next | 233 | | select-date | Date, Date | A date is selected in calendar. Output start & end date | 234 | | select-disabled-date | Date | A disabled date is selected in calendar | 235 | | on-reset | | On reset when button reset clicked | 236 | 237 | ## Translations 238 | 239 | ** available languages refer to [moment](https://momentjs.com/) ** 240 | --------------------------------------------------------------------------------