angular / components

Component infrastructure and Material Design components for Angular
https://material.angular.io
MIT License
24.35k stars 6.74k forks source link

Update NativeDateAdapter to use the new i18n api #8100

Open ocombe opened 7 years ago

ocombe commented 7 years ago

Bug, feature request, or proposal:

Feature Request

What is the expected behavior?

Material should no longer use the intl API with Angular v5.

What is the current behavior?

Material's NativeDateAdapter uses the intl API

What are the steps to reproduce?

Providing a StackBlitz/Plunker (or similar) is the best way to get the team to see your issue.
Plunker starter (using on @master): https://goo.gl/DlHd6U
StackBlitz starter (using latest npm release): https://goo.gl/wwnhMV

What is the use-case or motivation for changing an existing behavior?

In Angular v5 we have removed the dependency on the intl API to use CLDR data instead. We've also added a new I18n API that can be used by libraries for such a case. It's very easy to rewrite the NativeDateAdapter to use this instead of intl. The only problem is that Material requires a few formats that are not yet supported by the API. We can write an adapter that does partial matching, but it'd be better to wait for https://github.com/angular/angular/issues/19823 to be resolved first.

jelbourn commented 6 years ago

@ocombe can you give an example of something specific that would change?

ocombe commented 6 years ago

Sure, search for any block of text with SUPPORTS_INTL_API in https://github.com/angular/material2/blob/master/src/lib/core/datetime/native-date-adapter.ts, you could remove it and use the Angular i18n API instead.

Here is an example for the method getMonthNames.

Original:

getMonthNames(style: 'long' | 'short' | 'narrow'): string[] {
  if (SUPPORTS_INTL_API) {
    let dtf = new Intl.DateTimeFormat(this.locale, {month: style});
    return range(12, i => this._stripDirectionalityCharacters(dtf.format(new Date(2017, i, 1))));
  }
  return DEFAULT_MONTH_NAMES[style];
}

After change:

getMonthNames(style: 'long' | 'short' | 'narrow'): string[] {
  return getLocaleMonthNames(this.matDateLocale, FormStyle.Format, this.getStyle(style));
}

Here is a complete working example of the new NativeDateAdapter

/**
 * @license
 * Copyright Google LLC All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.io/license
 */

import {DateAdapter, MAT_DATE_LOCALE, MatDateFormats} from '@angular/material';
import {Inject, Optional} from '@angular/core';
import {DatePipe, FormStyle, getLocaleDayNames, getLocaleFirstDayOfWeek, getLocaleMonthNames, TranslationWidth} from '@angular/common';

const DEFAULT_DATE_NAMES = range(31, i => String(i + 1));

/**
 * Matches strings that have the form of a valid RFC 3339 string
 * (https://tools.ietf.org/html/rfc3339). Note that the string may not actually be a valid date
 * because the regex will match strings and with out of bounds month, date, etc.
 */
const ISO_8601_REGEX = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|(?:(?:\+|-)\d{2}:\d{2}))?)?$/;

export const MAT_NATIVE_DATE_FORMATS: MatDateFormats = {
  parse: {
    dateInput: null,
  },
  display: {
    dateInput: 'shortDate',
    monthYearLabel: 'MMM yyyy', // todo
    dateA11yLabel: 'longDate',
    monthYearA11yLabel: 'MMMM yyyy', // todo
  }
};

export class NativeDateAdapter extends DateAdapter<Date> {
  private datePipe: DatePipe;

  constructor(@Optional() @Inject(MAT_DATE_LOCALE) public matDateLocale: string) {
    super();
    super.setLocale(this.matDateLocale);
    this.datePipe = new DatePipe(matDateLocale);
  }

  getYear(date: Date): number {
    return date.getFullYear();
  }

  getMonth(date: Date): number {
    return date.getMonth();
  }

  getDate(date: Date): number {
    return date.getDate();
  }

  getDayOfWeek(date: Date): number {
    return date.getDay();
  }

  getMonthNames(style: 'long' | 'short' | 'narrow'): string[] {
    return getLocaleMonthNames(this.matDateLocale, FormStyle.Format, this.getStyle(style));
  }

  getDateNames(): string[] {
    return DEFAULT_DATE_NAMES;
  }

  getDayOfWeekNames(style: 'long' | 'short' | 'narrow'): string[] {
    return getLocaleDayNames(this.matDateLocale, FormStyle.Format, this.getStyle(style));
  }

  getYearName(date: Date): string {
    return String(this.getYear(date));
  }

  getFirstDayOfWeek(): number {
    return getLocaleFirstDayOfWeek(this.matDateLocale);
  }

  getNumDaysInMonth(date: Date): number {
    return this.getDate(this._createDateWithOverflow(
      this.getYear(date), this.getMonth(date) + 1, 0));
  }

