From 6955294283da9f4d1b7c084015d275ddb6d3c2f4 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Tue, 14 Jan 2025 23:47:03 -0300 Subject: [PATCH] feat(datepicker): drop native datepickers in favor of AirDatePicker for better compatibility As Firefox (still) doesn't support month input type --- .../common/fields/forms/dynamic_select.py | 1 - app/apps/common/fields/month_year.py | 12 +- app/apps/common/utils/django.py | 32 +++ app/apps/common/widgets/datepicker.py | 185 ++++++++++++++++++ app/apps/currencies/forms.py | 10 +- app/apps/dca/forms.py | 9 +- app/apps/transactions/filters.py | 5 +- app/apps/transactions/forms.py | 40 ++-- app/templates/extends/offcanvas.html | 3 +- app/templates/includes/scripts.html | 5 +- .../scripts/hyperscript/init_date_picker.html | 22 +++ .../currency_converter.html | 4 +- .../monthly_overview/pages/overview.html | 3 +- .../transactions/pages/transactions.html | 3 +- frontend/package-lock.json | 7 + frontend/package.json | 1 + frontend/src/application/datepicker.js | 148 ++++++++++++++ frontend/src/styles/_datepicker.scss | 89 +++++++++ frontend/src/styles/style.scss | 1 + 19 files changed, 545 insertions(+), 35 deletions(-) create mode 100644 app/apps/common/utils/django.py create mode 100644 app/apps/common/widgets/datepicker.py create mode 100644 app/templates/includes/scripts/hyperscript/init_date_picker.html create mode 100644 frontend/src/application/datepicker.js create mode 100644 frontend/src/styles/_datepicker.scss diff --git a/app/apps/common/fields/forms/dynamic_select.py b/app/apps/common/fields/forms/dynamic_select.py index 2d2ab01..6e0a8c6 100644 --- a/app/apps/common/fields/forms/dynamic_select.py +++ b/app/apps/common/fields/forms/dynamic_select.py @@ -61,7 +61,6 @@ class DynamicModelChoiceField(forms.ModelChoiceField): self._created_instance = instance return instance except Exception as e: - print(e) raise ValidationError( self.error_messages["invalid_choice"], code="invalid_choice" ) diff --git a/app/apps/common/fields/month_year.py b/app/apps/common/fields/month_year.py index 70eb33c..d4481fe 100644 --- a/app/apps/common/fields/month_year.py +++ b/app/apps/common/fields/month_year.py @@ -1,9 +1,11 @@ import datetime from django import forms -from django.db import models from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ +from apps.common.widgets.datepicker import AirMonthYearPickerInput from apps.common.widgets.month_year import MonthYearWidget @@ -27,7 +29,7 @@ class MonthYearModelField(models.DateField): class MonthYearFormField(forms.DateField): - widget = MonthYearWidget + widget = AirMonthYearPickerInput def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -42,7 +44,11 @@ class MonthYearFormField(forms.DateField): date = datetime.datetime.strptime(value, "%Y-%m") return date.replace(day=1).date() except ValueError: - raise ValidationError(_("Invalid date format. Use YYYY-MM.")) + try: + date = datetime.datetime.strptime(value, "%Y-%m-%d") + return date.replace(day=1).date() + except ValueError: + raise ValidationError(_("Invalid date format. Use YYYY-MM.")) def prepare_value(self, value): if isinstance(value, datetime.date): diff --git a/app/apps/common/utils/django.py b/app/apps/common/utils/django.py new file mode 100644 index 0000000..c1a6342 --- /dev/null +++ b/app/apps/common/utils/django.py @@ -0,0 +1,32 @@ +def django_to_python_datetime(django_format): + mapping = { + # Day + "j": "%d", # Day of the month without leading zeros + "d": "%d", # Day of the month with leading zeros + "D": "%a", # Day of the week, short version + "l": "%A", # Day of the week, full version + # Month + "n": "%m", # Month without leading zeros + "m": "%m", # Month with leading zeros + "M": "%b", # Month, short version + "F": "%B", # Month, full version + # Year + "y": "%y", # Year, 2 digits + "Y": "%Y", # Year, 4 digits + # Time + "g": "%I", # Hour (12-hour), without leading zeros + "G": "%H", # Hour (24-hour), without leading zeros + "h": "%I", # Hour (12-hour), with leading zeros + "H": "%H", # Hour (24-hour), with leading zeros + "i": "%M", # Minutes + "s": "%S", # Seconds + "a": "%p", # am/pm + "A": "%p", # AM/PM + "P": "%I:%M %p", + } + + python_format = django_format + for django_code, python_code in mapping.items(): + python_format = python_format.replace(django_code, python_code) + + return python_format diff --git a/app/apps/common/widgets/datepicker.py b/app/apps/common/widgets/datepicker.py new file mode 100644 index 0000000..9ef45df --- /dev/null +++ b/app/apps/common/widgets/datepicker.py @@ -0,0 +1,185 @@ +import datetime + +from django.forms import widgets +from django.utils import formats, translation, dates +from django.utils.formats import get_format + +from apps.common.utils.django import django_to_python_datetime + + +class AirDatePickerInput(widgets.DateInput): + def __init__( + self, + attrs=None, + format=None, + clear_button=True, + auto_close=True, + *args, + **kwargs, + ): + attrs = attrs or {} + + super().__init__(attrs=attrs, format=format, *args, **kwargs) + + self.clear_button = clear_button + self.auto_close = auto_close + + def _get_current_language(self): + """Get current language code in format compatible with AirDatepicker""" + lang_code = translation.get_language() + # AirDatepicker uses simple language codes + return lang_code.split("-")[0] + + def build_attrs(self, base_attrs, extra_attrs=None): + attrs = super().build_attrs(base_attrs, extra_attrs) + + # Add data attributes for AirDatepicker configuration + attrs["data-auto-close"] = str(self.auto_close).lower() + attrs["data-clear-button"] = str(self.clear_button).lower() + attrs["data-language"] = self._get_current_language() + attrs["data-date-format"] = self.format or get_format( + "SHORT_DATE_FORMAT", use_l10n=True + ) + + return attrs + + def format_value(self, value): + """Format the value for display in the widget.""" + if value: + self.attrs["data-value"] = ( + value # We use this to dynamically select the initial date on AirDatePicker + ) + + if value is None: + return "" + if isinstance(value, (datetime.date, datetime.datetime)): + return formats.date_format( + value, format=self.format or "SHORT_DATE_FORMAT", use_l10n=True + ) + + return str(value) + + +class AirDateTimePickerInput(widgets.DateTimeInput): + def __init__( + self, + attrs=None, + format=None, + timepicker=True, + clear_button=True, + auto_close=True, + *args, + **kwargs, + ): + attrs = attrs or {} + + super().__init__(attrs=attrs, format=format, *args, **kwargs) + + self.timepicker = timepicker + self.clear_button = clear_button + self.auto_close = auto_close + + def _get_current_language(self): + """Get current language code in format compatible with AirDatepicker""" + lang_code = translation.get_language() + # AirDatepicker uses simple language codes + return lang_code.split("-")[0] + + def build_attrs(self, base_attrs, extra_attrs=None): + attrs = super().build_attrs(base_attrs, extra_attrs) + + # Add data attributes for AirDatepicker configuration + attrs["data-timepicker"] = str(self.timepicker).lower() + attrs["data-auto-close"] = str(self.auto_close).lower() + attrs["data-clear-button"] = str(self.clear_button).lower() + attrs["data-language"] = self._get_current_language() + attrs["data-date-format"] = self.format or get_format( + "SHORT_DATETIME_FORMAT", use_l10n=True + ) + + return attrs + + def format_value(self, value): + """Format the value for display in the widget.""" + if value: + self.attrs["data-value"] = ( + value # We use this to dynamically select the initial date on AirDatePicker + ) + + if value is None: + return "" + if isinstance(value, (datetime.date, datetime.datetime)): + return formats.date_format( + value, format=self.format or "SHORT_DATETIME_FORMAT", use_l10n=True + ) + + return str(value) + + def value_from_datadict(self, data, files, name): + """Parse the datetime string from the form data.""" + value = super().value_from_datadict(data, files, name) + if value: + try: + # This does it's best to convert Django's PHP-Style date format to a datetime format and reformat the + # value to be read by Django. Probably could be improved + return datetime.datetime.strptime( + value, + self.format + or django_to_python_datetime(get_format("SHORT_DATETIME_FORMAT")), + ).strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, TypeError) as e: + return value + return None + + +class AirMonthYearPickerInput(AirDatePickerInput): + def __init__(self, attrs=None, format=None, *args, **kwargs): + super().__init__(attrs=attrs, format=format, *args, **kwargs) + # Store the display format for AirDatepicker + self.display_format = "MMMM yyyy" + # Store the Python format for internal use + self.python_format = "%B %Y" + + def _get_month_names(self): + """Get month names using Django's date translation""" + return {dates.MONTHS[i]: i for i in range(1, 13)} + + def format_value(self, value): + """Format the value for display in the widget.""" + if value: + self.attrs["data-value"] = ( + value # We use this to dynamically select the initial date on AirDatePicker + ) + + if value is None: + return "" + if isinstance(value, str): + try: + value = datetime.datetime.strptime(value, "%Y-%m-%d").date() + except ValueError: + return value + if isinstance(value, (datetime.datetime, datetime.date)): + # Use Django's date translation + month_name = dates.MONTHS[value.month] + return f"{month_name} {value.year}" + return value + + def value_from_datadict(self, data, files, name): + """Convert the value from the widget format back to a format Django can handle.""" + value = super().value_from_datadict(data, files, name) + if value: + try: + # Split the value into month name and year + month_str, year_str = value.rsplit(" ", 1) + year = int(year_str) + + # Get month number from translated month name + month_names = self._get_month_names() + month = month_names.get(month_str) + + if month and year: + # Return the first day of the month in Django's expected format + return datetime.date(year, month, 1).strftime("%Y-%m-%d") + except (ValueError, KeyError): + return None + return None diff --git a/app/apps/currencies/forms.py b/app/apps/currencies/forms.py index 59f0658..b6b36c4 100644 --- a/app/apps/currencies/forms.py +++ b/app/apps/currencies/forms.py @@ -6,9 +6,10 @@ from django.forms import CharField from django.utils.translation import gettext_lazy as _ from apps.common.widgets.crispy.submit import NoClassSubmit +from apps.common.widgets.datepicker import AirDateTimePickerInput from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput -from apps.currencies.models import Currency, ExchangeRate from apps.common.widgets.tom_select import TomSelect +from apps.currencies.models import Currency, ExchangeRate class CurrencyForm(forms.ModelForm): @@ -64,9 +65,10 @@ class CurrencyForm(forms.ModelForm): class ExchangeRateForm(forms.ModelForm): date = forms.DateTimeField( - widget=forms.DateTimeInput( - attrs={"type": "datetime-local"}, format="%Y-%m-%dT%H:%M" - ) + widget=AirDateTimePickerInput( + clear_button=False, + ), + label=_("Date"), ) class Meta: diff --git a/app/apps/dca/forms.py b/app/apps/dca/forms.py index 7e916e7..2b61163 100644 --- a/app/apps/dca/forms.py +++ b/app/apps/dca/forms.py @@ -1,13 +1,14 @@ from crispy_forms.bootstrap import FormActions -from django import forms from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout, Row, Column +from django import forms from django.utils.translation import gettext_lazy as _ +from apps.common.widgets.crispy.submit import NoClassSubmit +from apps.common.widgets.datepicker import AirDatePickerInput +from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput from apps.common.widgets.tom_select import TomSelect from apps.dca.models import DCAStrategy, DCAEntry -from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput -from apps.common.widgets.crispy.submit import NoClassSubmit class DCAStrategyForm(forms.ModelForm): @@ -61,7 +62,7 @@ class DCAEntryForm(forms.ModelForm): "notes", ] widgets = { - "date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), + "date": AirDatePickerInput(clear_button=False), "notes": forms.Textarea(attrs={"rows": 3}), } diff --git a/app/apps/transactions/filters.py b/app/apps/transactions/filters.py index 93032c6..9f4b516 100644 --- a/app/apps/transactions/filters.py +++ b/app/apps/transactions/filters.py @@ -8,6 +8,7 @@ from django_filters import Filter from apps.accounts.models import Account from apps.common.fields.month_year import MonthYearFormField +from apps.common.widgets.datepicker import AirDatePickerInput from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput from apps.common.widgets.tom_select import TomSelectMultiple from apps.currencies.models import Currency @@ -87,13 +88,13 @@ class TransactionsFilter(django_filters.FilterSet): date_start = django_filters.DateFilter( field_name="date", lookup_expr="gte", - widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), + widget=AirDatePickerInput(), label=_("Date from"), ) date_end = django_filters.DateFilter( field_name="date", lookup_expr="lte", - widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), + widget=AirDatePickerInput(), label=_("Until"), ) reference_date_start = MonthYearFilter( diff --git a/app/apps/transactions/forms.py b/app/apps/transactions/forms.py index 9045986..190ddf5 100644 --- a/app/apps/transactions/forms.py +++ b/app/apps/transactions/forms.py @@ -16,10 +16,11 @@ from apps.common.fields.forms.dynamic_select import ( DynamicModelChoiceField, DynamicModelMultipleChoiceField, ) -from apps.common.fields.month_year import MonthYearFormField from apps.common.widgets.crispy.submit import NoClassSubmit +from apps.common.widgets.datepicker import AirDatePickerInput, AirMonthYearPickerInput from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput from apps.common.widgets.tom_select import TomSelect +from apps.rules.signals import transaction_created, transaction_updated from apps.transactions.models import ( Transaction, TransactionCategory, @@ -28,7 +29,6 @@ from apps.transactions.models import ( RecurringTransaction, TransactionEntity, ) -from apps.rules.signals import transaction_created, transaction_updated class TransactionForm(forms.ModelForm): @@ -59,7 +59,14 @@ class TransactionForm(forms.ModelForm): label=_("Account"), widget=TomSelect(clear_button=False, group_by="group"), ) - reference_date = MonthYearFormField(label=_("Reference Date"), required=False) + + date = forms.DateField( + widget=AirDatePickerInput(clear_button=False), label=_("Date") + ) + + reference_date = forms.DateField( + widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False + ) class Meta: model = Transaction @@ -77,7 +84,6 @@ class TransactionForm(forms.ModelForm): "entities", ] widgets = { - "date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), "notes": forms.Textarea(attrs={"rows": 3}), "account": TomSelect(clear_button=False, group_by="group"), } @@ -118,8 +124,8 @@ class TransactionForm(forms.ModelForm): css_class="form-row", ), Row( - Column("date", css_class="form-group col-md-6 mb-0"), - Column("reference_date", css_class="form-group col-md-6 mb-0"), + Column(Field("date"), css_class="form-group col-md-6 mb-0"), + Column(Field("reference_date"), css_class="form-group col-md-6 mb-0"), css_class="form-row", ), "description", @@ -235,10 +241,13 @@ class TransferForm(forms.Form): ) date = forms.DateField( - label=_("Date"), - widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), + widget=AirDatePickerInput(clear_button=False), label=_("Date") ) - reference_date = MonthYearFormField(label=_("Reference Date"), required=False) + + reference_date = forms.DateField( + widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False + ) + description = forms.CharField(max_length=500, label=_("Description")) notes = forms.CharField( required=False, @@ -404,7 +413,10 @@ class InstallmentPlanForm(forms.ModelForm): queryset=TransactionEntity.objects.filter(active=True), ) type = forms.ChoiceField(choices=Transaction.Type.choices) - reference_date = MonthYearFormField(label=_("Reference Date"), required=False) + + reference_date = forms.DateField( + widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False + ) class Meta: model = InstallmentPlan @@ -424,10 +436,10 @@ class InstallmentPlanForm(forms.ModelForm): "entities", ] widgets = { - "start_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), "account": TomSelect(), "recurrence": TomSelect(clear_button=False), "notes": forms.Textarea(attrs={"rows": 3}), + "start_date": AirDatePickerInput(clear_button=False), } def __init__(self, *args, **kwargs): @@ -646,7 +658,6 @@ class RecurringTransactionForm(forms.ModelForm): queryset=TransactionEntity.objects.filter(active=True), ) type = forms.ChoiceField(choices=Transaction.Type.choices) - reference_date = MonthYearFormField(label=_("Reference Date"), required=False) class Meta: model = RecurringTransaction @@ -666,8 +677,9 @@ class RecurringTransactionForm(forms.ModelForm): "entities", ] widgets = { - "start_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), - "end_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), + "start_date": AirDatePickerInput(clear_button=False), + "end_date": AirDatePickerInput(), + "reference_date": AirMonthYearPickerInput(), "recurrence_type": TomSelect(clear_button=False), "notes": forms.Textarea( attrs={ diff --git a/app/templates/extends/offcanvas.html b/app/templates/extends/offcanvas.html index c2d7f5b..6f5cef9 100644 --- a/app/templates/extends/offcanvas.html +++ b/app/templates/extends/offcanvas.html @@ -5,6 +5,7 @@
+ _="install init_tom_select + install init_datepicker"> {% block body %}{% endblock %}
diff --git a/app/templates/includes/scripts.html b/app/templates/includes/scripts.html index c156627..ddedee4 100644 --- a/app/templates/includes/scripts.html +++ b/app/templates/includes/scripts.html @@ -3,19 +3,18 @@ {% javascript_pack 'bootstrap' attrs="defer" %} {% javascript_pack 'sweetalert2' attrs="defer" %} {% javascript_pack 'select' attrs="defer" %} +{% javascript_pack 'datepicker' %} {% include 'includes/scripts/hyperscript/init_tom_select.html' %} +{% include 'includes/scripts/hyperscript/init_date_picker.html' %} {% include 'includes/scripts/hyperscript/hide_amount.html' %} {% include 'includes/scripts/hyperscript/tooltip.html' %} {% include 'includes/scripts/hyperscript/htmx_error_handler.html' %} {% include 'includes/scripts/hyperscript/sounds.html' %} {% include 'includes/scripts/hyperscript/swal.html' %} - {% javascript_pack 'htmx' attrs="defer" %} {% javascript_pack 'charts' %} -{##} - diff --git a/app/templates/mini_tools/currency_converter/currency_converter.html b/app/templates/mini_tools/currency_converter/currency_converter.html index 8918801..59f50d3 100644 --- a/app/templates/mini_tools/currency_converter/currency_converter.html +++ b/app/templates/mini_tools/currency_converter/currency_converter.html @@ -8,7 +8,9 @@ {% block title %}{% translate 'Currency Converter' %}{% endblock %} {% block content %} -
+
{% translate 'Currency Converter' %}
diff --git a/app/templates/monthly_overview/pages/overview.html b/app/templates/monthly_overview/pages/overview.html index 6d8d2fe..9003de4 100644 --- a/app/templates/monthly_overview/pages/overview.html +++ b/app/templates/monthly_overview/pages/overview.html @@ -124,7 +124,8 @@
{% crispy filter.form %}
diff --git a/app/templates/transactions/pages/transactions.html b/app/templates/transactions/pages/transactions.html index 2a53c95..ef89d67 100644 --- a/app/templates/transactions/pages/transactions.html +++ b/app/templates/transactions/pages/transactions.html @@ -21,7 +21,8 @@
+ _="install init_tom_select + install init_datepicker"> {% crispy filter.form %}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ff21a9e..7beead9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "@babel/preset-env": "^7.16.8", "@fortawesome/fontawesome-free": "^6.6.0", "@popperjs/core": "^2.11.8", + "air-datepicker": "^3.5.3", "alpinejs": "^3.14.1", "autoprefixer": "^10.4.14", "autosize": "^6.0.1", @@ -2703,6 +2704,12 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/air-datepicker": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/air-datepicker/-/air-datepicker-3.5.3.tgz", + "integrity": "sha512-Elf9gLhv/jidN1+TfeRJYMQRUfYx5apXw2dY5DuAMPRnNtQ4Iw9fTTJK772osmXSUB9xQ2Y8Q1Pt6pgBOQLPQw==", + "license": "MIT" + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0fb153b..ee3e8a1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,7 @@ "@babel/preset-env": "^7.16.8", "@fortawesome/fontawesome-free": "^6.6.0", "@popperjs/core": "^2.11.8", + "air-datepicker": "^3.5.3", "alpinejs": "^3.14.1", "autoprefixer": "^10.4.14", "autosize": "^6.0.1", diff --git a/frontend/src/application/datepicker.js b/frontend/src/application/datepicker.js new file mode 100644 index 0000000..66ecfb2 --- /dev/null +++ b/frontend/src/application/datepicker.js @@ -0,0 +1,148 @@ +import AirDatepicker from 'air-datepicker'; +import en from 'air-datepicker/locale/en'; +import ptBr from 'air-datepicker/locale/pt-BR'; +import {createPopper} from '@popperjs/core'; + +const locales = { + 'pt': ptBr, + 'en': en +}; + +function isMobileDevice() { + const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i; + return mobileRegex.test(navigator.userAgent); +} + +function isTouchDevice() { + return ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || (navigator.msMaxTouchPoints > 0); +} + +function isMobile() { + return isMobileDevice() || isTouchDevice(); +} + +window.DatePicker = function createDynamicDatePicker(element) { + let isOnMobile = isMobile(); + + let baseOpts = { + isMobile: isOnMobile, + timepicker: element.dataset.timepicker === 'true', + autoClose: element.dataset.autoClose === 'true', + buttons: element.dataset.clearButton === 'true' ? ['clear', 'today'] : ['today'], + locale: locales[element.dataset.language], + onSelect: ({date, formattedDate, datepicker}) => { + const _event = new CustomEvent("change", { + bubbles: true, + }); + datepicker.$el.dispatchEvent(_event); + } + }; + + const positionConfig = !isOnMobile ? { + position({$datepicker, $target, $pointer, done}) { + let popper = createPopper($target, $datepicker, { + placement: 'bottom', + modifiers: [ + { + name: 'flip', + options: { + padding: { + top: 64 + } + } + }, + { + name: 'offset', + options: { + offset: [0, 20] + } + }, + { + name: 'arrow', + options: { + element: $pointer + } + } + ] + }); + + return function completeHide() { + popper.destroy(); + done(); + }; + } + } : {}; + + let opts = {...baseOpts, ...positionConfig}; + + if (element.dataset.value) { + opts["selectedDates"] = [element.dataset.value]; + opts["startDate"] = [element.dataset.value]; + } + + return new AirDatepicker(element, opts); +}; + + +window.MonthYearPicker = function createDynamicDatePicker(element) { + let isOnMobile = isMobile(); + + let baseOpts = { + isMobile: isOnMobile, + view: 'months', + minView: 'months', + dateFormat: 'MMMM yyyy', + autoClose: element.dataset.autoClose === 'true', + buttons: element.dataset.clearButton === 'true' ? ['clear', 'today'] : ['today'], + locale: locales[element.dataset.language], + onSelect: ({date, formattedDate, datepicker}) => { + const _event = new CustomEvent("change", { + bubbles: true, + }); + datepicker.$el.dispatchEvent(_event); + } + }; + + const positionConfig = !isOnMobile ? { + position({$datepicker, $target, $pointer, done}) { + let popper = createPopper($target, $datepicker, { + placement: 'bottom', + modifiers: [ + { + name: 'flip', + options: { + padding: { + top: 64 + } + } + }, + { + name: 'offset', + options: { + offset: [0, 20] + } + }, + { + name: 'arrow', + options: { + element: $pointer + } + } + ] + }); + + return function completeHide() { + popper.destroy(); + done(); + }; + } + } : {}; + + let opts = {...baseOpts, ...positionConfig}; + + if (element.dataset.value) { + opts["selectedDates"] = [element.dataset.value]; + opts["startDate"] = [element.dataset.value]; + } + return new AirDatepicker(element, opts); +}; diff --git a/frontend/src/styles/_datepicker.scss b/frontend/src/styles/_datepicker.scss new file mode 100644 index 0000000..d0f99a6 --- /dev/null +++ b/frontend/src/styles/_datepicker.scss @@ -0,0 +1,89 @@ +@import 'air-datepicker/air-datepicker.css'; + +.air-datepicker-global-container { + z-index: 2000; // Allows the datepicker to be shown on top of offcanvas +} + +.air-datepicker { + --adp-accent-color: #fbb700; + --adp-day-name-color: #fbb700; + --adp-background-color: #303030; /* $gray-800 */ + --adp-color: #fff; + --adb-color-other-month: #888; /* $gray-600 */ + --adp-cell-background-color-selected: #fbb700; + + --adp-border-color-inline: #444; + + --adp-background-color-selected-other-month-focused: #e6a600; /* Slightly darker than $yellow */ + --adp-background-color-selected-other-month: #fbb700; + + --adp-color-secondary: #adb5bd; /* $gray-500 */ + --adp-background-color-hover: #444; + --adp-background-color-active: #3c3c3c; + --adp-cell-background-color-selected-hover: #e6a600; + --adp-color-other-month: #888; /* $gray-600 */ + --adp-color-disabled: #444; /* $gray-700 */ + --adp-color-disabled-in-range: #666; /* Between $gray-600 and $gray-700 */ + --adp-color-other-month-hover: #ced4da; /* $gray-400 */ + --adp-time-track-color: #444; /* $gray-700 */ + --adp-time-track-color-hover: #888; /* $gray-600 */ +} + +.air-datepicker-cell.-selected-, +.air-datepicker-cell.-selected-.-current-, +.-selected-.air-datepicker-cell.-year-.-other-decade-, +.-selected-.air-datepicker-cell.-day-.-other-month-{ + color: #222; /* $gray-900 */ +} + +/* Additional styles for better dark theme integration */ +.air-datepicker { + border-color: #444; /* $gray-700 */ +} + +.air-datepicker-body--day-names { + color: #fbb700; /* $yellow */ +} + +.air-datepicker-cell:hover { + background-color: #444; /* $gray-700 */ +} + +.air-datepicker-cell.-current- { + color: #fbb700; /* $yellow */ +} + +.air-datepicker-cell.-range-from-, +.air-datepicker-cell.-range-to- { + border: 1px solid #fbb700; /* $yellow */ +} + +.air-datepicker-cell.-range-from-::before, +.air-datepicker-cell.-range-to-::before { + background-color: rgba(251, 183, 0, 0.1); /* $yellow with opacity */ +} + +.air-datepicker-cell.-in-range- { + background-color: rgba(251, 183, 0, 0.1); /* $yellow with opacity */ +} + +.air-datepicker-time--row input[type='range']::-webkit-slider-thumb { + background-color: #fbb700; /* $yellow */ +} + +.air-datepicker-time--row input[type='range']::-moz-range-thumb { + background-color: #fbb700; /* $yellow */ +} + +.air-datepicker-button, +.air-datepicker-button:hover { + color: #fbb700; /* $yellow */ +} + +.air-datepicker-button:hover { + background-color: #444; /* $gray-700 */ +} + +.air-datepicker--pointer:after { + background: #303030 +} diff --git a/frontend/src/styles/style.scss b/frontend/src/styles/style.scss index 3f0d423..1bb7f8a 100644 --- a/frontend/src/styles/style.scss +++ b/frontend/src/styles/style.scss @@ -2,6 +2,7 @@ @import "font-awesome.scss"; @import "tailwind.scss"; @import "bootstrap.scss"; +@import "datepicker.scss"; @import "tom-select.scss"; @import "animations.scss"; @import "scrollbar.scss";