blob: d292c47c9976785cab932a6db2c24865b3779db1 [file] [log] [blame]
'use strict';
// Copyright (c) 2014 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.
var global = {argumentsReceived: false, params: null, picker: null};
/**
* @param {Event} event
*/
function handleMessage(event) {
window.removeEventListener('message', handleMessage, false);
initialize(JSON.parse(event.data));
global.argumentsReceived = true;
}
/**
* @param {!Object} args
*/
function initialize(args) {
global.params = args;
var main = $('main');
main.innerHTML = '';
if (global.params.isFormControlsRefreshEnabled) {
document.body.classList.add('controls-refresh');
}
global.picker = new ListPicker(main, args);
}
function handleArgumentsTimeout() {
if (global.argumentsReceived)
return;
initialize({});
}
/**
* @constructor
* @param {!Element} element
* @param {!Object} config
*/
function ListPicker(element, config) {
Picker.call(this, element, config);
this._selectElement = createElement('select');
this._selectElement.size = 20;
this._element.appendChild(this._selectElement);
this._delayedChildrenConfig = null;
this._delayedChildrenConfigIndex = 0;
this._layout();
this._selectElement.addEventListener(
'mouseup', this._handleMouseUp.bind(this), false);
this._selectElement.addEventListener(
'touchstart', this._handleTouchStart.bind(this), false);
this._selectElement.addEventListener(
'keydown', this._handleKeyDown.bind(this), false);
this._selectElement.addEventListener(
'change', this._handleChange.bind(this), false);
window.addEventListener(
'message', this._handleWindowMessage.bind(this), false);
window.addEventListener(
'mousemove', this._handleWindowMouseMove.bind(this), false);
window.addEventListener(
'mouseover', this._handleWindowMouseOver.bind(this), false);
this._handleWindowTouchMoveBound = this._handleWindowTouchMove.bind(this);
this._handleWindowTouchEndBound = this._handleWindowTouchEnd.bind(this);
this._handleTouchSelectModeScrollBound =
this._handleTouchSelectModeScroll.bind(this);
this.lastMousePositionX = Infinity;
this.lastMousePositionY = Infinity;
this._selectionSetByMouseHover = false;
this._trackingTouchId = null;
this._handleWindowDidHide();
this._selectElement.focus();
this._selectElement.value = this._config.selectedIndex;
}
ListPicker.prototype = Object.create(Picker.prototype);
ListPicker.prototype._handleWindowDidHide = function() {
this._fixWindowSize();
var selectedOption =
this._selectElement.options[this._selectElement.selectedIndex];
if (selectedOption)
selectedOption.scrollIntoView(false);
window.removeEventListener('didHide', this._handleWindowDidHideBound, false);
};
ListPicker.prototype._handleWindowMessage = function(event) {
eval(event.data);
if (window.updateData.type === 'update') {
this._config.baseStyle = window.updateData.baseStyle;
this._config.children = window.updateData.children;
const prev_children_count = this._selectElement.children.length;
this._update();
if (this._config.anchorRectInScreen.x !==
window.updateData.anchorRectInScreen.x ||
this._config.anchorRectInScreen.y !==
window.updateData.anchorRectInScreen.y ||
this._config.anchorRectInScreen.width !==
window.updateData.anchorRectInScreen.width ||
this._config.anchorRectInScreen.height !==
window.updateData.anchorRectInScreen.height ||
prev_children_count !== window.updateData.children.length) {
this._config.anchorRectInScreen = window.updateData.anchorRectInScreen;
this._fixWindowSize();
}
}
delete window.updateData;
};
// This should be matched to the border width of the internal listbox
// SELECT. See listPicker.css and html.css.
ListPicker.ListboxSelectBorder = 1;
ListPicker.prototype._handleWindowMouseMove = function(event) {
var visibleTop = ListPicker.ListboxSelectBorder;
var visibleBottom =
this._selectElement.offsetHeight - ListPicker.ListboxSelectBorder;
var optionBounds = event.target.getBoundingClientRect();
if (optionBounds.height >= 1.0) {
// If the height of the visible part of event.target is less than 1px,
// ignore this event because it may be an error by sub-pixel layout.
if (optionBounds.top < visibleTop) {
if (optionBounds.bottom - visibleTop < 1.0)
return;
} else if (optionBounds.bottom > visibleBottom) {
if (visibleBottom - optionBounds.top < 1.0)
return;
}
}
this.lastMousePositionX = event.clientX;
this.lastMousePositionY = event.clientY;
this._selectionSetByMouseHover = true;
// Prevent the select element from firing change events for mouse input.
event.preventDefault();
};
ListPicker.prototype._handleWindowMouseOver = function(event) {
this._highlightOption(event.target);
};
ListPicker.prototype._handleMouseUp = function(event) {
if (event.target.tagName !== 'OPTION')
return;
window.pagePopupController.setValueAndClosePopup(
0, this._selectElement.value);
};
ListPicker.prototype._handleTouchStart = function(event) {
if (this._trackingTouchId !== null)
return;
// Enter touch select mode. In touch select mode the highlight follows the
// finger and on touchend the highlighted item is selected.
var touch = event.touches[0];
this._trackingTouchId = touch.identifier;
this._highlightOption(touch.target);
this._selectionSetByMouseHover = false;
this._selectElement.addEventListener(
'scroll', this._handleTouchSelectModeScrollBound, false);
window.addEventListener('touchmove', this._handleWindowTouchMoveBound, false);
window.addEventListener('touchend', this._handleWindowTouchEndBound, false);
};
ListPicker.prototype._handleTouchSelectModeScroll = function(event) {
this._exitTouchSelectMode();
};
ListPicker.prototype._exitTouchSelectMode = function(event) {
this._trackingTouchId = null;
this._selectElement.removeEventListener(
'scroll', this._handleTouchSelectModeScrollBound, false);
window.removeEventListener(
'touchmove', this._handleWindowTouchMoveBound, false);
window.removeEventListener(
'touchend', this._handleWindowTouchEndBound, false);
};
ListPicker.prototype._handleWindowTouchMove = function(event) {
if (this._trackingTouchId === null)
return;
var touch = this._getTouchForId(event.touches, this._trackingTouchId);
if (!touch)
return;
this._highlightOption(
document.elementFromPoint(touch.clientX, touch.clientY));
this._selectionSetByMouseHover = false;
};
ListPicker.prototype._handleWindowTouchEnd = function(event) {
if (this._trackingTouchId === null)
return;
var touch = this._getTouchForId(event.changedTouches, this._trackingTouchId);
if (!touch)
return;
var target = document.elementFromPoint(touch.clientX, touch.clientY);
if (target.tagName === 'OPTION' && !target.disabled)
window.pagePopupController.setValueAndClosePopup(
0, this._selectElement.value);
this._exitTouchSelectMode();
};
ListPicker.prototype._getTouchForId = function(touchList, id) {
for (var i = 0; i < touchList.length; i++) {
if (touchList[i].identifier === id)
return touchList[i];
}
return null;
};
ListPicker.prototype._highlightOption = function(target) {
if (target.tagName !== 'OPTION' || target.selected || target.disabled)
return;
var savedScrollTop = this._selectElement.scrollTop;
// TODO(tkent): Updating HTMLOptionElement::selected is not efficient. We
// should optimize it, or use an alternative way.
target.selected = true;
this._selectElement.scrollTop = savedScrollTop;
};
ListPicker.prototype._handleChange = function(event) {
window.pagePopupController.setValue(this._selectElement.value);
this._selectionSetByMouseHover = false;
};
ListPicker.prototype._handleKeyDown = function(event) {
var key = event.key;
if (key === 'Escape') {
window.pagePopupController.closePopup();
event.preventDefault();
} else if (key === 'Tab' || key === 'Enter') {
window.pagePopupController.setValueAndClosePopup(
0, this._selectElement.value);
event.preventDefault();
} else if (event.altKey && (key === 'ArrowDown' || key === 'ArrowUp')) {
// We need to add a delay here because, if we do it immediately the key
// press event will be handled by HTMLSelectElement and this popup will
// be reopened.
setTimeout(function() {
window.pagePopupController.closePopup();
}, 0);
event.preventDefault();
}
};
ListPicker.prototype._fixWindowSize = function() {
this._selectElement.style.height = '';
var scale = this._config.scaleFactor;
var maxHeight = this._selectElement.offsetHeight;
var noScrollHeight =
(this._calculateScrollHeight() + ListPicker.ListboxSelectBorder * 2);
var scrollbarWidth = getScrollbarWidth();
var elementOffsetWidth = this._selectElement.offsetWidth;
var desiredWindowHeight = noScrollHeight;
var desiredWindowWidth = elementOffsetWidth;
// If we already have a vertical scrollbar, subtract it out, it will get re-added below.
if (this._selectElement.scrollHeight > this._selectElement.clientHeight)
desiredWindowWidth -= scrollbarWidth;
var expectingScrollbar = false;
if (desiredWindowHeight > maxHeight) {
desiredWindowHeight = maxHeight;
// Setting overflow to auto does not increase width for the scrollbar
// so we need to do it manually.
desiredWindowWidth += scrollbarWidth;
expectingScrollbar = true;
}
// Screen coordinate for anchorRectInScreen and windowRect is DIP.
desiredWindowWidth = Math.max(
this._config.anchorRectInScreen.width * scale, desiredWindowWidth);
var windowRect = adjustWindowRect(
desiredWindowWidth / scale, desiredWindowHeight / scale,
elementOffsetWidth / scale, 0);
// If the available screen space is smaller than maxHeight, we will get an unexpected scrollbar.
if (!expectingScrollbar && windowRect.height < noScrollHeight / scale) {
desiredWindowWidth = windowRect.width * scale + scrollbarWidth;
windowRect = adjustWindowRect(
desiredWindowWidth / scale, windowRect.height, windowRect.width,
windowRect.height);
}
this._selectElement.style.width = (windowRect.width * scale) + 'px';
this._selectElement.style.height = (windowRect.height * scale) + 'px';
this._element.style.height = (windowRect.height * scale) + 'px';
setWindowRect(windowRect);
};
ListPicker.prototype._calculateScrollHeight = function() {
// Element.scrollHeight returns an integer value but this calculate the
// actual fractional value.
// TODO(tkent): This can be too large? crbug.com/579863
var top = Infinity;
var bottom = -Infinity;
for (var i = 0; i < this._selectElement.children.length; i++) {
var rect = this._selectElement.children[i].getBoundingClientRect();
// Skip hidden elements.
if (rect.width === 0 && rect.height === 0)
continue;
top = Math.min(top, rect.top);
bottom = Math.max(bottom, rect.bottom);
}
return Math.max(bottom - top, 0);
};
ListPicker.prototype._listItemCount = function() {
return this._selectElement.querySelectorAll('option,optgroup,hr').length;
};
ListPicker.prototype._layout = function() {
if (this._config.isRTL)
this._element.classList.add('rtl');
this._selectElement.style.backgroundColor =
this._config.baseStyle.backgroundColor;
this._selectElement.style.color = this._config.baseStyle.color;
this._selectElement.style.textTransform =
this._config.baseStyle.textTransform;
this._selectElement.style.fontSize = this._config.baseStyle.fontSize + 'px';
this._selectElement.style.fontFamily =
this._config.baseStyle.fontFamily.map(s => '"' + s + '"').join(',');
this._selectElement.style.fontStyle = this._config.baseStyle.fontStyle;
this._selectElement.style.fontVariant = this._config.baseStyle.fontVariant;
this._updateChildren(this._selectElement, this._config);
};
ListPicker.prototype._update = function() {
var scrollPosition = this._selectElement.scrollTop;
var oldValue = this._selectElement.value;
this._layout();
this._selectElement.value = this._config.selectedIndex;
this._selectElement.scrollTop = scrollPosition;
var optionUnderMouse = null;
if (this._selectionSetByMouseHover) {
var elementUnderMouse = document.elementFromPoint(
this.lastMousePositionX, this.lastMousePositionY);
optionUnderMouse = elementUnderMouse && elementUnderMouse.closest('option');
}
if (optionUnderMouse)
optionUnderMouse.selected = true;
else
this._selectElement.value = oldValue;
this._selectElement.scrollTop = scrollPosition;
this.dispatchEvent('didUpdate');
};
ListPicker.DelayedLayoutThreshold = 1000;
/**
* @param {!Element} parent Select element or optgroup element.
* @param {!Object} config
*/
ListPicker.prototype._updateChildren = function(parent, config) {
var outOfDateIndex = 0;
var fragment = null;
var inGroup = parent.tagName === 'OPTGROUP';
var lastListIndex = -1;
var limit =
Math.max(this._config.selectedIndex, ListPicker.DelayedLayoutThreshold);
var i;
for (i = 0; i < config.children.length; ++i) {
if (!inGroup && lastListIndex >= limit)
break;
var childConfig = config.children[i];
var item = this._findReusableItem(parent, childConfig, outOfDateIndex) ||
this._createItemElement(childConfig);
this._configureItem(item, childConfig, inGroup);
lastListIndex = item.value ? Number(item.value) : -1;
if (outOfDateIndex < parent.children.length) {
parent.insertBefore(item, parent.children[outOfDateIndex]);
} else {
if (!fragment)
fragment = document.createDocumentFragment();
fragment.appendChild(item);
}
outOfDateIndex++;
}
if (fragment) {
parent.appendChild(fragment);
} else {
var unused = parent.children.length - outOfDateIndex;
for (var j = 0; j < unused; j++) {
parent.removeChild(parent.lastElementChild);
}
}
if (i < config.children.length) {
// We don't bind |config.children| and |i| to _updateChildrenLater
// because config.children can get invalid before _updateChildrenLater
// is called.
this._delayedChildrenConfig = config.children;
this._delayedChildrenConfigIndex = i;
// Needs some amount of delay to kick the first paint.
setTimeout(this._updateChildrenLater.bind(this), 100);
}
};
ListPicker.prototype._updateChildrenLater = function(timeStamp) {
if (!this._delayedChildrenConfig)
return;
var fragment = document.createDocumentFragment();
var startIndex = this._delayedChildrenConfigIndex;
for (; this._delayedChildrenConfigIndex < this._delayedChildrenConfig.length;
++this._delayedChildrenConfigIndex) {
var childConfig =
this._delayedChildrenConfig[this._delayedChildrenConfigIndex];
var item = this._createItemElement(childConfig);
this._configureItem(item, childConfig, false);
fragment.appendChild(item);
}
this._selectElement.appendChild(fragment);
this._selectElement.classList.add('wrap');
this._delayedChildrenConfig = null;
};
ListPicker.prototype._findReusableItem = function(parent, config, startIndex) {
if (startIndex >= parent.children.length)
return null;
var tagName = 'OPTION';
if (config.type === 'optgroup')
tagName = 'OPTGROUP';
else if (config.type === 'separator')
tagName = 'HR';
for (var i = startIndex; i < parent.children.length; i++) {
var child = parent.children[i];
if (tagName === child.tagName) {
return child;
}
}
return null;
};
ListPicker.prototype._createItemElement = function(config) {
var element;
if (!config.type || config.type === 'option')
element = createElement('option');
else if (config.type === 'optgroup')
element = createElement('optgroup');
else if (config.type === 'separator')
element = createElement('hr');
return element;
};
ListPicker.prototype._applyItemStyle = function(element, styleConfig) {
if (!styleConfig)
return;
var style = element.style;
style.visibility = styleConfig.visibility ? styleConfig.visibility : '';
style.display = styleConfig.display ? styleConfig.display : '';
style.direction = styleConfig.direction ? styleConfig.direction : '';
style.unicodeBidi = styleConfig.unicodeBidi ? styleConfig.unicodeBidi : '';
style.color = styleConfig.color ? styleConfig.color : '';
style.backgroundColor =
styleConfig.backgroundColor ? styleConfig.backgroundColor : '';
style.fontSize =
styleConfig.fontSize !== undefined ? styleConfig.fontSize + 'px' : '';
style.fontWeight = styleConfig.fontWeight ? styleConfig.fontWeight : '';
style.fontFamily = styleConfig.fontFamily ?
styleConfig.fontFamily.map(s => '"' + s + '"').join(',') :
'';
style.fontStyle = styleConfig.fontStyle ? styleConfig.fontStyle : '';
style.fontVariant = styleConfig.fontVariant ? styleConfig.fontVariant : '';
style.textTransform =
styleConfig.textTransform ? styleConfig.textTransform : '';
};
ListPicker.prototype._configureItem = function(element, config, inGroup) {
if (!config.type || config.type === 'option') {
element.label = config.label;
element.value = config.value;
if (config.title)
element.title = config.title;
else
element.removeAttribute('title');
element.disabled = !!config.disabled
if (config.ariaLabel)
element.setAttribute('aria-label', config.ariaLabel);
else element.removeAttribute('aria-label');
element.style.paddingInlineStart = this._config.paddingStart + 'px';
if (inGroup) {
element.style.marginInlineStart = (-this._config.paddingStart) + 'px';
// Should be synchronized with padding-end in listPicker.css.
element.style.marginInlineEnd = '-2px';
}
} else if (config.type === 'optgroup') {
element.label = config.label;
element.title = config.title;
element.disabled = config.disabled;
element.setAttribute('aria-label', config.ariaLabel);
this._updateChildren(element, config);
element.style.paddingInlineStart = this._config.paddingStart + 'px';
} else if (config.type === 'separator') {
element.title = config.title;
element.disabled = config.disabled;
element.setAttribute('aria-label', config.ariaLabel);
if (inGroup) {
element.style.marginInlineStart = (-this._config.paddingStart) + 'px';
// Should be synchronized with padding-end in listPicker.css.
element.style.marginInlineEnd = '-2px';
}
}
this._applyItemStyle(element, config.style);
};
if (window.dialogArguments) {
initialize(dialogArguments);
} else {
window.addEventListener('message', handleMessage, false);
window.setTimeout(handleArgumentsTimeout, 1000);
}