blob: 677c3baf4711caa628db664b2887c6e67df9b72e [file] [log] [blame]
'use strict';
// Copyright 2014 The Chromium Authors
// 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);
initialize(JSON.parse(event.data));
global.argumentsReceived = true;
}
/**
* @param {!Object} args
*/
function initialize(args) {
global.params = args;
const main = $('main');
main.innerHTML = '';
global.picker = new ListPicker(main, args);
}
function handleArgumentsTimeout() {
if (global.argumentsReceived)
return;
initialize({});
}
class ListPicker extends Picker {
/**
* @param {!Element} element
* @param {!Object} config
*/
constructor(element, config) {
super(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));
this.selectElement_.addEventListener(
'touchstart', this.handleTouchStart_.bind(this));
this.selectElement_.addEventListener(
'keydown', this.handleKeyDown_.bind(this));
this.selectElement_.addEventListener(
'change', this.handleChange_.bind(this));
window.addEventListener('message', this.handleWindowMessage_.bind(this));
window.addEventListener(
'mousemove', this.handleWindowMouseMove_.bind(this));
window.addEventListener(
'mouseover', this.handleWindowMouseOver_.bind(this));
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;
}
handleWindowDidHide_() {
this.fixWindowSize_();
const selectedOption =
this.selectElement_.options[this.selectElement_.selectedIndex];
if (selectedOption)
selectedOption.scrollIntoView(false);
window.removeEventListener('didHide', this.handleWindowDidHideBound_);
}
handleWindowMessage_(event) {
eval(event.data);
if (window.updateData.type === 'update') {
this.config_.baseStyle = window.updateData.baseStyle;
this.config_.children = window.updateData.children;
const prevChildrenCount = 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 ||
prevChildrenCount !== 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 list_picker.css and html.css.
static LISTBOX_SELECT_BORDER = 1;
handleWindowMouseMove_(event) {
const visibleTop = ListPicker.LISTBOX_SELECT_BORDER;
const visibleBottom =
this.selectElement_.offsetHeight - ListPicker.LISTBOX_SELECT_BORDER;
const 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();
}
handleWindowMouseOver_(event) {
this.highlightOption_(event.target);
}
handleMouseUp_(event) {
if (event.target.tagName !== 'OPTION')
return;
window.pagePopupController.setValueAndClosePopup(
0, this.selectElement_.value);
}
handleTouchStart_(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.
const touch = event.touches[0];
this.trackingTouchId_ = touch.identifier;
this.highlightOption_(touch.target);
this.selectionSetByMouseHover_ = false;
this.selectElement_.addEventListener(
'scroll', this.handleTouchSelectModeScrollBound_);
window.addEventListener('touchmove', this.handleWindowTouchMoveBound_);
window.addEventListener('touchend', this.handleWindowTouchEndBound_);
}
handleTouchSelectModeScroll_(event) {
this.exitTouchSelectMode_();
}
exitTouchSelectMode_(event) {
this.trackingTouchId_ = null;
this.selectElement_.removeEventListener(
'scroll', this.handleTouchSelectModeScrollBound_);
window.removeEventListener('touchmove', this.handleWindowTouchMoveBound_);
window.removeEventListener('touchend', this.handleWindowTouchEndBound_);
}
handleWindowTouchMove_(event) {
if (this.trackingTouchId_ === null)
return;
const touch = this.getTouchForId_(event.touches, this.trackingTouchId_);
if (!touch)
return;
this.highlightOption_(
document.elementFromPoint(touch.clientX, touch.clientY));
this.selectionSetByMouseHover_ = false;
}
handleWindowTouchEnd_(event) {
if (this.trackingTouchId_ === null)
return;
const touch =
this.getTouchForId_(event.changedTouches, this.trackingTouchId_);
if (!touch)
return;
const target = document.elementFromPoint(touch.clientX, touch.clientY);
if (target.tagName === 'OPTION' && !target.disabled)
window.pagePopupController.setValueAndClosePopup(
0, this.selectElement_.value);
this.exitTouchSelectMode_();
}
getTouchForId_(touchList, id) {
for (let i = 0; i < touchList.length; i++) {
if (touchList[i].identifier === id)
return touchList[i];
}
return null;
}
highlightOption_(target) {
if (target.tagName !== 'OPTION' || target.selected || target.disabled)
return;
const 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;
}
handleChange_(event) {
window.pagePopupController.setValue(this.selectElement_.value);
this.selectionSetByMouseHover_ = false;
}
handleKeyDown_(event) {
const 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();
}
}
fixWindowSize_() {
this.selectElement_.style.height = '';
const scale = this.config_.scaleFactor;
const maxHeight = this.selectElement_.offsetHeight;
const noScrollHeight =
(this.calculateScrollHeight_() + ListPicker.LISTBOX_SELECT_BORDER * 2);
const scrollbarWidth = getScrollbarWidth();
const elementOffsetWidth = this.selectElement_.offsetWidth;
let desiredWindowHeight = noScrollHeight;
let 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;
let 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);
let windowRect = adjustWindowRect(
desiredWindowWidth / scale, desiredWindowHeight / scale,
elementOffsetWidth / scale, 0, /*allowOverlapWithAnchor=*/ false);
// 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);
}
calculateScrollHeight_() {
// Element.scrollHeight returns an integer value but this calculate the
// actual fractional value.
// TODO(tkent): This can be too large? crbug.com/579863
let top = Infinity;
let bottom = -Infinity;
for (let i = 0; i < this.selectElement_.children.length; i++) {
const 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);
}
listItemCount_() {
return this.selectElement_.querySelectorAll('option,optgroup,hr').length;
}
layout_() {
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;
this.selectElement_.style.fontStyle = this.config_.baseStyle.fontStyle;
this.selectElement_.style.fontVariant = this.config_.baseStyle.fontVariant;
if (this.config_.baseStyle.textAlign)
this.selectElement_.style.textAlign = this.config_.baseStyle.textAlign;
this.updateChildren_(this.selectElement_, this.config_);
}
update_() {
const scrollPosition = this.selectElement_.scrollTop;
const oldValue = this.selectElement_.value;
this.layout_();
this.selectElement_.value = this.config_.selectedIndex;
this.selectElement_.scrollTop = scrollPosition;
let optionUnderMouse = null;
if (this.selectionSetByMouseHover_) {
const 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');
}
static DELAYED_LAYOUT_THRESHOLD = 1000;
/**
* @param {!Element} parent Select element or optgroup element.
* @param {!Object} config
*/
updateChildren_(parent, config) {
let outOfDateIndex = 0;
let fragment = null;
const inGroup = parent.tagName === 'OPTGROUP';
let lastListIndex = -1;
const limit = Math.max(
this.config_.selectedIndex, ListPicker.DELAYED_LAYOUT_THRESHOLD);
let i;
for (i = 0; i < config.children.length; ++i) {
if (!inGroup && lastListIndex >= limit)
break;
const childConfig = config.children[i];
const 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 {
const unused = parent.children.length - outOfDateIndex;
for (let 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);
}
}
updateChildrenLater_(timeStamp) {
if (!this.delayedChildrenConfig_)
return;
const fragment = document.createDocumentFragment();
const startIndex = this.delayedChildrenConfigIndex;
for (; this.delayedChildrenConfigIndex < this.delayedChildrenConfig_.length;
++this.delayedChildrenConfigIndex) {
const childConfig =
this.delayedChildrenConfig_[this.delayedChildrenConfigIndex];
const item = this.createItemElement_(childConfig);
this.configureItem_(item, childConfig, false);
fragment.appendChild(item);
}
this.selectElement_.appendChild(fragment);
this.selectElement_.classList.add('wrap');
this.delayedChildrenConfig_ = null;
}
findReusableItem_(parent, config, startIndex) {
if (startIndex >= parent.children.length)
return null;
let tagName = 'OPTION';
if (config.type === 'optgroup')
tagName = 'OPTGROUP';
else if (config.type === 'separator')
tagName = 'HR';
for (let i = startIndex; i < parent.children.length; i++) {
const child = parent.children[i];
if (tagName === child.tagName) {
return child;
}
}
return null;
}
createItemElement_(config) {
let 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;
}
applyItemStyle_(element, styleConfig) {
if (!styleConfig)
return;
const 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.colorScheme = styleConfig.colorScheme ? styleConfig.colorScheme : '';
style.fontSize =
styleConfig.fontSize !== undefined ? styleConfig.fontSize + 'px' : '';
style.fontWeight = styleConfig.fontWeight ? styleConfig.fontWeight : '';
style.fontFamily = styleConfig.fontFamily ? styleConfig.fontFamily : '';
style.fontStyle = styleConfig.fontStyle ? styleConfig.fontStyle : '';
style.fontVariant = styleConfig.fontVariant ? styleConfig.fontVariant : '';
style.textTransform =
styleConfig.textTransform ? styleConfig.textTransform : '';
style.textAlign = styleConfig.textAlign ? styleConfig.textAlign : '';
}
configureItem_(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 list_picker.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 list_picker.css.
element.style.marginInlineEnd = '-2px';
}
}
this.applyItemStyle_(element, config.style);
}
}
if (window.dialogArguments) {
initialize(dialogArguments);
} else {
window.addEventListener('message', handleMessage);
window.setTimeout(handleArgumentsTimeout, 1000);
}