chromium/third_party/blink/renderer/core/html/forms/resources/calendar_picker.js

'use strict';
/*
 * Copyright (C) 2012 Google Inc. All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 *     * Redistributions of source code must retain the above copyright
 * notice, this list of conditions and the following disclaimer.
 *     * Redistributions in binary form must reproduce the above
 * copyright notice, this list of conditions and the following disclaimer
 * in the documentation and/or other materials provided with the
 * distribution.
 *     * Neither the name of Google Inc. nor the names of its
 * contributors may be used to endorse or promote products derived from
 * this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */


/**
 * @enum {number}
 */
const WeekDay = {
  Sunday: 0,
  Monday: 1,
  Tuesday: 2,
  Wednesday: 3,
  Thursday: 4,
  Friday: 5,
  Saturday: 6
};

/**
 * @type {Object}
 */
const global = {
  picker: null,
  params: {
    locale: 'en-US',
    weekStartDay: WeekDay.Sunday,
    dayLabels: ['S', 'M', 'T', 'W', 'T', 'F', 'S'],
    ampmLabels: ['AM', 'PM'],
    shortMonthLabels: [
      'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sept', 'Oct',
      'Nov', 'Dec'
    ],
    isLocaleRTL: false,
    isBorderTransparent: false,
    mode: 'date',
    isAMPMFirst: false,
    hasAMPM: false,
    hasSecond: false,
    hasMillisecond: false,
    weekLabel: 'Week',
    anchorRectInScreen: new Rectangle(0, 0, 0, 0),
    currentValue: null
  }
};

// ----------------------------------------------------------------
// Utility functions

/**
 * @return {!boolean}
 */
function hasInaccuratePointingDevice() {
  return matchMedia('(any-pointer: coarse)').matches;
}

/**
 * @return {!string} lowercase locale name. e.g. "en-us"
 */
function getLocale() {
  return (global.params.locale || 'en-us').toLowerCase();
}

/**
 * @return {!string} lowercase language code. e.g. "en"
 */
function getLanguage() {
  const locale = getLocale();
  const result = locale.match(/^([a-z]+)/);
  if (!result)
    return 'en';
  return result[1];
}

/**
 * @param {!number} number
 * @return {!string}
 */
function localizeNumber(number) {
  return window.pagePopupController.localizeNumberString(number);
}

/**
 * @type {Intl.DateTimeFormat}
 */
let japaneseEraFormatter = null;

/**
 * @param {!number} year
 * @param {!number} month
 * @return {!string}
 */
function formatJapaneseImperialEra(year, month) {
  // Eras prior to Meiji are not helpful.
  if (year <= 1867 || year == 1868 && month <= 9)
    return '';
  if (!japaneseEraFormatter) {
    japaneseEraFormatter = new Intl.DateTimeFormat(
        'ja-JP-u-ca-japanese', {era: 'long', year: 'numeric'});
  }
  // Produce the era for day 16 because it's almost the midpoint of a month.
  // 275760-09-13 is the last valid date in ECMAScript. We apply day 7 in that
  // case because it's the midpoint between 09-01 and 09-13.
  let sampleDay = year == 275760 && month == 8 ? 7 : 16;
  let yearPart = japaneseEraFormatter.format(new Date(year, month, sampleDay));

  // We don't show an imperial era if it is greater than 99 because of space
  // limitation.
  if (yearPart.length > 5)
    return '';

  // Replace 1-nen with Gan-nen.
  if (yearPart.length == 4 && yearPart[2] == '1')
    yearPart = yearPart.substring(0, 2) + '\u5143\u5e74';

  return '(' + yearPart + ')';
}

function createUTCDate(year, month, date) {
  const newDate = new Date(0);
  newDate.setUTCFullYear(year);
  newDate.setUTCMonth(month);
  newDate.setUTCDate(date);
  return newDate;
}

/**
 * @param {string} dateString
 * @return {?Day|Week|Month}
 */
function parseDateString(dateString) {
  const month = Month.parse(dateString);
  if (month)
    return month;
  const week = Week.parse(dateString);
  if (week)
    return week;
  return Day.parse(dateString);
}

/**
 * @const
 * @type {number}
 */
const DaysPerWeek = 7;

/**
 * @const
 * @type {number}
 */
const MonthsPerYear = 12;

/**
 * @const
 * @type {number}
 */
const MillisecondsPerDay = 24 * 60 * 60 * 1000;

/**
 * @const
 * @type {number}
 */
const MillisecondsPerWeek = DaysPerWeek * MillisecondsPerDay;

// ----------------------------------------------------------------

/**
 * The base class of Day, Week, and Month.
 */
class DateType {
  constructor() {
  }
}

// ----------------------------------------------------------------

class Day extends DateType {
  /**
   * @param {!number} year
   * @param {!number} month
   * @param {!number} date
   */
  constructor(year, month, date) {
    super();
    const dateObject = createUTCDate(year, month, date);
    if (isNaN(dateObject.valueOf()))
      throw 'Invalid date';
    /**
     * @type {number}
     * @const
     */
    this.year = dateObject.getUTCFullYear();
    /**
     * @type {number}
     * @const
     */
    this.month = dateObject.getUTCMonth();
    /**
     * @type {number}
     * @const
     */
    this.date = dateObject.getUTCDate();
  }

  /** @const */
  static ISOStringRegExp = /^(\d+)-(\d+)-(\d+)/;

  /**
   * @param {!string} str
   * @return {?Day}
   */
  static parse(str) {
    const match = Day.ISOStringRegExp.exec(str);
    if (!match)
      return null;
    const year = parseInt(match[1], 10);
    const month = parseInt(match[2], 10) - 1;
    const date = parseInt(match[3], 10);
    return new Day(year, month, date);
  }

  /**
   * @param {!number} value
   * @return {!Day}
   */
  static createFromValue(millisecondsSinceEpoch) {
    return Day.createFromDate(new Date(millisecondsSinceEpoch))
  }

  /**
   * @param {!Date} date
   * @return {!Day}
   */
  static createFromDate(date) {
    if (isNaN(date.valueOf()))
      throw 'Invalid date';
    return new Day(
        date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
  }

  /**
   * @param {!Day} day
   * @return {!Day}
   */
  static createFromDay(day) {
    return day;
  }

  /**
   * @return {!Day}
   */
  static createFromToday() {
    const now = new Date();
    return new Day(now.getFullYear(), now.getMonth(), now.getDate());
  }

  /**
   * @param {!DateType} other
   * @return {!boolean}
   */
  equals(other) {
    return other instanceof Day && this.year === other.year &&
        this.month === other.month && this.date === other.date;
  }

  /**
   * @param {!number=} offset
   * @return {!Day}
   */
  previous(offset) {
    if (typeof offset === 'undefined')
      offset = 1;
    return new Day(this.year, this.month, this.date - offset);
  }

  /**
   * @param {!number=} offset
   * @return {!Day}
   */
  next(offset) {
    if (typeof offset === 'undefined')
      offset = 1;
    return new Day(this.year, this.month, this.date + offset);
  }

  /**
   * @return {!Day}
   */
  nextHome() {
    if (this.date !== 1)
      return new Day(this.year, this.month, 1);
    return new Day(this.year, this.month - 1, 1);
  }

  /**
   * @return {!Day}
   */
  nextEnd() {
    let tomorrow = this.next();
    if (tomorrow.month === this.month)
      return new Day(this.year, this.month + 1, 1).previous();
    return new Day(tomorrow.year, tomorrow.month + 1, 1).previous();
  }

  /**
   * Given that 'this' is the Nth day of the month, returns the Nth
   * day of the month that is specified by the parameter.
   * Clips the date if necessary, e.g. if 'this' Day is October 31st and
   * the parameter is a November, returns November 30th.
   * @param {!Month} month
   * @return {!Day}
   */
  thisRangeInMonth(month) {
    const newDate = month.startDate();
    const originalMonthInt = newDate.getUTCMonth();
    newDate.setUTCDate(this.date);
    if (newDate.getUTCMonth() != originalMonthInt) {
      newDate.setUTCDate(0);
    }
    return Day.createFromDate(newDate);
  }

  /**
   * @param {!Month} month
   * @return {!boolean}
   */
  overlapsMonth(month) {
    return (month.firstDay() <= this && month.lastDay() >= this);
  }

  /**
   * @param {!Month} month
   * @return {!boolean}
   */
  isFullyContainedInMonth(month) {
    return (month.firstDay() <= this && month.lastDay() >= this);
  }

  /**
   * @return {!Date}
   */
  startDate() {
    return createUTCDate(this.year, this.month, this.date);
  }

  /**
   * @return {!Date}
   */
  endDate() {
    return createUTCDate(this.year, this.month, this.date + 1);
  }

  /**
   * @return {!Day}
   */
  firstDay() {
    return this;
  }

  /**
   * @return {!Day}
   */
  middleDay() {
    return this;
  }

  /**
   * @return {!Day}
   */
  lastDay() {
    return this;
  }

  /**
   * @return {!number}
   */
  valueOf() {
    return createUTCDate(this.year, this.month, this.date).getTime();
  }

  /**
   * @return {!WeekDay}
   */
  weekDay() {
    return createUTCDate(this.year, this.month, this.date).getUTCDay();
  }

  /**
   * @return {!string}
   */
  toString() {
    let yearString = String(this.year);
    if (yearString.length < 4)
      yearString = ('000' + yearString).substr(-4, 4);
    return yearString + '-' + ('0' + (this.month + 1)).substr(-2, 2) + '-' +
        ('0' + this.date).substr(-2, 2);
  }

  /**
   * @return {!string}
   */
  format() {
    if (!Day.formatter) {
      Day.formatter = new Intl.DateTimeFormat(getLocale(), {
        weekday: 'long',
        year: 'numeric',
        month: 'long',
        day: 'numeric',
        timeZone: 'UTC'
      });
    }
    return Day.formatter.format(this.startDate());
  }

  // See platform/text/date_components.h.
  /** @const */
  static Minimum = Day.createFromValue(-62135596800000.0);
  /** @const */
  static Maximum = Day.createFromValue(8640000000000000.0);

  // See core/html/forms/date_input_type.cc.
  /** @const */
  static DefaultStep = 86400000;
  /** @const */
  static DefaultStepBase = 0;
}

// ----------------------------------------------------------------

class Week extends DateType {
  /**
   * @param {!number} year
   * @param {!number} week
   */
  constructor(year, week) {
    super();
    /**
     * @type {number}
     * @const
     */
    this.year = year;
    /**
     * @type {number}
     * @const
     */
    this.week = week;
    // Number of years per year is either 52 or 53.
    if (this.week < 1 ||
        (this.week > 52 && this.week > Week.numberOfWeeksInYear(this.year))) {
      const normalizedWeek = Week.createFromDay(this.firstDay());
      this.year = normalizedWeek.year;
      this.week = normalizedWeek.week;
    }
  }

  static ISOStringRegExp = /^(\d+)-[wW](\d+)$/;

  // See platform/text/date_components.h.
  static Minimum = new Week(1, 1);
  static Maximum = new Week(275760, 37);

  // See core/html/forms/week_input_type.cc.
  static DefaultStep = 604800000;
  static DefaultStepBase = -259200000;

  static EpochWeekDay = createUTCDate(1970, 0, 0).getUTCDay();

  /**
   * @param {!string} str
   * @return {?Week}
   */
  static parse(str) {
    const match = Week.ISOStringRegExp.exec(str);
    if (!match)
      return null;
    const year = parseInt(match[1], 10);
    const week = parseInt(match[2], 10);
    return new Week(year, week);
  }

  /**
   * @param {!number} millisecondsSinceEpoch
   * @return {!Week}
   */
  static createFromValue(millisecondsSinceEpoch) {
    return Week.createFromDate(new Date(millisecondsSinceEpoch))
  }

  /**
   * @param {!Date} date
   * @return {!Week}
   */
  static createFromDate(date) {
    if (isNaN(date.valueOf()))
      throw 'Invalid date';
    let year = date.getUTCFullYear();
    if (year <= Week.Maximum.year &&
        Week.weekOneStartDateForYear(year + 1).getTime() <= date.getTime())
      year++;
    else if (
        year > 1 &&
        Week.weekOneStartDateForYear(year).getTime() > date.getTime())
      year--;
    const week = 1 +
        Week._numberOfWeeksSinceDate(Week.weekOneStartDateForYear(year), date);
    return new Week(year, week);
  }

  /**
   * @param {!Day} day
   * @return {!Week}
   */
  static createFromDay(day) {
    let year = day.year;
    if (year <= Week.Maximum.year &&
        Week.weekOneStartDayForYear(year + 1) <= day)
      year++;
    else if (year > 1 && Week.weekOneStartDayForYear(year) > day)
      year--;
    const week = Math.floor(
        1 +
        (day.valueOf() - Week.weekOneStartDayForYear(year).valueOf()) /
            MillisecondsPerWeek);
    return new Week(year, week);
  }

  /**
   * @return {!Week}
   */
  static createFromToday() {
    const now = new Date();
    return Week.createFromDate(
        createUTCDate(now.getFullYear(), now.getMonth(), now.getDate()));
  }

  /**
   * @param {!number} year
   * @return {!Date}
   */
  static weekOneStartDateForYear(year) {
    if (year < 1)
      return createUTCDate(1, 0, 1);
    // The week containing January 4th is week one.
    const yearStartDay = createUTCDate(year, 0, 4).getUTCDay();
    return createUTCDate(year, 0, 4 - (yearStartDay + 6) % DaysPerWeek);
  }

  /**
   * @param {!number} year
   * @return {!Day}
   */
  static weekOneStartDayForYear(year) {
    if (year < 1)
      return Day.Minimum;
    // The week containing January 4th is week one.
    const yearStartDay = createUTCDate(year, 0, 4).getUTCDay();
    return new Day(year, 0, 4 - (yearStartDay + 6) % DaysPerWeek);
  }

  /**
   * @param {!number} year
   * @return {!number}
   */
  static numberOfWeeksInYear(year) {
    if (year < 1 || year > Week.Maximum.year)
      return 0;
    else if (year === Week.Maximum.year)
      return Week.Maximum.week;
    return Week._numberOfWeeksSinceDate(
        Week.weekOneStartDateForYear(year),
        Week.weekOneStartDateForYear(year + 1));
  }

  /**
   * @param {!Date} baseDate
   * @param {!Date} date
   * @return {!number}
   */
  static _numberOfWeeksSinceDate(baseDate, date) {
    return Math.floor(
        (date.getTime() - baseDate.getTime()) / MillisecondsPerWeek);
  }

  /**
   * @param {!DateType} other
   * @return {!boolean}
   */
  equals(other) {
    return other instanceof Week && this.year === other.year &&
        this.week === other.week;
  }

  /**
   * @param {!number=} offset
   * @return {!Week}
   */
  previous(offset) {
    if (typeof offset === 'undefined')
      offset = 1;
    return new Week(this.year, this.week - offset);
  }

  /**
   * @param {!number=} offset
   * @return {!Week}
   */
  next(offset) {
    if (typeof offset === 'undefined')
      offset = 1;
    return new Week(this.year, this.week + offset);
  }

  /**
   * @return {!Week}
   */
  nextHome() {
    // Go back weeks until we find the one that is the first week of a month. Do
    // that by finding the first day in the current week, then go back a day. We
    // want the first week of the month for that day.
    const desiredDay = this.firstDay().previous();
    desiredDay.date = 1;
    return Week.createFromDay(desiredDay);
  }

  /**
   * @return {!Week}
   */
  nextEnd() {
    // Go forward weeks until we find the one that is the last week of a month. Do
    // that by finding the week containing the last day of the month for the day
    // following the last day included in the current week.
    let desiredDay = this.lastDay().next();
    desiredDay = new Day(desiredDay.year, desiredDay.month + 1, 1).previous();
    return Week.createFromDay(desiredDay);
  }

  /**
   * Given that 'this' is the Nth week of the month, returns
   * the Week that is the Nth week in the month specified
   * by the parameter.
   * Clips the date if necessary, e.g. if 'this' is the 5th week
   * of a month that has 5 weeks and the parameter month only has
   * 4 weeks, returns the 4th week of that month.
   * @param {!Month} month
   * @return {!Week}
   */
  thisRangeInMonth(month) {
    const firstDateInCurrentMonth = this.startDate();
    firstDateInCurrentMonth.setUTCDate(1);

    const offsetInOriginalMonth =
        Week._numberOfWeeksSinceDate(firstDateInCurrentMonth, this.startDate());

    // Determine the first Monday in the new month (the week control shows weeks
    // starting on Monday).
    const firstWeekStartInNewMonth = month.startDate();
    firstWeekStartInNewMonth.setUTCDate(
        1 +
        ((DaysPerWeek + 1 - firstWeekStartInNewMonth.getUTCDay()) %
         DaysPerWeek));


    // Find the Nth Monday in the month where N == offsetInOriginalMonth.
    firstWeekStartInNewMonth.setUTCDate(
        firstWeekStartInNewMonth.getUTCDate() +
        (DaysPerWeek * offsetInOriginalMonth));

    if (firstWeekStartInNewMonth.getUTCMonth() != month.month) {
      // If we overshot into the next month (can happen if we were
      // on the 5th week of the old month), go back to the last week
      // of the target month.
      firstWeekStartInNewMonth.setUTCDate(
          firstWeekStartInNewMonth.getUTCDate() - DaysPerWeek);
    }

    return Week.createFromDate(firstWeekStartInNewMonth);
  }

  /**
   * @param {!Month} month
   * @return {!boolean}
   */
  overlapsMonth(month) {
    return (
        month.firstDay() <= this.lastDay() &&
        month.lastDay() >= this.firstDay());
  }

  /**
   * @param {!Month} month
   * @return {!boolean}
   */
  isFullyContainedInMonth(month) {
    return (
        month.firstDay() <= this.firstDay() &&
        month.lastDay() >= this.lastDay());
  }

