| 'use strict'; |
| // Copyright (C) 2019 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| /** |
| * @fileoverview Picker used by <input type='time' /> |
| */ |
| |
| function initializeTimePicker(config) { |
| const timePicker = new TimePicker(config); |
| global.picker = timePicker; |
| main.append(timePicker); |
| resizeWindow(timePicker.width, timePicker.height); |
| } |
| |
| /** |
| * Supported time column types. |
| * @enum {number} |
| */ |
| const TimeColumnType = { |
| UNDEFINED: 0, |
| HOUR: 1, |
| MINUTE: 2, |
| SECOND: 3, |
| MILLISECOND: 4, |
| AMPM: 5, |
| }; |
| |
| /** |
| * Supported label types. |
| * @enum {number} |
| */ |
| const Label = { |
| AM: 0, |
| PM: 1, |
| }; |
| |
| /** |
| * @param {string} dateTimeString |
| * @param {string} mode - used to differentiate between date and time for datetime-local |
| * @return {?Day|Week|Month|Time} |
| */ |
| function parseDateTimeString(dateTimeString, mode) { |
| const time = Time.parse(dateTimeString); |
| if (time) |
| return time; |
| const dateTime = DateTime.parse(dateTimeString); |
| if (dateTime) { |
| if (mode == 'date') { |
| return dateTime.date; |
| } else if (mode == 'time') { |
| return dateTime.time; |
| } |
| } |
| return parseDateString(dateTimeString); |
| } |
| |
| class Time { |
| constructor(hour, minute, second, millisecond) { |
| this.hour_ = hour; |
| this.minute_ = minute; |
| this.second_ = second; |
| this.millisecond_ = millisecond; |
| }; |
| |
| next = (columnType) => { |
| switch (columnType) { |
| case TimeColumnType.HOUR: |
| this.hour_ = (this.hour_ + 1) % Time.HOUR_VALUES; |
| break; |
| case TimeColumnType.MINUTE: |
| this.minute_ = (this.minute_ + 1) % Time.MINUTE_VALUES; |
| break; |
| case TimeColumnType.SECOND: |
| this.second_ = (this.second_ + 1) % Time.SECOND_VALUES; |
| break; |
| case TimeColumnType.MILLISECOND: |
| // TODO(https://crbug.com/1008294): Use increments of 1 instead of 100 for milliseconds. |
| // support 100, 200, 300... for milliseconds |
| this.millisecond_ = |
| (Math.round(this.millisecond_ / 100) * 100 + 100) % 1000; |
| break; |
| } |
| }; |
| |
| value = (columnType, hasAMPM) => { |
| switch (columnType) { |
| case TimeColumnType.HOUR: |
| let hour = hasAMPM ? |
| (this.hour_ % Time.Maximum_Hour_AMPM || Time.Maximum_Hour_AMPM) : |
| this.hour_; |
| return hour.toString().padStart(2, '0'); |
| case TimeColumnType.MINUTE: |
| return this.minute_.toString().padStart(2, '0'); |
| case TimeColumnType.SECOND: |
| return this.second_.toString().padStart(2, '0'); |
| case TimeColumnType.MILLISECOND: |
| return this.millisecond_.toString().padStart(3, '0'); |
| } |
| }; |
| |
| toString = (hasSecond, hasMillisecond) => { |
| let value = `${this.value(TimeColumnType.HOUR)}:${ |
| this.value(TimeColumnType.MINUTE)}`; |
| if (hasSecond) { |
| value += `:${this.value(TimeColumnType.SECOND)}`; |
| } |
| if (hasMillisecond) { |
| value += `.${this.value(TimeColumnType.MILLISECOND)}`; |
| } |
| return value; |
| }; |
| |
| clone = |
| () => { |
| return new Time( |
| this.hour_, this.minute_, this.second_, this.millisecond_); |
| } |
| |
| isAM = () => { |
| return this.hour_ < Time.Maximum_Hour_AMPM; |
| }; |
| |
| static parse = (str) => { |
| var match = Time.ISOStringRegExp.exec(str); |
| if (!match) |
| return null; |
| var hour = parseInt(match[1], 10); |
| var minute = parseInt(match[2], 10); |
| var second = 0; |
| if (match[3]) |
| second = parseInt(match[3], 10); |
| var millisecond = 0; |
| if (match[4]) |
| millisecond = parseInt(match[4], 10); |
| return new Time(hour, minute, second, millisecond); |
| }; |
| |
| static currentTime = () => { |
| var currentDate = new Date(); |
| return new Time( |
| currentDate.getHours(), currentDate.getMinutes(), |
| currentDate.getSeconds(), currentDate.getMilliseconds()); |
| }; |
| |
| static numberOfValues = (columnType, hasAMPM) => { |
| switch (columnType) { |
| case TimeColumnType.HOUR: |
| return hasAMPM ? Time.HOUR_VALUES_AMPM : Time.HOUR_VALUES; |
| case TimeColumnType.MINUTE: |
| return Time.MINUTE_VALUES; |
| case TimeColumnType.SECOND: |
| return Time.SECOND_VALUES; |
| case TimeColumnType.MILLISECOND: |
| return Time.MILLISECOND_VALUES; |
| } |
| }; |
| } |
| // See platform/date_components.h. |
| Time.Minimum = new Time(0, 0, 0, 0); |
| Time.Maximum = new Time(23, 59, 59, 999); |
| Time.Maximum_Hour_AMPM = 12; |
| Time.ISOStringRegExp = /^(\d+):(\d+):?(\d*).?(\d*)/; |
| // Number of values for each column. |
| Time.HOUR_VALUES = 24; |
| Time.HOUR_VALUES_AMPM = 12; |
| Time.MINUTE_VALUES = 60; |
| Time.SECOND_VALUES = 60; |
| Time.MILLISECOND_VALUES = 10; |
| |
| class DateTime { |
| constructor(date, time) { |
| this.date_ = date; |
| this.time_ = time; |
| }; |
| |
| static parse = (str) => { |
| const match = DateTime.ISOStringRegExp.exec(str); |
| if (!match) |
| return null; |
| const year = parseInt(match[1], 10); |
| const month = parseInt(match[2], 10) - 1; |
| const day = parseInt(match[3], 10); |
| const date = new Day(year, month, day); |
| |
| const hour = parseInt(match[4], 10); |
| const minute = parseInt(match[5], 10); |
| let second = 0; |
| if (match[6]) |
| second = parseInt(match[6], 10); |
| let millisecond = 0; |
| if (match[7]) |
| millisecond = parseInt(match[7], 10); |
| const time = new Time(hour, minute, second, millisecond); |
| |
| return new DateTime(date, time); |
| }; |
| |
| get date() { |
| return this.date_; |
| } |
| |
| get time() { |
| return this.time_; |
| } |
| } |
| DateTime.ISOStringRegExp = /^(\d+)-(\d+)-(\d+)T(\d+):(\d+):?(\d*).?(\d*)/; |
| |
| /** |
| * TimePicker: Custom element providing a time picker implementation. |
| */ |
| class TimePicker extends HTMLElement { |
| constructor(config) { |
| super(); |
| |
| this.className = TimePicker.ClassName; |
| if (global.params.isBorderTransparent) { |
| this.style.borderColor = 'transparent'; |
| } |
| this.initializeFromConfig_(config); |
| |
| this.timeColumns_ = new TimeColumns(this); |
| this.append(this.timeColumns_); |
| |
| if (config.mode == 'time') { |
| // TimePicker doesn't handle the submission when used for non-time types. |
| this.addEventListener('keydown', this.onKeyDown_); |
| this.addEventListener('click', this.onClick_); |
| } |
| |
| window.addEventListener('resize', this.onWindowResize_, {once: true}); |
| }; |
| |
| initializeFromConfig_ = (config) => { |
| const initialSelection = parseDateTimeString(config.currentValue, 'time'); |
| this.initialSelectedTime_ = |
| initialSelection ? initialSelection : Time.currentTime(); |
| this.hadValidValueWhenOpened_ = (initialSelection != null); |
| this.hasSecond_ = config.hasSecond; |
| this.hasMillisecond_ = config.hasMillisecond; |
| this.hasAMPM_ = config.hasAMPM; |
| this.initialFocusedFieldIndex_ = config.focusedFieldIndex || 0; |
| }; |
| |
| onWindowResize_ = (event) => { |
| this.timeColumns_.scrollColumnsToSelectedCells(); |
| if (!this.focusOnFieldIndex(this.initialFocusedFieldIndex_)) |
| this.timeColumns_.firstChild.focus(); |
| }; |
| |
| /** Focus on given index if valid. Return true if so. */ |
| focusOnFieldIndex = (index) => { |
| if (index >= 0 && index < this.timeColumns_.children.length) { |
| this.timeColumns_.children[index].focus(); |
| return true; |
| } |
| return false |
| }; |
| |
| onKeyDown_ = (event) => { |
| switch (event.key) { |
| case 'Enter': |
| window.pagePopupController.setValueAndClosePopup(0, this.selectedValue); |
| break; |
| case 'Escape': |
| if (this.selectedValue === |
| this.initialSelectedTime.toString( |
| this.hasSecond, this.hasMillisecond)) { |
| window.pagePopupController.closePopup(); |
| } else { |
| this.resetToInitialValue(); |
| window.pagePopupController.setValue( |
| this.hadValidValueWhenOpened ? this.selectedValue : ''); |
| } |
| break; |
| case 'ArrowUp': |
| case 'ArrowDown': |
| window.pagePopupController.setValue(this.selectedValue); |
| event.stopPropagation(); |
| event.preventDefault(); |
| break; |
| case 'Home': |
| case 'End': |
| window.pagePopupController.setValue(this.selectedValue); |
| event.stopPropagation(); |
| // Prevent an attempt to scroll to the end of |
| // of an infinitely looping column. |
| event.preventDefault(); |
| break; |
| } |
| }; |
| |
| onClick_ = (event) => { |
| window.pagePopupController.setValue(this.selectedValue); |
| }; |
| |
| resetToInitialValue = () => { |
| this.timeColumns_.resetToInitialValues(); |
| this.timeColumns_.scrollColumnsToSelectedCells(); |
| } |
| |
| get selectedValue() { |
| return this.timeColumns_.selectedValue().toString( |
| this.hasSecond, this.hasMillisecond); |
| } |
| |
| get initialSelectedTime() { |
| return this.initialSelectedTime_; |
| } |
| |
| get hadValidValueWhenOpened() { |
| return this.hadValidValueWhenOpened_; |
| } |
| |
| get hasSecond() { |
| return this.hasSecond_; |
| } |
| |
| get hasMillisecond() { |
| return this.hasMillisecond_; |
| } |
| |
| get hasAMPM() { |
| return this.hasAMPM_; |
| } |
| |
| get width() { |
| return this.timeColumns_.width; |
| } |
| |
| get height() { |
| return TimePicker.Height; |
| } |
| |
| get timeColumns() { |
| return this.timeColumns_; |
| } |
| } |
| TimePicker.ClassName = 'time-picker'; |
| TimePicker.Height = 260; |
| TimePicker.ColumnWidth = 56; |
| TimePicker.BorderWidth = 1; |
| window.customElements.define('time-picker', TimePicker); |
| |
| /** |
| * TimeColumns: Columns container that provides functionality for creating |
| * the required columns and for updating the selected value. |
| */ |
| class TimeColumns extends HTMLElement { |
| constructor(timePicker) { |
| super(); |
| |
| this.className = TimeColumns.ClassName; |
| this.hourColumn_ = new TimeColumn(TimeColumnType.HOUR, timePicker); |
| this.width_ = TimePicker.BorderWidth * 2 + TimeColumns.Margin * 2; |
| this.minuteColumn_ = new TimeColumn(TimeColumnType.MINUTE, timePicker); |
| if (timePicker.hasAMPM) { |
| this.ampmColumn_ = new TimeColumn(TimeColumnType.AMPM, timePicker); |
| } |
| if (timePicker.hasAMPM && global.params.isAMPMFirst) { |
| this.append(this.ampmColumn_, this.hourColumn_, this.minuteColumn_); |
| this.width_ += 3 * TimePicker.ColumnWidth; |
| } else { |
| this.append(this.hourColumn_, this.minuteColumn_); |
| this.width_ += 2 * TimePicker.ColumnWidth; |
| } |
| if (timePicker.hasSecond) { |
| this.secondColumn_ = new TimeColumn(TimeColumnType.SECOND, timePicker); |
| this.append(this.secondColumn_); |
| this.width_ += TimePicker.ColumnWidth; |
| } |
| if (timePicker.hasMillisecond) { |
| this.millisecondColumn_ = |
| new TimeColumn(TimeColumnType.MILLISECOND, timePicker); |
| this.append(this.millisecondColumn_); |
| this.width_ += TimePicker.ColumnWidth; |
| } |
| if (timePicker.hasAMPM && !global.params.isAMPMFirst) { |
| this.append(this.ampmColumn_); |
| this.width_ += TimePicker.ColumnWidth; |
| } |
| }; |
| |
| get width() { |
| return this.width_; |
| } |
| |
| selectedValue = () => { |
| let hour = parseInt(this.hourColumn_.selectedTimeCell.value, 10); |
| const minute = parseInt(this.minuteColumn_.selectedTimeCell.value, 10); |
| const second = this.secondColumn_ ? |
| parseInt(this.secondColumn_.selectedTimeCell.value, 10) : |
| 0; |
| const millisecond = this.millisecondColumn_ ? |
| parseInt(this.millisecondColumn_.selectedTimeCell.value, 10) : |
| 0; |
| if (this.ampmColumn_) { |
| const isAM = this.ampmColumn_.selectedTimeCell.textContent == |
| global.params.ampmLabels[Label.AM]; |
| if (isAM && hour == Time.Maximum_Hour_AMPM) { |
| hour = 0; |
| } else if (!isAM && hour != Time.Maximum_Hour_AMPM) { |
| hour += Time.Maximum_Hour_AMPM; |
| } |
| } |
| return new Time(hour, minute, second, millisecond); |
| }; |
| |
| resetToInitialValues = |
| () => { |
| Array.prototype.forEach.call(this.children, (column) => { |
| column.resetToInitialValue(); |
| }); |
| } |
| |
| scrollColumnsToSelectedCells = () => { |
| this.hourColumn_.scrollToSelectedCell(); |
| this.minuteColumn_.scrollToSelectedCell(); |
| if (this.secondColumn_) { |
| this.secondColumn_.scrollToSelectedCell(); |
| } |
| if (this.millisecondColumn_) { |
| this.millisecondColumn_.scrollToSelectedCell(); |
| } |
| } |
| } |
| TimeColumns.ClassName = 'time-columns'; |
| TimeColumns.Margin = 1; |
| window.customElements.define('time-columns', TimeColumns); |
| |
| /** |
| * TimeColumn: Column that contains all values available for a time column type. |
| */ |
| class TimeColumn extends HTMLUListElement { |
| constructor(columnType, timePicker) { |
| super(); |
| |
| this.className = TimeColumn.ClassName; |
| this.tabIndex = 0; |
| this.columnType_ = columnType; |
| this.setAttribute('role', 'listbox'); |
| if (this.columnType_ === TimeColumnType.HOUR) { |
| this.setAttribute('aria-label', global.params.axHourLabel); |
| } else if (this.columnType_ === TimeColumnType.MINUTE) { |
| this.setAttribute('aria-label', global.params.axMinuteLabel); |
| } else if (this.columnType_ === TimeColumnType.SECOND) { |
| this.setAttribute('aria-label', global.params.axSecondLabel); |
| } else if (this.columnType_ === TimeColumnType.MILLISECOND) { |
| this.setAttribute('aria-label', global.params.axMillisecondLabel); |
| } else { |
| this.setAttribute('aria-label', global.params.axAmPmLabel); |
| } |
| |
| if (this.columnType_ == TimeColumnType.AMPM) { |
| this.createAndInitializeAMPMCells_(timePicker); |
| } else { |
| this.createAndInitializeCells_(timePicker); |
| this.setupScrollHandler_(); |
| } |
| |
| this.addEventListener('click', this.onClick_); |
| this.addEventListener('keydown', this.onKeyDown_); |
| }; |
| |
| createAndInitializeCells_ = (timePicker) => { |
| const totalCells = Time.numberOfValues(this.columnType_, timePicker.hasAMPM); |
| let currentTime = timePicker.initialSelectedTime.clone(); |
| |
| // The granularity of millisecond cells is once cell per 100ms. |
| // But, we want to have a cell with the exact millisecond value of the |
| // in-page control, so we'll replace the millisecond cell closest to that |
| // value with the exact value. We do that by figuring out here which of |
| // the cells will be the closest one here, and then matching against that |
| // one in the subsequent loop. |
| let roundedMillisecondValue = 0; |
| if (this.columnType_ === TimeColumnType.MILLISECOND) { |
| let millisecondValue = |
| currentTime.value(TimeColumnType.MILLISECOND, timePicker.hasAMPM); |
| roundedMillisecondValue = |
| (100 * Math.floor((Number(millisecondValue) + 50.0) / 100.0)) % 1000; |
| } |
| |
| let time = new Time(1, 1, 1, 100); |
| let cells = []; |
| let initialCellIndex = -1; |
| for (let i = 0; i < totalCells; i++) { |
| let value = time.value(this.columnType_, timePicker.hasAMPM); |
| |
| if (this.columnType_ === TimeColumnType.MILLISECOND && |
| Number(value) === roundedMillisecondValue) { |
| // Set this cell to the exact ms value of the in-page control |
| value = |
| currentTime.value(TimeColumnType.MILLISECOND, timePicker.hasAMPM); |
| initialCellIndex = i; |
| } else if ( |
| time.value(this.columnType_, timePicker.hasAMPM) === |
| currentTime.value(this.columnType_, timePicker.hasAMPM)) { |
| initialCellIndex = i; |
| } |
| |
| let timeCell = new TimeCell(value, localizeNumber(value)); |
| cells.push(timeCell); |
| |
| timeCell.initialOffsetTop = TimeColumn.CELL_HEIGHT * i; |
| timeCell.style.top = `${TimeColumn.SCROLL_OFFSET}px`; |
| |
| time.next(this.columnType_); |
| } |
| this.selectedTimeCell = this.initialTimeCell_ = cells[initialCellIndex]; |
| this.cellsInLayoutOrder = cells; |
| this.append(...cells); |
| }; |
| |
| /* |
| * Create a scroll handler that implements infinite looping scroll by |
| * rotating TimeCells up/down so that there is always at least one cell |
| * offscreen in the direction of the scroll. This activity should be |
| * invisible to the user. |
| */ |
| setupScrollHandler_ = () => { |
| let lastScrollPosition = 0; |
| let upcomingSnapToCellEdge = null; |
| this.addEventListener('scroll', (event) => { |
| let isGoingDown = (this.scrollTop > lastScrollPosition); |
| lastScrollPosition = this.scrollTop; |
| |
| // Rotate cells down until there is one cell beyond the bottom |
| // of the visible scroller area. |
| while (this.cellsInLayoutOrder[this.cellsInLayoutOrder.length - 1] |
| .offsetTop - |
| this.scrollTop - this.clientHeight < |
| TimeColumn.CELL_HEIGHT) { |
| this.rotateCells_( |
| /*topToBottom*/ true); |
| } |
| |
| // Rotate cells up until there is one cell beyond the top |
| // of the visible scroller area. |
| while (this.scrollTop - this.cellsInLayoutOrder[0].offsetTop < |
| TimeColumn.CELL_HEIGHT * 2) { |
| this.rotateCells_( |
| /*topToBottom*/ false); |
| } |
| |
| // Snap the scroll amount to the nearest TimeCell top edge 1 second |
| // after the user has stopped scrolling. This would be done with |
| // CSS scroll-snap-align, but it interferes with this scroll handler |
| // and causes jittery scrolling. |
| window.clearTimeout(upcomingSnapToCellEdge); |
| upcomingSnapToCellEdge = |
| window.setTimeout(() => {this.snapToCellEdge_(isGoingDown)}, 1000); |
| }); |
| }; |
| |
| /* |
| * Scroll the column so that the top is aligned with the top edge of the |
| * nearest TimeCell in the given direction. |
| */ |
| snapToCellEdge_ = (isGoingDown) => { |
| let offsetFromCellEdge = |
| (this.cellsInLayoutOrder[this.cellsInLayoutOrder.length - 1].offsetTop - |
| this.scrollTop) % |
| TimeColumn.CELL_HEIGHT; |
| if (isGoingDown) { |
| this.scrollTop += offsetFromCellEdge; |
| } else { |
| if (offsetFromCellEdge != 0) { |
| this.scrollTop -= TimeColumn.CELL_HEIGHT - offsetFromCellEdge; |
| } |
| } |
| }; |
| |
| // Ideally we would have truly infinite scrolling in both directions. |
| // However, the platform does not allow scrolling into negative scroll |
| // offsets. So, we start the column at a large positive scroll so that |
| // the column will be unlikely to hit the top during normal use. |
| static SCROLL_OFFSET = 100000; |
| static CELL_HEIGHT = 36; // Height of one TimeCell, including border |
| |
| // Using position:absolute for TimeCells seems like the natural choice, |
| // but absolutely positioned children don't cause the TimeColumn scroll |
| // container to expand to hold the cells, so they fall off the end of |
| // the popup. Instead, we use relative positioning and use these |
| // helpers to convert to an "absolute" position that is easier to reason |
| // about when manipulating the layout position of the TimeCells. |
| static getCellAbsolutePosition = (cell) => { |
| let cellOffset = parseInt(cell.style.top.substring( |
| 0, cell.style.top.length - 2)); // Chop off the 'px' |
| return (cellOffset + cell.initialOffsetTop); |
| }; |
| static setCellAbsolutePosition = (cell, absolutePosition) => { |
| cell.style.top = `${absolutePosition - cell.initialOffsetTop}px`; |
| }; |
| |
| // Take the top/bottom TimeCell in this column and move it to the |
| // bottom/top. This should only be done for offscreen cells so that |
| // it is invisible to the user -- but it ensures that the cells will |
| // always be visible wherever the user scrolls. |
| rotateCells_ = (topToBottom) => { |
| if (topToBottom) { |
| let topCell = this.cellsInLayoutOrder.shift(); |
| let bottomCell = |
| this.cellsInLayoutOrder[this.cellsInLayoutOrder.length - 1]; |
| let bottomCellAbsoluteOffset = |
| TimeColumn.getCellAbsolutePosition(bottomCell); |
| TimeColumn.setCellAbsolutePosition( |
| topCell, bottomCellAbsoluteOffset + TimeColumn.CELL_HEIGHT); |
| this.cellsInLayoutOrder.push(topCell); |
| } else { |
| let topCell = this.cellsInLayoutOrder[0]; |
| let bottomCell = this.cellsInLayoutOrder.pop(); |
| let absoluteTopCellOffset = TimeColumn.getCellAbsolutePosition(topCell); |
| TimeColumn.setCellAbsolutePosition( |
| bottomCell, absoluteTopCellOffset - TimeColumn.CELL_HEIGHT); |
| this.cellsInLayoutOrder.unshift(bottomCell); |
| } |
| }; |
| |
| createAndInitializeAMPMCells_ = (timePicker) => { |
| let cells = []; |
| for (let i = 0; i < 2; i++) { |
| let value = global.params.ampmLabels[i]; |
| let timeCell = new TimeCell(value, value); |
| cells.push(timeCell); |
| } |
| |
| if (timePicker.initialSelectedTime.isAM()) { |
| this.append(cells[Label.AM], cells[Label.PM]); |
| this.selectedTimeCell = cells[Label.AM]; |
| } else { |
| this.append(cells[Label.PM], cells[Label.AM]); |
| this.selectedTimeCell = cells[Label.PM]; |
| } |
| }; |
| |
| onClick_ = (event) => { |
| this.selectedTimeCell = event.target; |
| }; |
| |
| /** |
| * Continuous looping navigation for up/down arrows and scrolling is |
| * supported by rotating the layout positions of the TimeCells. This |
| * is done in a scroll event handler and the following keydown handler. |
| * Cells are rotated in before they are reached by the visible part of |
| * the scroller, so the user just sees an infinitely looping column. |
| */ |
| onKeyDown_ = (event) => { |
| switch (event.key) { |
| case 'ArrowUp': |
| const previousTimeCell = this.selectedTimeCell.previousSibling ? |
| this.selectedTimeCell.previousSibling : |
| this.lastElementChild; |
| |
| if (this.scrollTop === 0 && previousTimeCell.offsetTop <= 0) { |
| // If the user somehow made it all the way to the top of the |
| // scroller, stop going up and rotating cells into negative |
| // offsets. This should not be a normal scenario. |
| break; |
| } |
| |
| // Ensure that we don't run out of cells ahead of the selected cell in |
| // the event that the scroll event handler can't keep up. This can |
| // happen e.g. if the user holds down the arrow key. |
| if (this.columnType_ !== TimeColumnType.AMPM && |
| this.selectedTimeCell === this.cellsInLayoutOrder[0]) { |
| this.rotateCells_(/*topToBottom*/ false); |
| } |
| |
| this.selectedTimeCell = previousTimeCell; |
| this.selectedTimeCell.scrollIntoViewIfNeeded(false); |
| break; |
| case 'ArrowDown': |
| const nextTimeCell = this.selectedTimeCell.nextSibling ? |
| this.selectedTimeCell.nextSibling : |
| this.firstElementChild; |
| |
| // Ensure that we don't run out of cells ahead of the selected cell in |
| // the event that the scroll event handler can't keep up. This can |
| // happen e.g. if the user holds down the arrow key. |
| if (this.columnType_ !== TimeColumnType.AMPM && |
| this.selectedTimeCell === |
| this.cellsInLayoutOrder[this.cellsInLayoutOrder.length - 1]) { |
| this.rotateCells_(/*topToBottom*/ true); |
| } |
| |
| this.selectedTimeCell = nextTimeCell; |
| this.selectedTimeCell.scrollIntoViewIfNeeded(false); |
| break; |
| case 'ArrowLeft': |
| const previousTimeColumn = this.previousSibling; |
| if (previousTimeColumn) { |
| previousTimeColumn.focus(); |
| } |
| break; |
| case 'ArrowRight': |
| const nextTimeColumn = this.nextSibling; |
| if (nextTimeColumn) { |
| nextTimeColumn.focus(); |
| } |
| break; |
| case 'Home': |
| this.setToMinValue(); |
| this.scrollToSelectedCell(); |
| break; |
| case 'End': |
| this.setToMaxValue(); |
| this.scrollToSelectedCell(); |
| break; |
| } |
| }; |
| |
| scrollToSelectedCell = (cell) => { |
| while(this.cellsInLayoutOrder[1] != this.selectedTimeCell) { |
| this.rotateCells_(/*topToBottom*/true); |
| } |
| this.scrollTop = this.selectedTimeCell.offsetTop; |
| } |
| |
| get selectedTimeCell() { |
| return this.selectedTimeCell_; |
| } |
| |
| set selectedTimeCell(timeCell) { |
| if (this.selectedTimeCell_) { |
| this.selectedTimeCell_.classList.remove('selected'); |
| this.selectedTimeCell_.removeAttribute('aria-selected'); |
| } |
| this.selectedTimeCell_ = timeCell; |
| this.setAttribute('aria-activedescendant', timeCell.id); |
| this.selectedTimeCell_.classList.add('selected'); |
| this.selectedTimeCell_.setAttribute('aria-selected', 'true'); |
| } |
| |
| resetToInitialValue = () => { |
| if (this.columnType_ == TimeColumnType.AMPM) { |
| this.selectedTimeCell = this.firstChild; |
| } else { |
| this.selectedTimeCell = this.initialTimeCell_; |
| } |
| }; |
| |
| setToMinValue = () => { |
| if (this.columnType_ == TimeColumnType.AMPM) { |
| this.selectedTimeCell = this.firstChild; |
| const isAM = this.selectedTimeCell.textContent == |
| global.params.ampmLabels[Label.AM]; |
| if (!isAM) |
| this.selectedTimeCell = this.lastChild; |
| } else { |
| this.selectedTimeCell = this.firstChild; |
| for (let timeCell of this.children) { |
| if (timeCell.value < this.selectedTimeCell.value) |
| this.selectedTimeCell = timeCell; |
| } |
| } |
| }; |
| |
| setToMaxValue = () => { |
| if (this.columnType_ == TimeColumnType.AMPM) { |
| this.selectedTimeCell = this.firstChild; |
| const isAM = this.selectedTimeCell.textContent == |
| global.params.ampmLabels[Label.AM]; |
| if (isAM) |
| this.selectedTimeCell = this.lastChild; |
| } else { |
| this.selectedTimeCell = this.lastChild; |
| for (let timeCell of this.children) { |
| if (timeCell.value > this.selectedTimeCell.value) |
| this.selectedTimeCell = timeCell; |
| } |
| } |
| }; |
| |
| get columnType() { |
| return this.columnType_; |
| } |
| } |
| TimeColumn.ClassName = 'time-column'; |
| window.customElements.define('time-column', TimeColumn, {extends: 'ul'}); |
| |
| /** |
| * TimeCell: List item with a custom look that displays a time value. |
| */ |
| class TimeCell extends HTMLLIElement { |
| constructor(value, localizedValue) { |
| super(); |
| |
| this.className = TimeCell.ClassName; |
| this.textContent = localizedValue; |
| this.value = value; |
| |
| this.setAttribute('role', 'option'); |
| this.id = TimeCell.getNextUniqueId(); |
| }; |
| |
| static getNextUniqueId() { |
| return `timeCell${TimeCell.idCount++}`; |
| } |
| |
| static idCount = 0; |
| } |
| TimeCell.ClassName = 'time-cell'; |
| window.customElements.define('time-cell', TimeCell, {extends: 'li'}); |