diff --git a/src/main/java/com/flowingcode/addons/ycalendar/YearMonthField.java b/src/main/java/com/flowingcode/addons/ycalendar/YearMonthField.java index 894defb..699f8b9 100644 --- a/src/main/java/com/flowingcode/addons/ycalendar/YearMonthField.java +++ b/src/main/java/com/flowingcode/addons/ycalendar/YearMonthField.java @@ -26,6 +26,8 @@ import com.vaadin.flow.component.dependency.JsModule; import com.vaadin.flow.function.SerializableFunction; import com.vaadin.flow.internal.JsonSerializer; +import elemental.json.Json; +import elemental.json.JsonValue; import java.time.YearMonth; import java.util.Objects; import java.util.Optional; @@ -33,10 +35,14 @@ @SuppressWarnings("serial") @Tag("fc-year-month-field") @JsModule("./fc-year-month-field/fc-year-month-field.js") -public class YearMonthField extends AbstractSinglePropertyField implements HasTheme { +public class YearMonthField extends AbstractSinglePropertyField + implements HasTheme { private static final String VALUE_PROPERTY = "value"; + private YearMonth max; + private YearMonth min; + private static SerializableFunction map(SerializableFunction f) { return r->Optional.ofNullable(r).map(f).orElse(null); } @@ -57,4 +63,50 @@ public void setI18n(DatePickerI18n i18n) { getElement().setPropertyJson("i18n", JsonSerializer.toJson(i18n)); } + /** + * Sets the minimum year/month in the field. + * + * @param min the minimum year/month that is allowed to be selected, or null to + * remove any minimum constraints + */ + public void setMin(YearMonth min) { + JsonValue value = min == null ? Json.createNull() + : Json.parse("{'month': " + min.getMonth().ordinal() + ", 'year': " + min.getYear() + "}"); + getElement().setPropertyJson("min", value); + this.min = min; + } + + /** + * Gets the minimum year/month in the field. + * + * @return the minimum year/month that is allowed to be selected, or null if there's + * no minimum + */ + public YearMonth getMin() { + return min; + } + + /** + * Sets the maximum year/month in the field. + * + * @param min the maximum year/month that is allowed to be selected, or null to + * remove any maximum constraints + */ + public void setMax(YearMonth max) { + JsonValue value = max == null ? Json.createNull() + : Json.parse("{'month': " + max.getMonth().ordinal() + ", 'year': " + max.getYear() + "}"); + getElement().setPropertyJson("max", value); + this.max = max; + } + + /** + * Gets the maximum year/month in the field. + * + * @return the maximum year/month that is allowed to be selected, or null if there's + * no maximum + */ + public YearMonth getMax() { + return max; + } + } diff --git a/src/main/resources/META-INF/frontend/fc-year-month-field/fc-year-month-field.js b/src/main/resources/META-INF/frontend/fc-year-month-field/fc-year-month-field.js index d54b3cd..faaf69a 100644 --- a/src/main/resources/META-INF/frontend/fc-year-month-field/fc-year-month-field.js +++ b/src/main/resources/META-INF/frontend/fc-year-month-field/fc-year-month-field.js @@ -18,7 +18,7 @@ * #L% */ import { css, html, LitElement } from 'lit'; - + export class YearMonthField extends LitElement { static get is() { return 'fc-year-month-field'; } @@ -29,6 +29,8 @@ export class YearMonthField extends LitElement { date: {type: Date}, year: {type: Number, readOnly: true, state: true}, month: {type: Number, readOnly: true, state: true}, + min: {type: Object, readOnly: true}, + max: {type: Object, readOnly: true}, i18n: {type: Object} } } @@ -39,6 +41,10 @@ export class YearMonthField extends LitElement { this._i18n = {}; this.__setDefaultFormatTitle(this._i18n); this.__setDefaultMonthNames(this._i18n); + this._decMonthDisabled = false; + this._incMonthDisabled = false; + this._decYearDisabled = false; + this._incYearDisabled = false; } set i18n(value) { @@ -56,19 +62,42 @@ export class YearMonthField extends LitElement { get i18n() { return this._i18n; } + /** + * Checks if value is between min and max (inclusive). + */ + __checkRange(value) { + return (!this.min || value >= this._minAsNumber) + && (!this.max || value <= this._maxAsNumber); + } + willUpdate(changedProperties) { if (changedProperties.has('value')) { - this.date = this.value ? new Date(this.value.substring(0,7)+'-02') : new Date(); - } - if (changedProperties.has('date')) { - this.value = this.date.toISOString().substring(0,7); - this.date.setDate(1); - this._year = this.date.getFullYear(); - this._month = this.date.getMonth()+1; + this.date = this.value ? new Date(this.value.substring(0,7)+'-02') : new Date(); } if (changedProperties.has('i18n') && !this.i18n) { this.i18n = changedProperties.get('i18n'); } + if (changedProperties.has('date') || changedProperties.has('min') || changedProperties.has('max')) { + const strValue = this.date.toISOString().substring(0,7); + this.__normalizeValue(strValue); + this.date.setDate(1); + this._year = this.date.getFullYear(); + this._month = this.date.getMonth() + 1; + this.__toggleButtons(this.min, this.max); + } + } + + __normalizeValue(value) { + const intValue = parseInt(this.__yearMonthAsNumber(this.date.getFullYear(), this.date.getMonth())); + if (this.min && intValue < this._minAsNumber){ + this.value = this.min.year + '-' + String(this.min.month + 1).padStart(2, '0'); + this.date = new Date(this.min.year, this.min.month); + } if (this.max && intValue > this._maxAsNumber){ + this.value = this.max.year + '-' + String(this.max.month + 1).padStart(2, '0'); + this.date = new Date(this.max.year, this.max.month); + } else { + this.value = value; + } } updated(changedProperties) { @@ -96,11 +125,11 @@ export class YearMonthField extends LitElement { render() { return html` - << - < + << + <
${this.formatTitle(this._month, this._year)}
- > - >> + > + >> `; } @@ -116,19 +145,27 @@ export class YearMonthField extends LitElement { } __decYear() { - this.__addMonths(-12); + if(!this._minAsNumber || this._minAsNumber < this.__dateAsNumber()){ + this.__addMonths(-12); + } } __decMonth() { - this.__addMonths(-1); + if(!this._minAsNumber || this._minAsNumber < this.__dateAsNumber()){ + this.__addMonths(-1); + } } __incYear() { - this.__addMonths(12); + if(!this._maxAsNumber || this._maxAsNumber > this.__dateAsNumber()){ + this.__addMonths(12); + } } __incMonth() { - this.__addMonths(1); + if(!this._maxAsNumber || this._maxAsNumber > this.__dateAsNumber()){ + this.__addMonths(1); + } } __setDefaultFormatTitle(obj){ @@ -138,6 +175,65 @@ export class YearMonthField extends LitElement { __setDefaultMonthNames(obj){ obj.monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; } + + /** + * Returns a number joining year and month. Month is left padded with zero up to two chars length. + */ + __yearMonthAsNumber(year, month) { + return parseInt(year + '' + String(month).padStart(2, '0')); + } + + /** + * Converts this.date to yearMonth number + */ + __dateAsNumber() { + return this.date ? this.__yearMonthAsNumber(this.date.getFullYear(), this.date.getMonth()) : undefined; + } + + /** + * Converts min or max value to yearMonth number. + */ + __minMaxAsNumber(value) { + return value ? this.__yearMonthAsNumber(value.year, value.month) : undefined; + } + + /** + * Converts this.min to number + * @private + */ + get _minAsNumber() { + return this.__minMaxAsNumber(this.min); + } + + /** + * Converts this.max to number + * @private + */ + get _maxAsNumber() { + return this.__minMaxAsNumber(this.max); + } + + /** + * Returns true if delta between minOrMax and this.date is less than 1 year (12 months) + */ + __dateDeltaLtYear(minOrMax) { + const toMonths = (year, month) => year * 12 + month; + const dateToMonths = toMonths(this.date.getFullYear(), this.date.getMonth()); + return Math.abs(dateToMonths - toMonths(minOrMax.year, minOrMax.month)) < 12; + } + + /** + * Enable or disabled navigation buttons + */ + __toggleButtons(min, max) { + const minAsNumber = this.__minMaxAsNumber(min); + const maxAsNumber = this.__minMaxAsNumber(max); + + this._decMonthDisabled = minAsNumber && minAsNumber >= this.__dateAsNumber(); + this._incMonthDisabled = maxAsNumber && maxAsNumber <= this.__dateAsNumber(); + this._decYearDisabled = minAsNumber && (minAsNumber >= this.__dateAsNumber() || this.__dateDeltaLtYear(min)); + this._incYearDisabled = maxAsNumber && (maxAsNumber <= this.__dateAsNumber() || this.__dateDeltaLtYear(max)); + } } diff --git a/src/test/java/com/flowingcode/addons/ycalendar/YearMonthFieldDemo.java b/src/test/java/com/flowingcode/addons/ycalendar/YearMonthFieldDemo.java index 562c3ae..28dd537 100644 --- a/src/test/java/com/flowingcode/addons/ycalendar/YearMonthFieldDemo.java +++ b/src/test/java/com/flowingcode/addons/ycalendar/YearMonthFieldDemo.java @@ -20,12 +20,18 @@ package com.flowingcode.addons.ycalendar; import com.flowingcode.vaadin.addons.demo.DemoSource; +import com.vaadin.flow.component.Text; +import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.datepicker.DatePicker.DatePickerI18n; import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.radiobutton.RadioButtonGroup; import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; +import java.time.YearMonth; import java.util.List; +import java.util.Optional; @DemoSource @PageTitle("Year-Month Field") @@ -62,6 +68,41 @@ public YearMonthFieldDemo() { }); add(languageSelector); + + Span minRangeValue = new Span("-"); + Span maxRangeValue = new Span("-"); + add(new Div(new Text("Min: "), minRangeValue, new Text(" :: Max: "), maxRangeValue)); + + YearMonth min = YearMonth.now().minusYears(2); + Button setMinRangeButton = new Button("Set min " + min.toString()); + setMinRangeButton.addClickListener(e -> { + field.setMin(min); + minRangeValue + .setText(Optional.ofNullable(field.getMin()).map(YearMonth::toString).orElse("-")); + }); + + YearMonth max = YearMonth.now().plusYears(2); + Button setMaxRangeButton = new Button("Set max " + max.toString()); + setMaxRangeButton.addClickListener(e -> { + field.setMax(max); + maxRangeValue + .setText(Optional.ofNullable(field.getMax()).map(YearMonth::toString).orElse("-")); + }); + + Button clearRangeButton = new Button("Clear range"); + clearRangeButton.addClickListener(e -> { + field.setMin(null); + field.setMax(null); + minRangeValue.setText("-"); + maxRangeValue.setText("-"); + }); + + YearMonth newValue = YearMonth.now().plusYears(3); + Button setValueButton = new Button("Set value " + newValue.toString()); + setValueButton.addClickListener(e -> field.setValue(newValue)); + + add(new HorizontalLayout(setMinRangeButton, setMaxRangeButton, clearRangeButton, + setValueButton)); } }