  clone(date: Date): Date {
    return this.createDate(this.getYear(date), this.getMonth(date), this.getDate(date));
  }

  createDate(year: number, month: number, date: number): Date {
    // Check for invalid month and date (except upper bound on date which we have to check after
    // creating the Date).
    if (month < 0 || month > 11) {
      throw Error(`Invalid month index "${month}". Month index has to be between 0 and 11.`);
    }

    if (date < 1) {
      throw Error(`Invalid date "${date}". Date has to be greater than 0.`);
    }

    const result = this._createDateWithOverflow(year, month, date);

    // Check that the date wasn't above the upper bound for the month, causing the month to overflow
    if (result.getMonth() !== month) {
      throw Error(`Invalid date "${date}" for month with index "${month}".`);
    }

    return result;
  }

  today(): Date {
    return new Date();
  }

  parse(value: any, parseFormat: any): Date | null {
    // We have no way using the native JS Date to set the parse format or locale, so we ignore these parameters.
    if (typeof value === 'number') {
      return new Date(value);
    }
    return value ? new Date(Date.parse(value)) : null;
  }

  format(date: Date, displayFormat: string): string {
    if (!this.isValid(date)) {
      throw Error('I18nDateAdapter: Cannot format invalid date.');
    }
    return this.datePipe.transform(date, displayFormat);
  }

  addCalendarYears(date: Date, years: number): Date {
    return this.addCalendarMonths(date, years * 12);
  }

  addCalendarMonths(date: Date, months: number): Date {
    let newDate = this._createDateWithOverflow(this.getYear(date), this.getMonth(date) + months, this.getDate(date));

    // It's possible to wind up in the wrong month if the original month has more days than the new
    // month. In this case we want to go to the last day of the desired month.
    // Note: the additional + 12 % 12 ensures we end up with a positive number, since JS % doesn't
    // guarantee this.
    if (this.getMonth(newDate) !== ((this.getMonth(date) + months) % 12 + 12) % 12) {
      newDate = this._createDateWithOverflow(this.getYear(newDate), this.getMonth(newDate), 0);
    }

    return newDate;
  }

  addCalendarDays(date: Date, days: number): Date {
    return this._createDateWithOverflow(this.getYear(date), this.getMonth(date), this.getDate(date) + days);
  }

  toIso8601(date: Date): string {
    return [
      date.getUTCFullYear(),
      this._2digit(date.getUTCMonth() + 1),
      this._2digit(date.getUTCDate())
    ].join('-');
  }

  fromIso8601(iso8601String: string): Date | null {
    // The `Date` constructor accepts formats other than ISO 8601, so we need to make sure the
    // string is the right format first.
    if (ISO_8601_REGEX.test(iso8601String)) {
      const d = new Date(iso8601String);
      if (this.isValid(d)) {
        return d;
      }
    }
    return null;
  }

  isDateInstance(obj: any): boolean {
    return obj instanceof Date;
  }

  isValid(date: Date): boolean {
    return !isNaN(date.getTime());
  }

  private getStyle(style: 'long' | 'short' | 'narrow'): TranslationWidth {
    switch(style) {
      case 'long':
        return TranslationWidth.Wide;
      case 'short':
        return TranslationWidth.Abbreviated;
      case 'narrow':
        return TranslationWidth.Narrow;
    }
  }

  /**
   * Pads a number to make it two digits.
   * @param n The number to pad.
   * @returns The padded number.
   */
  private _2digit(n: number) {
    return ('00' + n).slice(-2);
  }

  /** Creates a date but allows the month and date to overflow. */
  private _createDateWithOverflow(year: number, month: number, date: number) {
    const result = new Date(year, month, date);

    // We need to correct for the fact that JS native Date treats years in range [0, 99] as
    // abbreviations for 19xx.
    if (year >= 0 && year < 100) {
      result.setFullYear(this.getYear(result) - 1900);
    }
    return result;
  }
}

/** Creates an array and fills it with values. */
function range<T>(length: number, valueFunction: (index: number) => T): T[] {
  const valuesArray = Array(length);
  for (let i = 0; i < length; i++) {
    valuesArray[i] = valueFunction(i);
  }
  return valuesArray;
}

The only issues here are the formats display.monthYearLabel & display.monthYearA11yLabel which are fixed, it requires fixing https://github.com/angular/angular/issues/19823 first

denirun commented 6 years ago

I agree, right now this is very strange situation with locale. Angular 5+ work with locale by registerLocaleData. Material 5+ work with locale by Intl API.

I personally don't like Intl API, because it's always different results. And technic with registerLocaleData more strictly and making expected result (big thanks to angular team).