  /**
   * @return {!Date}
   */
  startDate() {
    const weekStartDate = Week.weekOneStartDateForYear(this.year);
    weekStartDate.setUTCDate(weekStartDate.getUTCDate() + (this.week - 1) * 7);
    return weekStartDate;
  }

  /**
   * @return {!Date}
   */
  endDate() {
    if (this.equals(Week.Maximum))
      return Day.Maximum.startDate();
    return this.next().startDate();
  }

  /**
   * @return {!Day}
   */
  firstDay() {
    const weekOneStartDay = Week.weekOneStartDayForYear(this.year);
    return weekOneStartDay.next((this.week - 1) * DaysPerWeek);
  }

  /**
   * @return {!Day}
   */
  middleDay() {
    return this.firstDay().next(3);
  }

  /**
   * @return {!Day}
   */
  lastDay() {
    if (this.equals(Week.Maximum))
      return Day.Maximum;
    return this.next().firstDay().previous();
  }

  /**
   * @return {!number}
   */
  valueOf() {
    return this.firstDay().valueOf() - createUTCDate(1970, 0, 1).getTime();
  }

  /**
   * @return {!string}
   */
  toString() {
    let yearString = String(this.year);
    if (yearString.length < 4)
      yearString = ('000' + yearString).substr(-4, 4);
    return yearString + '-W' + ('0' + this.week).substr(-2, 2);
  }
}

// ----------------------------------------------------------------

class Month extends DateType {
  /**
   * @param {!number} year
   * @param {!number} month
   */
  constructor(year, month) {
    super();
    /**
     * @type {number}
     * @const
     */
    this.year = year + Math.floor(month / MonthsPerYear);
    /**
     * @type {number}
     * @const
     */
    this.month = month % MonthsPerYear < 0 ?
        month % MonthsPerYear + MonthsPerYear :
        month % MonthsPerYear;
  }

  static ISOStringRegExp = /^(\d+)-(\d+)$/;

  // See platform/text/date_components.h.
  static Minimum = new Month(1, 0);
  static Maximum = new Month(275760, 8);

  // See core/html/forms/month_input_type.cc.
  static DefaultStep = 1;
  static DefaultStepBase = 0;

  /**
   * @param {!string} str
   * @return {?Month}
   */
  static parse(str) {
    const match = Month.ISOStringRegExp.exec(str);
    if (!match)
      return null;
    const year = parseInt(match[1], 10);
    const month = parseInt(match[2], 10) - 1;
    return new Month(year, month);
  }

  /**
   * @param {!number} value
   * @return {!Month}
   */
  static createFromValue(monthsSinceEpoch) {
    return new Month(1970, monthsSinceEpoch)
  }

  /**
   * @param {!Date} date
   * @return {!Month}
   */
  static createFromDate(date) {
    if (isNaN(date.valueOf()))
      throw 'Invalid date';
    return new Month(date.getUTCFullYear(), date.getUTCMonth());
  }

  /**
   * @param {!Day} day
   * @return {!Month}
   */
  static createFromDay(day) {
    return new Month(day.year, day.month);
  }

  /**
   * @return {!Month}
   */
  static createFromToday() {
    const now = new Date();
    return new Month(now.getFullYear(), now.getMonth());
  }

  /**
   * @param {!Month} other
   * @return {!boolean}
   */
  equals(other) {
    return other instanceof Month && this.year === other.year &&
        this.month === other.month;
  }

  /**
   * @param {!number=} offset
   * @return {!Month}
   */
  previous(offset) {
    if (typeof offset === 'undefined')
      offset = 1;
    return new Month(this.year, this.month - offset);
  }

  /**
   * @param {!number=} offset
   * @return {!Month}
   */
  next(offset) {
    if (typeof offset === 'undefined')
      offset = 1;
    return new Month(this.year, this.month + offset);
  }

  /**
   * @return {!Month}
   */
  nextHome() {
    if (this.month !== 0)
      return new Month(this.year, 0);
    return new Month(this.year - 1, 0);
  }

  /**
   * @return {!Month}
   */
  nextEnd() {
    if (this.month !== MonthsPerYear - 1)
      return new Month(this.year, MonthsPerYear - 1);
    return new Month(this.year + 1, MonthsPerYear - 1);
  }

  /**
   * @return {!Date}
   */
  startDate() {
    return createUTCDate(this.year, this.month, 1);
  }

  /**
   * @return {!Date}
   */
  endDate() {
    if (this.equals(Month.Maximum))
      return Day.Maximum.startDate();
    return this.next().startDate();
  }

  /**
   * @return {!Day}
   */
  firstDay() {
    return new Day(this.year, this.month, 1);
  }

  /**
   * @return {!Day}
   */
  middleDay() {
    return new Day(this.year, this.month, this.month === 1 ? 14 : 15);
  }

  /**
   * @return {!Day}
   */
  lastDay() {
    if (this.equals(Month.Maximum))
      return Day.Maximum;
    return this.next().firstDay().previous();
  }

  /**
   * @return {!number}
   */
  valueOf() {
    return (this.year - 1970) * MonthsPerYear + this.month;
  }

  /**
   * @return {!string}
   */
  toString() {
    let yearString = String(this.year);
    if (yearString.length < 4)
      yearString = ('000' + yearString).substr(-4, 4);
    return yearString + '-' + ('0' + (this.month + 1)).substr(-2, 2);
  }

  /**
   * @return {!string}
   */
  toLocaleString() {
    if (global.params.locale === 'ja')
      return '' + this.year + '\u5e74' +
          formatJapaneseImperialEra(this.year, this.month) + ' ' +
          (this.month + 1) + '\u6708';
    return window.pagePopupController.formatMonth(this.year, this.month);
  }

  /**
   * @return {!string}
   */
  toShortLocaleString() {
    return window.pagePopupController.formatShortMonth(this.year, this.month);
  }
}

// ----------------------------------------------------------------
// Initialization

/**
 * @param {Event} event
 */
function handleMessage(event) {
  if (global.argumentsReceived)
    return;
  global.argumentsReceived = true;
  initialize(JSON.parse(event.data));
}

/**
 * @param {!Object} params
 */
function setGlobalParams(params) {
  let name;
  for (name in global.params) {
    if (typeof params[name] === 'undefined')
      console.warn('Missing argument: ' + name);
  }
  for (name in params) {
    global.params[name] = params[name];
  }
};

/**
 * @param {!Object} args
 */
function initialize(args) {
  setGlobalParams(args);
  if (global.params.suggestionValues && global.params.suggestionValues.length)
    openSuggestionPicker();
  else
    openCalendarPicker();
}

function closePicker() {
  if (global.picker)
    global.picker.cleanup();
  const main = $('main');
  main.innerHTML = '';
  main.className = '';
};

function openSuggestionPicker() {
  closePicker();
  global.picker = new SuggestionPicker($('main'), global.params);
};

function openCalendarPicker() {
  closePicker();
  if (global.params.mode == 'month') {
    return initializeMonthPicker(global.params);
  } else if (global.params.mode == 'time') {
    return initializeTimePicker(global.params);
  } else if (global.params.mode == 'datetime-local') {
    return initializeDateTimeLocalPicker(global.params);
  }

  global.picker = new CalendarPicker(global.params.mode, global.params);
  global.picker.attachTo($('main'));
};

// Parameter t should be a number between 0 and 1.
const AnimationTimingFunction = {
  Linear: function(t) {
    return t;
  },
  EaseInOut: function(t) {
    t *= 2;
    if (t < 1)
      return Math.pow(t, 3) / 2;
    t -= 2;
    return Math.pow(t, 3) / 2 + 1;
  }
};

// ----------------------------------------------------------------

class AnimationManager extends EventEmitter {
  constructor() {
    super();

    this._isRunning = false;
    this._runningAnimatorCount = 0;
    this._runningAnimators = {};
    this._animationFrameCallbackBound = this._animationFrameCallback.bind(this);
  }

  static EventTypeAnimationFrameWillFinish = 'animationFrameWillFinish';

  _startAnimation() {
    if (this._isRunning)
      return;
    this._isRunning = true;
    window.requestAnimationFrame(this._animationFrameCallbackBound);
  }

  _stopAnimation() {
    if (!this._isRunning)
      return;
    this._isRunning = false;
  }

  /**
   * @param {!Animator} animator
   */
  add(animator) {
    if (this._runningAnimators[animator.id])
      return;
    this._runningAnimators[animator.id] = animator;
    this._runningAnimatorCount++;
    if (this._needsTimer())
      this._startAnimation();
  }

  /**
   * @param {!Animator} animator
   */
  remove(animator) {
    if (!this._runningAnimators[animator.id])
      return;
    delete this._runningAnimators[animator.id];
    this._runningAnimatorCount--;
    if (!this._needsTimer())
      this._stopAnimation();
  }

  _animationFrameCallback(now) {
    if (this._runningAnimatorCount > 0) {
      for (let id in this._runningAnimators) {
        this._runningAnimators[id].onAnimationFrame(now);
      }
    }
    this.dispatchEvent(AnimationManager.EventTypeAnimationFrameWillFinish);
    if (this._isRunning)
      window.requestAnimationFrame(this._animationFrameCallbackBound);
  }

  /**
   * @return {!boolean}
   */
  _needsTimer() {
    return this._runningAnimatorCount > 0 ||
        this.hasListener(AnimationManager.EventTypeAnimationFrameWillFinish);
  }

  /**
   * @param {!string} type
   * @param {!Function} callback
   * @override
   */
  on(type, callback) {
    EventEmitter.prototype.on.call(this, type, callback);
    if (this._needsTimer())
      this._startAnimation();
  }

  /**
   * @param {!string} type
   * @param {!Function} callback
   * @override
   */
  removeListener(type, callback) {
    EventEmitter.prototype.removeListener.call(this, type, callback);
    if (!this._needsTimer())
      this._stopAnimation();
  }

  static shared = new AnimationManager();
}

// ----------------------------------------------------------------

class Animator extends EventEmitter {
  constructor() {
    super();
    /**
     * @type {!number}
     * @const
     */
    this.id = Animator._lastId++;
    /**
     * @type {!number}
     */
    this.duration = 100;
    /**
     * @type {?function}
     */
    this.step = null;
    /**
     * @type {!boolean}
     * @protected
     */
    this._isRunning = false;
    /**
     * @type {!number}
     */
    this.currentValue = 0;
    /**
     * @type {!number}
     * @protected
     */
    this._lastStepTime = 0;
  }

  static _lastId = 0;

  static EventTypeDidAnimationStop = 'didAnimationStop';

  /**
   * @return {!boolean}
   */
  isRunning() {
    return this._isRunning;
  }

  start() {
    this._lastStepTime = performance.now();
    this._isRunning = true;
    AnimationManager.shared.add(this);
  }

  stop() {
    if (!this._isRunning)
      return;
    this._isRunning = false;
    AnimationManager.shared.remove(this);
    this.dispatchEvent(Animator.EventTypeDidAnimationStop, this);
  }

  /**
   * @param {!number} now
   */
  onAnimationFrame(now) {
    this._lastStepTime = now;
    this.step(this);
  }
}

// ----------------------------------------------------------------

class TransitionAnimator extends Animator {
  constructor() {
    super();
    /**
     * @type {!number}
     * @protected
     */
    this._from = 0;
    /**
     * @type {!number}
     * @protected
     */
    this._to = 0;
    /**
     * @type {!number}
     * @protected
     */
    this._delta = 0;
    /**
     * @type {!number}
     */
    this.progress = 0.0;
    /**
     * @type {!function}
     */
    this.timingFunction = AnimationTimingFunction.Linear;
  }

  /**
   * @param {!number} value
   */
  setFrom(value) {
    this._from = value;
    this._delta = this._to - this._from;
  }

  start() {
    console.assert(isFinite(this.duration));
    this.progress = 0.0;
    this.currentValue = this._from;
    super.start();
  }

  /**
   * @param {!number} value
   */
  setTo(value) {
    this._to = value;
    this._delta = this._to - this._from;
  }

  /**
   * @param {!number} now
   */
  onAnimationFrame(now) {
    this.progress += (now - this._lastStepTime) / this.duration;
    this.progress = Math.min(1.0, this.progress);
    this._lastStepTime = now;
    this.currentValue =
        this.timingFunction(this.progress) * this._delta + this._from;
    this.step(this);
    if (this.progress === 1.0) {
      this.stop();
      return;
    }
  }
}

// ----------------------------------------------------------------

class FlingGestureAnimator extends Animator {
  /**
   * @param {!number} initialVelocity
   * @param {!number} initialValue
   */
  constructor(initialVelocity, initialValue) {
    super();
    /**
     * @type {!number}
     */
    this.initialVelocity = initialVelocity;
    /**
     * @type {!number}
     */
    this.initialValue = initialValue;
    /**
     * @type {!number}
     * @protected
     */
    this._elapsedTime = 0;
    let startVelocity = Math.abs(this.initialVelocity);
    if (startVelocity > this._velocityAtTime(0))
      startVelocity = this._velocityAtTime(0);
    if (startVelocity < 0)
      startVelocity = 0;
    /**
     * @type {!number}
     * @protected
     */
    this._timeOffset = this._timeAtVelocity(startVelocity);
    /**
     * @type {!number}
     * @protected
     */
    this._positionOffset = this._valueAtTime(this._timeOffset);
    /**
     * @type {!number}
     */
    this.duration = this._timeAtVelocity(0);
  }

  // Velocity is subject to exponential decay. These parameters are coefficients
  // that determine the curve.
  static _P0 = -5707.62;
  static _P1 = 0.172;
  static _P2 = 0.0037;

  /**
   * @param {!number} t
   */
  _valueAtTime(t) {
    return (
        FlingGestureAnimator._P0 * Math.exp(-FlingGestureAnimator._P2 * t) -
        FlingGestureAnimator._P1 * t - FlingGestureAnimator._P0);
  }

  /**
   * @param {!number} t
   */
  _velocityAtTime(t) {
    return (
        -FlingGestureAnimator._P0 * FlingGestureAnimator._P2 *
            Math.exp(-FlingGestureAnimator._P2 * t) -
        FlingGestureAnimator._P1);
  }

  /**
   * @param {!number} v
   */
  _timeAtVelocity(v) {
    return (
        -Math.log(
            (v + FlingGestureAnimator._P1) /
            (-FlingGestureAnimator._P0 * FlingGestureAnimator._P2)) /
        FlingGestureAnimator._P2);
  }

  start() {
    this._lastStepTime = performance.now();
    super.start();
  }

  /**
   * @param {!number} now
   */
  onAnimationFrame(now) {
    this._elapsedTime += now - this._lastStepTime;
    this._lastStepTime = now;
    if (this._elapsedTime + this._timeOffset >= this.duration) {
      this.stop();
      return;
    }
    let position = this._valueAtTime(this._elapsedTime + this._timeOffset) -
        this._positionOffset;
    if (this.initialVelocity < 0)
      position = -position;
    this.currentValue = position + this.initialValue;
    this.step(this);
  }
}

// ----------------------------------------------------------------

class View extends EventEmitter {
  /**
   * @param {?Element} element
   * View adds itself as a property on the element so we can access it from Event.target.
   */
  constructor(element) {
    super();
    /**
     * @type {Element}
     * @const
     */
    this.element = element || createElement('div');
    this.element.$view = this;
    this.bindCallbackMethods();
  }

  /**
   * @param {!Element} ancestorElement
   * @return {?Object}
   */
  offsetRelativeTo(ancestorElement) {
    let x = 0;
    let y = 0;
    let element = this.element;
    while (element) {
      x += element.offsetLeft || 0;
      y += element.offsetTop || 0;
      element = element.offsetParent;
      if (element === ancestorElement)
        return {x: x, y: y};
    }
    return null;
  }

  /**
   * @param {!View|Node} parent
   * @param {?View|Node=} before
   */
  attachTo(parent, before) {
    if (parent instanceof View)
      return this.attachTo(parent.element, before);
    if (typeof before === 'undefined')
      before = null;
    if (before instanceof View)
      before = before.element;
    parent.insertBefore(this.element, before);
  }

  bindCallbackMethods() {
    for (let methodName in this) {
      if (!/^on[A-Z]/.test(methodName))
        continue;
      if (this.hasOwnProperty(methodName))
        continue;
      let method = this[methodName];
      if (!(method instanceof Function))
        continue;
      this[methodName] = method.bind(this);
    }
  }
}

// ----------------------------------------------------------------

class ScrollView extends View {
  /**
   * @extends View
   */
  constructor() {
    super(createElement('div', ScrollView.ClassNameScrollView));
    /**
     * @type {Element}
     * @const
     */
    this.contentElement =
        createElement('div', ScrollView.ClassNameScrollViewContent);
    this.element.appendChild(this.contentElement);
    /**
     * @type {number}
     */
    this.minimumContentOffset = -Infinity;
    /**
     * @type {number}
     */
    this.maximumContentOffset = Infinity;
    /**
     * @type {number}
     * @protected
     */
    this._contentOffset = 0;
    /**
     * @type {number}
     * @protected
     */
    this._width = 0;
    /**
     * @type {number}
     * @protected
     */
    this._height = 0;
    /**
     * @type {Animator}
     * @protected
     */
    this._scrollAnimator = null;
    /**
     * @type {?Object}
     */
    this.delegate = null;
    /**
     * @type {!number}
     */
    this._lastTouchPosition = 0;
    /**
     * @type {!number}
     */
    this._lastTouchVelocity = 0;
    /**
     * @type {!number}
     */
    this._lastTouchTimeStamp = 0;

    this._onWindowTouchMoveBound = this.onWindowTouchMove.bind(this);
    this._onWindowTouchEndBound = this.onWindowTouchEnd.bind(this);
    this._onFlingGestureAnimatorStepBound =
        this.onFlingGestureAnimatorStep.bind(this);

    this.element.addEventListener(
        'mousewheel', this.onMouseWheel.bind(this), false);
    this.element.addEventListener(
        'touchstart', this.onTouchStart.bind(this), false);

    /**
     * The content offset is partitioned so the it can go beyond the CSS limit
     * of 33554433px.
     * @type {number}
     * @protected
     */
    this._partitionNumber = 0;
  }

