date-picker.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. <template>
  2. <div class="formcontrol date-input-control" :class="{ 'formcontrol--error': !isValid }">
  3. <div class="formcontrol-label">{{ label }}</div>
  4. <div class="input-wrapper" @click="openPicker">
  5. <input
  6. class="date-input"
  7. :class="{ 'date-input--invalid': !isValid }"
  8. type="text"
  9. :placeholder="placeholder"
  10. v-model="textValue"
  11. @change="parseNewText"
  12. @focus="openPicker"
  13. />
  14. </div>
  15. <div class="datepicker-container" :class="[{ show: isPickerOpen }, position]">
  16. <div class="datepicker" :class="zoom">
  17. <div class="datepicker-header">
  18. <div class="datepicker-arrow" @click="prev" tabindex="-1">chevron_left</div>
  19. <div class="datepicker-current" @click="goUp">{{ pickerTitle }}</div>
  20. <div class="datepicker-arrow" @click="next" tabindex="-1">chevron_right</div>
  21. </div>
  22. <div class="datepicker-weekday" v-if="zoom == 'month'">
  23. <div v-for="(d, i) in weekDays" :key="i">{{ d }}</div>
  24. </div>
  25. <div class="datepicker-items" :class="zoom">
  26. <div
  27. v-for="(d, i) in pickerContent"
  28. @click="selectItem(i)"
  29. tabindex="-1"
  30. :key="i"
  31. :class="{
  32. disable: d.isBefore(min) || d.isAfter(max),
  33. out: isOut(d),
  34. selected: d.isSame(valueObject, sameKey),
  35. today: d.isSame(today, sameKey),
  36. }"
  37. >
  38. {{ d.format(pickerFormat) }}
  39. </div>
  40. </div>
  41. </div>
  42. </div>
  43. </div>
  44. </template>
  45. <script lang="ts">
  46. import { defineComponent, PropType } from "vue";
  47. import dayjs, { Dayjs, OpUnitType } from "dayjs";
  48. // eslint-disable-next-line
  49. const dateValidator = (d: any) => dayjs(d).isValid();
  50. const decadeSpan = 10;
  51. type ZoomLevel = "day" | "month" | "year" | "decade";
  52. type ContentParameter = { getFirstDate: (d: Dayjs) => Dayjs; delta: OpUnitType; maxItem: number };
  53. type ZoomParameter = {
  54. pickerTitle: (d: Dayjs) => string;
  55. content: ContentParameter;
  56. pickerFormat: string;
  57. outOfCurrentZoom: (activeDate: Dayjs, other: Dayjs) => boolean;
  58. sameKey: OpUnitType;
  59. nextKey: OpUnitType;
  60. };
  61. type PickerParameter = {
  62. [k in ZoomLevel]: ZoomParameter;
  63. };
  64. const datepickerParameter: PickerParameter = {
  65. day: {
  66. pickerTitle: (d) => d.format("ddd D MMMM YYYY"),
  67. content: {
  68. getFirstDate: (d) => d.startOf("d").startOf("h"),
  69. delta: "hour",
  70. maxItem: 24,
  71. },
  72. pickerFormat: "HH:mm",
  73. outOfCurrentZoom: () => false,
  74. sameKey: "hour",
  75. nextKey: "day",
  76. },
  77. month: {
  78. pickerTitle: (d) => d.format("MMMM YYYY"),
  79. content: {
  80. getFirstDate: (d) => d.startOf("month").startOf("week"),
  81. delta: "day",
  82. maxItem: 6 * 7,
  83. },
  84. pickerFormat: "D",
  85. outOfCurrentZoom: (d, other) => !d.isSame(other, "month"),
  86. sameKey: "day",
  87. nextKey: "month",
  88. },
  89. year: {
  90. pickerTitle: (d) => d.format("YYYY"),
  91. content: {
  92. getFirstDate: (d) => d.startOf("year"),
  93. delta: "month",
  94. maxItem: 12,
  95. },
  96. pickerFormat: "MMM",
  97. outOfCurrentZoom: (d, other) => !d.isSame(other, "year"),
  98. sameKey: "month",
  99. nextKey: "year",
  100. },
  101. decade: {
  102. pickerTitle: (d) => {
  103. let tmp = d.startOf("y");
  104. tmp = tmp.year(Math.floor(tmp.year() / decadeSpan) * decadeSpan);
  105. return tmp.format("YYYY") + "-" + tmp.add(decadeSpan - 1, "y").format("YYYY");
  106. },
  107. content: {
  108. getFirstDate: (d) =>
  109. d
  110. .startOf("y")
  111. .year(Math.floor(d.year() / decadeSpan) * decadeSpan)
  112. .subtract(1, "y"),
  113. delta: "year",
  114. maxItem: 12,
  115. },
  116. pickerFormat: "YYYY",
  117. outOfCurrentZoom: (d, other) =>
  118. Math.floor(d.year() / decadeSpan) !== Math.floor(other.year() / decadeSpan),
  119. sameKey: "year",
  120. nextKey: "year",
  121. },
  122. };
  123. export default defineComponent({
  124. name: "DatePicker",
  125. emits: ["update:modelValue"],
  126. props: {
  127. label: {
  128. type: String,
  129. required: false,
  130. default: () => "Select date",
  131. },
  132. modelValue: {
  133. type: [Number, Object, String, Date],
  134. required: false,
  135. default: () => "",
  136. },
  137. min: {
  138. type: [Number, Object, String, Date],
  139. required: false,
  140. default: () => {
  141. return dayjs().year(0);
  142. },
  143. validator: dateValidator,
  144. },
  145. max: {
  146. type: [Number, Object, String, Date],
  147. required: false,
  148. default: () => {
  149. return dayjs().year(9999);
  150. },
  151. validator: dateValidator,
  152. },
  153. lang: {
  154. type: String,
  155. required: false,
  156. default: "en",
  157. },
  158. position: {
  159. type: String,
  160. required: false,
  161. default: "",
  162. },
  163. target: {
  164. type: String as PropType<"day" | "hour">,
  165. default: "day",
  166. },
  167. },
  168. data: function () {
  169. const now = dayjs();
  170. return {
  171. isValid: true,
  172. today: now,
  173. currentMonth: now.startOf("month"),
  174. zoom: "month" as "day" | "month" | "year" | "decade",
  175. isPickerOpen: false,
  176. valueObject: null as Dayjs | null,
  177. textValue: "",
  178. textRegex: /^(?<year>\d{4})\/(?<month>(?:0[1-9])|(?:1[0-2]))\/(?<day>(?:0[1-9])|(?:[12][0-9])|(?:3[01]))$/,
  179. };
  180. },
  181. computed: {
  182. placeholder(): string {
  183. return "yyyy/mm/dd" + (this.target == "hour" ? " HH:mm" : "");
  184. },
  185. currentZoomParameter(): ZoomParameter {
  186. return datepickerParameter[this.zoom];
  187. },
  188. pickerTitle(): string {
  189. var title: string = this.currentZoomParameter.pickerTitle(this.currentMonth);
  190. return title[0].toUpperCase() + title.slice(1);
  191. },
  192. pickerContent(): Array<Dayjs> {
  193. const output: Array<Dayjs> = [];
  194. let firstDay = this.currentZoomParameter.content.getFirstDate(this.currentMonth);
  195. let delta: OpUnitType = this.currentZoomParameter.content.delta;
  196. let maxItem: number = this.currentZoomParameter.content.maxItem;
  197. for (let i = 0; i < maxItem; i++) {
  198. output.push(firstDay.add(i, delta));
  199. }
  200. return output;
  201. },
  202. pickerFormat(): string {
  203. return this.currentZoomParameter.pickerFormat;
  204. },
  205. sameKey(): OpUnitType {
  206. return this.currentZoomParameter.sameKey;
  207. },
  208. weekDays: function (): Array<string> {
  209. const start = dayjs().startOf("week");
  210. return Array(7)
  211. .fill(0)
  212. .map((_, i) => start.add(i, "d").format("dd"));
  213. },
  214. },
  215. watch: {
  216. modelValue(val) {
  217. const d = dayjs(val);
  218. if (d.isValid()) {
  219. if (!this.valueObject?.isSame(d)) {
  220. this.valueObject = d;
  221. }
  222. }
  223. },
  224. valueObject(v: Dayjs) {
  225. if (v.isValid()) {
  226. this.currentMonth = v;
  227. if (this.target == "day") {
  228. this.textValue = v.format("YYYY/MM/DD");
  229. this.zoom = "month";
  230. } else {
  231. this.textValue = v.format("YYYY/MM/DD HH:mm");
  232. this.zoom = "day";
  233. }
  234. this.$emit("update:modelValue", v);
  235. } else {
  236. this.textValue = "";
  237. }
  238. },
  239. },
  240. methods: {
  241. prev(): void {
  242. this.changeCurrentDate(-1);
  243. },
  244. next(): void {
  245. this.changeCurrentDate(1);
  246. },
  247. changeCurrentDate(direction: number): void {
  248. const amount = direction * (this.zoom == "decade" ? decadeSpan : 1);
  249. this.currentMonth = this.currentMonth.add(amount, this.currentZoomParameter.nextKey);
  250. },
  251. isOut(d: Dayjs): boolean {
  252. return this.currentZoomParameter.outOfCurrentZoom(this.currentMonth, d);
  253. },
  254. inputEventLintener(event: InputEvent) {
  255. this.parseNewText((event.target as HTMLInputElement).value);
  256. },
  257. parseNewText(newValue: string) {
  258. let candidate: Dayjs | null = null;
  259. if (this.target == "day") {
  260. candidate = dayjs(newValue, "YYYY/M/D");
  261. } else {
  262. candidate = dayjs(newValue, "YYYY/M/D HH:mm");
  263. }
  264. if (candidate && candidate.isValid() && !this.valueObject?.isSame(candidate)) {
  265. this.valueObject = candidate;
  266. this.isValid = true;
  267. }
  268. if (candidate && !candidate.isValid()) {
  269. this.isValid = false;
  270. } else {
  271. this.isValid = true;
  272. }
  273. },
  274. selectItem: function (i: number): void {
  275. const d = this.pickerContent[i];
  276. if (
  277. (this.zoom == "day" && this.target == "hour") ||
  278. (this.zoom == "month" && this.target == "day")
  279. ) {
  280. if (d.isAfter(this.min) && d.isBefore(this.max)) {
  281. this.valueObject = d;
  282. }
  283. } else {
  284. this.currentMonth = d;
  285. this.zoom = this.zoom == "decade" ? "year" : this.zoom == "year" ? "month" : "day";
  286. }
  287. },
  288. goUp() {
  289. if (this.zoom === "year") {
  290. this.zoom = "decade";
  291. }
  292. if (this.zoom === "month") {
  293. this.zoom = "year";
  294. }
  295. if (this.zoom === "day") {
  296. this.zoom = "month";
  297. }
  298. },
  299. openPicker: function () {
  300. if (!this.isPickerOpen) {
  301. this.isPickerOpen = true;
  302. window.addEventListener("focusin", this.loseFocusListener);
  303. window.addEventListener("click", this.loseFocusListener);
  304. }
  305. },
  306. loseFocusListener: function (e: Event) {
  307. if (
  308. !this.$el.contains(e.target) ||
  309. (e.target as HTMLElement).classList.contains("date-input-title")
  310. ) {
  311. window.removeEventListener("focusin", this.loseFocusListener);
  312. window.removeEventListener("click", this.loseFocusListener);
  313. this.isPickerOpen = false;
  314. this.parseNewText(this.textValue);
  315. }
  316. },
  317. },
  318. mounted() {
  319. if (this.modelValue) {
  320. if (["object", "string", "number"].includes(typeof this.modelValue)) {
  321. this.valueObject = dayjs(this.modelValue);
  322. }
  323. }
  324. this.zoom = "month";
  325. },
  326. });
  327. </script>
  328. <style>
  329. .date-input-control {
  330. margin-right: 4px;
  331. position: relative;
  332. }
  333. .date-input-title {
  334. color: #505d74;
  335. font-weight: bold;
  336. padding-bottom: 4px;
  337. }
  338. .input-wrapper {
  339. position: relative;
  340. }
  341. .input-wrapper:after {
  342. font-family: "Material Icons";
  343. font-weight: normal;
  344. font-style: normal;
  345. font-size: 20px;
  346. content: "calendar_today";
  347. position: absolute;
  348. right: 8px;
  349. top: 3px;
  350. color: var(--color-accent-400);
  351. cursor: pointer;
  352. }
  353. .date-input[type="text"] {
  354. all: initial;
  355. color: var(--color-neutral-100);
  356. width: 100%;
  357. border: none;
  358. height: 2rem;
  359. margin: 0;
  360. outline: 0;
  361. padding: 0.2rem 0.5rem;
  362. font-size: 0.875rem;
  363. -webkit-appearance: none;
  364. -moz-appearance: none;
  365. appearance: none;
  366. box-shadow: 0 -1px 0 0 var(--color-accent-700) inset;
  367. box-sizing: border-box;
  368. -webkit-transition: 0.1s ease-in-out;
  369. transition: 0.1s ease-in-out;
  370. font-family: Roboto, Helvetica Neue, Helvetica, Arial, sans-serif;
  371. line-height: 1.25rem;
  372. background-color: var(--color-accent-950);
  373. -webkit-transition-property: box-shadow, border;
  374. transition-property: box-shadow, border;
  375. position: relative;
  376. }
  377. .date-input[type="text"]:focus {
  378. border-bottom: solid 2px var(--color-accent-400);
  379. }
  380. .date-input[type="text"]:focus + .datepicker-container,
  381. .date-input[type="text"]:hover + .datepicker-container {
  382. display: block;
  383. }
  384. .date-input--invalid {
  385. box-shadow: 0 -1px 0 0 #e93255 inset !important;
  386. }
  387. [type="text"].date-input--invalid:focus {
  388. box-shadow: 0 0 0 2px #e93255 !important;
  389. border-bottom: 0;
  390. }
  391. .datepicker-container {
  392. position: absolute;
  393. width: 315px;
  394. height: 350px;
  395. display: none;
  396. z-index: 10;
  397. }
  398. .datepicker-container.show {
  399. display: block;
  400. }
  401. .datepicker-container.same-size {
  402. width: 100%;
  403. height: auto;
  404. padding-top: 114%;
  405. }
  406. .datepicker-container.right {
  407. right: 0px;
  408. }
  409. .datepicker {
  410. position: absolute;
  411. top: 0;
  412. left: 0;
  413. bottom: 0;
  414. right: 0;
  415. background: white;
  416. box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.14);
  417. padding: 8px;
  418. z-index: 10;
  419. display: grid;
  420. grid-template: 1fr 7fr/ 1fr;
  421. row-gap: 4px;
  422. }
  423. .datepicker.month {
  424. grid-template-rows: 1fr 1fr 6fr;
  425. }
  426. .datepicker-header,
  427. .datepicker-weekday,
  428. .datepicker-items {
  429. color: var(--color-neutral-400);
  430. display: grid;
  431. column-gap: 4px;
  432. row-gap: 4px;
  433. grid-template-rows: 1fr;
  434. grid-template-columns: repeat(7, 1fr);
  435. }
  436. .datepicker-items.day {
  437. grid-template-columns: repeat(4, 1fr);
  438. grid-template-rows: repeat(6, 1fr);
  439. }
  440. .datepicker-items.month {
  441. grid-template-rows: repeat(6, 1fr);
  442. }
  443. .datepicker-items.decade,
  444. .datepicker-items.year {
  445. grid-template-columns: repeat(4, 1fr);
  446. grid-template-rows: repeat(3, 1fr);
  447. }
  448. .datepicker-header > div,
  449. .datepicker-weekday > div,
  450. .datepicker-items > div {
  451. border: solid 2px transparent;
  452. border-radius: 3px;
  453. display: flex;
  454. justify-content: center;
  455. align-items: center;
  456. text-align: center;
  457. }
  458. .datepicker-arrow {
  459. font-family: "Material Icons";
  460. font-weight: normal;
  461. font-style: normal;
  462. font-size: 35px;
  463. text-align: center;
  464. color: var(--color-accent-400);
  465. }
  466. .datepicker-current {
  467. grid-column: 2/7;
  468. font-weight: bold;
  469. }
  470. .datepicker-header > div,
  471. .datepicker-items > div {
  472. cursor: pointer;
  473. }
  474. .datepicker-header > div:hover,
  475. .datepicker-items > div:not(.disable):hover {
  476. background: #f0f4f6;
  477. color: var(--color-neutral-100);
  478. }
  479. .datepicker-header > div:focus,
  480. .datepicker-items > div:not(.disable):focus {
  481. border-color: var(--color-accent-500);
  482. border-radius: 2px;
  483. }
  484. .datepicker-items > div.selected:not(.disable) {
  485. background: var(--color-accent-500);
  486. color: #fff;
  487. font-weight: bold;
  488. }
  489. .datepicker-weekday > div {
  490. color: var(--color-neutral-600);
  491. }
  492. .datepicker-items .today {
  493. border: solid 2px var(--color-accent-500);
  494. font-weight: bold;
  495. color: var(--color-accent-500);
  496. }
  497. .datepicker-items .disable,
  498. .datepicker-items .out {
  499. color: var(--color-neutral-800);
  500. }
  501. .datepicker-items .disable {
  502. cursor: not-allowed;
  503. }
  504. </style>