@qooxdoo/framework
Version:
The JS Framework for Coders
780 lines (669 loc) • 22.3 kB
JavaScript
/* ************************************************************************
qooxdoo - the new era of web development
http://qooxdoo.org
Copyright:
2011 1&1 Internet AG, Germany, http://www.1und1.de
License:
MIT: https://opensource.org/licenses/MIT
See the LICENSE file in the project's top-level directory for details.
Authors:
* Christian Hagendorn (chris_schmidt)
************************************************************************ */
/**
* A form virtual widget which allows a single selection. Looks somewhat like
* a normal button, but opens a virtual list of items to select when tapping
* on it.
*
* @childControl spacer {qx.ui.core.Spacer} Flexible spacer widget.
* @childControl atom {qx.ui.basic.Atom} Shows the text and icon of the content.
* @childControl arrow {qx.ui.basic.Image} Shows the arrow to open the drop-down list.
*
* Item labels are plain text by default. Property <code>rich</code> can be used
* to enable HTML formatting.
*
* Incremental search can be enabled by setting property <code>incrementalSearch</code>
* to <code>true</code>. Highlightung of the search string is controlled by property
* <code>highlightMode</code>. BEWARE that html highlighting sets property <code>rich</code>
* automatically to <code>true</code>.
*
* With <code>highlightMode=='html'</code> item label parts and search value are all HTML-escaped.
* If HTML-formatted item labels are used, this might lead to unexpected or undesirable (but save)
* results. In this case you might want to override the various protected methods involved.
*
*/
qx.Class.define("qx.ui.form.VirtualSelectBox", {
extend: qx.ui.form.core.AbstractVirtualBox,
implement: [qx.data.controller.ISelection, qx.ui.form.IField],
construct(model) {
super(model);
this._createChildControl("atom");
this._createChildControl("spacer");
this._createChildControl("arrow");
// Register listener
this.addListener("pointerover", this._onPointerOver, this);
this.addListener("pointerout", this._onPointerOut, this);
this.__bindings = [];
this.initSelection(this.getChildControl("dropdown").getSelection());
this.__searchTimer = new qx.event.Timer(500);
this.__searchTimer.addListener("interval", this.__preselect, this);
this.getSelection().addListener("change", this._updateSelectionValue, this);
if (this.isIncrementalSearch()) {
this.__initIncrementalSearch();
}
this.initHtmlMarkers([
'<span style="' + this.__getHighlightStyleFromAppearance() + '">',
"</span>"
]);
},
properties: {
// overridden
appearance: {
refine: true,
init: "virtual-selectbox"
},
// overridden
width: {
refine: true,
init: 120
},
/**
* Whether or not to use incremental search.
*/
incrementalSearch: {
apply: "_applyIncrementalSearch",
init: false,
check: "Boolean"
},
/**
* Array of non-HTML strings for opening an closing marker for incremental search highlighting
*/
plainMarkers: {
apply: "__applyMarkers",
init: ["|", "|"],
check: "Array"
},
/**
* Array of HTML strings for opening an closing marker for incremental search highlighting.
* Initialized from 'list-search-highlight' theme if not set explicitly.
*/
htmlMarkers: {
apply: "__applyMarkers",
deferredInit: true,
check: "Array"
},
/**
* Setting rich to true sets both the select box atom and the list items to rich.
*/
rich: {
apply: "_applyRich",
init: null,
check: "Boolean"
},
/**
* Define how to highlight the incremental search string.
*
* none: no highlighting (this is the default)
* plain: use characters from plainMarkers property
* html: use HTML for highlighting; this automatically calls <code>this.setRich(true)</code> and thus sets item labels to rich as well.
*
*/
highlightMode: {
apply: "_applyHighlightMode",
init: "none",
check: ["plain", "html", "none"]
},
/** Current selected items. */
selection: {
check: "qx.data.Array",
event: "changeSelection",
apply: "_applySelection",
nullable: false,
deferredInit: true
}
},
events: {
/**
* This event is fired as soon as the content of the selection property changes, but
* this is not equal to the change of the selection of the widget. If the selection
* of the widget changes, the content of the array stored in the selection property
* changes. This means you have to listen to the change event of the selection array
* to get an event as soon as the user changes the selected item.
* <pre class="javascript">obj.getSelection().addListener("change", listener, this);</pre>
*/
changeSelection: "qx.event.type.Data",
/** Fires after the value was modified */
changeValue: "qx.event.type.Data"
},
members: {
/** @type {String} The search value to {@link #__preselect} an item. */
__searchValue: "",
/**
* @type {qx.event.Timer} The time which triggers the search for pre-selection.
*/
__searchTimer: null,
/** @type {Array} Contains the id from all bindings. */
__bindings: null,
/**
* @param selected {var|null} Item to select as value.
* @returns {null|TypeError} The status of this operation.
*/
setValue(selected) {
if (null === selected) {
this.getSelection().removeAll();
return null;
}
this.getSelection().setItem(0, selected);
return null;
},
/**
* @returns {null|var} The currently selected item or null if there is none.
*/
getValue() {
var s = this.getSelection();
return s.length === 0 ? null : s.getItem(0);
},
resetValue() {
this.setValue(null);
},
// overridden
syncWidget(jobs) {
this._removeBindings();
this._addBindings();
},
/*
---------------------------------------------------------------------------
INTERNAl API
---------------------------------------------------------------------------
*/
// overridden
_createChildControlImpl(id, hash) {
var control;
switch (id) {
case "spacer":
control = new qx.ui.core.Spacer();
this._add(control, { flex: 1 });
break;
case "atom":
control = new qx.ui.form.ListItem("");
control.setCenter(false);
control.setAnonymous(true);
this._add(control, { flex: 1 });
break;
case "arrow":
control = new qx.ui.basic.Image();
control.setAnonymous(true);
this._add(control);
break;
}
return control || super._createChildControlImpl(id, hash);
},
// overridden
_getAction(event) {
var keyIdentifier = event.getKeyIdentifier();
var isOpen = this.getChildControl("dropdown").isVisible();
var isModifierPressed = this._isModifierPressed(event);
if (
!isOpen &&
!isModifierPressed &&
(keyIdentifier === "Enter" || keyIdentifier === "Space")
) {
return "open";
} else if (isOpen && event.isPrintable()) {
return "search";
} else {
return super._getAction(event);
}
},
/**
* This method is called when the binding can be added to the
* widget. For e.q. bind the drop-down selection with the widget.
*/
_addBindings() {
var atom = this.getChildControl("atom");
var modelPath = this._getBindPath("selection", "");
var id = this.bind(modelPath, atom, "model", null);
this.__bindings.push(id);
var labelSourcePath = this._getBindPath("selection", this.getLabelPath());
id = this.bind(labelSourcePath, atom, "label", this.getLabelOptions());
this.__bindings.push(id);
if (this.getIconPath() != null) {
var iconSourcePath = this._getBindPath("selection", this.getIconPath());
id = this.bind(iconSourcePath, atom, "icon", this.getIconOptions());
this.__bindings.push(id);
}
},
/**
* This method is called when the binding can be removed from the
* widget. For e.q. remove the bound drop-down selection.
*/
_removeBindings() {
while (this.__bindings.length > 0) {
var id = this.__bindings.pop();
this.removeBinding(id);
}
},
/*
---------------------------------------------------------------------------
EVENT LISTENERS
---------------------------------------------------------------------------
*/
_onBlur() {
if (!this.isIncrementalSearch()) {
this.close();
}
},
// overridden
_handlePointer(event) {
super._handlePointer(event);
var type = event.getType();
if (type === "tap") {
this.toggle();
}
},
// overridden
_handleKeyboard(event) {
var action = this._getAction(event);
switch (action) {
case "search":
if (!this.isIncrementalSearch()) {
this.__searchValue += this.__convertKeyIdentifier(
event.getKeyIdentifier()
);
this.__searchTimer.restart();
}
break;
default:
super._handleKeyboard(event);
break;
}
},
/**
* Listener method for "pointerover" event.
*
* <ul>
* <li>Adds state "hovered"</li>
* <li>Removes "abandoned" and adds "pressed" state (if "abandoned" state
* is set)</li>
* </ul>
*
* @param event {qx.event.type.Pointer} Pointer event
*/
_onPointerOver(event) {
if (!this.isEnabled() || event.getTarget() !== this) {
return;
}
if (this.hasState("abandoned")) {
this.removeState("abandoned");
this.addState("pressed");
}
this.addState("hovered");
},
/**
* Listener method for "pointerout" event.
*
* <ul>
* <li>Removes "hovered" state</li>
* <li>Adds "abandoned" and removes "pressed" state (if "pressed" state
* is set)</li>
* </ul>
*
* @param event {qx.event.type.Pointer} Pointer event
*/
_onPointerOut(event) {
if (!this.isEnabled() || event.getTarget() !== this) {
return;
}
this.removeState("hovered");
if (this.hasState("pressed")) {
this.removeState("pressed");
this.addState("abandoned");
}
},
/*
---------------------------------------------------------------------------
APPLY ROUTINES
---------------------------------------------------------------------------
*/
// property apply
_applySelection(value, old) {
this.getChildControl("dropdown").setSelection(value);
qx.ui.core.queue.Widget.add(this);
},
/*
---------------------------------------------------------------------------
HELPER METHODS
---------------------------------------------------------------------------
*/
/**
* Preselects an item in the drop-down, when item starts with the
* __searchValue value.
*/
__preselect() {
this.__searchTimer.stop();
var searchValue = this.__searchValue;
if (searchValue === null || searchValue === "") {
return;
}
var model = this.getModel();
var list = this.getChildControl("dropdown").getChildControl("list");
var selection = list.getSelection();
var length = list._getLookupTable().length;
var startIndex = model.indexOf(selection.getItem(0));
var startRow = list._reverseLookup(startIndex);
for (var i = 1; i <= length; i++) {
var row = (i + startRow) % length;
var item = model.getItem(list._lookup(row));
if (!item) {
// group items aren't in the model
continue;
}
var value = item;
if (this.getLabelPath()) {
value = qx.data.SingleValueBinding.resolvePropertyChain(
item,
this.getLabelPath()
);
var labelOptions = this.getLabelOptions();
if (labelOptions) {
var converter = qx.util.Delegate.getMethod(
labelOptions,
"converter"
);
if (converter) {
value = converter(value, item);
}
}
}
if (value.toLowerCase().startsWith(searchValue.toLowerCase())) {
selection.push(item);
break;
}
}
this.__searchValue = "";
},
/**
* Converts the keyIdentifier to a printable character e.q. <code>"Space"</code>
* to <code>" "</code>.
*
* @param keyIdentifier {String} The keyIdentifier to convert.
* @return {String} The converted keyIdentifier.
*/
__convertKeyIdentifier(keyIdentifier) {
if (keyIdentifier === "Space") {
return " ";
} else {
return keyIdentifier;
}
},
/**
* Called when selection changes.
*
* @param event {qx.event.type.Data} {@link qx.data.Array} change event.
*/
_updateSelectionValue(event) {
var d = event.getData();
var old = d.removed.length ? d.removed[0] : null;
this.fireDataEvent("changeValue", d.added[0], old);
},
/*
---------------------------------------------------------------------------
INCREMENTAL SEARCH
---------------------------------------------------------------------------
*/
__filterValue: null,
__lastMatch: "",
// prevent recursion problems when deleting unsucessful filtering
__filterUpdateRunning: 0,
__filterInput: null,
__highlightMarkers: null,
_highlightFilterValueFunction: null,
_searchRegExp: null,
__addFilterInput() {
var input = (this.__filterInput = new qx.ui.form.TextField().set({
appearance: "widget",
liveUpdate: true,
height: 0,
width: 1 // must be > 0
}));
// we don't want the browser to set this
// works with Chrome even
input.getContentElement().setAttribute("autocomplete", "new-password");
this._add(input);
var dropdown = this.getChildControl("dropdown");
dropdown.addListener("appear", () => {
// we must delay so that the focus is only set once the list is ready
window.setTimeout(function () {
input.focus();
}, 0);
});
dropdown.addListener("disappear", () => {
input.blur();
// clear filter
var sel = this.getValue();
input.resetValue();
this.setValue(sel);
});
input.addListener("blur", e => {
this.close();
});
input.addListener("changeValue", e => {
if (this.__filterUpdateRunning === 0) {
this.__updateDelegate();
}
});
},
__getHighlightStyleFromAppearance() {
var highlightAppearance =
qx.theme.manager.Appearance.getInstance().styleFrom(
"list-search-highlight"
);
// default style
if (!highlightAppearance) {
this.debug(
'The current theme is missing the "list-search-highlight" appearance setting, using default.'
);
highlightAppearance = {
backgroundColor: "rgba(255, 251, 0, 0.53)",
textDecorationStyle: "dotted",
textDecorationLine: "underline"
};
}
var highlightStyle = "",
styles = [];
var keys = Object.keys(highlightAppearance);
for (var k = 0; k < keys.length; k++) {
var key = qx.module.util.String.hyphenate(keys[k]);
styles.push(key + ":" + highlightAppearance[keys[k]]);
}
highlightStyle = styles.join(";") + ";";
return highlightStyle;
},
// filterValue is passed below to allow usage in overridden _searchMatch
// we use _searchRegExp here for efficiency
_searchMatch(item, filterValue) {
return item.match(this._searchRegExp);
},
// highlight plain
_highlightFilterValuePlainFunction(parts) {
// the array elements will contain '' if empty
return (
parts[1] +
this.__highlightMarkers[0] +
parts[2] +
this.__highlightMarkers[1] +
parts[3]
);
},
// htmlEscape all label parts
_highlightFilterValueHtmlFunction(parts) {
// the array elements will contain '' if empty
// the markers will be HTML strings
return (
qx.module.util.String.escapeHtml(parts[1]) +
this.__highlightMarkers[0] +
qx.module.util.String.escapeHtml(parts[2]) +
this.__highlightMarkers[1] +
qx.module.util.String.escapeHtml(parts[3])
);
},
_configureItemRich(item) {
item.setRich(true);
},
_configureItemPlain(item) {
item.setRich(false);
},
__updateDelegate(lastFilterValue) {
this.__filterUpdateRunning++;
var filterValue =
lastFilterValue !== undefined
? lastFilterValue
: this.__filterInput.getValue();
this.__filterValue = filterValue;
// _searchRegExp is used in default _searchMatch function to avoid recreation of regexp object
// for each list item
var filterValueEscaped =
filterValue != null
? qx.module.util.String.escapeRegexpChars(filterValue)
: "";
this._searchRegExp = new RegExp(
"(.*?)(" + filterValueEscaped + ")(.*)",
"i"
);
// create and apply new filter
var that = this;
var delegate = {
filter(item) {
if (that.getLabelPath() != null) {
item = qx.data.SingleValueBinding.resolvePropertyChain(
item,
that.getLabelPath()
);
}
// we pass filterValue in case _searchMatch() is overridden and wants to use it.
return that._searchMatch(item, filterValue);
}
};
// needed for newly created items on filterValue backspacing
if (this.isRich()) {
delegate.configureItem = this._configureItemRich;
}
this.setDelegate(delegate);
// update selection if there is at least one item left,
// otherwise shorten filterValue and re-run filtering
// This deals with multi-char input like for ü on MacOS where
// where this is entered as option-: followed by u on a keyboard
// without a separat key for it.
var item = this.getModel().getItem(
this.getChildControl("dropdown").getChildControl("list")._lookup(0)
);
if (item) {
this.__lastMatch = filterValue;
this.getSelection().setItem(0, item);
} else {
var len = filterValue.length;
var last =
len > this.__lastMatch.length + 1
? this.__filterInput.getValue().charAt(len - 1)
: "";
filterValue = this.__lastMatch + last;
this.__updateDelegate(filterValue);
}
// make sure length of dropdown is updated
this.__filterUpdateRunning--;
},
__initIncrementalSearch() {
// add search input field
this.__addFilterInput();
// set label converter
var that = this;
var labelOptions = this.getLabelOptions() || {};
labelOptions.converter = function (data, model, source, target) {
var filterValue = that.__filterValue;
if (filterValue && data && that._highlightFilterValueFunction) {
var match = that._searchMatch(data, filterValue);
if (match) {
data = that._highlightFilterValueFunction(match);
}
}
if (data === undefined) {
data = "";
}
return data;
};
this.setLabelOptions(labelOptions);
},
_applyDelegate(value, old) {
// we assume that if the user sets configureItem himself
// he keeps this consistent with rich
if (this.isRich() && !value.configureItem) {
value.configureItem = this._configureItemRich;
}
super._applyDelegate(value, old);
},
_applyRich(value, old) {
if (!value && this.getHighlightMode() == "html") {
this.debug(
"highlightMode html requires rich==true, ignoring setting it to false"
);
return;
}
this.getChildControl("atom").setRich(value);
var configureItemFunction = value
? this._configureItemRich
: this._configureItemPlain;
this.setDelegate({
configureItem: configureItemFunction
});
},
_applyHighlightMode(value, old) {
switch (value) {
case "html":
// set rich item labels
this.setRich(true);
this._highlightFilterValueFunction =
this._highlightFilterValueHtmlFunction;
this.__highlightMarkers = this.getHtmlMarkers();
break;
case "plain":
this._highlightFilterValueFunction =
this._highlightFilterValuePlainFunction;
this.__highlightMarkers = this.getPlainMarkers();
break;
default:
this._highlightFilterValueFunction = null;
break;
}
},
__applyMarkers(value, old) {
this.__highlightMarkers = value;
// make sure we have strings for both markers
if (value.length < 1) {
this.__highlightMarkers[0] = "";
}
// this most likely won't work for HTML highlighting
if (value.length < 2) {
this.__highlightMarkers[1] = this.__highlightMarkers[0];
}
},
_applyIncrementalSearch(value, old) {
if (value) {
this.__searchTimer.stop();
this.__searchTimer.setEnabled(false);
this.__initIncrementalSearch();
} else {
this.__searchTimer.setEnabled(true);
}
}
},
destruct() {
this._removeBindings();
this.getSelection().removeListener(
"change",
this._updateSelectionValue,
this
);
this.__searchTimer.removeListener("interval", this.__preselect, this);
this.__searchTimer.dispose();
this.__searchTimer = null;
}
});