  static PartitionHeight = 100000;
  static ClassNameScrollView = 'scroll-view';
  static ClassNameScrollViewContent = 'scroll-view-content';

  /**
   * @param {!Event} event
   */
  onTouchStart(event) {
    const touch = event.touches[0];
    this._lastTouchPosition = touch.clientY;
    this._lastTouchVelocity = 0;
    this._lastTouchTimeStamp = event.timeStamp;
    if (this._scrollAnimator)
      this._scrollAnimator.stop();
    window.addEventListener('touchmove', this._onWindowTouchMoveBound);
    window.addEventListener('touchend', this._onWindowTouchEndBound);
  }

  /**
   * @param {!Event} event
   */
  onWindowTouchMove(event) {
    const touch = event.touches[0];
    const deltaTime = event.timeStamp - this._lastTouchTimeStamp;
    const deltaY = this._lastTouchPosition - touch.clientY;
    this.scrollBy(deltaY, false);
    this._lastTouchVelocity = deltaY / deltaTime;
    this._lastTouchPosition = touch.clientY;
    this._lastTouchTimeStamp = event.timeStamp;
    event.stopPropagation();
    event.preventDefault();
  }

  /**
   * @param {!Event} event
   */
  onWindowTouchEnd(event) {
    if (Math.abs(this._lastTouchVelocity) > 0.01) {
      this._scrollAnimator = new FlingGestureAnimator(
          this._lastTouchVelocity, this._contentOffset);
      this._scrollAnimator.step = this._onFlingGestureAnimatorStepBound;
      this._scrollAnimator.start();
    }
    window.removeEventListener('touchmove', this._onWindowTouchMoveBound);
    window.removeEventListener('touchend', this._onWindowTouchEndBound);
  }

  /**
   * @param {!Animator} animator
   */
  onFlingGestureAnimatorStep(animator) {
    this.scrollTo(animator.currentValue, false);
  }

  /**
   * @return {!Animator}
   */
  scrollAnimator() {
    return this._scrollAnimator;
  }

  /**
   * @param {!number} width
   */
  setWidth(width) {
    console.assert(isFinite(width));
    if (this._width === width)
      return;
    this._width = width;
    this.element.style.width = this._width + 'px';
  }

  /**
   * @return {!number}
   */
  width() {
    return this._width;
  }

  /**
   * @param {!number} height
   */
  setHeight(height) {
    console.assert(isFinite(height));
    if (this._height === height)
      return;
    this._height = height;
    this.element.style.height = height + 'px';
    if (this.delegate)
      this.delegate.scrollViewDidChangeHeight(this);
  }

  /**
   * @return {!number}
   */
  height() {
    return this._height;
  }

  /**
   * @param {!Animator} animator
   */
  onScrollAnimatorStep(animator) {
    this.setContentOffset(animator.currentValue);
  }

  /**
   * @param {!number} offset
   * @param {?boolean} animate
   */
  scrollTo(offset, animate) {
    console.assert(isFinite(offset));
    if (!animate) {
      this.setContentOffset(offset);
      return;
    }
    if (this._scrollAnimator)
      this._scrollAnimator.stop();
    this._scrollAnimator = new TransitionAnimator();
    this._scrollAnimator.step = this.onScrollAnimatorStep.bind(this);
    this._scrollAnimator.setFrom(this._contentOffset);
    this._scrollAnimator.setTo(offset);
    this._scrollAnimator.duration = 300;
    this._scrollAnimator.start();
  }

  /**
   * @param {!number} offset
   * @param {?boolean} animate
   */
  scrollBy(offset, animate) {
    this.scrollTo(this._contentOffset + offset, animate);
  }

  /**
   * @return {!number}
   */
  contentOffset() {
    return this._contentOffset;
  }

  /**
   * @param {?Event} event
   */
  onMouseWheel(event) {
    this.setContentOffset(this._contentOffset - event.wheelDelta / 30);
    event.stopPropagation();
    event.preventDefault();
  }

  /**
   * @param {!number} value
   */
  setContentOffset(value) {
    console.assert(isFinite(value));
    value = Math.min(
        this.maximumContentOffset - this._height,
        Math.max(this.minimumContentOffset, Math.floor(value)));
    if (this._contentOffset === value)
      return;
    this._contentOffset = value;
    this._updateScrollContent();
    if (this.delegate)
      this.delegate.scrollViewDidChangeContentOffset(this);
  }

  _updateScrollContent() {
    const newPartitionNumber =
        Math.floor(this._contentOffset / ScrollView.PartitionHeight);
    const partitionChanged = this._partitionNumber !== newPartitionNumber;
    this._partitionNumber = newPartitionNumber;
    this.contentElement.style.webkitTransform = 'translate(0, ' +
        -this.contentPositionForContentOffset(this._contentOffset) + 'px)';
    if (this.delegate && partitionChanged)
      this.delegate.scrollViewDidChangePartition(this);
  }

  /**
   * @param {!View|Node} parent
   * @param {?View|Node=} before
   * @override
   */
  attachTo(parent, before) {
    View.prototype.attachTo.call(this, parent, before);
    this._updateScrollContent();
  }

  /**
   * @param {!number} offset
   */
  contentPositionForContentOffset(offset) {
    return offset - this._partitionNumber * ScrollView.PartitionHeight;
  }
}

// ----------------------------------------------------------------

class ListCell extends View {
  /**
   * @extends View
   */
  constructor() {
    super(createElement('div', ListCell.ClassNameListCell));
    /**
     * @type {!number}
     */
    this.row = NaN;
    /**
     * @type {!number}
     */
    this._width = 0;
    /**
     * @type {!number}
     */
    this._position = 0;
  }

  static DefaultRecycleBinLimit = 64;
  static ClassNameListCell = 'list-cell';
  static ClassNameHidden = 'hidden';

  /**
   * @return {!Array} An array to keep thrown away cells.
   */
  _recycleBin() {
    console.assert(
        false,
        'NOT REACHED: ListCell.prototype._recycleBin needs to be overridden.');
    return [];
  }

  throwAway() {
    this.hide();
    const limit = typeof this.constructor.RecycleBinLimit === 'undefined' ?
        ListCell.DefaultRecycleBinLimit :
        this.constructor.RecycleBinLimit;
    const recycleBin = this._recycleBin();
    if (recycleBin.length < limit)
      recycleBin.push(this);
  }

  show() {
    this.element.classList.remove(ListCell.ClassNameHidden);
  }

  hide() {
    this.element.classList.add(ListCell.ClassNameHidden);
  }

  /**
   * @return {!number} Width in pixels.
   */
  width() {
    return this._width;
  }

  /**
   * @param {!number} width Width in pixels.
   */
  setWidth(width) {
    if (this._width === width)
      return;
    this._width = width;
    this.element.style.width = this._width + 'px';
  }

  /**
   * @return {!number} Position in pixels.
   */
  position() {
    return this._position;
  }

  /**
   * @param {!number} y Position in pixels.
   */
  setPosition(y) {
    if (this._position === y)
      return;
    this._position = y;
    this.element.style.webkitTransform =
        'translate(0, ' + this._position + 'px)';
  }

  /**
   * @param {!boolean} selected
   */
  setSelected(selected) {
    if (this._selected === selected)
      return;
    this._selected = selected;
    if (this._selected) {
      this.element.classList.add('selected');
      this.element.setAttribute('aria-selected', true);
    } else {
      this.element.classList.remove('selected');
      this.element.setAttribute('aria-selected', false);
    }
  }
}

// ----------------------------------------------------------------

class ListView extends View {
  constructor() {
    super(createElement('div', ListView.ClassNameListView));
    this.element.tabIndex = 0;
    this.element.setAttribute('role', 'grid');

    /**
     * @type {!number}
     * @private
     */
    this._width = 0;
    /**
     * @type {!Object}
     * @private
     */
    this._cells = {};

    /**
     * @type {!number}
     */
    this.selectedRow = ListView.NoSelection;

    /**
     * @type {!ScrollView}
     */
    this.scrollView = new ScrollView();
    this.scrollView.delegate = this;
    this.scrollView.minimumContentOffset = 0;
    this.scrollView.setWidth(0);
    this.scrollView.setHeight(0);
    this.scrollView.attachTo(this);

    this.element.addEventListener('click', this.onClick.bind(this), false);

    /**
     * @type {!boolean}
     * @private
     */
    this._needsUpdateCells = false;
  }

  static NoSelection = -1;
  static ClassNameListView = 'list-view';

  onAnimationFrameWillFinish() {
    if (this._needsUpdateCells)
      this.updateCells();
  }

  /**
   * @param {!boolean} needsUpdateCells
   */
  setNeedsUpdateCells(needsUpdateCells) {
    if (this._needsUpdateCells === needsUpdateCells)
      return;
    this._needsUpdateCells = needsUpdateCells;
    if (this._needsUpdateCells)
      AnimationManager.shared.on(
          AnimationManager.EventTypeAnimationFrameWillFinish,
          this.onAnimationFrameWillFinish.bind(this));
    else
      AnimationManager.shared.removeListener(
          AnimationManager.EventTypeAnimationFrameWillFinish,
          this.onAnimationFrameWillFinish.bind(this));
  }

  /**
   * @param {!number} row
   * @return {?ListCell}
   */
  cellAtRow(row) {
    return this._cells[row];
  }

  /**
   * @param {!number} offset Scroll offset in pixels.
   * @return {!number}
   */
  rowAtScrollOffset(offset) {
    console.assert(
        false,
        'NOT REACHED: ListView.prototype.rowAtScrollOffset needs to be overridden.');
    return 0;
  }

  /**
   * @param {!number} row
   * @return {!number} Scroll offset in pixels.
   */
  scrollOffsetForRow(row) {
    console.assert(
        false,
        'NOT REACHED: ListView.prototype.scrollOffsetForRow needs to be overridden.');
    return 0;
  }

  /**
   * @param {!number} row
   * @return {!ListCell}
   */
  addCellIfNecessary(row) {
    let cell = this._cells[row];
    if (cell)
      return cell;
    cell = this.prepareNewCell(row);

    // Ensure that the DOM tree positions of the rows are in increasing
    // chronological order.  This is needed for correct application of
    // the :hover selector for the week control, which spans across multiple
    // calendar rows.
    const rowIndices = Object.keys(this._cells);
    const shouldPrepend = (rowIndices.length) > 0 && (row < rowIndices[0]);
    cell.attachTo(
        this.scrollView.contentElement,
        shouldPrepend ? this.scrollView.contentElement.firstElementChild :
                        undefined);

    cell.setWidth(this._width);
    cell.setPosition(this.scrollView.contentPositionForContentOffset(
        this.scrollOffsetForRow(row)));
    this._cells[row] = cell;
    return cell;
  }

  /**
   * @param {!number} row
   * @return {!ListCell}
   */
  prepareNewCell(row) {
    console.assert(
        false,
        'NOT REACHED: ListView.prototype.prepareNewCell should be overridden.');
    return new ListCell();
  }

  /**
   * @param {!ListCell} cell
   */
  throwAwayCell(cell) {
    delete this._cells[cell.row];
    cell.throwAway();
  }

  /**
   * @return {!number}
   */
  firstVisibleRow() {
    return this.rowAtScrollOffset(this.scrollView.contentOffset());
  }

  /**
   * @return {!number}
   */
  lastVisibleRow() {
    return this.rowAtScrollOffset(
        this.scrollView.contentOffset() + this.scrollView.height() - 1);
  }

  /**
   * @param {!ScrollView} scrollView
   */
  scrollViewDidChangeContentOffset(scrollView) {
    this.setNeedsUpdateCells(true);
  }

  /**
   * @param {!ScrollView} scrollView
   */
  scrollViewDidChangeHeight(scrollView) {
    this.setNeedsUpdateCells(true);
  }

  /**
   * @param {!ScrollView} scrollView
   */
  scrollViewDidChangePartition(scrollView) {
    this.setNeedsUpdateCells(true);
  }

  updateCells() {
    const firstVisibleRow = this.firstVisibleRow();
    const lastVisibleRow = this.lastVisibleRow();
    console.assert(firstVisibleRow <= lastVisibleRow);
    for (let c in this._cells) {
      const cell = this._cells[c];
      if (cell.row < firstVisibleRow || cell.row > lastVisibleRow)
        this.throwAwayCell(cell);
    }
    for (let i = firstVisibleRow; i <= lastVisibleRow; ++i) {
      const cell = this._cells[i];
      if (cell)
        cell.setPosition(this.scrollView.contentPositionForContentOffset(
            this.scrollOffsetForRow(cell.row)));
      else
        this.addCellIfNecessary(i);
    }
    this.setNeedsUpdateCells(false);
  }

  /**
   * @return {!number} Width in pixels.
   */
  width() {
    return this._width;
  }

  /**
   * @param {!number} width Width in pixels.
   */
  setWidth(width) {
    if (this._width === width)
      return;
    this._width = width;
    this.scrollView.setWidth(this._width);
    for (let c in this._cells) {
      this._cells[c].setWidth(this._width);
    }
    this.element.style.width = this._width + 'px';
    this.setNeedsUpdateCells(true);
  }

  /**
   * @return {!number} Height in pixels.
   */
  height() {
    return this.scrollView.height();
  }

  /**
   * @param {!number} height Height in pixels.
   */
  setHeight(height) {
    this.scrollView.setHeight(height);
  }

  /**
   * @param {?Event} event
   */
  onClick(event) {
    const clickedCellElement =
        enclosingNodeOrSelfWithClass(event.target, ListCell.ClassNameListCell);
    if (!clickedCellElement)
      return;
    const clickedCell = clickedCellElement.$view;
    if (clickedCell.row !== this.selectedRow)
      this.select(clickedCell.row);
  }

  /**
   * @param {!number} row
   */
  select(row) {
    if (this.selectedRow === row)
      return;
    this.deselect();
    if (row === ListView.NoSelection)
      return;
    this.selectedRow = row;
    const selectedCell = this._cells[this.selectedRow];
    if (selectedCell)
      selectedCell.setSelected(true);
  }

  deselect() {
    if (this.selectedRow === ListView.NoSelection)
      return;
    const selectedCell = this._cells[this.selectedRow];
    if (selectedCell)
      selectedCell.setSelected(false);
    this.selectedRow = ListView.NoSelection;
  }

  /**
   * @param {!number} row
   * @param {!boolean} animate
   */
  scrollToRow(row, animate) {
    this.scrollView.scrollTo(this.scrollOffsetForRow(row), animate);
  }
}

// ----------------------------------------------------------------

class ScrubbyScrollBar extends View {
  /**
   * @extends View
   * @param {!ScrollView} scrollView
   */
  constructor(scrollView) {
    super(createElement('div', ScrubbyScrollBar.ClassNameScrubbyScrollBar));
    /**
     * @type {!Element}
     * @const
     */
    this.thumb =
        createElement('div', ScrubbyScrollBar.ClassNameScrubbyScrollThumb);
    this.element.appendChild(this.thumb);

    /**
     * @type {!ScrollView}
     * @const
     */
    this.scrollView = scrollView;

    /**
     * @type {!number}
     * @protected
     */
    this._height = 0;
    /**
     * @type {!number}
     * @protected
     */
    this._thumbHeight = 0;
    /**
     * @type {!number}
     * @protected
     */
    this._thumbPosition = 0;

    this.setHeight(0);
    this.setThumbHeight(ScrubbyScrollBar.ThumbHeight);

    /**
     * @type {?Animator}
     * @protected
     */
    this._thumbStyleTopAnimator = null;

    /**
     * @type {?number}
     * @protected
     */
    this._timer = null;

    /**
     * @type {?function}
     * @protected
     */
    this._mouseUpEventListener = null;
    /**
     * @type {?function}
     * @protected
     */
    this._mouseMoveEventListener = null;
    /**
     * @type {?function}
     * @protected
     */
    this._touchEndEventListener = null;
    /**
     * @type {?function}
     * @protected
     */
    this._touchMoveEventListener = null;

    this.element.addEventListener(
        'mousedown', this.onMouseDown.bind(this), false);
    this.element.addEventListener(
        'touchstart', this.onTouchStart.bind(this), false);
  }

  static ScrollInterval = 16;
  static ThumbMargin = 2;
  static ThumbHeight = 30;
  static ClassNameScrubbyScrollBar = 'scrubby-scroll-bar';
  static ClassNameScrubbyScrollThumb = 'scrubby-scroll-thumb';

  /**
   * @param {?Event} event
   */
  onTouchStart(event) {
    const touch = event.touches[0];
    this._setThumbPositionFromEventPosition(touch.clientY);
    if (this._thumbStyleTopAnimator)
      this._thumbStyleTopAnimator.stop();
    this._timer = setInterval(
        this.onScrollTimer.bind(this), ScrubbyScrollBar.ScrollInterval);
    this._touchMoveEventListener = this.onWindowTouchMove.bind(this);
    window.addEventListener('touchmove', this._touchMoveEventListener, false);
    this._touchEndEventListener = this.onWindowTouchEnd.bind(this);
    window.addEventListener('touchend', this._touchEndEventListener, false);
    event.stopPropagation();
    event.preventDefault();
  }

  /**
   * @param {?Event} event
   */
  onWindowTouchMove(event) {
    const touch = event.touches[0];
    this._setThumbPositionFromEventPosition(touch.clientY);
    event.stopPropagation();
    event.preventDefault();
  }

