
com.vaadin.flow.component.timepicker.TimePicker Maven / Gradle / Ivy
/*
* Copyright 2000-2024 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.vaadin.flow.component.timepicker;
import java.time.Duration;
import java.time.LocalTime;
import java.time.temporal.ChronoUnit;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Stream;
import com.vaadin.flow.component.AbstractField;
import com.vaadin.flow.component.AbstractSinglePropertyField;
import com.vaadin.flow.component.AttachEvent;
import com.vaadin.flow.component.ComponentEvent;
import com.vaadin.flow.component.ComponentEventListener;
import com.vaadin.flow.component.Focusable;
import com.vaadin.flow.component.HasAriaLabel;
import com.vaadin.flow.component.HasPlaceholder;
import com.vaadin.flow.component.HasValue;
import com.vaadin.flow.component.Synchronize;
import com.vaadin.flow.component.Tag;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.dependency.JsModule;
import com.vaadin.flow.component.dependency.NpmPackage;
import com.vaadin.flow.component.shared.ClientValidationUtil;
import com.vaadin.flow.component.shared.HasAllowedCharPattern;
import com.vaadin.flow.component.shared.HasAutoOpen;
import com.vaadin.flow.component.shared.HasClearButton;
import com.vaadin.flow.component.shared.HasClientValidation;
import com.vaadin.flow.component.shared.HasOverlayClassName;
import com.vaadin.flow.component.shared.HasPrefix;
import com.vaadin.flow.component.shared.HasThemeVariant;
import com.vaadin.flow.component.shared.HasValidationProperties;
import com.vaadin.flow.component.shared.InputField;
import com.vaadin.flow.component.shared.ValidationUtil;
import com.vaadin.flow.data.binder.HasValidator;
import com.vaadin.flow.data.binder.ValidationResult;
import com.vaadin.flow.data.binder.ValidationStatusChangeEvent;
import com.vaadin.flow.data.binder.ValidationStatusChangeListener;
import com.vaadin.flow.data.binder.Validator;
import com.vaadin.flow.function.SerializableConsumer;
import com.vaadin.flow.function.SerializableFunction;
import com.vaadin.flow.internal.StateTree;
import com.vaadin.flow.shared.Registration;
/**
* Time Picker is an input field for entering or selecting a specific time. The
* time can be entered directly using a keyboard or by choosing a value from a
* set of predefined options presented in an overlay. The overlay opens when the
* field is clicked or any input is entered when the field is focused.
*
* @author Vaadin Ltd
*/
@Tag("vaadin-time-picker")
@NpmPackage(value = "@vaadin/polymer-legacy-adapter", version = "24.4.5")
@JsModule("@vaadin/polymer-legacy-adapter/style-modules.js")
@NpmPackage(value = "@vaadin/time-picker", version = "24.4.5")
@JsModule("@vaadin/time-picker/src/vaadin-time-picker.js")
@JsModule("./vaadin-time-picker/timepickerConnector.js")
public class TimePicker
extends AbstractSinglePropertyField
implements Focusable, HasAllowedCharPattern, HasAriaLabel,
HasAutoOpen, HasClearButton, HasClientValidation,
InputField, LocalTime>,
HasPrefix, HasOverlayClassName, HasThemeVariant,
HasValidationProperties, HasValidator, HasPlaceholder {
private static final SerializableFunction PARSER = valueFromClient -> {
return valueFromClient == null || valueFromClient.isEmpty() ? null
: LocalTime.parse(valueFromClient);
};
private static final SerializableFunction FORMATTER = valueFromModel -> {
return valueFromModel == null ? "" : valueFromModel.toString();
};
private Locale locale;
private LocalTime max;
private LocalTime min;
private boolean required;
private StateTree.ExecutionRegistration pendingLocaleUpdate;
private boolean manualValidationEnabled = false;
private final CopyOnWriteArrayList> validationStatusChangeListeners = new CopyOnWriteArrayList<>();
/**
* Default constructor.
*/
public TimePicker() {
this((LocalTime) null, true);
}
/**
* Convenience constructor to create a time picker with a pre-selected time.
*
* @param time
* the pre-selected time in the picker
*/
public TimePicker(LocalTime time) {
this(time, false);
}
/**
* Convenience constructor to create a time picker with a pre-selected time.
*
* @param time
* the pre-selected time in the picker
* @param isInitialValueOptional
* If {@code isInitialValueOptional} is {@code true} then the
* initial value is used only if element has no {@code "value"}
* property value, otherwise element {@code "value"} property is
* ignored and the initial value is set
*/
private TimePicker(LocalTime time, boolean isInitialValueOptional) {
super("value", time, String.class, PARSER, FORMATTER);
// Initialize property value unless it has already been set from a
// template
if ((getElement().getProperty("value") == null
|| !isInitialValueOptional)) {
setPresentationValue(time);
}
// workaround for https://github.com/vaadin/flow/issues/3496
setInvalid(false);
addValueChangeListener(e -> validate());
getElement().addEventListener("unparsable-change", event -> {
validate();
fireValidationStatusChangeEvent();
});
getElement().addPropertyChangeListener("invalid", event -> fireEvent(
new InvalidChangeEvent(this, event.isUserOriginated())));
}
/**
* Convenience constructor to create a time picker with a label.
*
* @param label
* the label describing the time picker
* @see #setLabel(String)
*/
public TimePicker(String label) {
this();
setLabel(label);
}
/**
* Convenience constructor to create a time picker with a pre-selected time
* and a label.
*
* @param label
* the label describing the time picker
* @param time
* the pre-selected time in the picker
*/
public TimePicker(String label, LocalTime time) {
this(time);
setLabel(label);
}
/**
* Convenience constructor to create a time picker with a
* {@link ValueChangeListener}.
*
* @param listener
* the listener to receive value change events
* @see #addValueChangeListener(HasValue.ValueChangeListener)
*/
public TimePicker(
ValueChangeListener> listener) {
this();
addValueChangeListener(listener);
}
/**
* Convenience constructor to create a time picker with a pre-selected time
* and {@link ValueChangeListener}.
*
* @param time
* the pre-selected time in the picker
* @param listener
* the listener to receive value change events
* @see #addValueChangeListener(HasValue.ValueChangeListener)
*/
public TimePicker(LocalTime time,
ValueChangeListener> listener) {
this(time);
addValueChangeListener(listener);
}
/**
* Convenience constructor to create a time picker with a label, a
* pre-selected time and a {@link ValueChangeListener}.
*
* @param label
* the label describing the time picker
* @param time
* the pre-selected time in the picker
* @param listener
* the listener to receive value change events
* @see #setLabel(String)
* @see #addValueChangeListener(HasValue.ValueChangeListener)
*/
public TimePicker(String label, LocalTime time,
ValueChangeListener> listener) {
this(time);
setLabel(label);
addValueChangeListener(listener);
}
/**
* Sets the label for the time picker.
*
* @param label
* value for the {@code label} property in the time picker
*/
public void setLabel(String label) {
getElement().setProperty("label", label == null ? "" : label);
}
/**
* Sets the selected time value of the component. The value can be cleared
* by setting null.
*
*
* The value will be truncated to millisecond precision, as that is the
* maximum that the time picker supports. This means that
* {@link #getValue()} might return a different value than what was passed
* in.
*
* @param value
* the LocalTime instance representing the selected time, or null
*/
@Override
public void setValue(LocalTime value) {
// Truncate the value to millisecond precision, as the is the maximum
// that the time picker web component supports.
if (value != null) {
value = value.truncatedTo(ChronoUnit.MILLIS);
}
LocalTime oldValue = getValue();
boolean isOldValueEmpty = valueEquals(oldValue, getEmptyValue());
boolean isNewValueEmpty = valueEquals(value, getEmptyValue());
boolean isValueRemainedEmpty = isOldValueEmpty && isNewValueEmpty;
boolean isInputValuePresent = isInputValuePresent();
// When the value is cleared programmatically, reset hasInputValue
// so that the following validation doesn't treat this as bad input.
if (isNewValueEmpty) {
getElement().setProperty("_hasInputValue", false);
}
super.setValue(value);
// Clear the input element from possible bad input.
if (isValueRemainedEmpty && isInputValuePresent) {
// The check for value presence guarantees that a non-empty value
// won't get cleared when setValue(null) and setValue(...) are
// subsequently called within one round-trip.
// Flow only sends the final component value to the client
// when you update the value multiple times during a round-trip
// and the final value is sent in place of the first one, so
// `executeJs` can end up invoked after a non-empty value is set.
getElement()
.executeJs("if (!this.value) this._inputElementValue = ''");
validate();
fireValidationStatusChangeEvent();
}
}
/**
* Gets the label of the time picker.
*
* @return the {@code label} property of the time picker
*/
public String getLabel() {
return getElement().getProperty("label");
}
@Override
public void setAriaLabel(String ariaLabel) {
getElement().setProperty("accessibleName", ariaLabel);
}
@Override
public Optional getAriaLabel() {
return Optional.ofNullable(getElement().getProperty("accessibleName"));
}
@Override
public void setAriaLabelledBy(String labelledBy) {
getElement().setProperty("accessibleNameRef", labelledBy);
}
@Override
public Optional getAriaLabelledBy() {
return Optional
.ofNullable(getElement().getProperty("accessibleNameRef"));
}
@Override
public Validator getDefaultValidator() {
return (value, context) -> checkValidity(value);
}
@Override
public Registration addValidationStatusChangeListener(
ValidationStatusChangeListener listener) {
return Registration.addAndRemove(validationStatusChangeListeners,
listener);
}
/**
* Notifies Binder that it needs to revalidate the component since the
* component's validity state may have changed. Note, there is no need to
* notify Binder separately in the case of a ValueChangeEvent, as Binder
* already listens to this event and revalidates automatically.
*/
private void fireValidationStatusChangeEvent() {
ValidationStatusChangeEvent event = new ValidationStatusChangeEvent<>(
this, !isInvalid());
validationStatusChangeListeners
.forEach(listener -> listener.validationStatusChanged(event));
}
private ValidationResult checkValidity(LocalTime value) {
boolean hasNonParsableValue = Objects.equals(value, getEmptyValue())
&& isInputValuePresent();
if (hasNonParsableValue) {
return ValidationResult.error("");
}
ValidationResult greaterThanMaxValidation = ValidationUtil
.checkGreaterThanMax(value, max);
if (greaterThanMaxValidation.isError()) {
return greaterThanMaxValidation;
}
ValidationResult smallThanMinValidation = ValidationUtil
.checkSmallerThanMin(value, min);
if (smallThanMinValidation.isError()) {
return smallThanMinValidation;
}
return ValidationResult.ok();
}
/**
* Performs a server-side validation of the given value. This is needed
* because it is possible to circumvent the client side validation
* constraints using browser development tools.
*/
private boolean isInvalid(LocalTime value) {
var requiredValidation = ValidationUtil.checkRequired(required, value,
getEmptyValue());
return requiredValidation.isError() || checkValidity(value).isError();
}
/**
* Returns whether the input element has a value or not.
*
* @return true
if the input element's value is populated,
* false
otherwise
*/
@Synchronize(property = "_hasInputValue", value = "has-input-value-changed")
protected boolean isInputValuePresent() {
return getElement().getProperty("_hasInputValue", false);
}
/**
* Sets whether the time picker is marked as input required.
*
* @param required
* the boolean value to set
*/
public void setRequired(boolean required) {
getElement().setProperty("required", required);
this.required = required;
}
@Override
public void setRequiredIndicatorVisible(boolean requiredIndicatorVisible) {
super.setRequiredIndicatorVisible(requiredIndicatorVisible);
this.required = requiredIndicatorVisible;
}
/**
* Determines whether the time picker is marked as input required.
*
* This property is not synchronized automatically from the client side, so
* the returned value may not be the same as in client side.
*
* @return {@code true} if the input is required, {@code false} otherwise
*/
public boolean isRequired() {
return getElement().getProperty("required", false);
}
/**
* Sets the {@code step} property of the time picker using duration. It
* specifies the intervals for the displayed items in the time picker
* dropdown and also the displayed time format.
*
* The set step needs to evenly divide a day or an hour and has to be larger
* than 0 milliseconds. By default, the format is {@code hh:mm} (same as *
* {@code Duration.ofHours(1)}
*
* If the step is less than 60 seconds, the format will be changed to
* {@code hh:mm:ss} and it can be in {@code hh:mm:ss.fff} format, when the
* step is less than 1 second.
*
* NOTE: If the step is less than 900 seconds, the dropdown is
* hidden.
*
* NOTE: changing the step to a larger duration can cause a new
* {@link com.vaadin.flow.component.HasValue.ValueChangeEvent} to be fired
* if some parts (eg. seconds) is discarded from the value.
*
* @param step
* the step to set, not {@code null} and should divide a day or
* an hour evenly
*/
public void setStep(Duration step) {
Objects.requireNonNull(step, "Step cannot be null");
getElement().setProperty("step",
StepsUtil.convertDurationToStepsValue(step));
}
/**
* Gets the step of the time picker.
*
*
* This property is not synchronized automatically from the client side, so
* the returned value may not be the same as in client side.
*
* @return the {@code step} property from the picker, unit seconds
*/
public Duration getStep() {
// if step was not set by the user, then assume default value of the
// time picker web component
if (!getElement().hasProperty("step")) {
return StepsUtil.DEFAULT_WEB_COMPONENT_STEP;
}
double step = getElement().getProperty("step", 0.0);
return StepsUtil.convertStepsValueToDuration(step);
}
/**
* {@code invalid-changed} event is sent when the invalid state changes.
*/
public static class InvalidChangeEvent extends ComponentEvent {
private final boolean invalid;
public InvalidChangeEvent(TimePicker source, boolean fromClient) {
super(source, fromClient);
this.invalid = source.isInvalid();
}
public boolean isInvalid() {
return invalid;
}
}
/**
* Adds a listener for {@code invalid-changed} events fired by the
* webcomponent.
*
* @param listener
* the listener
* @return a {@link Registration} for removing the event listener
*/
public Registration addInvalidChangeListener(
ComponentEventListener listener) {
return addListener(InvalidChangeEvent.class, listener);
}
@Override
public void setManualValidation(boolean enabled) {
this.manualValidationEnabled = enabled;
}
/**
* Performs server-side validation of the current value. This is needed
* because it is possible to circumvent the client-side validation
* constraints using browser development tools.
*/
protected void validate() {
if (!this.manualValidationEnabled) {
setInvalid(isInvalid(getValue()));
}
}
@Override
protected void onAttach(AttachEvent attachEvent) {
super.onAttach(attachEvent);
initConnector();
requestLocaleUpdate();
ClientValidationUtil.preventWebComponentFromModifyingInvalidState(this);
}
private void initConnector() {
// can't run this with getElement().executeJs(...) since then
// setLocale might be called before this causing client side error
runBeforeClientResponse(ui -> ui.getPage().executeJs(
"window.Vaadin.Flow.timepickerConnector.initLazy($0)",
getElement()));
}
/**
* Set the Locale for the Time Picker. The displayed time will be formatted
* by the browser using the given locale.
*
* By default, the locale is {@code null} until the component is attached to
* an UI, and then locale is set to {@link UI#getLocale()}, unless a locale
* has been explicitly set before that.
*
* The time formatting is done in the browser using the Date.toLocaleTimeString()
* function.
*
* If for some reason the browser doesn't support the given locale, the
* en-US locale is used.
*
* NOTE: only the language + country/region codes are used. This
* means that the script and variant information is not used and supported.
* NOTE: timezone related data is not supported. NOTE: changing
* the locale does not cause a new
* {@link com.vaadin.flow.component.HasValue.ValueChangeEvent} to be
* fired.
*
* @param locale
* the locale set to the time picker, cannot be [@code null}
*/
public void setLocale(Locale locale) {
Objects.requireNonNull(locale, "Locale must not be null.");
if (locale.getLanguage().isEmpty()) {
throw new UnsupportedOperationException("Given Locale "
+ locale.getDisplayName()
+ " is not supported by time picker because it is missing the language information.");
}
this.locale = locale;
requestLocaleUpdate();
}
/**
* Gets the Locale for this time picker.
*
* By default, the locale is {@code null} until the component is attached to
* an UI, and then locale is set to {@link UI#getLocale()}, unless
* {@link #setLocale(Locale)} has been explicitly called before that.
*
* @return the locale used for this time picker
*/
@Override
public Locale getLocale() {
if (locale != null) {
return locale;
} else {
return super.getLocale();
}
}
private void requestLocaleUpdate() {
getUI().ifPresent(ui -> {
if (pendingLocaleUpdate != null) {
pendingLocaleUpdate.remove();
}
pendingLocaleUpdate = ui.beforeClientResponse(this, context -> {
pendingLocaleUpdate = null;
executeLocaleUpdate();
});
});
}
private void executeLocaleUpdate() {
Locale appliedLocale = getLocale();
// we could support script & variant, but that requires more work on
// client side to detect the different
// number characters for other scripts (current only Arabic there)
StringBuilder bcp47LanguageTag = new StringBuilder(
appliedLocale.getLanguage());
if (!appliedLocale.getCountry().isEmpty()) {
bcp47LanguageTag.append("-").append(appliedLocale.getCountry());
}
runBeforeClientResponse(ui -> getElement().callJsFunction(
"$connector.setLocale", bcp47LanguageTag.toString()));
}
/**
* Sets the minimum time in the time picker. Times before that will be
* disabled in the popup.
*
* @param min
* the minimum time that is allowed to be selected, or
* null
to remove any minimum constraints
*/
public void setMin(LocalTime min) {
this.min = min;
String minString = format(min);
getElement().setProperty("min", minString == null ? "" : minString);
}
/**
* Gets the minimum time in the time picker. Time before that will be
* disabled in the popup.
*
* @return the minimum time that is allowed to be selected, or
* null
if there's no minimum
*/
public LocalTime getMin() {
return this.min;
}
/**
* Sets the maximum time in the time picker. Times after that will be
* disabled in the popup.
*
* @param max
* the maximum time that is allowed to be selected, or
* null
to remove any maximum constraints
*/
public void setMax(LocalTime max) {
this.max = max;
String maxString = format(max);
getElement().setProperty("max", maxString == null ? "" : maxString);
}
/**
* Gets the maximum time in the time picker. Times after that will be
* disabled in the popup.
*
* @return the maximum time that is allowed to be selected, or
* null
if there's no maximum
*/
public LocalTime getMax() {
return this.max;
}
private void runBeforeClientResponse(SerializableConsumer command) {
getElement().getNode().runWhenAttached(ui -> ui
.beforeClientResponse(this, context -> command.accept(ui)));
}
/**
* Returns a stream of all the available locales that are supported by the
* time picker component.
*
* This is a shorthand for {@link Locale#getAvailableLocales()} where all
* locales without the {@link Locale#getLanguage()} have been filtered out,
* as the browser cannot localize the time for those.
*
* @return a stream of the available locales that are supported by the time
* picker component
* @see #setLocale(Locale)
* @see Locale#getAvailableLocales()
* @see Locale#getLanguage()
*/
public static Stream getSupportedAvailableLocales() {
return Stream.of(Locale.getAvailableLocales())
.filter(locale -> !locale.getLanguage().isEmpty());
}
private static String format(LocalTime time) {
return time != null ? time.toString() : null;
}
}