| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507 |
- <template>
- <div class="formcontrol date-input-control" :class="{ 'formcontrol--error': !isValid }">
- <div class="formcontrol-label">{{ label }}</div>
- <div class="input-wrapper" @click="openPicker">
- <input
- class="date-input"
- :class="{ 'date-input--invalid': !isValid }"
- type="text"
- :placeholder="placeholder"
- v-model="textValue"
- @change="parseNewText"
- @focus="openPicker"
- />
- </div>
- <div class="datepicker-container" :class="[{ show: isPickerOpen }, position]">
- <div class="datepicker" :class="zoom">
- <div class="datepicker-header">
- <div class="datepicker-arrow" @click="prev" tabindex="-1">chevron_left</div>
- <div class="datepicker-current" @click="goUp">{{ pickerTitle }}</div>
- <div class="datepicker-arrow" @click="next" tabindex="-1">chevron_right</div>
- </div>
- <div class="datepicker-weekday" v-if="zoom == 'month'">
- <div v-for="(d, i) in weekDays" :key="i">{{ d }}</div>
- </div>
- <div class="datepicker-items" :class="zoom">
- <div
- v-for="(d, i) in pickerContent"
- @click="selectItem(i)"
- tabindex="-1"
- :key="i"
- :class="{
- disable: d.isBefore(min) || d.isAfter(max),
- out: isOut(d),
- selected: d.isSame(valueObject, sameKey),
- today: d.isSame(today, sameKey),
- }"
- >
- {{ d.format(pickerFormat) }}
- </div>
- </div>
- </div>
- </div>
- </div>
- </template>
- <script lang="ts">
- import { defineComponent, PropType } from "vue";
- import dayjs, { Dayjs, OpUnitType } from "dayjs";
- // eslint-disable-next-line
- const dateValidator = (d: any) => dayjs(d).isValid();
- const decadeSpan = 10;
- type ZoomLevel = "day" | "month" | "year" | "decade";
- type ContentParameter = { getFirstDate: (d: Dayjs) => Dayjs; delta: OpUnitType; maxItem: number };
- type ZoomParameter = {
- pickerTitle: (d: Dayjs) => string;
- content: ContentParameter;
- pickerFormat: string;
- outOfCurrentZoom: (activeDate: Dayjs, other: Dayjs) => boolean;
- sameKey: OpUnitType;
- nextKey: OpUnitType;
- };
- type PickerParameter = {
- [k in ZoomLevel]: ZoomParameter;
- };
- const datepickerParameter: PickerParameter = {
- day: {
- pickerTitle: (d) => d.format("ddd D MMMM YYYY"),
- content: {
- getFirstDate: (d) => d.startOf("d").startOf("h"),
- delta: "hour",
- maxItem: 24,
- },
- pickerFormat: "HH:mm",
- outOfCurrentZoom: () => false,
- sameKey: "hour",
- nextKey: "day",
- },
- month: {
- pickerTitle: (d) => d.format("MMMM YYYY"),
- content: {
- getFirstDate: (d) => d.startOf("month").startOf("week"),
- delta: "day",
- maxItem: 6 * 7,
- },
- pickerFormat: "D",
- outOfCurrentZoom: (d, other) => !d.isSame(other, "month"),
- sameKey: "day",
- nextKey: "month",
- },
- year: {
- pickerTitle: (d) => d.format("YYYY"),
- content: {
- getFirstDate: (d) => d.startOf("year"),
- delta: "month",
- maxItem: 12,
- },
- pickerFormat: "MMM",
- outOfCurrentZoom: (d, other) => !d.isSame(other, "year"),
- sameKey: "month",
- nextKey: "year",
- },
- decade: {
- pickerTitle: (d) => {
- let tmp = d.startOf("y");
- tmp = tmp.year(Math.floor(tmp.year() / decadeSpan) * decadeSpan);
- return tmp.format("YYYY") + "-" + tmp.add(decadeSpan - 1, "y").format("YYYY");
- },
- content: {
- getFirstDate: (d) =>
- d
- .startOf("y")
- .year(Math.floor(d.year() / decadeSpan) * decadeSpan)
- .subtract(1, "y"),
- delta: "year",
- maxItem: 12,
- },
- pickerFormat: "YYYY",
- outOfCurrentZoom: (d, other) =>
- Math.floor(d.year() / decadeSpan) !== Math.floor(other.year() / decadeSpan),
- sameKey: "year",
- nextKey: "year",
- },
- };
- export default defineComponent({
- name: "DatePicker",
- emits: ["update:modelValue"],
- props: {
- label: {
- type: String,
- required: false,
- default: () => "Select date",
- },
- modelValue: {
- type: [Number, Object, String, Date],
- required: false,
- default: () => "",
- },
- min: {
- type: [Number, Object, String, Date],
- required: false,
- default: () => {
- return dayjs().year(0);
- },
- validator: dateValidator,
- },
- max: {
- type: [Number, Object, String, Date],
- required: false,
- default: () => {
- return dayjs().year(9999);
- },
- validator: dateValidator,
- },
- lang: {
- type: String,
- required: false,
- default: "en",
- },
- position: {
- type: String,
- required: false,
- default: "",
- },
- target: {
- type: String as PropType<"day" | "hour">,
- default: "day",
- },
- },
- data: function () {
- const now = dayjs();
- return {
- isValid: true,
- today: now,
- currentMonth: now.startOf("month"),
- zoom: "month" as "day" | "month" | "year" | "decade",
- isPickerOpen: false,
- valueObject: null as Dayjs | null,
- textValue: "",
- textRegex: /^(?<year>\d{4})\/(?<month>(?:0[1-9])|(?:1[0-2]))\/(?<day>(?:0[1-9])|(?:[12][0-9])|(?:3[01]))$/,
- };
- },
- computed: {
- placeholder(): string {
- return "yyyy/mm/dd" + (this.target == "hour" ? " HH:mm" : "");
- },
- currentZoomParameter(): ZoomParameter {
- return datepickerParameter[this.zoom];
- },
- pickerTitle(): string {
- var title: string = this.currentZoomParameter.pickerTitle(this.currentMonth);
- return title[0].toUpperCase() + title.slice(1);
- },
- pickerContent(): Array<Dayjs> {
- const output: Array<Dayjs> = [];
- let firstDay = this.currentZoomParameter.content.getFirstDate(this.currentMonth);
- let delta: OpUnitType = this.currentZoomParameter.content.delta;
- let maxItem: number = this.currentZoomParameter.content.maxItem;
- for (let i = 0; i < maxItem; i++) {
- output.push(firstDay.add(i, delta));
- }
- return output;
- },
- pickerFormat(): string {
- return this.currentZoomParameter.pickerFormat;
- },
- sameKey(): OpUnitType {
- return this.currentZoomParameter.sameKey;
- },
- weekDays: function (): Array<string> {
- const start = dayjs().startOf("week");
- return Array(7)
- .fill(0)
- .map((_, i) => start.add(i, "d").format("dd"));
- },
- },
- watch: {
- modelValue(val) {
- const d = dayjs(val);
- if (d.isValid()) {
- if (!this.valueObject?.isSame(d)) {
- this.valueObject = d;
- }
- }
- },
- valueObject(v: Dayjs) {
- if (v.isValid()) {
- this.currentMonth = v;
- if (this.target == "day") {
- this.textValue = v.format("YYYY/MM/DD");
- this.zoom = "month";
- } else {
- this.textValue = v.format("YYYY/MM/DD HH:mm");
- this.zoom = "day";
- }
- this.$emit("update:modelValue", v);
- } else {
- this.textValue = "";
- }
- },
- },
- methods: {
- prev(): void {
- this.changeCurrentDate(-1);
- },
- next(): void {
- this.changeCurrentDate(1);
- },
- changeCurrentDate(direction: number): void {
- const amount = direction * (this.zoom == "decade" ? decadeSpan : 1);
- this.currentMonth = this.currentMonth.add(amount, this.currentZoomParameter.nextKey);
- },
- isOut(d: Dayjs): boolean {
- return this.currentZoomParameter.outOfCurrentZoom(this.currentMonth, d);
- },
- inputEventLintener(event: InputEvent) {
- this.parseNewText((event.target as HTMLInputElement).value);
- },
- parseNewText(newValue: string) {
- let candidate: Dayjs | null = null;
- if (this.target == "day") {
- candidate = dayjs(newValue, "YYYY/M/D");
- } else {
- candidate = dayjs(newValue, "YYYY/M/D HH:mm");
- }
- if (candidate && candidate.isValid() && !this.valueObject?.isSame(candidate)) {
- this.valueObject = candidate;
- this.isValid = true;
- }
- if (candidate && !candidate.isValid()) {
- this.isValid = false;
- } else {
- this.isValid = true;
- }
- },
- selectItem: function (i: number): void {
- const d = this.pickerContent[i];
- if (
- (this.zoom == "day" && this.target == "hour") ||
- (this.zoom == "month" && this.target == "day")
- ) {
- if (d.isAfter(this.min) && d.isBefore(this.max)) {
- this.valueObject = d;
- }
- } else {
- this.currentMonth = d;
- this.zoom = this.zoom == "decade" ? "year" : this.zoom == "year" ? "month" : "day";
- }
- },
- goUp() {
- if (this.zoom === "year") {
- this.zoom = "decade";
- }
- if (this.zoom === "month") {
- this.zoom = "year";
- }
- if (this.zoom === "day") {
- this.zoom = "month";
- }
- },
- openPicker: function () {
- if (!this.isPickerOpen) {
- this.isPickerOpen = true;
- window.addEventListener("focusin", this.loseFocusListener);
- window.addEventListener("click", this.loseFocusListener);
- }
- },
- loseFocusListener: function (e: Event) {
- if (
- !this.$el.contains(e.target) ||
- (e.target as HTMLElement).classList.contains("date-input-title")
- ) {
- window.removeEventListener("focusin", this.loseFocusListener);
- window.removeEventListener("click", this.loseFocusListener);
- this.isPickerOpen = false;
- this.parseNewText(this.textValue);
- }
- },
- },
- mounted() {
- if (this.modelValue) {
- if (["object", "string", "number"].includes(typeof this.modelValue)) {
- this.valueObject = dayjs(this.modelValue);
- }
- }
- this.zoom = "month";
- },
- });
- </script>
- <style>
- .date-input-control {
- margin-right: 4px;
- position: relative;
- }
- .date-input-title {
- color: #505d74;
- font-weight: bold;
- padding-bottom: 4px;
- }
- .input-wrapper {
- position: relative;
- }
- .input-wrapper:after {
- font-family: "Material Icons";
- font-weight: normal;
- font-style: normal;
- font-size: 20px;
- content: "calendar_today";
- position: absolute;
- right: 8px;
- top: 3px;
- color: var(--color-accent-400);
- cursor: pointer;
- }
- .date-input[type="text"] {
- all: initial;
- color: var(--color-neutral-100);
- width: 100%;
- border: none;
- height: 2rem;
- margin: 0;
- outline: 0;
- padding: 0.2rem 0.5rem;
- font-size: 0.875rem;
- -webkit-appearance: none;
- -moz-appearance: none;
- appearance: none;
- box-shadow: 0 -1px 0 0 var(--color-accent-700) inset;
- box-sizing: border-box;
- -webkit-transition: 0.1s ease-in-out;
- transition: 0.1s ease-in-out;
- font-family: Roboto, Helvetica Neue, Helvetica, Arial, sans-serif;
- line-height: 1.25rem;
- background-color: var(--color-accent-950);
- -webkit-transition-property: box-shadow, border;
- transition-property: box-shadow, border;
- position: relative;
- }
- .date-input[type="text"]:focus {
- border-bottom: solid 2px var(--color-accent-400);
- }
- .date-input[type="text"]:focus + .datepicker-container,
- .date-input[type="text"]:hover + .datepicker-container {
- display: block;
- }
- .date-input--invalid {
- box-shadow: 0 -1px 0 0 #e93255 inset !important;
- }
- [type="text"].date-input--invalid:focus {
- box-shadow: 0 0 0 2px #e93255 !important;
- border-bottom: 0;
- }
- .datepicker-container {
- position: absolute;
- width: 315px;
- height: 350px;
- display: none;
- z-index: 10;
- }
- .datepicker-container.show {
- display: block;
- }
- .datepicker-container.same-size {
- width: 100%;
- height: auto;
- padding-top: 114%;
- }
- .datepicker-container.right {
- right: 0px;
- }
- .datepicker {
- position: absolute;
- top: 0;
- left: 0;
- bottom: 0;
- right: 0;
- background: white;
- box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.14);
- padding: 8px;
- z-index: 10;
- display: grid;
- grid-template: 1fr 7fr/ 1fr;
- row-gap: 4px;
- }
- .datepicker.month {
- grid-template-rows: 1fr 1fr 6fr;
- }
- .datepicker-header,
- .datepicker-weekday,
- .datepicker-items {
- color: var(--color-neutral-400);
- display: grid;
- column-gap: 4px;
- row-gap: 4px;
- grid-template-rows: 1fr;
- grid-template-columns: repeat(7, 1fr);
- }
- .datepicker-items.day {
- grid-template-columns: repeat(4, 1fr);
- grid-template-rows: repeat(6, 1fr);
- }
- .datepicker-items.month {
- grid-template-rows: repeat(6, 1fr);
- }
- .datepicker-items.decade,
- .datepicker-items.year {
- grid-template-columns: repeat(4, 1fr);
- grid-template-rows: repeat(3, 1fr);
- }
- .datepicker-header > div,
- .datepicker-weekday > div,
- .datepicker-items > div {
- border: solid 2px transparent;
- border-radius: 3px;
- display: flex;
- justify-content: center;
- align-items: center;
- text-align: center;
- }
- .datepicker-arrow {
- font-family: "Material Icons";
- font-weight: normal;
- font-style: normal;
- font-size: 35px;
- text-align: center;
- color: var(--color-accent-400);
- }
- .datepicker-current {
- grid-column: 2/7;
- font-weight: bold;
- }
- .datepicker-header > div,
- .datepicker-items > div {
- cursor: pointer;
- }
- .datepicker-header > div:hover,
- .datepicker-items > div:not(.disable):hover {
- background: #f0f4f6;
- color: var(--color-neutral-100);
- }
- .datepicker-header > div:focus,
- .datepicker-items > div:not(.disable):focus {
- border-color: var(--color-accent-500);
- border-radius: 2px;
- }
- .datepicker-items > div.selected:not(.disable) {
- background: var(--color-accent-500);
- color: #fff;
- font-weight: bold;
- }
- .datepicker-weekday > div {
- color: var(--color-neutral-600);
- }
- .datepicker-items .today {
- border: solid 2px var(--color-accent-500);
- font-weight: bold;
- color: var(--color-accent-500);
- }
- .datepicker-items .disable,
- .datepicker-items .out {
- color: var(--color-neutral-800);
- }
- .datepicker-items .disable {
- cursor: not-allowed;
- }
- </style>
|