  /**
   * @param {?Event} event
   */
  onWindowTouchEnd(event) {
    this._thumbStyleTopAnimator = new TransitionAnimator();
    this._thumbStyleTopAnimator.step =
        this.onThumbStyleTopAnimationStep.bind(this);
    this._thumbStyleTopAnimator.setFrom(this.thumb.offsetTop);
    this._thumbStyleTopAnimator.setTo((this._height - this._thumbHeight) / 2);
    this._thumbStyleTopAnimator.timingFunction =
        AnimationTimingFunction.EaseInOut;
    this._thumbStyleTopAnimator.duration = 100;
    this._thumbStyleTopAnimator.start();

    window.removeEventListener(
        'touchmove', this._touchMoveEventListener, false);
    window.removeEventListener('touchend', this._touchEndEventListener, false);
    clearInterval(this._timer);
  }

  /**
   * @return {!number} Height of the view in pixels.
   */
  height() {
    return this._height;
  }

  /**
   * @param {!number} height Height of the view in pixels.
   */
  setHeight(height) {
    if (this._height === height)
      return;
    this._height = height;
    this.element.style.height = this._height + 'px';
    this.thumb.style.top = (this._height - this._thumbHeight) / 2 + 'px';
    this._thumbPosition = 0;
  }

  /**
   * @param {!number} height Height of the scroll bar thumb in pixels.
   */
  setThumbHeight(height) {
    if (this._thumbHeight === height)
      return;
    this._thumbHeight = height;
    this.thumb.style.height = this._thumbHeight + 'px';
    this.thumb.style.top = (this._height - this._thumbHeight) / 2 + 'px';
    this._thumbPosition = 0;
  }

  /**
   * @param {number} position
   */
  _setThumbPositionFromEventPosition(position) {
    const thumbMin = ScrubbyScrollBar.ThumbMargin;
    const thumbMax =
        this._height - this._thumbHeight - ScrubbyScrollBar.ThumbMargin * 2;
    const y = position - this.element.getBoundingClientRect().top -
        this.element.clientTop + this.element.scrollTop;
    let thumbPosition = y - this._thumbHeight / 2;
    thumbPosition = Math.max(thumbPosition, thumbMin);
    thumbPosition = Math.min(thumbPosition, thumbMax);
    this.thumb.style.top = thumbPosition + 'px';
    this._thumbPosition =
        1.0 - ((thumbPosition - thumbMin) / (thumbMax - thumbMin)) * 2;
  }

  /**
   * @param {?Event} event
   */
  onMouseDown(event) {
    this._setThumbPositionFromEventPosition(event.clientY);

    this._mouseMoveEventListener = this.onWindowMouseMove.bind(this);
    window.addEventListener('mousemove', this._mouseMoveEventListener, false);
    this._mouseUpEventListener = this.onWindowMouseUp.bind(this);
    window.addEventListener('mouseup', this._mouseUpEventListener, false);
    if (this._thumbStyleTopAnimator)
      this._thumbStyleTopAnimator.stop();
    this._timer = setInterval(
        this.onScrollTimer.bind(this), ScrubbyScrollBar.ScrollInterval);
    event.stopPropagation();
    event.preventDefault();
  }

  /**
   * @param {?Event} event
   */
  onWindowMouseMove(event) {
    this._setThumbPositionFromEventPosition(event.clientY);
  }

  /**
   * @param {?Event} event
   */
  onWindowMouseUp(event) {
    this._thumbStyleTopAnimator = new TransitionAnimator();
    this._thumbStyleTopAnimator.step =
        this.onThumbStyleTopAnimationStep.bind(this);
    this._thumbStyleTopAnimator.setFrom(this.thumb.offsetTop);
    this._thumbStyleTopAnimator.setTo((this._height - this._thumbHeight) / 2);
    this._thumbStyleTopAnimator.timingFunction =
        AnimationTimingFunction.EaseInOut;
    this._thumbStyleTopAnimator.duration = 100;
    this._thumbStyleTopAnimator.start();

    window.removeEventListener(
        'mousemove', this._mouseMoveEventListener, false);
    window.removeEventListener('mouseup', this._mouseUpEventListener, false);
    clearInterval(this._timer);
  }

  /**
   * @param {!Animator} animator
   */
  onThumbStyleTopAnimationStep(animator) {
    this.thumb.style.top = animator.currentValue + 'px';
  }

  onScrollTimer() {
    let scrollAmount = Math.pow(this._thumbPosition, 2) * 10;
    if (this._thumbPosition > 0)
      scrollAmount = -scrollAmount;
    this.scrollView.scrollBy(scrollAmount, false);
  }
}

// ----------------------------------------------------------------

// Mixin containing utilities for identifying and navigating between
// valid day/week/month ranges.
function dateRangeManagerMixin(baseClass) {
  class DateRangeManager extends baseClass {
    _setValidDateConfig(config) {
      this.config = {};

      this.config.minimum = (typeof config.min !== 'undefined' && config.min) ?
          parseDateString(config.min) :
          this._dateTypeConstructor.Minimum;
      this.config.maximum = (typeof config.max !== 'undefined' && config.max) ?
          parseDateString(config.max) :
          this._dateTypeConstructor.Maximum;
      this.config.minimumValue = this.config.minimum.valueOf();
      this.config.maximumValue = this.config.maximum.valueOf();
      this.config.step = (typeof config.step !== 'undefined') ?
          Number(config.step) :
          this._dateTypeConstructor.DefaultStep;
      this.config.stepBase = (typeof config.stepBase !== 'undefined') ?
          Number(config.stepBase) :
          this._dateTypeConstructor.DefaultStepBase;
    }

    _isValidForStep(value) {
      // nextAllowedValue is the time closest (looking forward) to value that is
      // within the interval specified by the step and the stepBase.  This may
      // be equal to value.
      const nextAllowedValue =
          (Math.ceil((value - this.config.stepBase) / this.config.step) *
           this.config.step) +
          this.config.stepBase;
      // If the nextAllowedValue is between value and the next nearest possible
      // time for this control type (determined by adding the smallest time
      // interval, given by DefaultStep, to value) then we consider it to be
      // valid.
      return nextAllowedValue < (value + this._dateTypeConstructor.DefaultStep);
    }

    /**
     * @param {!number} value
     * @return {!boolean}
     */
    _outOfRange(value) {
      return value < this.config.minimumValue ||
          value > this.config.maximumValue;
    }

    /**
     * @param {!DateType} dayOrWeekOrMonth
     * @return {!boolean}
     */
    isValid(dayOrWeekOrMonth) {
      const value = dayOrWeekOrMonth.valueOf();
      return dayOrWeekOrMonth instanceof this._dateTypeConstructor &&
          !this._outOfRange(value) && this._isValidForStep(value);
    }

    /**
     * @param {!DayOrWeekOrMonth} dayOrWeekOrMonth
     * @return {?DayOrWeekOrMonth}
     */
    getNearestValidRangeLookingForward(dayOrWeekOrMonth) {
      if (dayOrWeekOrMonth < this.config.minimumValue) {
        // Performance optimization: avoid wasting lots of time in the below
        // loop if dayOrWeekOrMonth is significantly less than the min.
        dayOrWeekOrMonth =
            this._dateTypeConstructor.createFromValue(this.config.minimumValue);
      }

      while (!this.isValid(dayOrWeekOrMonth) &&
             dayOrWeekOrMonth < this.config.maximumValue) {
        dayOrWeekOrMonth = dayOrWeekOrMonth.next();
      }

      return this.isValid(dayOrWeekOrMonth) ? dayOrWeekOrMonth : null;
    }

    /**
     * @param {!DayOrWeekOrMonth} dayOrWeekOrMonth
     * @return {?DayOrWeekOrMonth}
     */
    getNearestValidRangeLookingBackward(dayOrWeekOrMonth) {
      if (dayOrWeekOrMonth > this.config.maximumValue) {
        // Performance optimization: avoid wasting lots of time in the below
        // loop if dayOrWeekOrMonth is significantly greater than the max.
        dayOrWeekOrMonth =
            this._dateTypeConstructor.createFromValue(this.config.maximumValue);
      }

      while (!this.isValid(dayOrWeekOrMonth) &&
             dayOrWeekOrMonth > this.config.minimumValue) {
        dayOrWeekOrMonth = dayOrWeekOrMonth.previous();
      }

      return this.isValid(dayOrWeekOrMonth) ? dayOrWeekOrMonth : null;
    }

    /**
     * @param {!DayOrWeekOrMonth} dayOrWeekOrMonth
     * @param {!boolean} lookForwardFirst
     * @return {?DayOrWeekOrMonth}
     */
    getNearestValidRange(dayOrWeekOrMonth, lookForwardFirst) {
      let result = null;
      if (lookForwardFirst) {
        if (!(result =
                  this.getNearestValidRangeLookingForward(dayOrWeekOrMonth))) {
          result = this.getNearestValidRangeLookingBackward(dayOrWeekOrMonth);
        }
      } else {
        if (!(result =
                  this.getNearestValidRangeLookingBackward(dayOrWeekOrMonth))) {
          result = this.getNearestValidRangeLookingForward(dayOrWeekOrMonth);
        }
      }

      return result;
    }

    /**
     * @param {!Day} day
     * @param {!boolean} lookForwardFirst
     * @return {?DayOrWeekOrMonth}
     */
    getValidRangeNearestToDay(day, lookForwardFirst) {
      const dayOrWeekOrMonth = this._dateTypeConstructor.createFromDay(day);
      return this.getNearestValidRange(dayOrWeekOrMonth, lookForwardFirst);
    }
  }
  return DateRangeManager;
}

// ----------------------------------------------------------------

class YearListCell extends ListCell {
  /**
   * @param {!Array} shortMonthLabels
   */
  constructor(shortMonthLabels) {
    super();
    this.element.classList.add(YearListCell.ClassNameYearListCell);
    this.element.style.height = YearListCell.GetHeight() + 'px';

    /**
     * @type {!Element}
     * @const
     */
    this.label = createElement('div', YearListCell.ClassNameLabel, '----');
    this.element.appendChild(this.label);
    this.label.style.height =
        (YearListCell.GetHeight() - YearListCell.BorderBottomWidth) + 'px';
    this.label.style.lineHeight =
        (YearListCell.GetHeight() - YearListCell.BorderBottomWidth) + 'px';

    /**
     * @type {!Array} Array of the 12 month button elements.
     * @const
     */
    this.monthButtons = [];
    const monthChooserElement =
        createElement('div', YearListCell.ClassNameMonthChooser);
    for (let r = 0; r < YearListCell.ButtonRows; ++r) {
      const buttonsRow =
          createElement('div', YearListCell.ClassNameMonthButtonsRow);
      buttonsRow.setAttribute('role', 'row');
      for (let c = 0; c < YearListCell.ButtonColumns; ++c) {
        const month = c + r * YearListCell.ButtonColumns;
        const button = createElement(
            'div', YearListCell.ClassNameMonthButton, shortMonthLabels[month]);
        button.setAttribute('role', 'gridcell');
        button.dataset.month = month;
        buttonsRow.appendChild(button);
        this.monthButtons.push(button);
      }
      monthChooserElement.appendChild(buttonsRow);
    }
    this.element.appendChild(monthChooserElement);

    /**
     * @type {!boolean}
     * @private
     */
    this._selected = false;
    /**
     * @type {!number}
     * @private
     */
    this._height = 0;
  }

  static _Height = hasInaccuratePointingDevice() ? 31 : 25;
  static _Height = 25;
  static GetHeight() {
    return YearListCell._Height;
  }
  static BorderBottomWidth = 1;
  static ButtonRows = 3;
  static ButtonColumns = 4;
  static _SelectedHeight = 128;
  static GetSelectedHeight() {
    return YearListCell._SelectedHeight;
  }
  static ClassNameYearListCell = 'year-list-cell';
  static ClassNameLabel = 'label';
  static ClassNameMonthChooser = 'month-chooser';
  static ClassNameMonthButtonsRow = 'month-buttons-row';
  static ClassNameMonthButton = 'month-button';
  static ClassNameHighlighted = 'highlighted';
  static ClassNameSelected = 'selected';
  static ClassNameToday = 'today';

  static _recycleBin = [];

  /**
   * @return {!Array}
   * @override
   */
  _recycleBin() {
    return YearListCell._recycleBin;
  }

  /**
   * @param {!number} row
   */
  reset(row) {
    this.row = row;
    this.label.textContent = row + 1;
    for (let i = 0; i < this.monthButtons.length; ++i) {
      this.monthButtons[i].classList.remove(YearListCell.ClassNameHighlighted);
      this.monthButtons[i].classList.remove(YearListCell.ClassNameSelected);
      this.monthButtons[i].classList.remove(YearListCell.ClassNameToday);
    }
    this.show();
  }

  /**
   * @return {!number} The height in pixels.
   */
  height() {
    return this._height;
  }

  /**
   * @param {!number} height Height in pixels.
   */
  setHeight(height) {
    if (this._height === height)
      return;
    this._height = height;
    this.element.style.height = this._height + 'px';
  }
}

// ----------------------------------------------------------------

// clang-format off
class YearListView extends dateRangeManagerMixin(ListView) {
  // clang-format on
  /**
   * @param {!Month} minimumMonth
   * @param {!Month} maximumMonth
   */
  constructor(minimumMonth, maximumMonth, config) {
    super();
    this.element.classList.add('year-list-view');

    /**
     * @type {?Month}
     */
    this._selectedMonth = null;
    /**
     * @type {!Month}
     * @const
     * @protected
     */
    this._minimumMonth = minimumMonth;
    /**
     * @type {!Month}
     * @const
     * @protected
     */
    this._maximumMonth = maximumMonth;

    this.scrollView.minimumContentOffset =
        (this._minimumMonth.year - 1) * YearListCell.GetHeight();
    this.scrollView.maximumContentOffset =
        (this._maximumMonth.year - 1) * YearListCell.GetHeight() +
        YearListCell.GetSelectedHeight();

    /**
     * @type {!Object}
     * @const
     * @protected
     */
    this._runningAnimators = {};
    /**
     * @type {!Array}
     * @const
     * @protected
     */
    this._animatingRows = [];
    /**
     * @type {!boolean}
     * @protected
     */
    this._ignoreMouseOutUntillNextMouseOver = false;

    /**
     * @type {!ScrubbyScrollBar}
     * @const
     */
    this.scrubbyScrollBar = new ScrubbyScrollBar(this.scrollView);
    this.scrubbyScrollBar.attachTo(this);

    this.element.addEventListener('keydown', this.onKeyDown.bind(this));

    if (config && config.mode == 'month') {
      this.type = 'month';
      this._dateTypeConstructor = Month;

      this._setValidDateConfig(config);

      this._hadValidValueWhenOpened = false;
      const initialSelection = parseDateString(config.currentValue);
      if (initialSelection) {
        this._hadValidValueWhenOpened = this.isValid(initialSelection);
        this._selectedMonth = this.getNearestValidRange(
            initialSelection, /*lookForwardFirst*/ true);
      } else {
        // Ensure that the next month closest to today is selected to start with
        // so that the user can simply submit the popup to choose it.
        this._selectedMonth = this.getValidRangeNearestToDay(
            this._dateTypeConstructor.createFromToday(),
            /*lookForwardFirst*/ true);
      }

      this._initialSelectedMonth = this._selectedMonth;
    } else {
      // This is a month switcher menu embedded in another calendar control.
      // Set up our config so that getNearestValidRangeLookingForward(Backward)
      // when called on this YearListView will navigate by month.
      this.config = {};
      this.config.minimumValue = minimumMonth;
      this.config.maximumValue = maximumMonth;
      this.config.step = Month.DefaultStep;
      this.config.stepBase = Month.DefaultStepBase;
      this._dateTypeConstructor = Month;
    }
  }

  static _VisibleYears = 4;
  static _Height = YearListCell._SelectedHeight - 1 +
      YearListView._VisibleYears * YearListCell._Height;
  static GetHeight() {
    return YearListView._Height;
  };
  static EventTypeYearListViewDidHide = 'yearListViewDidHide';
  static EventTypeYearListViewDidSelectMonth = 'yearListViewDidSelectMonth';

  /**
   * @param {!number} width Width in pixels.
   * @override
   */
  setWidth(width) {
    super.setWidth(width - this.scrubbyScrollBar.element.offsetWidth);
    this.element.style.width = width + 'px';
  }

  /**
   * @param {!number} height Height in pixels.
   * @override
   */
  setHeight(height) {
    super.setHeight(height);
    this.scrubbyScrollBar.setHeight(height);
  }

  /**
   * @enum {number}
   */
  static RowAnimationDirection = {Opening: 0, Closing: 1};

  /**
   * @param {!number} row
   * @param {!YearListView.RowAnimationDirection} direction
   */
  _animateRow(row, direction) {
    let fromValue = direction === YearListView.RowAnimationDirection.Closing ?
        YearListCell.GetSelectedHeight() :
        YearListCell.GetHeight();
    const oldAnimator = this._runningAnimators[row];
    if (oldAnimator) {
      oldAnimator.stop();
      fromValue = oldAnimator.currentValue;
    }
    const cell = this.cellAtRow(row);
    const animator = new TransitionAnimator();
    animator.step = this.onCellHeightAnimatorStep.bind(this);
    animator.setFrom(fromValue);
    animator.setTo(
        direction === YearListView.RowAnimationDirection.Opening ?
            YearListCell.GetSelectedHeight() :
            YearListCell.GetHeight());
    animator.timingFunction = AnimationTimingFunction.EaseInOut;
    animator.duration = 300;
    animator.row = row;
    animator.on(
        Animator.EventTypeDidAnimationStop,
        this.onCellHeightAnimatorDidStop.bind(this));
    this._runningAnimators[row] = animator;
    this._animatingRows.push(row);
    this._animatingRows.sort();
    animator.start();
  }

