├── .eslintignore ├── .github └── workflows │ └── publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── deployment-config └── vercel.json ├── package.json ├── pnpm-lock.yaml ├── public ├── favicon.ico ├── index.html ├── logo.svg └── robots.txt ├── snowpack.config.js ├── src ├── App.svelte ├── calendar-style.js ├── components │ ├── DatePicker.svelte │ ├── Popover.svelte │ ├── Toolbar.svelte │ ├── lib │ │ ├── calendar-page.js │ │ ├── calendar-page.spec.js │ │ ├── calendar.js │ │ ├── calendar.spec.js │ │ ├── context.js │ │ ├── context.spec.js │ │ ├── date-manipulation.js │ │ ├── date-manipulation.spec.js │ │ ├── date-utils.js │ │ ├── day-selection-validator.js │ │ ├── day-selection-validator.spec.js │ │ ├── event-handling.js │ │ ├── formatter.js │ │ ├── positioning.js │ │ ├── sanitization.js │ │ └── view-context.js │ └── view │ │ ├── View.svelte │ │ ├── date-view │ │ ├── DateView.svelte │ │ ├── Month.svelte │ │ ├── NavBar.svelte │ │ ├── Week.svelte │ │ ├── date-comparison.js │ │ ├── feedback.js │ │ └── keyboard.js │ │ └── time-view │ │ ├── Chevron.svelte │ │ ├── TimeInput.svelte │ │ ├── TimeView.svelte │ │ ├── time-input.js │ │ └── time-store.js ├── index.js ├── main.js ├── normalize.css ├── prettify.css ├── style.css └── test.js └── web-test-runner.config.js /.eslintignore: -------------------------------------------------------------------------------- 1 | src/index.js 2 | build -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | tags: 8 | - 'v*' 9 | pull_request: 10 | branches: 11 | - '*' 12 | 13 | env: 14 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: actions/setup-node@v3 22 | with: 23 | node-version: 16 24 | - uses: pnpm/action-setup@v2 25 | name: Install pnpm 26 | with: 27 | version: 8 28 | run_install: true 29 | - run: npm run lint 30 | - run: npm run test:unit 31 | 32 | docs: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v3 36 | - uses: actions/setup-node@v3 37 | with: 38 | node-version: 16 39 | - uses: pnpm/action-setup@v2 40 | name: Install pnpm 41 | with: 42 | version: 8 43 | run_install: true 44 | - name: Deploy docs 45 | run: | 46 | pnpm build 47 | cd build 48 | npx vercel -A ../deployment-config/vercel.json --prod --token ${{ secrets.VERCEL_TOKEN }} 49 | env: 50 | NOW_PROJECT_ID: 'prj_1kSWQ4j4fKqpjLgCiqAydPBQPlmB' 51 | NOW_ORG_ID: 'team_ebDKq5BBTF4figtiNJc3VRH6' 52 | 53 | publish-npm: 54 | if: startsWith(github.ref, 'refs/tags/v') 55 | needs: build 56 | runs-on: ubuntu-latest 57 | steps: 58 | - uses: actions/checkout@v3 59 | - uses: actions/setup-node@v3 60 | with: 61 | node-version: 16 62 | - uses: pnpm/action-setup@v2 63 | name: Install pnpm 64 | with: 65 | version: 8 66 | run_install: true 67 | - name: Set publishing config 68 | run: pnpm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}" 69 | - run: pnpm publish --no-git-checks -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | web_modules 3 | node_modules -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Fred K. Schott 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 | 5 |
6 |
7 |
8 | 9 | ## Svelte Datepicker 10 | 11 | [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg)](http://standardjs.com) [![svelte-v3](https://img.shields.io/badge/svelte-v3-blueviolet.svg)](https://svelte.dev) ![publish](https://github.com/beyonk-adventures/svelte-datepicker/workflows/publish/badge.svg) 12 | 13 | This is a near total rewrite of the excellent [Svelte Calendar](https://github.com/6eDesign/svelte-calendar). It provides: 14 | 15 | * Calendar 16 | * Date Picker 17 | * Date Range Picker 18 | * Time Selection 19 | * Better Responsiveness 20 | * Improved theming 21 | * Context-aware theming 22 | * Toolbar to avoid awkward bindings 23 | * Works in tough situations such as inside iframes 24 | 25 | Roadmap: 26 | 27 | * Re-introduce Keyboard Support 28 | * Add code-samples to docs 29 | * Add legend for keyboard shortcuts [h for Help] 30 | 31 | ## Svelte Kit Support 32 | 33 | Due to the way dayjs is packaged, the following configuration is required to get this working with SvelteKit: 34 | 35 | ```js 36 | const config = { 37 | kit: { 38 | target: "#svelte", 39 | vite: { 40 | ssr: { 41 | noExternal: [ 'dayjs' ] 42 | } 43 | } 44 | } 45 | } 46 | ``` 47 | 48 | ## Usage 49 | 50 | * See [The Documentation](https://svelte-datepicker.vercel.app) which is a work in progress. 51 | * See [Small Svelte REPL](https://svelte.dev/repl/d812e880c6934f9e9a7cf9f760eddc11?version=3.31.2) for a minimum working verison. 52 | 53 | ## Contributing 54 | 55 | ### Tests 56 | 57 | Tests written in [uvu](https://github.com/lukeed/uvu) 58 | 59 | ```bash 60 | npm run test 61 | ``` 62 | -------------------------------------------------------------------------------- /deployment-config/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "routes": [ 4 | { "handle": "filesystem" }, 5 | { "src": "/.*", "dest": "/index.html" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@beyonk/svelte-datepicker", 3 | "svelte": "src/main.js", 4 | "version": "13.0.4", 5 | "license": "MIT", 6 | "scripts": { 7 | "dev": "snowpack dev", 8 | "build": "snowpack build", 9 | "lint": "eslint .", 10 | "test": "web-test-runner \"src/**/*.test.js\"", 11 | "test:unit": "uvu -r esm" 12 | }, 13 | "devDependencies": { 14 | "@beyonk/eslint-config": "^4.2.0", 15 | "@beyonk/snowpack-rollup-plugin": "0.0.1", 16 | "@snowpack/plugin-dotenv": "^2.0.5", 17 | "@snowpack/plugin-svelte": "^3.5.1", 18 | "@snowpack/web-test-runner-plugin": "^0.2.1", 19 | "@testing-library/svelte": "^3.0.0", 20 | "@web/test-runner": "^0.9.7", 21 | "dayjs": "^1.11.9", 22 | "eslint": "^7.14.0", 23 | "eslint-plugin-svelte3": "^2.7.3", 24 | "esm": "^3.2.25", 25 | "mockdate": "^3.0.2", 26 | "sinon": "^9.2.4", 27 | "snowpack": "^3.0.10", 28 | "svelte": "^3.59.2", 29 | "tinro": "^0.3.7", 30 | "uvu": "^0.5.1" 31 | }, 32 | "eslintConfig": { 33 | "extends": "@beyonk/eslint-config/svelte" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beyonk-group/svelte-datepicker/623cc625e184abd58f91598719d9ed9f2c5935c1/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Svelte Datepicker 9 | 10 | 11 | 12 | 13 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /snowpack.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import("snowpack").SnowpackUserConfig } */ 2 | module.exports = { 3 | mount: { 4 | public: '/', 5 | src: '/_dist_' 6 | }, 7 | routes: [ 8 | { match: 'routes', src: '.*', dest: '/index.html' } 9 | ], 10 | plugins: [ 11 | '@snowpack/plugin-svelte', 12 | '@snowpack/plugin-dotenv' 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/App.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 11 |
Open Calendar
12 |
13 |
14 |
15 |
16 | 17 |
18 |
19 |
20 |
21 | 44 |
45 |
46 |
47 | Svelte DatePicker Developer Documentation 48 |
49 |
50 |
51 | Github 52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | 72 | 300 |
301 |
302 |
303 |
304 | 313 | 322 |
323 | 324 | 339 | 340 | -------------------------------------------------------------------------------- /src/calendar-style.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | class CalendarStyle { 4 | constructor (overrides = {}) { 5 | this.style = '' 6 | this.buttonBackgroundColor = '#fff' 7 | this.buttonBorderColor = '#eee' 8 | this.buttonTextColor = '#333' 9 | this.buttonWidth = '300px' 10 | this.highlightColor = '#f7901e' 11 | this.passiveHighlightColor = '#FCD9B1' 12 | 13 | this.dayBackgroundColor = 'none' 14 | this.dayBackgroundColorIsNight = 'none' 15 | this.dayTextColor = '#4a4a4a' 16 | this.dayTextColorIsNight = '#4a4a4a' 17 | this.dayTextColorInRange = 'white' 18 | this.dayHighlightedBackgroundColor = '#efefef' 19 | this.dayHighlightedTextColor = '#4a4a4a' 20 | 21 | this.currentDayTextColor = '#000' 22 | this.selectedDayTextColor = 'white' 23 | 24 | this.timeNightModeTextColor = 'white' 25 | this.timeNightModeBackgroundColor = '#808080' 26 | this.timeDayModeTextColor = 'white' 27 | this.timeDayModeBackgroundColor = 'white' 28 | this.timeSelectedTextColor = '#3d4548' 29 | this.timeInputTextColor = '#3d4548' 30 | this.timeConfirmButtonColor = '#2196F3' 31 | this.timeConfirmButtonTextColor = 'white' 32 | 33 | this.toolbarBorderColor = '#888' 34 | 35 | this.contentBackground = 'white' 36 | 37 | this.monthYearTextColor = '#3d4548' 38 | this.legendTextColor = '#4a4a4a' 39 | 40 | this.datepickerWidth = 'auto' 41 | 42 | Object.entries(overrides).forEach(([ prop, value ]) => { 43 | this[prop] = value 44 | }) 45 | } 46 | 47 | toWrapperStyle () { 48 | return ` 49 | --button-background-color: ${this.buttonBackgroundColor}; 50 | --button-border-color: ${this.buttonBorderColor}; 51 | --button-text-color: ${this.buttonTextColor}; 52 | --button-width: ${this.buttonWidth}; 53 | --highlight-color: ${this.highlightColor}; 54 | --passive-highlight-color: ${this.passiveHighlightColor}; 55 | 56 | --day-background-color: ${this.dayBackgroundColor}; 57 | --day-background-color-is-night: ${this.dayBackgroundColorIsNight}; 58 | --day-text-color: ${this.dayTextColor}; 59 | --day-text-color-in-range: ${this.dayTextColorInRange}; 60 | --day-text-color-is-night: ${this.dayTextColorIsNight}; 61 | --day-highlighted-background-color: ${this.dayHighlightedBackgroundColor}; 62 | --day-highlighted-text-color: ${this.dayHighlightedTextColor}; 63 | 64 | --current-day-text-color: ${this.currentDayTextColor}; 65 | --selected-day-text-color: ${this.selectedDayTextColor}; 66 | 67 | --time-night-mode-text-color: ${this.timeNightModeTextColor}; 68 | --time-night-mode-background-color: ${this.timeNightModeBackgroundColor}; 69 | --time-day-mode-text-color: ${this.timeDayModeTextColor}; 70 | --time-day-mode-background-color: ${this.timeDayModeBackgroundColor}; 71 | 72 | --time-selected-text-color: ${this.timeSelectedTextColor}; 73 | --time-input-text-color: ${this.timeInputTextColor}; 74 | --time-confirm-button-text-color: ${this.timeConfirmButtonTextColor}; 75 | --time-confirm-button-color: ${this.timeConfirmButtonColor}; 76 | 77 | --toolbar-border-color: ${this.toolbarBorderColor}; 78 | 79 | --content-background: ${this.contentBackground}; 80 | 81 | --month-year-text-color: ${this.monthYearTextColor}; 82 | --legend-text-color: ${this.legendTextColor}; 83 | --datepicker-width: ${this.datepickerWidth}; 84 | 85 | ${this.style} 86 | ` 87 | } 88 | } 89 | 90 | export { 91 | CalendarStyle 92 | } 93 | -------------------------------------------------------------------------------- /src/components/DatePicker.svelte: -------------------------------------------------------------------------------- 1 | 120 | 121 | 170 | 171 |
176 | dispatch('close')}> 181 |
182 | 183 | {#if !trigger} 184 | 191 | {/if} 192 | 193 |
194 |
195 |
196 | 200 | {#if config.isRangePicker} 201 | 205 | {/if} 206 |
207 | 208 |
209 |
210 |
211 | -------------------------------------------------------------------------------- /src/components/Popover.svelte: -------------------------------------------------------------------------------- 1 | 66 | 67 | 68 |
69 |
e.key === 'Enter' && doOpen()} bind:this={triggerContainer}> 70 | 71 | 72 |
73 |
80 |
81 |
82 | 83 |
84 |
85 |
86 |
87 | 88 | 174 | -------------------------------------------------------------------------------- /src/components/Toolbar.svelte: -------------------------------------------------------------------------------- 1 |
2 | 5 |
6 | 7 | 35 | 36 | -------------------------------------------------------------------------------- /src/components/lib/calendar-page.js: -------------------------------------------------------------------------------- 1 | function getCalendarPage (date, dayValidator) { 2 | const displayedRangeStart = date.startOf('month').startOf('week') 3 | const displayedRangeEnd = date.endOf('month').endOf('week').add(1, 'day') 4 | 5 | const weeks = [] 6 | let currentDay = displayedRangeStart 7 | while (currentDay.isBefore(displayedRangeEnd, 'day')) { 8 | const weekOfMonth = Math.floor(currentDay.diff(displayedRangeStart, 'days') / 7) 9 | const isRequestedMonth = currentDay.isSame(date, 'month') 10 | weeks[weekOfMonth] = weeks[weekOfMonth] || { days: [], id: `${currentDay.format('YYYYMMYYYY')}${weekOfMonth}` } 11 | weeks[weekOfMonth].days.push( 12 | Object.assign({ 13 | partOfMonth: isRequestedMonth, 14 | firstOfMonth: isRequestedMonth && currentDay.date() === 1, 15 | lastOfMonth: isRequestedMonth && currentDay.date() === date.daysInMonth(), 16 | day: currentDay.date(), 17 | month: currentDay.month(), 18 | year: currentDay.year(), 19 | date: currentDay 20 | }, dayValidator(currentDay)) 21 | ) 22 | currentDay = currentDay.add(1, 'day') 23 | } 24 | 25 | return { month: date.month(), year: date.year(), weeks } 26 | } 27 | 28 | export { 29 | getCalendarPage 30 | } 31 | -------------------------------------------------------------------------------- /src/components/lib/calendar-page.spec.js: -------------------------------------------------------------------------------- 1 | import { suite as Suite } from 'uvu' 2 | import assert from 'uvu/assert' 3 | import { getCalendarPage } from './calendar-page.js' 4 | import { dayjs } from './date-utils.js' 5 | import MockDate from 'mockdate' 6 | 7 | const suite = Suite('calendar-page/getCalendarPage') 8 | 9 | suite.before(() => { 10 | MockDate.set('2020-04-20') 11 | const date = dayjs() 12 | suite.ctx = { 13 | date, 14 | page: getCalendarPage(date, () => ({ 15 | isInRange: true, 16 | isSelected: true, 17 | isToday: true 18 | })) 19 | } 20 | }) 21 | 22 | suite.after(() => { 23 | MockDate.reset() 24 | }) 25 | 26 | suite('returns calendar page month', () => { 27 | assert.equal( 28 | suite.ctx.page.month, 29 | suite.ctx.date.month() 30 | ) 31 | }) 32 | 33 | suite('returns calendar page year', () => { 34 | assert.equal( 35 | suite.ctx.page.year, 36 | suite.ctx.date.year() 37 | ) 38 | }) 39 | 40 | suite('returns calendar page weeks', () => { 41 | assert.equal( 42 | suite.ctx.page.weeks.length, 43 | 5 44 | ) 45 | }) 46 | 47 | suite.run() 48 | -------------------------------------------------------------------------------- /src/components/lib/calendar.js: -------------------------------------------------------------------------------- 1 | import { dayjs } from './date-utils' 2 | import { ensureFutureMonth } from './date-manipulation.js' 3 | import { buildDaySelectionValidator } from './day-selection-validator.js' 4 | import { getCalendarPage } from './calendar-page.js' 5 | 6 | function getMonths (config) { 7 | const { start, end, selectableCallback } = config 8 | const firstMonth = start.startOf('month').startOf('day') 9 | const lastMonth = ensureFutureMonth(firstMonth, end.startOf('month').startOf('day')) 10 | 11 | const months = [] 12 | const validator = buildDaySelectionValidator(start, end, selectableCallback) 13 | let date = dayjs(firstMonth) 14 | while (date.isSameOrBefore(lastMonth)) { 15 | months.push(getCalendarPage(date, validator)) 16 | date = date.add(1, 'month') 17 | } 18 | return months 19 | } 20 | 21 | export { 22 | getMonths 23 | } 24 | -------------------------------------------------------------------------------- /src/components/lib/calendar.spec.js: -------------------------------------------------------------------------------- 1 | import { suite as Suite } from 'uvu' 2 | import assert from 'uvu/assert' 3 | import { getMonths } from './calendar.js' 4 | import { dayjs } from './date-utils.js' 5 | import MockDate from 'mockdate' 6 | import { stub } from 'sinon' 7 | import * as daySelectionValidator from './day-selection-validator.js' 8 | 9 | const suite = Suite('calendar/getMonths') 10 | 11 | suite.before(() => { 12 | MockDate.set('2020-04-20') 13 | stub(daySelectionValidator, 'buildDaySelectionValidator').returns(() => {}) 14 | }) 15 | 16 | suite.after(() => { 17 | MockDate.reset() 18 | daySelectionValidator.buildDaySelectionValidator.restore() 19 | }) 20 | 21 | const config = { 22 | start: dayjs('2020-03-10'), 23 | end: dayjs('2020-06-25'), 24 | selectableCallback: () => {} 25 | } 26 | 27 | suite('has correct month count', () => { 28 | const months = getMonths(config) 29 | 30 | assert.equal( 31 | months.length, 32 | 4 33 | ) 34 | }) 35 | 36 | suite('calls day selection validator with correct arguments', () => { 37 | getMonths(config) 38 | 39 | assert.equal( 40 | daySelectionValidator.buildDaySelectionValidator.firstCall.args, 41 | [ config.start, config.end, config.selectableCallback ] 42 | ) 43 | }) 44 | 45 | suite.run() 46 | -------------------------------------------------------------------------------- /src/components/lib/context.js: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store' 2 | import { createFormatter } from './formatter.js' 3 | import { getMonths } from './calendar.js' 4 | import { sanitizeInitialValue } from './sanitization.js' 5 | import { dayjs } from './date-utils.js' 6 | import { ensureFutureMonth } from './date-manipulation.js' 7 | 8 | const contextKey = {} 9 | 10 | function setup (given, config) { 11 | const today = dayjs().startOf('day') 12 | 13 | const { isDateChosen, chosen: [ preSelectedStart, preSelectedEnd ] } = sanitizeInitialValue(given, config) 14 | const selectedStartDate = writable(preSelectedStart) 15 | const selectedEndDate = writable(preSelectedEnd) 16 | const { formatter } = createFormatter(selectedStartDate, selectedEndDate, config) 17 | const component = writable('date-view') 18 | 19 | const leftDate = preSelectedStart.subtract( 20 | config.isRangePicker && preSelectedStart.isSame(config.end, 'month') ? 1 : 0, 'month' 21 | ).startOf('month') 22 | const rightDate = config.isRangePicker ? ensureFutureMonth(leftDate, preSelectedEnd).startOf('month') : null 23 | 24 | return { 25 | months: getMonths(config), 26 | component, 27 | today, 28 | selectedStartDate, 29 | selectedEndDate, 30 | leftCalendarDate: writable(leftDate), 31 | rightCalendarDate: writable(rightDate), 32 | config, 33 | shouldShakeDate: writable(false), 34 | isOpen: writable(false), 35 | isClosing: writable(false), 36 | highlighted: writable(today), 37 | formatter, 38 | isDateChosen: writable(isDateChosen), 39 | resetView: () => { 40 | component.set('date-view') 41 | }, 42 | isSelectingFirstDate: writable(true) 43 | } 44 | } 45 | 46 | export { 47 | contextKey, 48 | setup 49 | } 50 | -------------------------------------------------------------------------------- /src/components/lib/context.spec.js: -------------------------------------------------------------------------------- 1 | import { suite as Suite } from 'uvu' 2 | import assert from 'uvu/assert' 3 | import { setup } from './context.js' 4 | import { dayjs } from './date-utils.js' 5 | import MockDate from 'mockdate' 6 | import { get } from 'svelte/store' 7 | 8 | const defaults = Suite('setup/range-picker/defaults') 9 | 10 | defaults.before(() => { 11 | MockDate.set('2020-04-20') 12 | defaults.ctx = { 13 | config: { 14 | start: dayjs().subtract(1, 'year'), 15 | end: dayjs().add(1, 'year'), 16 | isRangePicker: true 17 | } 18 | } 19 | defaults.ctx.output = setup(undefined, defaults.ctx.config) 20 | }) 21 | 22 | defaults.after(() => { 23 | MockDate.reset() 24 | }) 25 | 26 | defaults('has correct start date', () => { 27 | const selectedStartDate = get(defaults.ctx.output.selectedStartDate) 28 | assert.equal(selectedStartDate.toDate(), dayjs('2020-04-20').toDate()) 29 | }) 30 | 31 | defaults('has correct end date', () => { 32 | const selectedEndDate = get(defaults.ctx.output.selectedEndDate) 33 | assert.equal(selectedEndDate.toDate(), dayjs('2020-05-20').toDate()) 34 | }) 35 | 36 | defaults('has two years worth of months plus one extra', () => { 37 | assert.equal(defaults.ctx.output.months.length, 25) 38 | }) 39 | 40 | defaults('has correct view component', () => { 41 | const component = get(defaults.ctx.output.component) 42 | assert.equal(component, 'date-view') 43 | }) 44 | 45 | defaults('has today', () => { 46 | assert.equal(defaults.ctx.output.today, dayjs().startOf('day')) 47 | }) 48 | 49 | defaults('has correct left date', () => { 50 | const leftCalendarDate = get(defaults.ctx.output.leftCalendarDate) 51 | assert.equal(leftCalendarDate.toDate(), dayjs('2020-04-20').toDate()) 52 | }) 53 | 54 | defaults('has correct right date', () => { 55 | const rightCalendarDate = get(defaults.ctx.output.rightCalendarDate) 56 | assert.equal(rightCalendarDate.toDate(), dayjs('2020-05-20').toDate()) 57 | }) 58 | 59 | defaults('has passed configuration', () => { 60 | assert.is(defaults.ctx.output.config, defaults.ctx.config) 61 | }) 62 | 63 | defaults('has correct open state', () => { 64 | const state = get(defaults.ctx.output.isOpen) 65 | assert.not(state) 66 | }) 67 | 68 | defaults('has correct closing state', () => { 69 | const state = get(defaults.ctx.output.isClosing) 70 | assert.not(state) 71 | }) 72 | 73 | defaults('has correct highlighted day', () => { 74 | const highlightedDay = get(defaults.ctx.output.highlighted) 75 | assert.equal(highlightedDay.toDate(), dayjs().toDate()) 76 | }) 77 | 78 | defaults('does not have a chosen date', () => { 79 | const isDateChosen = get(defaults.ctx.output.isDateChosen) 80 | assert.not(isDateChosen) 81 | }) 82 | 83 | defaults('has correct user state', () => { 84 | const isSelectingFirstDate = get(defaults.ctx.output.isSelectingFirstDate) 85 | assert.ok(isSelectingFirstDate) 86 | }) 87 | 88 | defaults('has reset function', () => { 89 | assert.type(defaults.ctx.output.resetView, 'function') 90 | }) 91 | 92 | defaults.run() 93 | 94 | const sameMonth = Suite('setup/date-range/selected-dates/same-month') 95 | 96 | sameMonth.before(() => { 97 | MockDate.set('2020-04-20') 98 | sameMonth.ctx = { 99 | config: { 100 | start: dayjs().subtract(1, 'year'), 101 | end: dayjs().add(1, 'year'), 102 | selected: [ 103 | dayjs('2020-04-25'), 104 | dayjs('2020-04-27') 105 | ], 106 | isRangePicker: true 107 | } 108 | } 109 | sameMonth.ctx.output = setup(undefined, sameMonth.ctx.config) 110 | }) 111 | 112 | sameMonth.after(() => { 113 | MockDate.reset() 114 | }) 115 | 116 | sameMonth('correct left-hand month is displayed', () => { 117 | const date = get(sameMonth.ctx.output.leftCalendarDate) 118 | assert.equal(date.toDate(), dayjs('2020-04-01').toDate()) 119 | }) 120 | 121 | sameMonth('correct right-hand month is displayed', () => { 122 | const date = get(sameMonth.ctx.output.rightCalendarDate) 123 | assert.equal(date.toDate(), dayjs('2020-05-01').toDate()) 124 | }) 125 | 126 | sameMonth.run() 127 | 128 | const withSelectedEndInsideRange = Suite('setup/date-range/default-dates/selected-inside-range') 129 | 130 | withSelectedEndInsideRange.before(() => { 131 | MockDate.set('2021-02-04') 132 | withSelectedEndInsideRange.ctx = { 133 | config: { 134 | start: dayjs('2021-01-11'), 135 | end: dayjs('2021-04-18'), 136 | isRangePicker: true 137 | } 138 | } 139 | withSelectedEndInsideRange.ctx.output = setup(undefined, withSelectedEndInsideRange.ctx.config) 140 | }) 141 | 142 | withSelectedEndInsideRange.after(() => { 143 | MockDate.reset() 144 | }) 145 | 146 | withSelectedEndInsideRange('left-hand month is start of default selection', () => { 147 | const date = get(withSelectedEndInsideRange.ctx.output.leftCalendarDate) 148 | assert.equal(date.format('YYYY-MM-DD'), '2021-02-01') 149 | }) 150 | 151 | withSelectedEndInsideRange('right hand month is next month', () => { 152 | const date = get(withSelectedEndInsideRange.ctx.output.rightCalendarDate) 153 | assert.equal(date.format('YYYY-MM-DD'), '2021-03-01') 154 | }) 155 | 156 | withSelectedEndInsideRange.run() 157 | -------------------------------------------------------------------------------- /src/components/lib/date-manipulation.js: -------------------------------------------------------------------------------- 1 | function ensureFutureMonth (firstDate, secondDate) { 2 | return firstDate.isSame(secondDate, 'month') ? secondDate.add(1, 'month') : secondDate 3 | } 4 | 5 | export { 6 | ensureFutureMonth 7 | } 8 | -------------------------------------------------------------------------------- /src/components/lib/date-manipulation.spec.js: -------------------------------------------------------------------------------- 1 | import { suite as Suite } from 'uvu' 2 | import assert from 'uvu/assert' 3 | import { dayjs } from './date-utils.js' 4 | import MockDate from 'mockdate' 5 | import { ensureFutureMonth } from './date-manipulation.js' 6 | 7 | const month = Suite('ensureFutureMonth') 8 | 9 | month.before(() => { 10 | MockDate.set('2020-04-20') 11 | }) 12 | 13 | month.after(() => { 14 | MockDate.reset() 15 | }) 16 | 17 | month('when same month', () => { 18 | const start = dayjs('2020-04-01') 19 | const end = dayjs('2020-04-30') 20 | const expected = end.add(1, 'month').month() 21 | assert.equal( 22 | ensureFutureMonth(start, end).month(), 23 | expected 24 | ) 25 | }) 26 | 27 | month('when future month', () => { 28 | const start = dayjs('2020-04-30') 29 | const end = dayjs('2020-05-01') 30 | const expected = end.month() 31 | assert.equal( 32 | ensureFutureMonth(start, end).month(), 33 | expected 34 | ) 35 | }) 36 | 37 | month('when previous month', () => { 38 | const start = dayjs('2020-06-01') 39 | const end = dayjs('2020-05-31') 40 | const expected = end.month() 41 | assert.equal( 42 | ensureFutureMonth(start, end).month(), 43 | expected 44 | ) 45 | }) 46 | 47 | month.run() 48 | -------------------------------------------------------------------------------- /src/components/lib/date-utils.js: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import localeData from 'dayjs/plugin/localeData' 3 | import minMax from 'dayjs/plugin/minMax' 4 | import isSameOrBefore from 'dayjs/plugin/isSameOrBefore' 5 | import isSameOrAfter from 'dayjs/plugin/isSameOrAfter' 6 | 7 | dayjs.extend(localeData) 8 | dayjs.extend(minMax) 9 | dayjs.extend(isSameOrBefore) 10 | dayjs.extend(isSameOrAfter) 11 | 12 | export { 13 | dayjs 14 | } 15 | -------------------------------------------------------------------------------- /src/components/lib/day-selection-validator.js: -------------------------------------------------------------------------------- 1 | import { dayjs } from './date-utils' 2 | 3 | function buildDaySelectionValidator (start, end, selectableCallback) { 4 | return date => { 5 | const isInRange = date.isSameOrAfter(start, 'day') && date.isSameOrBefore(end, 'day') 6 | return { 7 | isInRange, 8 | selectable: isInRange && (!selectableCallback || selectableCallback(date.toDate())), 9 | isToday: date.isSame(dayjs(), 'day') 10 | } 11 | } 12 | } 13 | 14 | export { 15 | buildDaySelectionValidator 16 | } 17 | -------------------------------------------------------------------------------- /src/components/lib/day-selection-validator.spec.js: -------------------------------------------------------------------------------- 1 | import { suite as Suite } from 'uvu' 2 | import assert from 'uvu/assert' 3 | import { dayjs } from './date-utils.js' 4 | import MockDate from 'mockdate' 5 | import { buildDaySelectionValidator } from './day-selection-validator.js' 6 | 7 | const validator = Suite('buildDaySelectionValidator/no-selectable-callback') 8 | 9 | const start = dayjs('2020-07-20') 10 | const end = dayjs('2020-07-24') 11 | 12 | validator.before(() => { 13 | MockDate.set('2020-04-20') 14 | validator.ctx = { 15 | fn: buildDaySelectionValidator(start, end) 16 | } 17 | }) 18 | 19 | validator.after(() => { 20 | MockDate.reset() 21 | }) 22 | 23 | validator('returns a function', () => { 24 | assert.type(validator.ctx.fn, 'function') 25 | }) 26 | 27 | validator('with today', () => { 28 | const given = dayjs('2020-04-20') 29 | assert.equal( 30 | validator.ctx.fn(given), 31 | { 32 | isInRange: false, 33 | selectable: false, 34 | isToday: true 35 | } 36 | ) 37 | }) 38 | 39 | validator('later today', () => { 40 | const given = dayjs().endOf('day') 41 | assert.equal( 42 | validator.ctx.fn(given), 43 | { 44 | isInRange: false, 45 | selectable: false, 46 | isToday: true 47 | } 48 | ) 49 | }) 50 | 51 | validator('with date before start', () => { 52 | assert.equal( 53 | validator.ctx.fn(start.subtract(1, 'day')), 54 | { 55 | isInRange: false, 56 | selectable: false, 57 | isToday: false 58 | } 59 | ) 60 | }) 61 | 62 | validator('with first day of range', () => { 63 | assert.equal( 64 | validator.ctx.fn(start), 65 | { 66 | isInRange: true, 67 | selectable: true, 68 | isToday: false 69 | } 70 | ) 71 | }) 72 | 73 | validator('with last day of range', () => { 74 | assert.equal( 75 | validator.ctx.fn(end), 76 | { 77 | isInRange: true, 78 | selectable: true, 79 | isToday: false 80 | } 81 | ) 82 | }) 83 | 84 | validator('with date after end', () => { 85 | assert.equal( 86 | validator.ctx.fn(end.add(1, 'day')), 87 | { 88 | isInRange: false, 89 | selectable: false, 90 | isToday: false 91 | } 92 | ) 93 | }) 94 | 95 | validator.run() 96 | -------------------------------------------------------------------------------- /src/components/lib/event-handling.js: -------------------------------------------------------------------------------- 1 | 2 | const once = (el, evt, cb) => { 3 | if (!el) { return } 4 | function handler () { 5 | cb.apply(this, arguments) 6 | el.removeEventListener(evt, handler) 7 | } 8 | el.addEventListener(evt, handler) 9 | } 10 | 11 | export { 12 | once 13 | } 14 | -------------------------------------------------------------------------------- /src/components/lib/formatter.js: -------------------------------------------------------------------------------- 1 | import { derived } from 'svelte/store' 2 | 3 | function createFormatter (selectedStartDate, selectedEndDate, config) { 4 | const formatter = derived([ selectedStartDate, selectedEndDate ], ([ $selectedStartDate, $selectedEndDate ]) => { 5 | const formattedSelected = $selectedStartDate && $selectedStartDate.format(config.format) 6 | const formattedSelectedEnd = config.isRangePicker && $selectedEndDate && $selectedEndDate.format(config.format) 7 | 8 | return { 9 | formattedSelected, 10 | formattedSelectedEnd, 11 | formattedCombined: config.isRangePicker ? `${formattedSelected} - ${formattedSelectedEnd}` : formattedSelected 12 | } 13 | }) 14 | 15 | return { formatter } 16 | } 17 | 18 | export { 19 | createFormatter 20 | } 21 | -------------------------------------------------------------------------------- /src/components/lib/positioning.js: -------------------------------------------------------------------------------- 1 | function sizes (w) { 2 | const contentWidth = [ ...w.document.body.children ].reduce((a, el) => Math.max( 3 | a, el.getBoundingClientRect().right), 0 4 | ) - w.document.body.getBoundingClientRect().x 5 | 6 | return { 7 | pageWidth: Math.min(w.document.body.scrollWidth, contentWidth), 8 | pageHeight: w.document.body.scrollHeight, 9 | viewportHeight: w.innerHeight, 10 | viewportWidth: w.innerWidth 11 | } 12 | } 13 | 14 | const dimensions = { 15 | page: { 16 | padding: 6, 17 | deadzone: 80 18 | }, 19 | content: { 20 | medium: { 21 | single: { 22 | height: 410, 23 | width: 340 24 | }, 25 | range: { 26 | height: 410, 27 | width: 680 28 | } 29 | }, 30 | small: { 31 | single: { 32 | height: 410, 33 | width: 340 34 | }, 35 | range: { 36 | height: 786, 37 | width: 340 38 | } 39 | } 40 | } 41 | } 42 | 43 | function getPosition (w, e, config) { 44 | const { isRangePicker } = config 45 | const { pageWidth, viewportHeight, viewportWidth } = sizes(w) 46 | 47 | const display = pageWidth < 480 ? 'small' : 'medium' 48 | const mode = isRangePicker ? 'range' : 'single' 49 | const { padding, deadzone } = dimensions.page 50 | const { width, height } = dimensions.content[display][mode] 51 | 52 | if (viewportHeight < (height + padding + deadzone) || viewportWidth < (width + padding)) { 53 | return { 54 | fullscreen: true, 55 | top: 0, 56 | left: 0 57 | } 58 | } 59 | 60 | let left = Math.max(padding, e.pageX - (width / 2)) 61 | 62 | if ((left + width) > pageWidth) { 63 | left = (pageWidth - width) - padding 64 | } 65 | 66 | let top = Math.max(padding, e.pageY - (height / 2)) 67 | 68 | const willExceedViewableArea = (top + height) > viewportHeight 69 | if (willExceedViewableArea) { 70 | top = viewportHeight - height - padding 71 | } 72 | 73 | return { top, left } 74 | } 75 | 76 | export { 77 | getPosition 78 | } 79 | -------------------------------------------------------------------------------- /src/components/lib/sanitization.js: -------------------------------------------------------------------------------- 1 | import { dayjs } from './date-utils' 2 | 3 | function moveDateWithinAllowedRange (date, config, isStart) { 4 | const isOutsideRange = ( 5 | date.valueOf() < config.start.valueOf() || 6 | date.valueOf() > config.end.valueOf() 7 | ) 8 | 9 | if (isOutsideRange) { 10 | console.warn('Provided date', date.format(), 'is outside specified start-and-end range', config.start.format(), 'to', config.end.format()) 11 | return isStart ? config.start : config.end 12 | } 13 | 14 | return date 15 | } 16 | 17 | function sanitizeInitialValue (value, config) { 18 | let isDateChosen = false 19 | let chosen 20 | 21 | if (config.isRangePicker) { 22 | const [ from, to ] = value || [] 23 | isDateChosen = Boolean(from).valueOf() && Boolean(to).valueOf() 24 | chosen = isDateChosen ? value.map(dayjs) : [ dayjs.max(dayjs(), config.start), dayjs.min(dayjs().add(...config.defaultRange), config.end) ] 25 | } else { 26 | isDateChosen = Boolean(value).valueOf() 27 | chosen = [ isDateChosen ? dayjs(value) : dayjs.max(dayjs(), config.start) ] 28 | } 29 | 30 | const [ from, to ] = chosen 31 | 32 | return { 33 | isDateChosen, 34 | chosen: [ 35 | moveDateWithinAllowedRange(from, config, true), 36 | ...config.isRangePicker ? [ moveDateWithinAllowedRange(to, config, false) ] : [] 37 | ] 38 | } 39 | } 40 | 41 | export { 42 | sanitizeInitialValue 43 | } 44 | -------------------------------------------------------------------------------- /src/components/lib/view-context.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { dayjs } from './date-utils' 4 | import DateView from '../view/date-view/DateView.svelte' 5 | import { derived } from 'svelte/store' 6 | 7 | function createMonthView (months, displayedDate) { 8 | return derived([ displayedDate ], ([ $displayedDate ]) => { 9 | let monthIndex = 0 10 | 11 | const month = $displayedDate.month() 12 | const year = $displayedDate.year() 13 | for (let i = 0; i < months.length; i += 1) { 14 | if (months[i].month === month && months[i].year === year) { 15 | monthIndex = i 16 | } 17 | } 18 | 19 | return { 20 | monthIndex, 21 | visibleMonth: months[monthIndex] 22 | } 23 | }) 24 | } 25 | 26 | function createViewContext (isStart, mainContext) { 27 | const { config, months, leftCalendarDate, rightCalendarDate, selectedStartDate, selectedEndDate } = mainContext 28 | const [ date, displayedDate ] = isStart ? [ selectedStartDate, leftCalendarDate ] : [ selectedEndDate, rightCalendarDate ] 29 | const isDaytime = derived(date, $date => { 30 | if (!$date) { return true } 31 | const [ h ] = dayjs($date).format('HH:mm').split(':').map(d => parseInt(d)) 32 | return h > config.morning && h < config.night 33 | }) 34 | 35 | return { 36 | isStart, 37 | date, 38 | view: DateView, 39 | isDaytime, 40 | displayedDate, 41 | monthView: createMonthView(months, displayedDate) 42 | } 43 | } 44 | 45 | export { 46 | createViewContext 47 | } 48 | -------------------------------------------------------------------------------- /src/components/view/View.svelte: -------------------------------------------------------------------------------- 1 |
2 | 7 |
8 | 9 | 20 | 21 | -------------------------------------------------------------------------------- /src/components/view/date-view/DateView.svelte: -------------------------------------------------------------------------------- 1 |
2 | 3 | registerSelection(e.detail.date)} /> 7 |
8 | 9 | 14 | 15 | -------------------------------------------------------------------------------- /src/components/view/date-view/Month.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 |
21 |
22 |
23 |
24 | {#each dayjs.weekdaysShort() as day} 25 | {day} 26 | {/each} 27 |
28 |
29 | {#each $monthView.visibleMonth.weeks as week (week.id)} 30 | 36 | {/each} 37 |
38 |
39 | 40 | 73 | -------------------------------------------------------------------------------- /src/components/view/date-view/NavBar.svelte: -------------------------------------------------------------------------------- 1 | 62 | 63 |
64 |
65 | 72 | 75 | 82 |
83 |
84 | {#each availableMonths as monthDefinition, index} 85 | 93 | {/each} 94 |
95 |
96 | 97 | 224 | -------------------------------------------------------------------------------- /src/components/view/date-view/Week.svelte: -------------------------------------------------------------------------------- 1 | 16 |
20 | {#each days as day} 21 |
34 | 45 |
46 | {/each} 47 |
48 | 49 | 229 | -------------------------------------------------------------------------------- /src/components/view/date-view/date-comparison.js: -------------------------------------------------------------------------------- 1 | export function isDateBetweenSelected (a, b, c) { 2 | const start = a.startOf('day').toDate() 3 | const stop = b.startOf('day').toDate() 4 | const day = c.startOf('day').toDate() 5 | return day > start && day < stop 6 | } 7 | -------------------------------------------------------------------------------- /src/components/view/date-view/feedback.js: -------------------------------------------------------------------------------- 1 | let shakeHighlightTimeout 2 | 3 | function getDay (months, m, d, y) { 4 | const theMonth = months.find(aMonth => aMonth.month === m && aMonth.year === y) 5 | if (!theMonth) { 6 | return null 7 | } 8 | 9 | for (let i = 0; i < theMonth.weeks.length; i += 1) { 10 | for (let j = 0; j < theMonth.weeks[i].days.length; j += 1) { 11 | const aDay = theMonth.weeks[i].days[j] 12 | if (aDay.month === m && aDay.day === d && aDay.year === y) return aDay 13 | } 14 | } 15 | return null 16 | } 17 | 18 | function checkIfVisibleDateIsSelectable (months, date) { 19 | const proposedDay = getDay( 20 | months, 21 | date.month(), 22 | date.date(), 23 | date.year() 24 | ) 25 | return proposedDay && proposedDay.selectable 26 | } 27 | 28 | function shakeDate (shouldShakeDate, date) { 29 | clearTimeout(shakeHighlightTimeout) 30 | shouldShakeDate.set(date) 31 | shakeHighlightTimeout = setTimeout(() => { 32 | shouldShakeDate.set(false) 33 | }, 700) 34 | } 35 | 36 | export { 37 | checkIfVisibleDateIsSelectable, 38 | shakeDate 39 | } 40 | -------------------------------------------------------------------------------- /src/components/view/date-view/keyboard.js: -------------------------------------------------------------------------------- 1 | const keyCodes = { 2 | left: 37, 3 | up: 38, 4 | right: 39, 5 | down: 40, 6 | pgup: 33, 7 | pgdown: 34, 8 | enter: 13, 9 | escape: 27, 10 | tab: 9 11 | } 12 | 13 | const keyCodesArray = Object.keys(keyCodes).map(k => keyCodes[k]) 14 | 15 | function handleKeyPress (evt, incrementDayHighlighted, incrementMonth, registerSelection, close) { 16 | if (keyCodesArray.indexOf(evt.keyCode) === -1) { 17 | return false 18 | } 19 | 20 | evt.preventDefault() 21 | 22 | switch (evt.keyCode) { 23 | case keyCodes.left: 24 | return incrementDayHighlighted(-1) 25 | case keyCodes.up: 26 | return incrementDayHighlighted(-7) 27 | case keyCodes.right: 28 | return incrementDayHighlighted(1) 29 | case keyCodes.down: 30 | return incrementDayHighlighted(7) 31 | case keyCodes.pgup: 32 | return incrementMonth(-1) 33 | case keyCodes.pgdown: 34 | return incrementMonth(1) 35 | case keyCodes.escape: 36 | return close() 37 | case keyCodes.enter: 38 | return registerSelection() 39 | default: 40 | return false 41 | } 42 | } 43 | 44 | function createKeyboardHandler ({ incrementDayHighlighted, incrementMonth, registerSelection, close }) { 45 | return evt => handleKeyPress(evt, incrementDayHighlighted, incrementMonth, registerSelection, close) 46 | } 47 | 48 | export { 49 | createKeyboardHandler 50 | } 51 | -------------------------------------------------------------------------------- /src/components/view/time-view/Chevron.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/view/time-view/TimeInput.svelte: -------------------------------------------------------------------------------- 1 |
2 |
3 | increment('hour')} /> 4 | increment('minute')} /> 5 |
6 | 7 |
8 | decrement('hour')} /> 9 | decrement('minute')} /> 10 |
11 |
12 | 13 | 31 | 32 | -------------------------------------------------------------------------------- /src/components/view/time-view/TimeView.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
13 | {dayjs($date).format(config.format)} 14 | 15 |
16 | 17 | -------------------------------------------------------------------------------- /src/components/view/time-view/time-input.js: -------------------------------------------------------------------------------- 1 | import { get } from 'svelte/store' 2 | 3 | function timeInput (node, store) { 4 | node.addEventListener('keydown', types) 5 | node.addEventListener('focus', resetTime) 6 | node.addEventListener('blur', attemptValuePersist) 7 | 8 | let time 9 | 10 | const unsubscribe = store.subscribe(given => { 11 | time = given.split('') 12 | syncInput() 13 | }) 14 | 15 | function syncInput () { 16 | node.value = time.join('') 17 | } 18 | 19 | function resetTime () { 20 | time = [] 21 | syncInput() 22 | } 23 | 24 | function persistTime () { 25 | store.set(time.join('')) 26 | syncInput() 27 | } 28 | 29 | function attemptValuePersist () { 30 | if (time.digits === 5) { 31 | persistTime() 32 | return 33 | } 34 | 35 | time = get(store).split('') 36 | syncInput() 37 | } 38 | 39 | function types (e) { 40 | e.preventDefault() 41 | const k = e.which 42 | 43 | if (k >= 48 && k <= 57) { 44 | addDigit(k) 45 | } 46 | 47 | if (k === 8) { 48 | deleteDigit() 49 | } 50 | } 51 | 52 | function deleteDigit () { 53 | time.pop() 54 | time.length === 3 && time.pop() 55 | syncInput() 56 | } 57 | 58 | function isInvalidDigit (digit) { 59 | const tooManyDigits = time.length > 4 60 | const invalidFirstDigit = time.length === 0 && ![ 0, 1, 2 ].includes(digit) 61 | const invalidSecondDigit = time.length === 1 && time[0] === 2 && digit > 3 62 | const invalidThirdDigit = time.length === 3 && digit > 5 63 | return tooManyDigits || invalidFirstDigit || invalidSecondDigit || invalidThirdDigit 64 | } 65 | 66 | function addDigit (k) { 67 | const digit = k - 48 68 | if (isInvalidDigit(digit)) { return } 69 | 70 | time.length === 2 && time.push(':') 71 | time.push(digit) 72 | 73 | if (time.length === 5) { 74 | persistTime() 75 | } 76 | 77 | syncInput() 78 | } 79 | 80 | return { 81 | destroy () { 82 | unsubscribe() 83 | node.removeEventListener('keydown', types) 84 | node.removeEventListener('focus', resetTime) 85 | node.removeEventListener('blur', attemptValuePersist) 86 | } 87 | } 88 | } 89 | 90 | export { 91 | timeInput 92 | } 93 | -------------------------------------------------------------------------------- /src/components/view/time-view/time-store.js: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store' 2 | import { dayjs } from '../../lib/date-utils.js' 3 | 4 | function format (h, m) { 5 | return [ 6 | String(h).padStart(2, '0'), 7 | String(m).padStart(2, '0') 8 | ].join(':') 9 | } 10 | 11 | function createStore (date, config) { 12 | const time = writable(dayjs(date).format('HH:mm')) 13 | 14 | function increment (segment) { 15 | time.update(t => { 16 | let [ h, m ] = t.split(':') 17 | if (segment === 'hour' && h < 23) { ++h } 18 | if (segment === 'minute' && m < 59) { 19 | m = Math.min(59, parseInt(m) + config.minuteStep) 20 | } 21 | return format(h, m) 22 | }) 23 | } 24 | 25 | function decrement (segment) { 26 | time.update(t => { 27 | let [ h, m ] = t.split(':') 28 | if (segment === 'hour' && h > 0) { --h } 29 | if (segment === 'minute' && m > 0) { 30 | m = Math.max(0, parseInt(m) - config.minuteStep) 31 | } 32 | return format(h, m) 33 | }) 34 | } 35 | 36 | function set (t) { 37 | time.set(t) 38 | } 39 | return { 40 | increment, 41 | decrement, 42 | time, 43 | set 44 | } 45 | } 46 | 47 | export { 48 | createStore 49 | } 50 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import App from './App.svelte' 2 | 3 | const app = new App({ 4 | target: document.body, 5 | }) 6 | 7 | export default app 8 | 9 | // Hot Module Replacement (HMR) - Remove this snippet to remove HMR. 10 | // Learn more: https://www.snowpack.dev/#hot-module-replacement 11 | if (import.meta.hot) { 12 | import.meta.hot.accept() 13 | import.meta.hot.dispose(() => { 14 | app.$destroy() 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { CalendarStyle } from './calendar-style.js' 2 | import DatePicker from './components/DatePicker.svelte' 3 | 4 | export { 5 | CalendarStyle, 6 | DatePicker 7 | } 8 | -------------------------------------------------------------------------------- /src/normalize.css: -------------------------------------------------------------------------------- 1 | html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;} 2 | body{margin:0;} 3 | article, 4 | aside, 5 | details, 6 | figcaption, 7 | figure, 8 | footer, 9 | header, 10 | hgroup, 11 | main, 12 | menu, 13 | nav, 14 | section, 15 | summary{display:block;} 16 | audio, 17 | canvas, 18 | progress, 19 | video{display:inline-block;vertical-align:baseline;} 20 | audio:not([controls]){display:none;height:0;} 21 | [hidden], 22 | template{display:none;} 23 | a{background-color:transparent;} 24 | a:active, 25 | a:hover{outline:0;} 26 | abbr[title]{border-bottom:1px dotted;} 27 | b, 28 | strong{font-weight:bold;} 29 | dfn{font-style:italic;} 30 | h1{font-size:2em;margin:0.67em 0;} 31 | mark{background:#ff0;color:#000;} 32 | small{font-size:80%;} 33 | sub, 34 | sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline;} 35 | sup{top:-0.5em;} 36 | sub{bottom:-0.25em;} 37 | img{border:0;} 38 | svg:not(:root){overflow:hidden;} 39 | figure{margin:1em 40px;} 40 | hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0;} 41 | pre{overflow:auto;} 42 | code, 43 | kbd, 44 | pre, 45 | samp{font-family:monospace, monospace;font-size:1em;} 46 | button, 47 | input, 48 | optgroup, 49 | select, 50 | textarea{color:inherit;font:inherit;margin:0;} 51 | button{overflow:visible;} 52 | button, 53 | select{text-transform:none;} 54 | button, 55 | html input[type="button"], 56 | input[type="reset"], 57 | input[type="submit"]{-webkit-appearance:button;cursor:pointer;} 58 | button[disabled], 59 | html input[disabled]{cursor:default;} 60 | button::-moz-focus-inner, 61 | input::-moz-focus-inner{border:0;padding:0;} 62 | input{line-height:normal;} 63 | input[type="checkbox"], 64 | input[type="radio"]{box-sizing:border-box;padding:0;} 65 | input[type="number"]::-webkit-inner-spin-button, 66 | input[type="number"]::-webkit-outer-spin-button{height:auto;} 67 | input[type="search"]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box;} 68 | input[type="search"]::-webkit-search-cancel-button, 69 | input[type="search"]::-webkit-search-decoration{-webkit-appearance:none;} 70 | fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:0.35em 0.625em 0.75em;} 71 | legend{border:0;padding:0;} 72 | textarea{overflow:auto;} 73 | optgroup{font-weight:bold;} 74 | table{border-collapse:collapse;border-spacing:0;} 75 | td, 76 | th{padding:0;} -------------------------------------------------------------------------------- /src/prettify.css: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright (C) 2015 Google Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | /* Pretty printing styles. Used with prettify.js. */ 19 | 20 | 21 | /* SPAN elements with the classes below are added by prettyprint. */ 22 | .pln { color: #000 } /* plain text */ 23 | 24 | @media screen { 25 | .str { color: #080 } /* string content */ 26 | .kwd { color: #008 } /* a keyword */ 27 | .com { color: #800 } /* a comment */ 28 | .typ { color: #606 } /* a type name */ 29 | .lit { color: #066 } /* a literal value */ 30 | /* punctuation, lisp open bracket, lisp close bracket */ 31 | .pun, .opn, .clo { color: #660 } 32 | .tag { color: #008 } /* a markup tag name */ 33 | .atn { color: #606 } /* a markup attribute name */ 34 | .atv { color: #080 } /* a markup attribute value */ 35 | .dec, .var { color: #606 } /* a declaration; a variable name */ 36 | .fun { color: red } /* a function name */ 37 | } 38 | 39 | /* Use higher contrast and text-weight for printable form. */ 40 | @media print, projection { 41 | .str { color: #060 } 42 | .kwd { color: #006; font-weight: bold } 43 | .com { color: #600; font-style: italic } 44 | .typ { color: #404; font-weight: bold } 45 | .lit { color: #044 } 46 | .pun, .opn, .clo { color: #440 } 47 | .tag { color: #006; font-weight: bold } 48 | .atn { color: #404 } 49 | .atv { color: #060 } 50 | } 51 | 52 | /* Put a border around prettyprinted code snippets. */ 53 | pre.prettyprint { padding: 20px; border: 0!important; background: #f5f5f5!important;margin-bottom:14px; } 54 | 55 | /* Specify class=linenums on a pre to get line numbering */ 56 | ol.linenums { margin-top: 0; margin-bottom: 0 } /* IE indents via margin-left */ 57 | li.L0, 58 | li.L1, 59 | li.L2, 60 | li.L3, 61 | li.L5, 62 | li.L6, 63 | li.L7, 64 | li.L8 { list-style-type: decimal !important } 65 | /* Alternate shading for lines */ 66 | li.L1, 67 | li.L3, 68 | li.L5, 69 | li.L7, 70 | li.L9 { background: #f5f5f5!important } -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | font-family: 'Open Sans', sans-serif; 7 | background:#eaedf2; 8 | color:#666; 9 | font-size:14px; 10 | display: flex; 11 | flex-direction: column; 12 | height: 100%; 13 | } 14 | 15 | * { 16 | -webkit-box-sizing: border-box; 17 | -moz-box-sizing: border-box; 18 | box-sizing: border-box; 19 | } 20 | 21 | a{ 22 | -webkit-transition: all .3s ease; 23 | -moz-transition: all .3s ease; 24 | -o-transition: all .3s ease; 25 | transition: all .3s ease; 26 | text-decoration:none; 27 | } 28 | .content-info a{ 29 | color:#22A7F0 30 | } 31 | .content-info a:hover{ 32 | color:#000; 33 | } 34 | a img { 35 | border:0; 36 | } 37 | 38 | section img{ 39 | display:block; 40 | max-width:100%; 41 | height:auto; 42 | margin:15px 0 15px 0; 43 | } 44 | 45 | article, aside, details, figcaption, figure, footer, header, hgroup, main, nav, section, summary { 46 | display: block; 47 | } 48 | section{ 49 | position:relative; 50 | } 51 | 52 | .container { 53 | padding-right: 15px; 54 | padding-left: 15px; 55 | margin-right: auto; 56 | margin-left: auto; 57 | width: 1200px; 58 | } 59 | .container:before, .container:after, .clearfix:before, .row:before, .clearfix:after, .row:after { 60 | display: table; 61 | content: " "; 62 | } 63 | .container:after, .clearfix:after, .row:after { 64 | clear: both; 65 | } 66 | .row { 67 | margin-right: -15px; 68 | margin-left: -15px; 69 | } 70 | 71 | .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11 { 72 | float: left; 73 | } 74 | .col-xs-1, 75 | .col-sm-1, 76 | .col-md-1, 77 | .col-lg-1, 78 | .col-xs-2, 79 | .col-sm-2, 80 | .col-md-2, 81 | .col-lg-2, 82 | .col-xs-3, 83 | .col-sm-3, 84 | .col-md-3, 85 | .col-lg-3, 86 | .col-xs-4, 87 | .col-sm-4, 88 | .col-md-4, 89 | .col-lg-4, 90 | .col-xs-5, 91 | .col-sm-5, 92 | .col-md-5, 93 | .col-lg-5, 94 | .col-xs-6, 95 | .col-sm-6, 96 | .col-md-6, 97 | .col-lg-6, 98 | .col-xs-7, 99 | .col-sm-7, 100 | .col-md-7, 101 | .col-lg-7, 102 | .col-xs-8, 103 | .col-sm-8, 104 | .col-md-8, 105 | .col-lg-8, 106 | .col-xs-9, 107 | .col-sm-9, 108 | .col-md-9, 109 | .col-lg-9, 110 | .col-xs-10, 111 | .col-sm-10, 112 | .col-md-10, 113 | .col-lg-10, 114 | .col-xs-11, 115 | .col-sm-11, 116 | .col-md-11, 117 | .col-lg-11, .col-xs-12, .col-sm-12, .col-md-12, .col-lg-12 { 118 | position: relative; 119 | min-height: 1px; 120 | padding-right: 15px; 121 | padding-left: 15px; 122 | } 123 | .col-lg-12 { 124 | width: 100%; 125 | } 126 | .col-lg-11 { 127 | width: 91.66666667%; 128 | } 129 | .col-lg-10 { 130 | width: 83.33333333%; 131 | } 132 | .col-lg-9 { 133 | width: 75%; 134 | } 135 | .col-lg-8 { 136 | width: 66.66666667%; 137 | } 138 | .col-lg-7 { 139 | width: 58.33333333%; 140 | } 141 | .col-lg-6 { 142 | width: 50%; 143 | } 144 | .col-lg-5 { 145 | width: 41.66666667%; 146 | } 147 | .col-lg-4 { 148 | width: 33.33333333%; 149 | } 150 | .col-lg-3 { 151 | width: 25%; 152 | } 153 | .col-lg-2 { 154 | width: 16.66666667%; 155 | } 156 | .col-lg-1 { 157 | width: 8.33333333%; 158 | } 159 | .center{ 160 | text-align:center; 161 | } 162 | .left{ 163 | text-align:left; 164 | } 165 | .right{ 166 | text-align:right; 167 | } 168 | .btn{ 169 | display:inline-block; 170 | width:160px; 171 | height:40px; 172 | line-height:38px; 173 | background:#3498db; 174 | color:#fff; 175 | font-weight:400; 176 | text-align:center; 177 | text-transform:uppercase; 178 | font-size:14px; 179 | border-bottom:2px solid #2a8bcc; 180 | } 181 | .btn:hover{ 182 | background:#2a8bcc 183 | } 184 | header{ 185 | margin:0 0 50px 0; 186 | padding:20px 0; 187 | background:#22A7F0 188 | } 189 | .slogan{ 190 | color:#fff; 191 | font-weight:300; 192 | font-size:20px; 193 | line-height:34px; 194 | } 195 | #logo img{ 196 | display:block; 197 | height:34px; 198 | } 199 | 200 | section .container{ 201 | background:#fff; 202 | } 203 | 204 | .content-wrap{ 205 | padding:50px 0 206 | } 207 | 208 | aside{ 209 | color:#fff; 210 | float:left; 211 | padding-left:15px; 212 | width:285px; 213 | } 214 | .fixed{ 215 | position:fixed; 216 | top:15px; 217 | } 218 | aside h4{ 219 | font-size:20px; 220 | font-weight:400; 221 | margin:0 0 30px 0; 222 | } 223 | 224 | .menu-box{ 225 | padding:20px; 226 | background:#34495e; 227 | } 228 | .menu-box ul{ 229 | margin:0; 230 | padding:0; 231 | } 232 | .menu-box li{ 233 | display:block; 234 | } 235 | .menu-box li a{ 236 | display:block; 237 | padding:15px 20px; 238 | margin-left: -20px; 239 | margin-right: -20px; 240 | color:#fff; 241 | border-bottom:1px solid #314559; 242 | } 243 | .menu-box li a:hover, .menu-box li a.current{ 244 | background:#2c3e50; 245 | } 246 | .menu-box li:last-child a{ 247 | border-bottom:0; 248 | } 249 | 250 | .content-info{ 251 | padding-right:15px; 252 | padding-left:315px; 253 | } 254 | .section-txt{ 255 | padding-bottom:15px; 256 | margin-bottom:30px; 257 | border-bottom:1px solid #dcdcdc; 258 | } 259 | .section-txt:last-child{ 260 | margin-bottom:0; 261 | padding-bottom:0; 262 | border-bottom:0; 263 | } 264 | .content-info h3{ 265 | font-size:24px; 266 | font-weight:400; 267 | color:#444; 268 | margin:0 0 30px 0; 269 | } 270 | .content-info p{ 271 | color:#666; 272 | line-height:24px; 273 | font-size:16px; 274 | font-weight:300; 275 | } 276 | .content-info ul{ 277 | margin:0 0 14px 0; 278 | } 279 | .content-info ul li{ 280 | line-height:24px; 281 | font-size:16px; 282 | font-weight:300; 283 | } 284 | 285 | .content-info iframe { 286 | width: 100%!important; 287 | height: 350px; 288 | border: 0!important; 289 | } 290 | 291 | .footer-area{ 292 | margin-top:50px; 293 | padding:60px 0; 294 | background:#222; 295 | font-size:16px; 296 | line-height:24px; 297 | color:#fff; 298 | font-weight:300; 299 | } 300 | 301 | .footer-area a{ 302 | color:#999; 303 | } 304 | .footer-area a:hover{ 305 | color:#eee 306 | } 307 | footer{ 308 | background:#111; 309 | padding:20px 0; 310 | font-weight:300; 311 | font-size:12px; 312 | } 313 | 314 | @media only screen and (max-width: 1200px) { 315 | .container{ 316 | width:970px; 317 | } 318 | .hidden-md{ 319 | display:none; 320 | } 321 | .col-md-12 { 322 | width: 100%; 323 | } 324 | .col-md-11 { 325 | width: 91.66666667%; 326 | } 327 | .col-md-10 { 328 | width: 83.33333333%; 329 | } 330 | .col-md-9 { 331 | width: 75%; 332 | } 333 | .col-md-8 { 334 | width: 66.66666667%; 335 | } 336 | .col-md-7 { 337 | width: 58.33333333%; 338 | } 339 | .col-md-6 { 340 | width: 50%; 341 | } 342 | .col-md-5 { 343 | width: 41.66666667%; 344 | } 345 | .col-md-4 { 346 | width: 33.33333333%; 347 | } 348 | .col-md-3 { 349 | width: 25%; 350 | } 351 | .col-md-2 { 352 | width: 16.66666667%; 353 | } 354 | .col-md-1 { 355 | width: 8.33333333%; 356 | } 357 | 358 | } 359 | 360 | 361 | @media only screen and (max-width: 992px){ 362 | .container{ 363 | width:750px; 364 | } 365 | .hidden-sm{ 366 | display:none; 367 | } 368 | .col-sm-12 { 369 | width: 100%; 370 | } 371 | .col-sm-11 { 372 | width: 91.66666667%; 373 | } 374 | .col-sm-10 { 375 | width: 83.33333333%; 376 | } 377 | .col-sm-9 { 378 | width: 75%; 379 | } 380 | .col-sm-8 { 381 | width: 66.66666667%; 382 | } 383 | .col-sm-7 { 384 | width: 58.33333333%; 385 | } 386 | .col-sm-6 { 387 | width: 50%; 388 | } 389 | .col-sm-5 { 390 | width: 41.66666667%; 391 | } 392 | .col-sm-4 { 393 | width: 33.33333333%; 394 | } 395 | .col-sm-3 { 396 | width: 25%; 397 | } 398 | .col-sm-2 { 399 | width: 16.66666667%; 400 | } 401 | .col-sm-1 { 402 | width: 8.33333333%; 403 | } 404 | .slogan { 405 | font-size: 16px; 406 | } 407 | 408 | } 409 | 410 | @media only screen and (max-width: 768px){ 411 | .container{ 412 | width:100%; 413 | } 414 | .hidden-xs{ 415 | display:none; 416 | } 417 | .col-xs-12 { 418 | width: 100%; 419 | } 420 | .col-xs-11 { 421 | width: 91.66666667%; 422 | } 423 | .col-xs-10 { 424 | width: 83.33333333%; 425 | } 426 | .col-xs-9 { 427 | width: 75%; 428 | } 429 | .col-xs-8 { 430 | width: 66.66666667%; 431 | } 432 | .col-xs-7 { 433 | width: 58.33333333%; 434 | } 435 | .col-xs-6 { 436 | width: 50%; 437 | } 438 | .col-xs-5 { 439 | width: 41.66666667%; 440 | } 441 | .col-xs-4 { 442 | width: 33.33333333%; 443 | } 444 | .col-xs-3 { 445 | width: 25%; 446 | } 447 | .col-xs-2 { 448 | width: 16.66666667%; 449 | } 450 | .col-xs-1 { 451 | width: 8.33333333%; 452 | } 453 | header{ 454 | margin-bottom:30px; 455 | } 456 | .content-wrap { 457 | padding: 30px 0; 458 | } 459 | .slogan{ 460 | text-align:center; 461 | line-height:22px; 462 | margin-bottom:15px; 463 | } 464 | #logo { 465 | text-align:center; 466 | margin-bottom:15px; 467 | } 468 | #logo img{ 469 | margin:0 auto; 470 | } 471 | .btn{ 472 | display:block; 473 | margin:0 auto; 474 | } 475 | aside{ 476 | width:100%; 477 | float:none; 478 | padding:0 15px; 479 | margin-bottom:30px; 480 | } 481 | .content-info { 482 | padding-right: 15px; 483 | padding-left: 15px; 484 | } 485 | .content-info p, .content-info ul li{ 486 | font-size:14px; 487 | line-height:22px; 488 | } 489 | .content-info h3 { 490 | font-size: 20px; 491 | } 492 | .footer-area { 493 | margin-top: 30px; 494 | padding: 50px 0; 495 | font-size:14px; 496 | } 497 | } -------------------------------------------------------------------------------- /src/test.js: -------------------------------------------------------------------------------- 1 | import App from './App.svelte' 2 | 3 | const app = new App({ 4 | target: document.body, 5 | data: {} 6 | }) 7 | 8 | export default app 9 | -------------------------------------------------------------------------------- /web-test-runner.config.js: -------------------------------------------------------------------------------- 1 | // NODE_ENV=test - Needed by "@snowpack/web-test-runner-plugin" 2 | process.env.NODE_ENV = 'test' 3 | 4 | module.exports = { 5 | plugins: [ require('@snowpack/web-test-runner-plugin')() ] 6 | } 7 | --------------------------------------------------------------------------------