Anyway here is the main problems: Example locale: cs Example date: 5 Jan 2018

format problem

Czech shortDate in angular datePipe return: 05.01.18 Czech date format in material return: 5. 1. 2018.

Yes, this is not a bug, but i believe for user experience better to keep date formats in one way.

parse problem

Material method parse for string values call Date.parse() method. This method expected: RFC2822 standard. It's mean if user tries to change some value in Czech date inside input, this will be a problem. Because this is a not RFC2822.

If user change month to Feb: 5. 1. 2018 > 5. 2. 2018, than you get date: 1 May 2018. My opinion this is not good way of parsing, and for UI this is bug.

Ok after all i wrote my own adapter that extend NativeDateAdapter like @ocombe. I override some methods to support registerLocaleData format. And fix 2nd problem with parse method and keep Date.parse() compatibility, I hope this helps someone.

import {NativeDateAdapter} from '@angular/material';
import {Inject, LOCALE_ID} from '@angular/core';
import {
    DatePipe,
    FormStyle, getLocaleDayNames, getLocaleFirstDayOfWeek, getLocaleMonthNames,
    TranslationWidth
} from '@angular/common';

const DEFAULT_DATE_NAMES: string[] = Array.from(Array(31).keys()).map(i => (i + 1).toString());

export class MyDateAdapter extends NativeDateAdapter {

    private datePipe: DatePipe;

    private styleMap = {
        long: TranslationWidth.Wide,
        short: TranslationWidth.Abbreviated,
        narrow: TranslationWidth.Narrow
    };

    constructor(@Inject(LOCALE_ID) locale: string) {
        super(locale);
        this.datePipe = new DatePipe(locale);
    }

    getDateNames(): string[] {
        return DEFAULT_DATE_NAMES;
    }

    getMonthNames(style: 'long' | 'short' | 'narrow'): string[] {
        return getLocaleMonthNames(this.locale, FormStyle.Format, this.styleMap[style]);
    }

    getDayOfWeekNames(style: 'long' | 'short' | 'narrow'): string[] {
        return getLocaleDayNames(this.locale, FormStyle.Format, this.styleMap[style]);
    }

    getFirstDayOfWeek(): number {
        return getLocaleFirstDayOfWeek(this.locale);
    }

    format(date: Date, displayFormat: string): string {

        if (!this.isValid(date)) {
            throw Error('I18nDateAdapter: Cannot format invalid date.');
        }

        displayFormat = 'shortDate';

        return this.datePipe.transform(date, displayFormat);
    }

    parse(value: any): Date | null {
        const timestamp = typeof value === 'number'
            ? value
            : this.parseByFormat(value);

        return isNaN(timestamp)
            ? null
            : new Date(timestamp);
    }

    /**
     * This method helpts parse date value by locale format
     * @param value
     * @returns {number}
     */
    private parseByFormat(value: any): number | null {

        if (typeof value !== 'string') {
            return null;
        }

        // trick to find 12,13,14 positions
        const formatted = this.format(new Date('2014-12-13'), 'shortDate');
        const delimiter = formatted.match(/\D/);

        // delimiter not found
        if (!delimiter) {
            return Date.parse(value);
        }

        // split value
        const valueItems = value.split(delimiter[0]);
        const formattedItems = formatted.split(delimiter[0]);

        // delimiter is not the same as user input
        if (formattedItems.length <= 2 || valueItems.length <= 2) {
            return Date.parse(value);
        }

        // find indexes of positions
        const yearMatch = formatted.match(/[0-9]*14/);
        const monthIndex = formattedItems.indexOf('12');
        const dayIndex = formattedItems.indexOf('13');

        let yearIndex = -1;

        if (yearMatch) {
            yearIndex = formattedItems.indexOf(yearMatch[0]);
        }

        // all index for year/month/day found
        if (yearIndex !== -1 && monthIndex !== -1 && dayIndex !== -1) {

            valueItems.map(val => val.replace(/\D/g, ''));

            const year = valueItems[yearIndex];
            const month = valueItems[monthIndex];
            const day = valueItems[dayIndex];

            // fix problem with none four-digit year number
            const fullYear = (new Date())
                .getFullYear()
                .toString()
                .substr(0, 4 - year.length) + year;

            value = fullYear + '-' + month + '-' + day;
        }

        return Date.parse(value);
    }

}

* Don't forget to register adapter in @NgModule

 providers: [
  {provide: DateAdapter, useClass: MyDateAdapter}
 ]

* Don't forget to register your locale in registerLocaleData

I hope in feature releases google will add support of Material Date Adapter that based on format provided from registerLocaleData. This feature/request very required.

fetis commented 3 years ago

this issue is open for 3 years so far with a working example to fix, but still is not resolved 🤷‍♂️