  /**
   * @param {?Animator} animator
   */
  onCellHeightAnimatorDidStop(animator) {
    delete this._runningAnimators[animator.row];
    const index = this._animatingRows.indexOf(animator.row);
    this._animatingRows.splice(index, 1);
  }

  /**
   * @param {!Animator} animator
   */
  onCellHeightAnimatorStep(animator) {
    const cell = this.cellAtRow(animator.row);
    if (cell)
      cell.setHeight(animator.currentValue);
    this.updateCells();
  }

  /**
   * @param {?Event} event
   */
  onClick(event) {
    const oldSelectedRow = this.selectedRow;
    super.onClick(event);
    const year = this.selectedRow + 1;
    if (this.selectedRow !== oldSelectedRow) {
      // Always start with first month when changing the year.
      const month = new Month(year, 0);
      this.scrollView.scrollTo(
          this.selectedRow * YearListCell.GetHeight(), true);
    } else {
      const monthButton = enclosingNodeOrSelfWithClass(
          event.target, YearListCell.ClassNameMonthButton);
      if (!monthButton || monthButton.getAttribute('aria-disabled') == 'true')
        return;
      const month = parseInt(monthButton.dataset.month, 10);
      this.dispatchEvent(
          YearListView.EventTypeYearListViewDidSelectMonth, this,
          new Month(year, month));
    }
  }

  /**
   * @param {!number} scrollOffset
   * @return {!number}
   * @override
   */
  rowAtScrollOffset(scrollOffset) {
    let remainingOffset = scrollOffset;
    let lastAnimatingRow = 0;
    const rowsWithIrregularHeight = this._animatingRows.slice();
    if (this.selectedRow > -1 && !this._runningAnimators[this.selectedRow]) {
      rowsWithIrregularHeight.push(this.selectedRow);
      rowsWithIrregularHeight.sort();
    }
    for (let i = 0; i < rowsWithIrregularHeight.length; ++i) {
      const row = rowsWithIrregularHeight[i];
      const animator = this._runningAnimators[row];
      const rowHeight =
          animator ? animator.currentValue : YearListCell.GetSelectedHeight();
      if (remainingOffset <=
          (row - lastAnimatingRow) * YearListCell.GetHeight()) {
        return lastAnimatingRow +
            Math.floor(remainingOffset / YearListCell.GetHeight());
      }
      remainingOffset -= (row - lastAnimatingRow) * YearListCell.GetHeight();
      if (remainingOffset <= (rowHeight - YearListCell.GetHeight()))
        return row;
      remainingOffset -= rowHeight - YearListCell.GetHeight();
      lastAnimatingRow = row;
    }
    return lastAnimatingRow +
        Math.floor(remainingOffset / YearListCell.GetHeight());
  }

  /**
   * @param {!number} row
   * @return {!number}
   * @override
   */
  scrollOffsetForRow(row) {
    let scrollOffset = row * YearListCell.GetHeight();
    for (let i = 0; i < this._animatingRows.length; ++i) {
      const animatingRow = this._animatingRows[i];
      if (animatingRow >= row)
        break;
      const animator = this._runningAnimators[animatingRow];
      scrollOffset += animator.currentValue - YearListCell.GetHeight();
    }
    if (this.selectedRow > -1 && this.selectedRow < row &&
        !this._runningAnimators[this.selectedRow]) {
      scrollOffset +=
          YearListCell.GetSelectedHeight() - YearListCell.GetHeight();
    }
    return scrollOffset;
  }

  /**
   * @param {!number} row
   * @return {!YearListCell}
   * @override
   */
  prepareNewCell(row) {
    const cell = YearListCell._recycleBin.pop() ||
        new YearListCell(global.params.shortMonthLabels);
    cell.reset(row);
    cell.setSelected(this.selectedRow === row);
    for (let i = 0; i < cell.monthButtons.length; ++i) {
      const month = new Month(row + 1, i);
      cell.monthButtons[i].id = month.toString();
      if (this.type === 'month') {
        cell.monthButtons[i].setAttribute(
            'aria-disabled', this.isValid(month) ? 'false' : 'true');
      } else {
        cell.monthButtons[i].setAttribute(
            'aria-disabled',
            this._minimumMonth > month || this._maximumMonth < month ? 'true' :
                                                                       'false');
      }
      cell.monthButtons[i].setAttribute('aria-label', month.toLocaleString());
      cell.monthButtons[i].setAttribute('aria-selected', false);
    }
    if (this._selectedMonth && (this._selectedMonth.year - 1) === row) {
      const monthButton = cell.monthButtons[this._selectedMonth.month];
      monthButton.classList.add(YearListCell.ClassNameSelected);
      this.element.setAttribute('aria-activedescendant', monthButton.id);
      monthButton.setAttribute('aria-selected', true);
    }
    const todayMonth = Month.createFromToday();
    if ((todayMonth.year - 1) === row) {
      const monthButton = cell.monthButtons[todayMonth.month];
      monthButton.classList.add(YearListCell.ClassNameToday);
    }

    const animator = this._runningAnimators[row];
    if (animator)
      cell.setHeight(animator.currentValue);
    else if (row === this.selectedRow)
      cell.setHeight(YearListCell.GetSelectedHeight());
    else
      cell.setHeight(YearListCell.GetHeight());
    return cell;
  }

  /**
   * @override
   */
  updateCells() {
    const firstVisibleRow = this.firstVisibleRow();
    const lastVisibleRow = this.lastVisibleRow();
    console.assert(firstVisibleRow <= lastVisibleRow);
    for (let c in this._cells) {
      const cell = this._cells[c];
      if (cell.row < firstVisibleRow || cell.row > lastVisibleRow)
        this.throwAwayCell(cell);
    }
    for (let i = firstVisibleRow; i <= lastVisibleRow; ++i) {
      const cell = this._cells[i];
      if (cell)
        cell.setPosition(this.scrollView.contentPositionForContentOffset(
            this.scrollOffsetForRow(cell.row)));
      else
        this.addCellIfNecessary(i);
    }
    this.setNeedsUpdateCells(false);
  }

  /**
   * @override
   */
  deselect() {
    if (this.selectedRow === ListView.NoSelection)
      return;
    const selectedCell = this._cells[this.selectedRow];
    if (selectedCell)
      selectedCell.setSelected(false);
    this._animateRow(
        this.selectedRow, YearListView.RowAnimationDirection.Closing);
    this.selectedRow = ListView.NoSelection;
    this.setNeedsUpdateCells(true);
  }

  deselectWithoutAnimating() {
    if (this.selectedRow === ListView.NoSelection)
      return;
    const selectedCell = this._cells[this.selectedRow];
    if (selectedCell) {
      selectedCell.setSelected(false);
      selectedCell.setHeight(YearListCell.GetHeight());
    }
    this.selectedRow = ListView.NoSelection;
    this.setNeedsUpdateCells(true);
  }

  /**
   * @param {!number} row
   * @override
   */
  select(row) {
    if (this.selectedRow === row)
      return;
    this.deselect();
    if (row === ListView.NoSelection)
      return;
    this.selectedRow = row;
    if (this.selectedRow !== ListView.NoSelection) {
      const selectedCell = this._cells[this.selectedRow];
      this._animateRow(
          this.selectedRow, YearListView.RowAnimationDirection.Opening);
      if (selectedCell)
        selectedCell.setSelected(true);
    }
    this.setNeedsUpdateCells(true);
  }

  /**
   * @param {!number} row
   */
  selectWithoutAnimating(row) {
    if (this.selectedRow === row)
      return;
    this.deselectWithoutAnimating();
    if (row === ListView.NoSelection)
      return;
    this.selectedRow = row;
    if (this.selectedRow !== ListView.NoSelection) {
      const selectedCell = this._cells[this.selectedRow];
      if (selectedCell) {
        selectedCell.setSelected(true);
        selectedCell.setHeight(YearListCell.GetSelectedHeight());
      }
    }
    this.setNeedsUpdateCells(true);
  }

  /**
   * @param {!Month} month
   * @return {?HTMLDivElement}
   */
  buttonForMonth(month) {
    if (!month)
      return null;
    const row = month.year - 1;
    const cell = this.cellAtRow(row);
    if (!cell)
      return null;
    return cell.monthButtons[month.month];
  }

  dehighlightMonth() {
    if (!this.highlightedMonth)
      return;
    const monthButton = this.buttonForMonth(this.highlightedMonth);
    if (monthButton) {
      monthButton.classList.remove(YearListCell.ClassNameHighlighted);
    }
    this.highlightedMonth = null;
    this.element.removeAttribute('aria-activedescendant');
  }

  /**
   * @param {!Month} month
   */
  highlightMonth(month) {
    if (this.highlightedMonth && this.highlightedMonth.equals(month))
      return;
    this.dehighlightMonth();
    this.highlightedMonth = month;
    if (!this.highlightedMonth)
      return;
    const monthButton = this.buttonForMonth(this.highlightedMonth);
    if (monthButton) {
      monthButton.classList.add(YearListCell.ClassNameHighlighted);
      this.element.setAttribute('aria-activedescendant', monthButton.id);
    }
  }

  setSelectedMonth(month) {
    const oldMonthButton = this.buttonForMonth(this._selectedMonth);
    if (oldMonthButton) {
      oldMonthButton.classList.remove(YearListCell.ClassNameSelected);
      oldMonthButton.setAttribute('aria-selected', false);
    }

    this._selectedMonth = month;

    const newMonthButton = this.buttonForMonth(this._selectedMonth);
    if (newMonthButton) {
      newMonthButton.classList.add(YearListCell.ClassNameSelected);
      this.element.setAttribute('aria-activedescendant', newMonthButton.id);
      newMonthButton.setAttribute('aria-selected', true);
    }
  }

  setSelectedMonthAndUpdateView(month) {
    this.setSelectedMonth(month);

    this.select(this._selectedMonth.year - 1);

    this.scrollView.scrollTo(this.selectedRow * YearListCell.GetHeight(), true);
  }

  showSelectedMonth() {
    const monthButton = this.buttonForMonth(this._selectedMonth);
    if (monthButton) {
      monthButton.classList.add(YearListCell.ClassNameSelected);
    }
  }

  /**
   * @param {!Month} month
   */
  show(month) {
    this._ignoreMouseOutUntillNextMouseOver = true;

    this.scrollToRow(month.year - 1, false);
    this.selectWithoutAnimating(month.year - 1);
    this.showSelectedMonth();
  }

  hide() {
    this.dispatchEvent(YearListView.EventTypeYearListViewDidHide, this);
  }

  /**
   * @param {!Month} month
   */
  _moveHighlightTo(month) {
    this.highlightMonth(month);
    this.select(this.highlightedMonth.year - 1);
    this.scrollView.scrollTo(this.selectedRow * YearListCell.GetHeight(), true);
    return true;
  }

  /**
   * @param {?Event} event
   */
  onKeyDown(event) {
    const key = event.key;
    let eventHandled = false;
    if (this._selectedMonth) {
      if (global.params.isLocaleRTL ? key == 'ArrowRight' :
                                      key == 'ArrowLeft') {
        const newSelection = this.getNearestValidRangeLookingBackward(
            this._selectedMonth.previous());
        if (newSelection) {
          this.setSelectedMonthAndUpdateView(newSelection);
        }
      } else if (key == 'ArrowUp') {
        const newSelection = this.getNearestValidRangeLookingBackward(
            this._selectedMonth.previous(YearListCell.ButtonColumns));
        if (newSelection) {
          this.setSelectedMonthAndUpdateView(newSelection);
        }
      } else if (
          global.params.isLocaleRTL ? key == 'ArrowLeft' :
                                      key == 'ArrowRight') {
        const newSelection =
            this.getNearestValidRangeLookingForward(this._selectedMonth.next());
        if (newSelection) {
          this.setSelectedMonthAndUpdateView(newSelection);
        }
      } else if (key == 'ArrowDown') {
        const newSelection = this.getNearestValidRangeLookingForward(
            this._selectedMonth.next(YearListCell.ButtonColumns));
        if (newSelection) {
          this.setSelectedMonthAndUpdateView(newSelection);
        }
      } else if (key == 'PageUp') {
        const newSelection = this.getNearestValidRangeLookingBackward(
            this._selectedMonth.previous(MonthsPerYear));
        if (newSelection) {
          this.setSelectedMonthAndUpdateView(newSelection);
        }
      } else if (key == 'PageDown') {
        const newSelection = this.getNearestValidRangeLookingForward(
            this._selectedMonth.next(MonthsPerYear));
        if (newSelection) {
          this.setSelectedMonthAndUpdateView(newSelection);
        }
      } else if (key == 'Home') {
        const newMonth = this._selectedMonth.month === 0 ?
            new Month(this._selectedMonth.year - 1, 0) :
            new Month(this._selectedMonth.year, 0);
        const newSelection = this.getNearestValidRangeLookingBackward(newMonth);
        if (newSelection) {
          this.setSelectedMonthAndUpdateView(newSelection);
        }
      } else if (key == 'End') {
        const lastMonthNum = MonthsPerYear - 1;
        const newMonth = this._selectedMonth.month === lastMonthNum ?
            new Month(this._selectedMonth.year + 1, lastMonthNum) :
            new Month(this._selectedMonth.year, lastMonthNum);
        const newSelection = this.getNearestValidRangeLookingForward(newMonth);
        if (newSelection) {
          this.setSelectedMonthAndUpdateView(newSelection);
        }
      } else if (this.type !== 'month') {
        if (key == 'Enter') {
          this.dispatchEvent(
              YearListView.EventTypeYearListViewDidSelectMonth, this,
              this._selectedMonth);
        } else if (key == 'Escape') {
          this.hide();
          eventHandled = true;
        }
      }
    } else if (key == 'ArrowUp') {
      this.scrollView.scrollBy(-YearListCell.GetHeight(), true);
      eventHandled = true;
    } else if (key == 'ArrowDown') {
      this.scrollView.scrollBy(YearListCell.GetHeight(), true);
      eventHandled = true;
    } else if (key == 'PageUp') {
      this.scrollView.scrollBy(-this.scrollView.height(), true);
      eventHandled = true;
    } else if (key == 'PageDown') {
      this.scrollView.scrollBy(this.scrollView.height(), true);
      eventHandled = true;
    }

    if (eventHandled) {
      event.stopPropagation();
      event.preventDefault();
    }
  }
}

// ----------------------------------------------------------------

class MonthPopupView extends View {
  /**
   * @param {!Month} minimumMonth
   * @param {!Month} maximumMonth
   */
  constructor(minimumMonth, maximumMonth) {
    super(createElement('div', MonthPopupView.ClassNameMonthPopupView));

    /**
     * @type {!YearListView}
     * @const
     */
    this.yearListView = new YearListView(minimumMonth, maximumMonth);
    this.yearListView.attachTo(this);

    /**
     * @type {!boolean}
     */
    this.isVisible = false;

    this.element.addEventListener('click', this.onClick.bind(this), false);
  }

  static ClassNameMonthPopupView = 'month-popup-view';

  show(initialMonth, calendarTableRect) {
    this.isVisible = true;
    if (global.params.mode == 'datetime-local') {
      // Place the month popup under the datetimelocal-picker element so that
      // the datetimelocal-picker element receives its keyboard and click
      // events. For other calendar control types, these events are handled via
      // the body element.
      document.querySelector('datetimelocal-picker').appendChild(this.element);
    } else {
      document.body.appendChild(this.element);
    }
    this.yearListView.setWidth(calendarTableRect.width - 2);
    this.yearListView.setHeight(YearListView.GetHeight());
    if (global.params.isLocaleRTL)
      this.yearListView.element.style.right = calendarTableRect.x + 'px';
    else
      this.yearListView.element.style.left = calendarTableRect.x + 'px';
    this.yearListView.element.style.top = calendarTableRect.y + 'px';
    this.yearListView.show(initialMonth);
    this.yearListView.element.focus();
  }

  hide() {
    if (!this.isVisible)
      return;
    this.isVisible = false;
    this.element.parentNode.removeChild(this.element);
    this.yearListView.hide();
  }

  /**
   * @param {?Event} event
   */
  onClick(event) {
    if (event.target !== this.element)
      return;
    this.hide();
  }
}

// ----------------------------------------------------------------

class MonthPopupButton extends View {
  /**
   * @extends View
   * @param {!number} maxWidth Maximum width in pixels.
   */
  constructor(maxWidth) {
    super(createElement('button', MonthPopupButton.ClassNameMonthPopupButton));
    this.element.setAttribute('aria-label', global.params.axShowMonthSelector);

    /**
     * @type {!Element}
     * @const
     */
    this.labelElement = createElement(
        'span', MonthPopupButton.ClassNameMonthPopupButtonLabel, '-----');
    this.element.appendChild(this.labelElement);

    /**
     * @type {!Element}
     * @const
     */
    this.disclosureTriangleIcon =
        createElement('span', MonthPopupButton.ClassNameDisclosureTriangle);
    this.disclosureTriangleIcon.innerHTML =
        '<svg width=\'7\' height=\'5\'><polygon points=\'0,1 7,1 3.5,5\' style=\'fill:#000000;\' /></svg>';
    this.element.appendChild(this.disclosureTriangleIcon);

    /**
     * @type {!boolean}
     * @protected
     */
    this._useShortMonth = this._shouldUseShortMonth(maxWidth);
    this.element.style.maxWidth = maxWidth + 'px';

    this.element.addEventListener('click', this.onClick.bind(this), false);
  }

  static ClassNameMonthPopupButton = 'month-popup-button';
  static ClassNameMonthPopupButtonLabel = 'month-popup-button-label';
  static ClassNameDisclosureTriangle = 'disclosure-triangle';
  static EventTypeButtonClick = 'buttonClick';

  /**
   * @param {!number} maxWidth Maximum available width in pixels.
   * @return {!boolean}
   */
  _shouldUseShortMonth(maxWidth) {
    document.body.appendChild(this.element);
    let month = Month.Maximum;
    for (let i = 0; i < MonthsPerYear; ++i) {
      this.labelElement.textContent = month.toLocaleString();
      if (this.element.offsetWidth > maxWidth)
        return true;
      month = month.previous();
    }
    document.body.removeChild(this.element);
    return false;
  }

  /**
   * @param {!Month} month
   */
  setCurrentMonth(month) {
    this.labelElement.textContent = this._useShortMonth ?
        month.toShortLocaleString() :
        month.toLocaleString();
  }

  /**
   * @param {?Event} event
   */
  onClick(event) {
    this.dispatchEvent(MonthPopupButton.EventTypeButtonClick, this);
  }
}

// ----------------------------------------------------------------

class ClearButton extends View {
  /**
   * @extends View
   */
  constructor() {
    super(createElement('button', ClearButton.ClassNameClearButton));
    this.element.addEventListener('click', this.onClick.bind(this), false);
  }

  static ClassNameClearButton = 'clear-button';
  static EventTypeButtonClick = 'buttonClick';

  /**
   * @param {?Event} event
   */
  onClick(event) {
    this.dispatchEvent(ClearButton.EventTypeButtonClick, this);
  }
}

// ----------------------------------------------------------------

class CalendarNavigationButton extends View {
  /**
   * @extends View
   */
  constructor() {
    super(createElement(
        'button', CalendarNavigationButton.ClassNameCalendarNavigationButton));
    /**
     * @type {number} Threshold for starting repeating clicks in milliseconds.
     */
    this.repeatingClicksStartingThreshold =
        CalendarNavigationButton.DefaultRepeatingClicksStartingThreshold;
    /**
     * @type {number} Interval between repeating clicks in milliseconds.
     */
    this.repeatingClicksInterval =
        CalendarNavigationButton.DefaultRepeatingClicksInterval;
    /**
     * @type {?number} The ID for the timeout that triggers the repeating clicks.
     */
    this._timer = null;

    this._onWindowMouseUpBound = this.onWindowMouseUp.bind(this);

    this.element.addEventListener('click', this.onClick.bind(this), false);
    this.element.addEventListener(
        'mousedown', this.onMouseDown.bind(this), false);
    this.element.addEventListener(
        'touchstart', this.onTouchStart.bind(this), false);
  }

  static DefaultRepeatingClicksStartingThreshold = 600;
  static DefaultRepeatingClicksInterval = 300;
  static LeftMargin = 4;
  static Width = 24;
  static ClassNameCalendarNavigationButton = 'calendar-navigation-button';
  static EventTypeButtonClick = 'buttonClick';
  static EventTypeRepeatingButtonClick = 'repeatingButtonClick';

  /**
   * @param {!boolean} disabled
   */
  setDisabled(disabled) {
    this.element.disabled = disabled;
  }

  /**
   * @param {?Event} event
   */
  onClick(event) {
    this.dispatchEvent(CalendarNavigationButton.EventTypeButtonClick, this);
  }

  /**
   * @param {?Event} event
   */
  onTouchStart(event) {
    if (this._timer !== null)
      return;
    this._timer = setTimeout(
        this.onRepeatingClick.bind(this),
        this.repeatingClicksStartingThreshold);
    window.addEventListener(
        'touchend', this.onWindowTouchEnd.bind(this), false);
  }

  /**
   * @param {?Event} event
   */
  onWindowTouchEnd(event) {
    if (this._timer === null)
      return;
    clearTimeout(this._timer);
    this._timer = null;
    window.removeEventListener('touchend', this._onWindowMouseUpBound);
  }

  /**
   * @param {?Event} event
   */
  onMouseDown(event) {
    if (this._timer !== null)
      return;
    this._timer = setTimeout(
        this.onRepeatingClick.bind(this),
        this.repeatingClicksStartingThreshold);
    window.addEventListener('mouseup', this._onWindowMouseUpBound);
  }

  /**
   * @param {?Event} event
   */
  onWindowMouseUp(event) {
    if (this._timer === null)
      return;
    clearTimeout(this._timer);
    this._timer = null;
    window.removeEventListener('mouseup', this._onWindowMouseUpBound);
  }

  /**
   * @param {?Event} event
   */
  onRepeatingClick(event) {
    this.dispatchEvent(
        CalendarNavigationButton.EventTypeRepeatingButtonClick, this);
    this._timer = setTimeout(
        this.onRepeatingClick.bind(this), this.repeatingClicksInterval);
  }
}

// ----------------------------------------------------------------

/**
 * @param {!Day} day
 * @param {!Day} minDay
 * @param {!Day} maxDay
 * @return {boolean}
 */
function isDayOutsideOfRange(day, minDay, maxDay) {
  return day < minDay || maxDay < day;
}

/**
 * @param {!Week} week
 * @param {!Week} minWeek
 * @param {!Week} maxWeek
 * @return {boolean}
 */
function isWeekOutsideOfRange(week, minWeek, maxWeek) {
  return week < minWeek || maxWeek < week;
}

// ----------------------------------------------------------------

class CalendarHeaderView extends View {
  /**
   * @extends View
   * @param {!CalendarPicker} calendarPicker
   */
  constructor(calendarPicker) {
    super(createElement('div', CalendarHeaderView.ClassNameCalendarHeaderView));
    this.calendarPicker = calendarPicker;
    this.calendarPicker.on(
        CalendarPicker.EventTypeCurrentMonthChanged,
        this.onCurrentMonthChanged.bind(this));

    const titleElement =
        createElement('div', CalendarHeaderView.ClassNameCalendarTitle);
    this.element.appendChild(titleElement);

    /**
     * @type {!MonthPopupButton}
     */
    this.monthPopupButton = new MonthPopupButton(
        this.calendarPicker.calendarTableView.width() -
        CalendarTableView.GetBorderWidth() * 2 -
        CalendarNavigationButton.Width * 3 -
        CalendarNavigationButton.LeftMargin * 2);
    this.monthPopupButton.attachTo(titleElement);

    /**
     * @type {!CalendarNavigationButton}
     * @const
     */
    this._previousMonthButton = new CalendarNavigationButton();
    this._previousMonthButton.attachTo(this);
    this._previousMonthButton.on(
        CalendarNavigationButton.EventTypeButtonClick,
        this.onNavigationButtonClick.bind(this));
    this._previousMonthButton.on(
        CalendarNavigationButton.EventTypeRepeatingButtonClick,
        this.onNavigationButtonClick.bind(this));
    this._previousMonthButton.element.setAttribute(
        'aria-label', global.params.axShowPreviousMonth);
    this._previousMonthButton.element.setAttribute(
        'title', global.params.axShowPreviousMonth);

    /**
     * @type {!CalendarNavigationButton}
     * @const
     */
    this._nextMonthButton = new CalendarNavigationButton();
    this._nextMonthButton.attachTo(this);
    this._nextMonthButton.on(
        CalendarNavigationButton.EventTypeButtonClick,
        this.onNavigationButtonClick.bind(this));
    this._nextMonthButton.on(
        CalendarNavigationButton.EventTypeRepeatingButtonClick,
        this.onNavigationButtonClick.bind(this));
    this._nextMonthButton.element.setAttribute(
        'aria-label', global.params.axShowNextMonth);
    this._nextMonthButton.element.setAttribute(
        'title', global.params.axShowNextMonth);

    if (global.params.isLocaleRTL) {
      this._nextMonthButton.element.innerHTML =
          CalendarHeaderView.GetBackwardTriangle();
      this._previousMonthButton.element.innerHTML =
          CalendarHeaderView.GetForwardTriangle();
    } else {
      this._nextMonthButton.element.innerHTML =
          CalendarHeaderView.GetForwardTriangle();
      this._previousMonthButton.element.innerHTML =
          CalendarHeaderView.GetBackwardTriangle();
    }
  }

  static Height = 24;
  static BottomMargin = 10;
  static ClassNameCalendarNavigationButtonIcon = 'navigation-button-icon';
  static _ForwardTriangle = `<svg class="${
      CalendarHeaderView
          .ClassNameCalendarNavigationButtonIcon}" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
    <path class="${
      CalendarHeaderView
          .ClassNameCalendarNavigationButtonIcon}" d="M15.3516 8.60156L8 15.9531L0.648438 8.60156L1.35156 7.89844L7.5 14.0469V0H8.5V14.0469L14.6484 7.89844L15.3516 8.60156Z" fill="#101010"/>
    </svg>`;
  static GetForwardTriangle() {
    return CalendarHeaderView._ForwardTriangle;
  }
  static _BackwardTriangle = `<svg class="${
      CalendarHeaderView
          .ClassNameCalendarNavigationButtonIcon}" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
    <path class="${
      CalendarHeaderView
          .ClassNameCalendarNavigationButtonIcon}" d="M14.6484 8.10156L8.5 1.95312V16H7.5V1.95312L1.35156 8.10156L0.648438 7.39844L8 0.046875L15.3516 7.39844L14.6484 8.10156Z" fill="#101010"/>
    </svg>`;
  static GetBackwardTriangle() {
    return CalendarHeaderView._BackwardTriangle;
  }
  static ClassNameCalendarHeaderView = 'calendar-header-view';
  static ClassNameCalendarTitle = 'calendar-title';
  static ClassNameTodayButton = 'today-button';
  static GetClassNameTodayButton() {
    return CalendarHeaderView.ClassNameTodayButton;
  }

  onCurrentMonthChanged() {
    this.monthPopupButton.setCurrentMonth(this.calendarPicker.currentMonth());
    this._previousMonthButton.setDisabled(
        this.disabled ||
        this.calendarPicker.currentMonth() <= this.calendarPicker.minimumMonth);
    this._nextMonthButton.setDisabled(
        this.disabled ||
        this.calendarPicker.currentMonth() >= this.calendarPicker.maximumMonth);
  }

  onNavigationButtonClick(sender) {
    if (sender === this._previousMonthButton) {
      this.calendarPicker.setCurrentMonth(
          this.calendarPicker.currentMonth().previous(),
          CalendarPicker.NavigationBehavior.WithAnimation);
      this.calendarPicker.ensureSelectionIsWithinCurrentMonth();
    } else if (sender === this._nextMonthButton) {
      this.calendarPicker.setCurrentMonth(
          this.calendarPicker.currentMonth().next(),
          CalendarPicker.NavigationBehavior.WithAnimation);
      this.calendarPicker.ensureSelectionIsWithinCurrentMonth();
    } else
      this.calendarPicker.selectRangeContainingDay(Day.createFromToday());
  }

  /**
   * @param {!boolean} disabled
   */
  setDisabled(disabled) {
    this.disabled = disabled;
    this._previousMonthButton.element.style.visibility =
        this.disabled ? 'hidden' : 'visible';
    this._nextMonthButton.element.style.visibility =
        this.disabled ? 'hidden' : 'visible';
    this.monthPopupButton.element.disabled = this.disabled;
    this._previousMonthButton.setDisabled(
        this.disabled ||
        this.calendarPicker.currentMonth() <= this.calendarPicker.minimumMonth);
    this._nextMonthButton.setDisabled(
        this.disabled ||
        this.calendarPicker.currentMonth() >= this.calendarPicker.maximumMonth);
    if (this._todayButton) {
      if (this.disabled) {
        this._todayButton.setDisabled(true);
      } else if (this.calendarPicker.type === 'week') {
        this._todayButton.setDisabled(isWeekOutsideOfRange(
            Week.createFromToday(), this.calendarPicker.config.minimum,
            this.calendarPicker.config.maximum));
      } else {
        this._todayButton.setDisabled(isDayOutsideOfRange(
            Day.createFromToday(), this.calendarPicker.config.minimum,
            this.calendarPicker.config.maximum));
      }
    }
  }
}

// ----------------------------------------------------------------

class DayCell extends ListCell {
  constructor() {
    super();
    this.element.classList.add(DayCell.ClassNameDayCell);
    this.element.style.width = DayCell.GetWidth() + 'px';
    this.element.style.height = DayCell.GetHeight() + 'px';
    this.element.style.lineHeight =
        (DayCell.GetHeight() - DayCell.PaddingSize * 2) + 'px';
    this.element.setAttribute('role', 'gridcell');
    /**
     * @type {?Day}
     */
    this.day = null;
  }

  static _Width = 28;
  static GetWidth() {
    return DayCell._Width;
  }
  static _Height = 28;
  static GetHeight() {
    return DayCell._Height;
  }
  static PaddingSize = 1;
  static ClassNameDayCell = 'day-cell';
  static ClassNameHighlighted = 'highlighted';
  static ClassNameDisabled = 'disabled';
  static ClassNameCurrentMonth = 'current-month';
  static ClassNameToday = 'today';

  static _recycleBin = [];

  static recycleOrCreate() {
    return DayCell._recycleBin.pop() || new DayCell();
  }

  /**
   * @return {!Array}
   * @override
   */
  _recycleBin() {
    return DayCell._recycleBin;
  }

  /**
   * @override
   */
  throwAway() {
    super.throwAway();
    this.day = null;
  }

  /**
   * @param {!boolean} highlighted
   */
  setHighlighted(highlighted) {
    if (highlighted) {
      this.element.classList.add(DayCell.ClassNameHighlighted);
      this.element.setAttribute('aria-selected', 'true');
    } else {
      this.element.classList.remove(DayCell.ClassNameHighlighted);
      this.element.setAttribute('aria-selected', 'false');
    }
  }

  /**
   * @param {!boolean} disabled
   */
  setDisabled(disabled) {
    if (disabled)
      this.element.classList.add(DayCell.ClassNameDisabled);
    else
      this.element.classList.remove(DayCell.ClassNameDisabled);
  }

  /**
   * @param {!boolean} selected
   */
  setIsInCurrentMonth(selected) {
    if (selected)
      this.element.classList.add(DayCell.ClassNameCurrentMonth);
    else
      this.element.classList.remove(DayCell.ClassNameCurrentMonth);
  }

  /**
   * @param {!boolean} selected
   */
  setIsToday(selected) {
    if (selected)
      this.element.classList.add(DayCell.ClassNameToday);
    else
      this.element.classList.remove(DayCell.ClassNameToday);
  }

  /**
   * @param {!Day} day
   */
  reset(day) {
    this.day = day;
    this.element.textContent = localizeNumber(this.day.date.toString());
    this.element.setAttribute('aria-label', this.day.format());
    this.element.id = this.day.toString();
    this.show();
  }
}

// ----------------------------------------------------------------

class WeekNumberCell extends ListCell {
  constructor() {
    super();
    this.element.classList.add(WeekNumberCell.ClassNameWeekNumberCell);
    this.element.style.width =
        (WeekNumberCell.Width - WeekNumberCell.SeparatorWidth) + 'px';
    this.element.style.height = WeekNumberCell.GetHeight() + 'px';
    this.element.style.lineHeight =
        (WeekNumberCell.GetHeight() - WeekNumberCell.PaddingSize * 2) + 'px';
    /**
     * @type {?Week}
     */
    this.week = null;
  }

  static Width = 48;
  static _Height = DayCell._Height;
  static GetHeight() {
    return WeekNumberCell._Height;
  }
  static SeparatorWidth = 1;
  static PaddingSize = 1;
  static ClassNameWeekNumberCell = 'week-number-cell';
  static ClassNameHighlighted = 'highlighted';
  static ClassNameDisabled = 'disabled';

  static _recycleBin = [];

  /**
   * @return {!Array}
   * @override
   */
  _recycleBin() {
    return WeekNumberCell._recycleBin;
  }

  /**
   * @return {!WeekNumberCell}
   */
  static recycleOrCreate() {
    return WeekNumberCell._recycleBin.pop() || new WeekNumberCell();
  }

  /**
   * @param {!Week} week
   */
  reset(week) {
    this.week = week;
    this.element.id = week.toString();
    this.element.setAttribute('role', 'gridcell');
    this.element.setAttribute(
        'aria-label',
        window.pagePopupController.formatWeek(
            week.year, week.week, week.firstDay().format()));
    this.element.textContent = localizeNumber(this.week.week.toString());
    this.show();
  }

  /**
   * @override
   */
  throwAway() {
    super.throwAway();
    this.week = null;
  }

  setHighlighted(highlighted) {
    if (highlighted) {
      this.element.classList.add(WeekNumberCell.ClassNameHighlighted);
      this.element.setAttribute('aria-selected', 'true');
    } else {
      this.element.classList.remove(WeekNumberCell.ClassNameHighlighted);
      this.element.setAttribute('aria-selected', 'false');
    }
  }

  setDisabled(disabled) {
    if (disabled)
      this.element.classList.add(WeekNumberCell.ClassNameDisabled);
    else
      this.element.classList.remove(WeekNumberCell.ClassNameDisabled);
  }
}

// ----------------------------------------------------------------

class CalendarTableHeaderView extends View {
  /**
   * @param {!boolean} hasWeekNumberColumn
   */
  constructor(hasWeekNumberColumn) {
    super(createElement('div', 'calendar-table-header-view'));
    if (hasWeekNumberColumn) {
      const weekNumberLabelElement =
          createElement('div', 'week-number-label', global.params.weekLabel);
      weekNumberLabelElement.style.width = WeekNumberCell.Width + 'px';
      this.element.appendChild(weekNumberLabelElement);
    }
    for (let i = 0; i < DaysPerWeek; ++i) {
      const weekDayNumber = (global.params.weekStartDay + i) % DaysPerWeek;
      const labelElement = createElement(
          'div', 'week-day-label', global.params.dayLabels[weekDayNumber]);
      labelElement.style.width = DayCell.GetWidth() + 'px';
      this.element.appendChild(labelElement);
      if (getLanguage() === 'ja') {
        if (weekDayNumber === 0)
          labelElement.style.color = 'red';
        else if (weekDayNumber === 6)
          labelElement.style.color = 'blue';
      }
    }
  }

  static _Height = 29;
  static GetHeight() {
    return CalendarTableHeaderView._Height;
  }
}

// ----------------------------------------------------------------

class CalendarRowCell extends ListCell {
  constructor() {
    super();

    this.element.classList.add(CalendarRowCell.ClassNameCalendarRowCell);
    if (global.params.weekStartDay === WeekDay.Sunday) {
      this.element.classList.add(CalendarRowCell.ClassNameWeekStartsOnSunday);
    }
    this.element.style.height = CalendarRowCell.GetHeight() + 'px';
    this.element.setAttribute('role', 'row');

    /**
     * @type {!Array}
     * @protected
     */
    this._dayCells = [];
    /**
     * @type {!number}
     */
    this.row = 0;
    /**
     * @type {?CalendarTableView}
     */
    this.calendarTableView = null;
  }

  static _Height = DayCell._Height;
  static GetHeight() {
    return CalendarRowCell._Height;
  }
  static ClassNameCalendarRowCell = 'calendar-row-cell';
  static ClassNameWeekStartsOnSunday = 'week-starts-on-sunday';

  static _recycleBin = [];

  /**
   * @return {!Array}
   * @override
   */
  _recycleBin() {
    return CalendarRowCell._recycleBin;
  }

  /**
   * @param {!number} row
   * @param {!CalendarTableView} calendarTableView
   */
  reset(row, calendarTableView) {
    this.row = row;
    this.calendarTableView = calendarTableView;
    if (this.calendarTableView.hasWeekNumberColumn) {
      const middleDay = this.calendarTableView.dayAtColumnAndRow(3, row);
      const week = Week.createFromDay(middleDay);
      this.weekNumberCell =
          this.calendarTableView.prepareNewWeekNumberCell(week);
      this.weekNumberCell.attachTo(this);
    }
    let day = calendarTableView.dayAtColumnAndRow(0, row);
    for (let i = 0; i < DaysPerWeek; ++i) {
      const dayCell = this.calendarTableView.prepareNewDayCell(day);
      dayCell.attachTo(this);
      this._dayCells.push(dayCell);
      day = day.next();
    }
    this.show();
  }

  /**
   * @override
   */
  throwAway() {
    super.throwAway();
    if (this.weekNumberCell)
      this.calendarTableView.throwAwayWeekNumberCell(this.weekNumberCell);
    this._dayCells.forEach(
        this.calendarTableView.throwAwayDayCell, this.calendarTableView);
    this._dayCells.length = 0;
  }
}

// ----------------------------------------------------------------

class CalendarTableView extends ListView {
  /**
   * @param {!CalendarPicker} calendarPicker
   */
  constructor(calendarPicker) {
    super();
    this.element.classList.add(CalendarTableView.ClassNameCalendarTableView);
    this.element.tabIndex = 0;

    /**
     * @type {!boolean}
     * @const
     */
    this.hasWeekNumberColumn = calendarPicker.type === 'week';
    /**
     * @type {!CalendarPicker}
     * @const
     */
    this.calendarPicker = calendarPicker;
    /**
     * @type {!Object}
     * @const
     */
    this._dayCells = {};
    const headerView = new CalendarTableHeaderView(this.hasWeekNumberColumn);
    headerView.attachTo(this, this.scrollView);

    /**
     * @type {!ClearButton}
     * @const
     */
    const clearButton = new ClearButton();
    clearButton.attachTo(this);
    clearButton.on(
        ClearButton.EventTypeButtonClick, this.onClearButtonClick.bind(this));
    clearButton.element.textContent = global.params.clearLabel;
    clearButton.element.setAttribute('aria-label', global.params.clearLabel);

    /**
     * @type {!CalendarNavigationButton}
     * @const
     */
    const todayButton = new CalendarNavigationButton();
    todayButton.attachTo(this);
    todayButton.on(
        CalendarNavigationButton.EventTypeButtonClick,
        this.onTodayButtonClick.bind(this));
    todayButton.element.textContent = global.params.todayLabel;
    todayButton.element.classList.add(
        CalendarHeaderView.GetClassNameTodayButton());
    if (this.calendarPicker.type === 'week') {
      todayButton.setDisabled(isWeekOutsideOfRange(
          Week.createFromToday(), this.calendarPicker.config.minimum,
          this.calendarPicker.config.maximum));
    } else {
      todayButton.setDisabled(isDayOutsideOfRange(
          Day.createFromToday(), this.calendarPicker.config.minimum,
          this.calendarPicker.config.maximum));
    }
    todayButton.element.setAttribute('aria-label', global.params.todayLabel);

    if (this.hasWeekNumberColumn) {
      this.setWidth(DayCell.GetWidth() * DaysPerWeek + WeekNumberCell.Width);
      /**
       * @type {?Array}
       * @const
       */
      this._weekNumberCells = [];
    } else {
      this.setWidth(DayCell.GetWidth() * DaysPerWeek);
    }

    /**
     * @type {!boolean}
     * @protected
     */
    this._ignoreMouseOutUntillNextMouseOver = false;

    // You shouldn't be able to use the mouse wheel to scroll.
    this.scrollView.element.removeEventListener(
        'mousewheel', this.scrollView.onMouseWheel, false);
    // You shouldn't be able to do gesture scroll.
    this.scrollView.element.removeEventListener(
        'touchstart', this.scrollView.onTouchStart, false);
  }

  static _BorderWidth = 0;
  static GetBorderWidth() {
    return CalendarTableView._BorderWidth;
  }
  static _TodayButtonHeight = 28;
  static GetTodayButtonHeight() {
    return CalendarTableView._TodayButtonHeight;
  }
  static ClassNameCalendarTableView = 'calendar-table-view';

  /**
   * @param {!number} scrollOffset
   * @return {!number}
   */
  rowAtScrollOffset(scrollOffset) {
    return Math.floor(scrollOffset / CalendarRowCell.GetHeight());
  }

  /**
   * @param {!number} row
   * @return {!number}
   */
  scrollOffsetForRow(row) {
    return row * CalendarRowCell.GetHeight();
  }

  /**
   * @param {?Event} event
   */
  onClick(event) {
    if (this.hasWeekNumberColumn) {
      const weekNumberCellElement = enclosingNodeOrSelfWithClass(
          event.target, WeekNumberCell.ClassNameWeekNumberCell);
      if (weekNumberCellElement) {
        const weekNumberCell = weekNumberCellElement.$view;
        this.calendarPicker.selectRangeContainingDay(
            weekNumberCell.week.firstDay());
        return;
      }
    }
    const dayCellElement =
        enclosingNodeOrSelfWithClass(event.target, DayCell.ClassNameDayCell);
    if (!dayCellElement)
      return;
    const dayCell = dayCellElement.$view;
    this.calendarPicker.selectRangeContainingDay(dayCell.day);
  }

  onClearButtonClick() {
    window.pagePopupController.setValueAndClosePopup(0, '');
  }

  onTodayButtonClick(sender) {
    this.calendarPicker.selectRangeContainingDay(Day.createFromToday());
  }

  /**
   * @param {!number} row
   * @return {!CalendarRowCell}
   */
  prepareNewCell(row) {
    const cell = CalendarRowCell._recycleBin.pop() || new CalendarRowCell();
    cell.reset(row, this);
    return cell;
  }

  /**
   * @return {!number} Height in pixels.
   */
  height() {
    return this.scrollView.height() + CalendarTableHeaderView.GetHeight() +
        CalendarTableView.GetBorderWidth() * 2 +
        CalendarTableView.GetTodayButtonHeight();
  }

  /**
   * @param {!number} height Height in pixels.
   */
  setHeight(height) {
    this.scrollView.setHeight(
        height - CalendarTableHeaderView.GetHeight() -
        CalendarTableView.GetBorderWidth() * 2 -
        CalendarTableView.GetTodayButtonHeight());
    this.element.style.height = height + 'px';
  }

  /**
   * @param {!Month} month
   * @param {!boolean} animate
   */
  scrollToMonth(month, animate) {
    const rowForFirstDayInMonth = this.columnAndRowForDay(month.firstDay()).row;
    this.scrollView.scrollTo(
        this.scrollOffsetForRow(rowForFirstDayInMonth), animate);
  }

  /**
   * @param {!number} column
   * @param {!number} row
   * @return {!Day}
   */
  dayAtColumnAndRow(column, row) {
    const daysSinceMinimum = row * DaysPerWeek + column +
        global.params.weekStartDay - CalendarTableView._MinimumDayWeekDay;
    return Day.createFromValue(
        daysSinceMinimum * MillisecondsPerDay +
        CalendarTableView._MinimumDayValue);
  }

  static _MinimumDayValue = Day.Minimum.valueOf();
  static _MinimumDayWeekDay = Day.Minimum.weekDay();

  /**
   * @param {!Day} day
   * @return {!Object} Object with properties column and row.
   */
  columnAndRowForDay(day) {
    const daysSinceMinimum =
        (day.valueOf() - CalendarTableView._MinimumDayValue) /
        MillisecondsPerDay;
    const offset = daysSinceMinimum + CalendarTableView._MinimumDayWeekDay -
        global.params.weekStartDay;
    const row = Math.floor(offset / DaysPerWeek);
    const column = offset - row * DaysPerWeek;
    return {column: column, row: row};
  }

  updateCells() {
    super.updateCells();

    const selection = this.calendarPicker.selection();
    let firstDayInSelection;
    let lastDayInSelection;
    if (selection) {
      firstDayInSelection = selection.firstDay().valueOf();
      lastDayInSelection = selection.lastDay().valueOf();
    } else {
      firstDayInSelection = Infinity;
      lastDayInSelection = Infinity;
    }
    const highlight = this.calendarPicker.highlight();
    let firstDayInHighlight;
    let lastDayInHighlight;
    if (highlight) {
      firstDayInHighlight = highlight.firstDay().valueOf();
      lastDayInHighlight = highlight.lastDay().valueOf();
    } else {
      firstDayInHighlight = Infinity;
      lastDayInHighlight = Infinity;
    }
    const currentMonth = this.calendarPicker.currentMonth();
    const firstDayInCurrentMonth = currentMonth.firstDay().valueOf();
    const lastDayInCurrentMonth = currentMonth.lastDay().valueOf();
    let activeCell = null;
    for (let dayString in this._dayCells) {
      const dayCell = this._dayCells[dayString];
      const day = dayCell.day;
      dayCell.setIsToday(Day.createFromToday().equals(day));
      const isSelected =
          (day >= firstDayInSelection && day <= lastDayInSelection);
      dayCell.setSelected(isSelected);
      if (isSelected && firstDayInSelection == lastDayInSelection) {
        activeCell = dayCell;
      }
      dayCell.setIsInCurrentMonth(
          day >= firstDayInCurrentMonth && day <= lastDayInCurrentMonth);
      dayCell.setDisabled(!this.calendarPicker.isValidDay(day));
    }
    if (this.hasWeekNumberColumn) {
      for (let weekString in this._weekNumberCells) {
        const weekNumberCell = this._weekNumberCells[weekString];
        const week = weekNumberCell.week;
        const isSelected = (selection && selection.equals(week));
        weekNumberCell.setSelected(isSelected);
        if (isSelected) {
          activeCell = weekNumberCell;
        }
        weekNumberCell.setDisabled(!this.calendarPicker.isValid(week));
      }
    }
    if (activeCell) {
      // Ensure a layoutObject because an element with no layoutObject doesn't post
      // activedescendant events. This shouldn't run in the above |for| loop
      // to avoid CSS transition.
      activeCell.element.offsetLeft;
      this.element.setAttribute('aria-activedescendant', activeCell.element.id);
    }
  }

  /**
   * @param {!Day} day
   * @return {!DayCell}
   */
  prepareNewDayCell(day) {
    const dayCell = DayCell.recycleOrCreate();
    dayCell.reset(day);
    if (this.calendarPicker.type == 'month')
      dayCell.element.setAttribute(
          'aria-label', Month.createFromDay(day).toLocaleString());
    this._dayCells[dayCell.day.toString()] = dayCell;
    return dayCell;
  }

  /**
   * @param {!Week} week
   * @return {!WeekNumberCell}
   */
  prepareNewWeekNumberCell(week) {
    const weekNumberCell = WeekNumberCell.recycleOrCreate();
    weekNumberCell.reset(week);
    this._weekNumberCells[weekNumberCell.week.toString()] = weekNumberCell;
    return weekNumberCell;
  }

  /**
   * @param {!DayCell} dayCell
   */
  throwAwayDayCell(dayCell) {
    delete this._dayCells[dayCell.day.toString()];
    dayCell.throwAway();
  }

  /**
   * @param {!WeekNumberCell} weekNumberCell
   */
  throwAwayWeekNumberCell(weekNumberCell) {
    delete this._weekNumberCells[weekNumberCell.week.toString()];
    weekNumberCell.throwAway();
  }
}

// ----------------------------------------------------------------

// clang-format off
class CalendarPicker extends dateRangeManagerMixin(View) {
  // clang-format on
  /**
   * @param {!Object} config
   */
  constructor(type, config) {
    super(createElement('div', CalendarPicker.ClassNameCalendarPicker));
    this.element.classList.add(CalendarPicker.ClassNamePreparing);
    if (global.params.isBorderTransparent) {
      this.element.style.borderColor = 'transparent';
    }
    /**
     * @type {!string}
     * @const
     */
    this.type = type;
    if (this.type === 'week')
      this._dateTypeConstructor = Week;
    else if (this.type === 'month')
      this._dateTypeConstructor = Month;
    else
      this._dateTypeConstructor = Day;

    this._setValidDateConfig(config);

    if (this.type === 'week') {
      this.element.classList.add(CalendarPicker.ClassNameWeekPicker);
    }

    /**
     * @type {!Month}
     * @const
     */
    this.minimumMonth = Month.createFromDay(this.config.minimum.firstDay());
    /**
     * @type {!Month}
     * @const
     */
    this.maximumMonth = Month.createFromDay(this.config.maximum.lastDay());
    if (global.params.isLocaleRTL)
      this.element.classList.add('rtl');
    /**
     * @type {!CalendarTableView}
     * @const
     */
    this.calendarTableView = new CalendarTableView(this);
    this.calendarTableView.hasNumberColumn = this.type === 'week';
    /**
     * @type {!CalendarHeaderView}
     * @const
     */
    this.calendarHeaderView = new CalendarHeaderView(this);
    this.calendarHeaderView.monthPopupButton.on(
        MonthPopupButton.EventTypeButtonClick,
        this.onMonthPopupButtonClick.bind(this));
    /**
     * @type {!MonthPopupView}
     * @const
     */
    this.monthPopupView =
        new MonthPopupView(this.minimumMonth, this.maximumMonth);
    this.monthPopupView.yearListView.on(
        YearListView.EventTypeYearListViewDidSelectMonth,
        this.onYearListViewDidSelectMonth.bind(this));
    this.monthPopupView.yearListView.on(
        YearListView.EventTypeYearListViewDidHide,
        this.onYearListViewDidHide.bind(this));
    this.calendarHeaderView.attachTo(this);
    this.calendarTableView.attachTo(this);
    /**
     * @type {!Month}
     * @protected
     */
    this._currentMonth = new Month(NaN, NaN);
    /**
     * @type {?DateType}
     * @protected
     */
    this._selection = null;

    this.calendarTableView.element.addEventListener(
        'keydown', this.onCalendarTableKeyDown.bind(this));

    document.body.addEventListener('click', this.onBodyClick.bind(this));
    this._onBodyKeyDownBound = this.onBodyKeyDown.bind(this);
    document.body.addEventListener('keydown', this._onBodyKeyDownBound);

    this._onWindowResizeBound = this.onWindowResize.bind(this);
    window.addEventListener('resize', this._onWindowResizeBound);

    /**
     * @type {!number}
     * @protected
     */
    this._height = -1;

    this._hadValidValueWhenOpened = false;

    const initialSelection = parseDateString(config.currentValue);
    if (initialSelection) {
      this.setCurrentMonth(
          Month.createFromDay(initialSelection.middleDay()),
          CalendarPicker.NavigationBehavior.None);

      this._hadValidValueWhenOpened = this.isValid(initialSelection);
      this.setSelection(this.getNearestValidRange(
          initialSelection, /*lookForwardFirst*/ true));
    } else {
      this.setCurrentMonth(
          Month.createFromToday(), CalendarPicker.NavigationBehavior.None);

      // Ensure that the next date closest to today is selected to start with
      // so that the user can simply submit the popup to choose it.
      this.setSelection(this.getValidRangeNearestToDay(
          this._dateTypeConstructor.createFromToday(),
          /*lookForwardFirst*/ true));
    }

    /**
     * @type {?DateType}
     * @protected
     */
    this._initialSelection = this._selection;
  }

  static Padding = 10;
  static BorderWidth = 1;
  static ClassNameCalendarPicker = 'calendar-picker';
  static ClassNameWeekPicker = 'week-picker';
  static ClassNamePreparing = 'preparing';
  static EventTypeCurrentMonthChanged = 'currentMonthChanged';
  static commitDelayMs = 100;
  static VisibleRows = 6;

  /**
   * @param {!Event} event
   */
  onWindowResize(event) {
    this.element.classList.remove(CalendarPicker.ClassNamePreparing);
    window.removeEventListener('resize', this._onWindowResizeBound);
  }

  resetToInitialValue() {
    this.setSelection(this._initialSelection);
  }

  /**
   * @param {!YearListView} sender
   */
  onYearListViewDidHide(sender) {
    this.monthPopupView.hide();
    this.calendarHeaderView.setDisabled(false);
    this.calendarTableView.element.style.visibility = 'visible';
    this.calendarTableView.element.focus();
  }

  /**
   * @param {!YearListView} sender
   * @param {!Month} month
   */
  onYearListViewDidSelectMonth(sender, month) {
    this.setCurrentMonth(month, CalendarPicker.NavigationBehavior.None);

    this.ensureSelectionIsWithinCurrentMonth();
    this.onYearListViewDidHide();
  }

  /**
   * @param {!View|Node} parent
   * @param {?View|Node=} before
   * @override
   */
  attachTo(parent, before) {
    View.prototype.attachTo.call(this, parent, before);
    this.calendarTableView.element.focus();
  }

  cleanup() {
    window.removeEventListener('resize', this._onWindowResizeBound);
    this.calendarTableView.element.removeEventListener(
        'keydown', this._onBodyKeyDownBound);
    // Month popup view might be attached to document.body.
    this.monthPopupView.hide();
  }

  /**
   * @param {?MonthPopupButton} sender
   */
  onMonthPopupButtonClick(sender) {
    const clientRect = this.calendarTableView.element.getBoundingClientRect();
    const calendarTableRect = new Rectangle(
        clientRect.left + document.body.scrollLeft,
        clientRect.top + document.body.scrollTop, clientRect.width,
        clientRect.height);
    this.monthPopupView.show(this.currentMonth(), calendarTableRect);
    this.calendarHeaderView.setDisabled(true);
    this.calendarTableView.element.style.visibility = 'hidden';
  }

  /**
   * @return {!Month}
   */
  currentMonth() {
    return this._currentMonth;
  }

  /**
   * @enum {number}
   */
  static NavigationBehavior = {None: 0, WithAnimation: 1};

  /**
   * @param {!Month} month
   * @param {!CalendarPicker.NavigationBehavior} animate
   */
  setCurrentMonth(month, behavior) {
    if (month > this.maximumMonth)
      month = this.maximumMonth;
    else if (month < this.minimumMonth)
      month = this.minimumMonth;
    if (this._currentMonth.equals(month))
      return;
    this._currentMonth = month;
    this.calendarTableView.scrollToMonth(
        this._currentMonth,
        behavior === CalendarPicker.NavigationBehavior.WithAnimation);
    this.adjustHeight();
    this.calendarTableView.setNeedsUpdateCells(true);
    this.dispatchEvent(
        CalendarPicker.EventTypeCurrentMonthChanged, {target: this});
  }

  adjustHeight() {
    const numberOfRows = CalendarPicker.VisibleRows;
    const calendarTableViewHeight = CalendarTableHeaderView.GetHeight() +
        numberOfRows * DayCell.GetHeight() +
        CalendarTableView.GetBorderWidth() * 2 +
        CalendarTableView.GetTodayButtonHeight();
    const height = calendarTableViewHeight + CalendarHeaderView.Height +
        CalendarHeaderView.BottomMargin + CalendarPicker.Padding * 2 +
        CalendarPicker.BorderWidth * 2;
    this.setHeight(height);
  }

  selection() {
    return this._selection;
  }

  highlight() {
    return this._highlight;
  }

  /**
   * @return {!Day}
   */
  firstVisibleDay() {
    const firstVisibleRow =
        this.calendarTableView
            .columnAndRowForDay(this.currentMonth().firstDay())
            .row;
    let firstVisibleDay =
        this.calendarTableView.dayAtColumnAndRow(0, firstVisibleRow);
    if (!firstVisibleDay)
      firstVisibleDay = Day.Minimum;
    return firstVisibleDay;
  }

  /**
   * @return {!Day}
   */
  lastVisibleDay() {
    let lastVisibleRow =
        this.calendarTableView.columnAndRowForDay(this.currentMonth().lastDay())
            .row;
    lastVisibleRow = this.calendarTableView
                         .columnAndRowForDay(this.currentMonth().firstDay())
                         .row +
        CalendarPicker.VisibleRows - 1;
    let lastVisibleDay = this.calendarTableView.dayAtColumnAndRow(
        DaysPerWeek - 1, lastVisibleRow);
    if (!lastVisibleDay)
      lastVisibleDay = Day.Maximum;
    return lastVisibleDay;
  }

  /**
   * @param {?Day} day
   */
  selectRangeContainingDay(day) {
    const selection = day ? this._dateTypeConstructor.createFromDay(day) : null;
    this.setSelectionAndCommit(selection);
  }

  /**
   * @param {?Day} day
   */
  highlightRangeContainingDay(day) {
    const highlight = day ? this._dateTypeConstructor.createFromDay(day) : null;
    this._setHighlight(highlight);
  }

  /**
   * Select the specified date.
   * @param {?DateType} dayOrWeekOrMonth
   */
  setSelection(dayOrWeekOrMonth) {
    if (!this._selection && !dayOrWeekOrMonth)
      return;
    if (this._selection && this._selection.equals(dayOrWeekOrMonth))
      return;
    if (this._selection && !dayOrWeekOrMonth) {
      this._selection = null;
      return;
    }
    const firstDayInSelection = dayOrWeekOrMonth.firstDay();
    const lastDayInSelection = dayOrWeekOrMonth.lastDay();
    const candidateCurrentMonth = Month.createFromDay(firstDayInSelection);
    if (this.firstVisibleDay() > lastDayInSelection ||
        this.lastVisibleDay() < firstDayInSelection) {
      // Change current month if the selection is not visible at all.
      this.setCurrentMonth(
          candidateCurrentMonth,
          CalendarPicker.NavigationBehavior.WithAnimation);
    } else if (
        this.firstVisibleDay() < firstDayInSelection ||
        this.lastVisibleDay() > lastDayInSelection) {
      // If the selection is partly visible, only change the current month if
      // doing so will make the whole selection visible.
      const firstVisibleRow =
          this.calendarTableView
              .columnAndRowForDay(candidateCurrentMonth.firstDay())
              .row;
      const firstVisibleDay =
          this.calendarTableView.dayAtColumnAndRow(0, firstVisibleRow);
      const lastVisibleRow =
          this.calendarTableView
              .columnAndRowForDay(candidateCurrentMonth.lastDay())
              .row;
      const lastVisibleDay = this.calendarTableView.dayAtColumnAndRow(
          DaysPerWeek - 1, lastVisibleRow);
      if (firstDayInSelection >= firstVisibleDay &&
          lastDayInSelection <= lastVisibleDay)
        this.setCurrentMonth(
            candidateCurrentMonth,
            CalendarPicker.NavigationBehavior.WithAnimation);
    }
    if (!this.isValid(dayOrWeekOrMonth))
      return;
    this._selection = dayOrWeekOrMonth;
    this.monthPopupView.yearListView.setSelectedMonth(
        Month.createFromDay(dayOrWeekOrMonth.middleDay()));
    this.calendarTableView.setNeedsUpdateCells(true);
  }

  getSelectedValue() {
    return this._selection.toString();
  }

  /**
   * Select the specified date, commit it, and close the popup.
   * @param {?DateType} dayOrWeekOrMonth
   */
  setSelectionAndCommit(dayOrWeekOrMonth) {
    this.setSelection(dayOrWeekOrMonth);
    // Redraw the widget immediately, and wait for some time to give feedback to
    // a user.
    this.element.offsetLeft;

    // CalendarPicker doesn't handle the submission when used for
    // datetime-local.
    if (this.type == 'datetime-local')
      return;

    const value = this._selection.toString();
    if (CalendarPicker.commitDelayMs == 0) {
      // For testing.
      window.pagePopupController.setValueAndClosePopup(0, value);
    } else if (CalendarPicker.commitDelayMs < 0) {
      // For testing.
      window.pagePopupController.setValue(value);
    } else {
      setTimeout(function() {
        window.pagePopupController.setValueAndClosePopup(0, value);
      }, CalendarPicker.commitDelayMs);
    }
  }

  /**
   * @param {?DateType} dayOrWeekOrMonth
   */
  _setHighlight(dayOrWeekOrMonth) {
    if (!this._highlight && !dayOrWeekOrMonth)
      return;
    if (!dayOrWeekOrMonth && !this._highlight)
      return;
    if (this._highlight && this._highlight.equals(dayOrWeekOrMonth))
      return;
    this._highlight = dayOrWeekOrMonth;
    this.calendarTableView.setNeedsUpdateCells(true);
  }

  /**
   * @param {!Day} day
   * @return {!boolean}
   */
  isValidDay(day) {
    return this.isValid(this._dateTypeConstructor.createFromDay(day));
  }

  /**
   * If the selection is not inside the month currently shown in the control,
   * adjust the selection so that it is within the current month.
   * The new selection value is determined in the following manner:
   * 1) If the old selection is on the Nth day of the month, try to place it
   * on the Nth day of the new month.
   * 2) If the Nth day of the new month is not valid, choose the closest
   * valid date that is within the new month.
   * 3) If the next and previous valid date are equidistant and both within
   * the new month, arbitrarily choose the older date.
   */
  ensureSelectionIsWithinCurrentMonth() {
    if (!this._selection)
      return;
    if (this._selection.isFullyContainedInMonth(this.currentMonth()))
      return;

    let newSelection = null;
    const currentRangeInNewMonth =
        this._selection.thisRangeInMonth(this.currentMonth());

    if (this.isValid(currentRangeInNewMonth)) {
      newSelection = currentRangeInNewMonth;
    } else {
      const validRangeLookingBackward =
          this.getNearestValidRangeLookingBackward(currentRangeInNewMonth);
      const validRangeLookingForward =
          this.getNearestValidRangeLookingForward(currentRangeInNewMonth);
      if (validRangeLookingBackward && validRangeLookingForward) {
        const newMonthIsForwardOfSelection =
            (currentRangeInNewMonth.firstDay() > this._selection.firstDay());
        const [validRangeInDirectionOfAdvancement, validRangeAgainstDirectionOfAdvancement] =
            newMonthIsForwardOfSelection ?
            [validRangeLookingForward, validRangeLookingBackward] :
            [validRangeLookingBackward, validRangeLookingForward];

        if (!validRangeAgainstDirectionOfAdvancement.overlapsMonth(
                this.currentMonth())) {
          // If the range going against our direction of movement is not
          // entirely within the new month, go with the range in the
          // other direction to ensure we that we don't backtrack.
          newSelection = validRangeInDirectionOfAdvancement;
        } else if (!validRangeInDirectionOfAdvancement.overlapsMonth(
                       this.currentMonth())) {
          newSelection = validRangeAgainstDirectionOfAdvancement;
        } else {
          // If both of the ranges are in the new month, select the closest one
          // to the target date in the new month.
          const diffFromForwardRange = Math.abs(
              currentRangeInNewMonth.valueOf() -
              validRangeLookingForward.valueOf());
          const diffFromBackwardRange = Math.abs(
              currentRangeInNewMonth.valueOf() -
              validRangeLookingBackward.valueOf());
          if (diffFromForwardRange < diffFromBackwardRange) {
            newSelection = validRangeLookingForward;
          } else {  // In a tie, arbitrarily choose older date
            newSelection = validRangeLookingBackward;
          }
        }
      } else if (!validRangeLookingForward) {
        newSelection = validRangeLookingBackward;
      } else {  // !validRangeLookingBackward
        newSelection = validRangeLookingForward;
      }  // No additional clause because they can't both be null; we have a
         // selection so there's at least one valid date.
    }

    if (newSelection) {
      this.setSelection(newSelection);
    }
  }

  /**
   * @param {!DateType} dateRange
   * @return {!boolean} Returns true if the highlight was changed.
   */
  _moveHighlight(dateRange) {
    if (!dateRange)
      return false;
    if (this._outOfRange(dateRange.valueOf()))
      return false;
    if (this.firstVisibleDay() > dateRange.middleDay() ||
        this.lastVisibleDay() < dateRange.middleDay())
      this.setCurrentMonth(
          Month.createFromDay(dateRange.middleDay()),
          CalendarPicker.NavigationBehavior.WithAnimation);
    this._setHighlight(dateRange);
    return true;
  }

  /**
   * @param {?Event} event
   */
  onCalendarTableKeyDown(event) {
    const key = event.key;
    if (!event.target.matches('.today-button, .clear-button') &&
        this._selection) {
      switch (key) {
        case 'PageUp':
          const previousMonth = this.currentMonth().previous();
          if (previousMonth && previousMonth >= this.config.minimumValue) {
            this.setCurrentMonth(
                previousMonth, CalendarPicker.NavigationBehavior.WithAnimation);
            this.ensureSelectionIsWithinCurrentMonth();
          }
          break;
        case 'PageDown':
          const nextMonth = this.currentMonth().next();
          if (nextMonth && nextMonth >= this.config.minimumValue) {
            this.setCurrentMonth(
                nextMonth, CalendarPicker.NavigationBehavior.WithAnimation);
            this.ensureSelectionIsWithinCurrentMonth();
          }
          break;
        case 'ArrowUp':
        case 'ArrowDown':
        case 'ArrowLeft':
        case 'ArrowRight':
          const upOrDownArrowStepSize =
              this.type === 'date' || this.type === 'datetime-local' ?
              DaysPerWeek :
              1;
          if (global.params.isLocaleRTL ? key == 'ArrowRight' :
                                          key == 'ArrowLeft') {
            const newSelection = this.getNearestValidRangeLookingBackward(
                this._selection.previous());
            if (newSelection) {
              this.setSelection(newSelection);
            }
          } else if (key == 'ArrowUp') {
            const newSelection = this.getNearestValidRangeLookingBackward(
                this._selection.previous(upOrDownArrowStepSize));
            if (newSelection) {
              this.setSelection(newSelection);
            }
          } else if (
              global.params.isLocaleRTL ? key == 'ArrowLeft' :
                                          key == 'ArrowRight') {
            const newSelection =
                this.getNearestValidRangeLookingForward(this._selection.next());
            if (newSelection) {
              this.setSelection(newSelection);
            }
          } else if (key == 'ArrowDown') {
            const newSelection = this.getNearestValidRangeLookingForward(
                this._selection.next(upOrDownArrowStepSize));
            if (newSelection) {
              this.setSelection(newSelection);
            }
          }
          break;
        case 'Home': {
          const newSelection = this.getNearestValidRangeLookingBackward(
              this._selection.nextHome());
          if (newSelection) {
            this.setSelection(newSelection);
          }
          break;
        }
        case 'End': {
          const newSelection = this.getNearestValidRangeLookingForward(
              this._selection.nextEnd());
          if (newSelection) {
            this.setSelection(newSelection);
          }
          break;
        }
      }
    }
    // else if there is no selection it must be the case that there are no
    // valid values (because min >= max).  Otherwise we would have set the
    // selection during initialization.  In this case there's nothing to do.
  }

  /**
   * @return {!number} Width in pixels.
   */
  width() {
    return this.calendarTableView.width() +
        (CalendarTableView.GetBorderWidth() + CalendarPicker.BorderWidth +
         CalendarPicker.Padding) *
        2;
  }

  /**
   * @return {!number} Height in pixels.
   */
  height() {
    return this._height;
  }

  /**
   * @param {!number} height Height in pixels.
   */
  setHeight(height) {
    if (this._height === height)
      return;
    this._height = height;
    resizeWindow(this.width(), this._height);
    this.calendarTableView.setHeight(
        this._height - CalendarHeaderView.Height -
        CalendarHeaderView.BottomMargin - CalendarPicker.Padding * 2 -
        CalendarPicker.BorderWidth * 2);
  }

  /**
   * @param {?Event} event
   */
  onBodyClick(event) {
    if (this.type !== 'datetime-local') {
      if (event.target.matches(
              '.calendar-navigation-button, ' +
              '.navigation-button-icon, .month-button')) {
        window.pagePopupController.setValue(this.getSelectedValue());
      }
    }
  }

  /**
   * @param {?Event} event
   */
  onBodyKeyDown(event) {
    const key = event.key;
    switch (key) {
      case 'Escape':
        // The datetime-local control handles submission/cancellation at
        // the top level, so if we're in a datetime-local let event bubble
        // up instead of handling it here.
        if (this.type !== 'datetime-local') {
          if (!this._selection ||
              (this._selection.equals(this._initialSelection))) {
            window.pagePopupController.closePopup();
          } else {
            this.resetToInitialValue();
            window.pagePopupController.setValue(
                this._hadValidValueWhenOpened ?
                    this._initialSelection.toString() :
                    '');
          }
        }
        break;
      case 'ArrowUp':
      case 'ArrowDown':
      case 'ArrowLeft':
      case 'ArrowRight':
      case 'PageUp':
      case 'PageDown':
      case 'Home':
      case 'End':
        if (this.type !== 'datetime-local' &&
            event.target.matches('.calendar-table-view') && this._selection) {
          window.pagePopupController.setValue(this.getSelectedValue());
        }
        break;
      case 'Enter':
        // Submit the popup for an Enter keypress except when the user is
        // hitting Enter to activate the month switcher button, Clear button,
        // Today button, or previous/next month arrows.
        if (this.type !== 'datetime-local') {
          if (!event.target.matches(
                  '.calendar-navigation-button, .clear-button, ' +
                  '.month-popup-button, .year-list-view')) {
            if (this._selection) {
              window.pagePopupController.setValueAndClosePopup(
                  0, this.getSelectedValue());
            } else {
              // If there is no selection it must be the case that there are no
              // valid values (because min >= max).  There's nothing useful to
              // do with the popup in this case so just close on Enter.
              window.pagePopupController.closePopup();
            }
          } else if (event.target.matches(
                         '.calendar-navigation-button, .year-list-view')) {
            // Navigating with the previous/next arrows may change selection,
            // so push this change to the in-page control but don't
            // close the popup.
            window.pagePopupController.setValue(this.getSelectedValue());
          }
        }
        break;
    }
  }
}

// ----------------------------------------------------------------

if (window.dialogArguments) {
  initialize(dialogArguments);
} else {
  window.addEventListener('message', handleMessage, false);
}

// Necessary for some web tests.
window.AnimationManager = AnimationManager;
window.CalendarTableHeaderView = CalendarTableHeaderView;
window.CalendarPicker = CalendarPicker;
window.Day = Day;
window.DayCell = DayCell;
window.Month = Month;
window.Week = Week;
window.WeekNumberCell = WeekNumberCell;
window.global = global;