zul/src/main/java/org/zkoss/zul/Combobox.java

Summary

Maintainability
F
1 wk
Test Coverage
/* Combobox.java

    Purpose:

    Description:

    History:
        Thu Dec 15 17:33:01     2005, Created by tomyeh

Copyright (C) 2005 Potix Corporation. All Rights Reserved.

{{IS_RIGHT
    This program is distributed under LGPL Version 2.1 in the hope that
    it will be useful, but WITHOUT ANY WARRANTY.
}}IS_RIGHT
*/
package org.zkoss.zul;

import static org.zkoss.lang.Generics.cast;

import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.zkoss.lang.Classes;
import org.zkoss.lang.Exceptions;
import org.zkoss.xel.VariableResolver;
import org.zkoss.zk.au.AuRequest;
import org.zkoss.zk.au.out.AuInvoke;
import org.zkoss.zk.ui.Component;
import org.zkoss.zk.ui.Components;
import org.zkoss.zk.ui.Page;
import org.zkoss.zk.ui.UiException;
import org.zkoss.zk.ui.WrongValueException;
import org.zkoss.zk.ui.event.Event;
import org.zkoss.zk.ui.event.EventListener;
import org.zkoss.zk.ui.event.Events;
import org.zkoss.zk.ui.event.InputEvent;
import org.zkoss.zk.ui.event.OpenEvent;
import org.zkoss.zk.ui.event.SelectEvent;
import org.zkoss.zk.ui.ext.Blockable;
import org.zkoss.zk.ui.sys.BooleanPropertyAccess;
import org.zkoss.zk.ui.sys.PropertyAccess;
import org.zkoss.zk.ui.sys.ShadowElementsCtrl;
import org.zkoss.zk.ui.util.ComponentCloneListener;
import org.zkoss.zk.ui.util.ForEachStatus;
import org.zkoss.zk.ui.util.Template;
import org.zkoss.zul.event.ListDataEvent;
import org.zkoss.zul.event.ListDataListener;
import org.zkoss.zul.event.ZulEvents;
import org.zkoss.zul.ext.Selectable;
import org.zkoss.zul.impl.Utils;

/**
 * A combobox.
 *
 * <p>Non-XUL extension. It is used to replace XUL menulist. This class
 * is more flexible than menulist, such as {@link #setAutocomplete}
 * {@link #setAutodrop}.
 *
 * <p>Default {@link #getZclass}: z-combobox.(since 3.5.0)
 *
 * <p>Events: onOpen, onSelect, onAfterRender<br/>
 * Developers can listen to the onOpen event and initializes it
 * when {@link org.zkoss.zk.ui.event.OpenEvent#isOpen} is true, and/or
 * clean up if false.<br/>
 * onAfterRender is sent when the model's data has been rendered.(since 5.0.4)
 *
 * <p>Besides assign a list model, you could assign a renderer
 * (a {@link ComboitemRenderer} instance) to a combobox, such that
 * the combobox will use this renderer to render the data returned by
 * {@link ListModel#getElementAt}.
 * If not assigned, the default renderer, which assumes a label per
 * combo item, is used.
 * In other words, the default renderer adds a label to
 * a row by calling toString against the object returned
 * by {@link ListModel#getElementAt}. (since 3.0.2)
 *
 * <p>Note: to have better performance, onOpen is sent only if
 * a non-deferrable event listener is registered
 * (see {@link org.zkoss.zk.ui.event.Deferrable}).
 *
 * <p>Like {@link Datebox},
 * the value of a read-only comobobox ({@link #isReadonly}) can be changed
 * by dropping down the list and selecting an combo item
 * (though users cannot type anything in the input box).
 *
 * @author tomyeh
 * @see Comboitem
 */
@SuppressWarnings("serial")
public class Combobox extends Textbox {
    public static final String ICON_SCLASS = "z-icon-caret-down";

    private static final Logger log = LoggerFactory.getLogger(Combobox.class);
    private boolean _autodrop, _autocomplete = true, _btnVisible = true, _open;
    //Note: _selItem is maintained loosely, i.e., its value might not be correct
    //unless syncValueToSelection is called. So call getSelectedItem/getSelectedIndex
    //if you want the correct value
    private transient Comboitem _selItem;
    /** The last checked value for selected item.
     * If null, it means syncValueToSelection is required.
     */
    private transient String _lastCkVal;
    private ListModel<?> _model;
    /** The submodel used if _model is ListSubModel. */
    private Object[] _subModel;
    private ComboitemRenderer<?> _renderer;
    private transient ListDataListener _dataListener;
    private transient EventListener<InputEvent> _eventListener;
    /**Used to detect whether to sync Comboitem's index later. */
    private boolean _syncItemIndicesLater;
    private String _popupWidth;
    private String _emptySearchMessage;
    private boolean _instantSelect = true;
    private String _iconSclass = ICON_SCLASS;

    private static final String ATTR_ON_INIT_RENDER = "org.zkoss.zul.Combobox.onInitRender";

    static {
        addClientEvent(Combobox.class, Events.ON_OPEN, CE_IMPORTANT | CE_DUPLICATE_IGNORE);
        addClientEvent(Combobox.class, Events.ON_SELECT, CE_IMPORTANT | CE_DUPLICATE_IGNORE);
    }

    public Combobox() {
    }

    public Combobox(String value) throws WrongValueException {
        this();
        setValue(value);
    }

    protected String coerceToString(Object value) {
        final Constraint constr = getConstraint();
        final String val = super.coerceToString(value);
        if (val.length() > 0 && constr != null && constr instanceof SimpleConstraint
                && (((SimpleConstraint) constr).getFlags() & SimpleConstraint.STRICT) != 0) {
            for (Iterator<Comboitem> it = getItems().iterator(); it.hasNext();) {
                final String label = ((Comboitem) it.next()).getLabel();
                if (val.equalsIgnoreCase(label))
                    return label;
            }
        }
        return val;
    }

    //-- ListModel dependent codes --//
    /** Returns the list model associated with this combobox, or null
     * if this combobox is not associated with any list data model.
     * <p> Note: for implementation of auto-complete, the result of {@link #getItemCount()} is a subset of model.
     * So, if the model implemented {@link ListSubModel} interface, you can't use the index of model to find the comboitem by {@link #getItemAtIndex(int)}.
     * @since 3.0.2
     * @see ListSubModel#getSubModel(Object, int)
     */
    @SuppressWarnings({ "unchecked", "rawtypes" })
    public <T> ListModel<T> getModel() {
        return (ListModel) _model;
    }

    /** Sets the list model associated with this combobox.
     * If a non-null model is assigned, no matter whether it is the same as
     * the previous, it will always cause re-render.
     *
     * @param model the list model to associate, or null to dissociate
     * any previous model.
     * @exception UiException if failed to initialize with the model
     * @since 3.0.2
     */
    public void setModel(ListModel<?> model) {
        if (model != null) {
            if (!(model instanceof Selectable))
                throw new UiException(model.getClass() + " must implement " + Selectable.class);

            if (_model != model) {
                if (_model != null) {
                    _model.removeListDataListener(_dataListener);
                }
                // ZK-1702: do not clear Comboitems if using Data Binding 1
                if (_model != null
                        && !_model.getClass().getName().equals("org.zkoss.zkplus.databind.BindingListModelList")) {
                    // Bug B60-ZK-1202.zul
                    // Remove current items anyway, when changing models
                    if (!getItems().isEmpty()) {
                        getItems().clear();
                    }
                }

                _model = model;
                _subModel = null; //clean up (generated later)
                initDataListener();
                setAttribute(Attributes.BEFORE_MODEL_ITEMS_RENDERED, Boolean.TRUE);
            }

            postOnInitRender(null);
            //Since user might setModel and setRender separately or repeatedly,
            //we don't handle it right now until the event processing phase
            //such that we won't render the same set of data twice
            //--
            //For better performance, we shall load the first few row now
            //(to save a round trip)
        } else if (_model != null) {
            _model.removeListDataListener(_dataListener);
            if (_model instanceof ListSubModel)
                removeEventListener(Events.ON_CHANGING, _eventListener);
            _model = null;
            _subModel = null;
            if (!getItems().isEmpty())
                getItems().clear();
        }
    }

    /** Sets empty search message.
     * This message would be displayed, when no matching results was found.
     * Note: it's meaningless if no model case.
     *
     * @param msg
     * @since 8.5.1
     */
    public void setEmptySearchMessage(String msg) {
        if (!Objects.equals(_emptySearchMessage, msg)) {
            _emptySearchMessage = msg;
            smartUpdate("emptySearchMessage", _emptySearchMessage);
        }
    }

    /**
     * Returns the empty search message if any.
     * Default: null
     * @since 10.0.0
     */
    public String getEmptySearchMessage() {
        return _emptySearchMessage;
    }

    private int INVALIDATE_THRESHOLD = -1;

    private void initDataListener() {
        if (INVALIDATE_THRESHOLD == -1) {
            INVALIDATE_THRESHOLD = Utils.getIntAttribute(this, "org.zkoss.zul.invalidateThreshold", 10, true);
        }
        if (_dataListener == null)
            _dataListener = new ListDataListener() {
                public void onChange(ListDataEvent event) {
                    int type = event.getType();
                    if (getAttribute(Attributes.BEFORE_MODEL_ITEMS_RENDERED) != null
                            && (type == ListDataEvent.INTERVAL_ADDED || type == ListDataEvent.INTERVAL_REMOVED))
                        return;
                    final ListModel _model = getModel();
                    final int newsz = _model.getSize(), oldsz = getItemCount();
                    int min = event.getIndex0(), max = event.getIndex1(), cnt;

                    // Bug B30-1906748.zul
                    switch (type) {
                    case ListDataEvent.SELECTION_CHANGED:
                        doSelectionChanged();
                        return; //nothing changed so need to rerender
                    case ListDataEvent.MULTIPLE_CHANGED:
                        return; //nothing to do
                    case ListDataEvent.INTERVAL_ADDED:
                        cnt = newsz - oldsz;
                        if (cnt < 0)
                            throw new UiException("Adding causes a smaller list?");
                        if (cnt == 0) //no change, nothing to do here
                            return;
                        if ((oldsz <= 0 || cnt > INVALIDATE_THRESHOLD) && !isOpen())//ZK-2704: don't invalidate when the combobox is open
                            invalidate();
                        //Also better performance (outer better than remove a lot)
                        if (min < 0)
                            if (max < 0)
                                min = 0;
                            else
                                min = max - cnt + 1;
                        if (min > oldsz)
                            min = oldsz;

                        final Renderer renderer = new Renderer();
                        final Component next = min < oldsz ? getItemAtIndex(min) : null;
                        int index = min;
                        try {
                            ComboitemRenderer cirenderer = null;
                            while (--cnt >= 0) {
                                if (cirenderer == null)
                                    cirenderer = (ComboitemRenderer) getRealRenderer();
                                Comboitem item = newUnloadedItem(cirenderer);
                                insertBefore(item, next);
                                renderer.render(item, _model.getElementAt(index), index++);
                            }

                            // Fix ZK-5468: the content of the subsequence item might be changed
                            List<Comboitem> comboitems = new ArrayList<>(getItems());
                            Iterator<Comboitem> iterator = comboitems.iterator();
                            int start = 0;
                            for (int i = max + 1, j = comboitems.size(); i < j && iterator.hasNext(); start++) {
                                if (start < i) {
                                    iterator.next();
                                    continue;
                                }
                                renderer.render(iterator.next(), _model.getElementAt(i), i++);
                            }
                        } catch (Throwable ex) {
                            renderer.doCatch(ex);
                        } finally {
                            renderer.doFinally();
                        }
                        break;
                    case ListDataEvent.INTERVAL_REMOVED:
                        cnt = oldsz - newsz;
                        if (cnt < 0)
                            throw new UiException("Removal causes a larger list?");
                        if (cnt == 0) //no change, nothing to do here
                            return;
                        if (min >= 0)
                            max = min + cnt - 1;
                        else if (max < 0)
                            max = cnt - 1; //0 ~ cnt - 1
                        if (max > oldsz - 1)
                            max = oldsz - 1;

                        if ((newsz <= 0 || cnt > INVALIDATE_THRESHOLD) && !isOpen())//ZK-2704: don't invalidate when the combobox is open
                            invalidate();
                        //Also better performance (outer better than remove a lot)

                        //detach from end (due to groupfoot issue)
                        Component comp = getItemAtIndex(max);
                        while (--cnt >= 0) {
                            Component p = comp.getPreviousSibling();
                            comp.detach();
                            comp = p;
                        }

                        // Fix ZK-5468: the content of the subsequence item might be changed
                        List<Comboitem> comboitems = new ArrayList<>(getItems());
                        Iterator<Comboitem> iterator = comboitems.iterator();
                        final Renderer renderer1 = new Renderer();
                        try {
                            int start = 0;
                            // no need to plus one for "max" here for removal
                            for (int i = max, j = comboitems.size();
                                 i < j && iterator.hasNext(); start++) {
                                if (start < i) {
                                    iterator.next();
                                    continue;
                                }
                                renderer1.render(iterator.next(), _model.getElementAt(i), i++);
                            }
                        } catch (Throwable ex) {
                            renderer1.doCatch(ex);
                        } finally {
                            renderer1.doFinally();
                        }
                        break;
                    default:
                        postOnInitRender(null);
                    }
                }
            };
        if (_eventListener == null)
            _eventListener = new EventListener<InputEvent>() {
                public void onEvent(InputEvent event) throws Exception {
                    if (getModel() instanceof ListSubModel) {
                        if (!event.isChangingBySelectBack())
                            postOnInitRender(event.getValue());
                    }
                }
            };

        _model.addListDataListener(_dataListener);
        if (_model instanceof ListSubModel)
            addEventListener(Events.ON_CHANGING, _eventListener);
    }

    private void doSelectionChanged() {
        final Selectable<Object> smodel = getSelectableModel();
        if (smodel.isSelectionEmpty()) {
            if (_selItem != null)
                setSelectedItem(null);
            return;
        }

        if (_selItem != null && smodel.isSelected(getElementAt(_selItem.getIndex())))
            return; //nothing changed

        int j = 0;
        for (final Comboitem item : getItems()) {
            if (smodel.isSelected(getElementAt(j++))) {
                setSelectedItem(item);
                return;
            }
        }

        //Possible to reach here if _model is ListSubModel, because
        //getText() might be different (so is the list of comboitems).
        if (_model instanceof ListSubModel) {
            //Unfortunately, we can't really fix it because the conversion from
            //Object to String is done by ComboitemRenderer
            //So, we only handle the very simple case
            //(though it could be wrong too -- at least less obvious to users)
            final Object selObj = smodel.getSelection().iterator().next();
            if (selObj instanceof String || selObj == null) {
                setValue((String) selObj);
                postOnInitRender(null); //cause Comboitem to be generated
            }
            return;
        }

        //don't call setSelectedItem(null) either. or, it will clear _value
    }

    private Object getElementAt(int index) {
        return _subModel != null ? _subModel[index] : _model.getElementAt(index);
    }

    @SuppressWarnings("unchecked")
    private Selectable<Object> getSelectableModel() {
        return (Selectable<Object>) _model;
    }

    /** Returns the renderer to render each row, or null if the default
     * renderer is used.
     * @since 3.0.2
     */
    @SuppressWarnings({ "unchecked", "rawtypes" })
    public <T> ComboitemRenderer<T> getItemRenderer() {
        return (ComboitemRenderer) _renderer;
    }

    /** Sets the renderer which is used to render each row
     * if {@link #getModel} is not null.
     *
     * <p>Note: changing a render will not cause the combobox to re-render.
     * If you want it to re-render, you could assign the same model again
     * (i.e., setModel(getModel())), or fire an {@link ListDataEvent} event.
     *
     * @param renderer the renderer, or null to use the default.
     * @exception UiException if failed to initialize with the model
     * @since 3.0.2
     */
    public void setItemRenderer(ComboitemRenderer<?> renderer) {
        _renderer = renderer;
    }

    /** Sets the renderer by use of a class name.
     * It creates an instance automatically.
     *@since 3.0.2
     */
    @SuppressWarnings("rawtypes")
    public void setItemRenderer(String clsnm) throws ClassNotFoundException, NoSuchMethodException,
            IllegalAccessException, InstantiationException, java.lang.reflect.InvocationTargetException {
        if (clsnm != null)
            setItemRenderer((ComboitemRenderer) Classes.newInstanceByThread(clsnm));
    }

    /** Synchronizes the combobox to be consistent with the specified model.
     */
    private ListModel<?> syncModel(Object index) {
        ComboitemRenderer<?> renderer = null;
        final ListModel<?> subset = _model instanceof ListSubModel ? ((ListSubModel<?>) _model).getSubModel(index, -1)
                : _model;
        final int newsz = subset.getSize();

        if (!getItems().isEmpty())
            getItems().clear();

        for (int j = 0; j < newsz; ++j) {
            if (renderer == null)
                renderer = getRealRenderer();
            newUnloadedItem(renderer).setParent(this);
        }
        return subset;
    }

    /** Creates an new and unloaded Comboitem. */
    private Comboitem newUnloadedItem(ComboitemRenderer<?> renderer) {
        Comboitem item = null;
        if (renderer instanceof ComboitemRendererExt)
            item = ((ComboitemRendererExt) renderer).newComboitem(this);

        if (item == null) {
            item = new Comboitem();
            item.applyProperties();
        }
        return item;
    }

    /** Handles a private event, onInitRender. It is used only for
     * implementation, and you rarely need to invoke it explicitly.
     * @since 3.0.2
     */
    @SuppressWarnings("rawtypes")
    public void onInitRender(Event data) {
        //Bug #2010389
        removeAttribute(ATTR_ON_INIT_RENDER); //clear syncModel flag
        final Renderer renderer = new Renderer();
        final List<Object> subModel = _model instanceof ListSubModel ? new ArrayList<Object>() : null;
        final ListModel subset = syncModel(data.getData() != null ? data.getData() : getRawText());
        try {
            int pgsz = subset.getSize(), ofs = 0, j = 0;
            for (Comboitem item = getItems().size() <= ofs ? null : getItems().get(ofs), nxt; j < pgsz
                    && item != null; ++j, item = nxt) {
                nxt = (Comboitem) item.getNextSibling(); //store it first
                final int index = j + ofs;
                final Object value = subset.getElementAt(index);
                if (subModel != null)
                    subModel.add(value);
                renderer.render(item, value, index);
            }
            if (subModel != null)
                _subModel = subModel.toArray(new Object[subModel.size()]);
        } catch (Throwable ex) {
            renderer.doCatch(ex);
        } finally {
            renderer.doFinally();
        }
        Events.postEvent("onInitRenderLater", this, null); // notify databinding load-when.
        Events.postEvent(ZulEvents.ON_AFTER_RENDER, this, null); // notify the combobox when items have been rendered.
        removeAttribute(Attributes.BEFORE_MODEL_ITEMS_RENDERED);
    }

    private void postOnInitRender(String idx) {
        //20080724, Henri Chen: optimize to avoid postOnInitRender twice
        if (getAttribute(ATTR_ON_INIT_RENDER) == null) {
            //Bug #2010389
            setAttribute(ATTR_ON_INIT_RENDER, Boolean.TRUE); //flag syncModel
            Events.postEvent("onInitRender", this, idx);
        }
    }

    @SuppressWarnings("rawtypes")
    private static final ComboitemRenderer _defRend = new ComboitemRenderer() {
        public void render(final Comboitem item, final Object data, final int index) {
            final Combobox cb = (Combobox) item.getParent();
            final Template tm = cb.getTemplate("model");
            if (tm == null) {
                item.setLabel(Objects.toString(data));
                item.setValue(data);
            } else {
                final Component[] items = ShadowElementsCtrl
                        .filterOutShadows(tm.create(item.getParent(), item, new VariableResolver() {
                    public Object resolveVariable(String name) {
                        if ("each".equals(name)) {
                            return data;
                        } else if ("forEachStatus".equals(name)) {
                            return new ForEachStatus() {

                                public ForEachStatus getPrevious() {
                                    return null;
                                }

                                public Object getEach() {
                                    return getCurrent();
                                }

                                public int getIndex() {
                                    return index;
                                }

                                public Integer getBegin() {
                                    return 0;
                                }

                                public Integer getEnd() {
                                    return cb.getModel().getSize();
                                }

                                public Object getCurrent() {
                                    return data;
                                }

                                public boolean isFirst() {
                                    return getCount() == 1;
                                }

                                public boolean isLast() {
                                    return getIndex() + 1 == getEnd();
                                }

                                public Integer getStep() {
                                    return null;
                                }

                                public int getCount() {
                                    return getIndex() + 1;
                                }
                            };
                        } else {
                            return null;
                        }
                    }
                }, null));
                if (items.length != 1)
                    throw new UiException("The model template must have exactly one item, not " + items.length);

                final Comboitem nci = (Comboitem) items[0];
                if (nci.getValue() == null) //template might set it
                    nci.setValue(data);
                item.setAttribute(Attributes.MODEL_RENDERAS, nci);
                //indicate a new item is created to replace the existent one
                item.detach();
            }
        }
    };

    /** Returns the renderer used to render items.
     */
    @SuppressWarnings("unchecked")
    private <T> ComboitemRenderer<T> getRealRenderer() {
        return _renderer != null ? (ComboitemRenderer<T>) _renderer : _defRend;
    }

    /** Used to render comboitem if _model is specified. */
    private class Renderer implements java.io.Serializable {
        @SuppressWarnings("rawtypes")
        private final ComboitemRenderer _renderer;
        private boolean _rendered, _ctrled;

        private Renderer() {
            _renderer = getRealRenderer();
        }

        @SuppressWarnings("unchecked")
        private void render(Comboitem item, Object value, int index) throws Throwable {
            if (!_rendered && (_renderer instanceof RendererCtrl)) {
                ((RendererCtrl) _renderer).doTry();
                _ctrled = true;
            }

            try {
                try {
                    _renderer.render(item, value, index);
                } catch (AbstractMethodError ex) {
                    final Method m = _renderer.getClass().getMethod("render",
                            new Class<?>[] { Comboitem.class, Object.class });
                    m.setAccessible(true);
                    m.invoke(_renderer, new Object[] { item, value });
                }
                Object v = item.getAttribute(Attributes.MODEL_RENDERAS);
                if (v != null) //a new item is created to replace the existent one
                    item = (Comboitem) v;
            } catch (Throwable ex) {
                try {
                    item.setLabel(Exceptions.getMessage(ex));
                } catch (Throwable t) {
                    log.error("", t);
                }
                throw ex;
            }
            if (getSelectableModel().isSelected(value))
                setSelectedItem(item);
            _rendered = true;
        }

        private void doCatch(Throwable ex) {
            if (_ctrled) {
                try {
                    ((RendererCtrl) _renderer).doCatch(ex);
                } catch (Throwable t) {
                    throw UiException.Aide.wrap(t);
                }
            } else {
                throw UiException.Aide.wrap(ex);
            }
        }

        private void doFinally() {
            if (_ctrled)
                ((RendererCtrl) _renderer).doFinally();
        }
    }

    /** Returns whether to automatically drop the list if users is changing
     * this text box.
     * <p>Default: false.
     */
    public boolean isAutodrop() {
        return _autodrop;
    }

    /** Sets whether to automatically drop the list if users is changing
     * this text box.
     */
    public void setAutodrop(boolean autodrop) {
        if (_autodrop != autodrop) {
            _autodrop = autodrop;
            smartUpdate("autodrop", autodrop);
        }
    }

    /** Returns whether to automatically complete this text box
     * by matching the nearest item ({@link Comboitem}.
     * It is also known as auto-type-ahead.
     *
     * <p>Default: true (since 5.0.0).
     *
     * <p>If true, the nearest item will be searched and the text box is
     * updated automatically.
     * If false, user has to click the item or use the DOWN or UP keys to
     * select it back.
     *
     * <p>Don't confuse it with the auto-completion feature mentioned by
     * other framework. Such kind of auto-completion is supported well
     * by listening to the onChanging event.
     */
    public boolean isAutocomplete() {
        return _autocomplete;
    }

    /** Sets whether to automatically complete this text box
     * by matching the nearest item ({@link Comboitem}.
     */
    public void setAutocomplete(boolean autocomplete) {
        if (_autocomplete != autocomplete) {
            _autocomplete = autocomplete;
            smartUpdate("autocomplete", autocomplete);
        }
    }

    /** Returns whether this combobox is open.
     *
     * <p>Default: false.
     * @since 6.0.0
     */
    public boolean isOpen() {
        return _open;
    }

    /** Drops down or closes the list of combo items ({@link Comboitem}.
     * only works while visible
     * @since 3.0.1
     */
    public void setOpen(boolean open) {
        if (isVisible()) {
            if (_open != open) {
                _open = open;
                smartUpdate("open", open);
            }
        }
    }

    /** Drops down the list of combo items ({@link Comboitem}.
     * It is the same as setOpen(true).
     *
     * @since 3.0.1
     */
    public void open() {
        _open = true;
        response("open", new AuInvoke(this, "setOpen", true)); //don't use smartUpdate
    }

    /** Closes the list of combo items ({@link Comboitem} if it was
     * dropped down.
     * It is the same as setOpen(false).
     *
     * @since 3.0.1
     */
    public void close() {
        _open = false;
        response("open", new AuInvoke(this, "setOpen", false)); //don't use smartUpdate
    }

    /** Returns whether the button (on the right of the textbox) is visible.
     * <p>Default: true.
     */
    public boolean isButtonVisible() {
        return _btnVisible;
    }

    /** Sets whether the button (on the right of the textbox) is visible.
     */
    public void setButtonVisible(boolean visible) {
        if (_btnVisible != visible) {
            _btnVisible = visible;
            smartUpdate("buttonVisible", visible);
        }
    }

    /**
     * Returns true if onSelect event is sent as soon as user selects using keyboard navigation.
     * <p>Default: true
     *
     * @since 8.6.1
     */
    public boolean isInstantSelect() {
        return _instantSelect;
    }

    /**
     * Sets the instantSelect attribute. When the attribute is true, onSelect event
     * will be fired as soon as user selects using keyboard navigation.
     *
     * If the attribute is false, user needs to press Enter key to finish the selection using keyboard navigation.
     *
     * @since 8.6.1
     */
    public void setInstantSelect(boolean instantSelect) {
        if (_instantSelect != instantSelect) {
            _instantSelect = instantSelect;
            smartUpdate("instantSelect", instantSelect);
        }
    }

    /** Returns a 'live' list of all {@link Comboitem}.
     * By live we mean you can add or remove them directly with
     * the List interface.
     *
     * <p>Currently, it is the same as {@link #getChildren}. However,
     * we might add other kind of children in the future.
     */
    public List<Comboitem> getItems() {
        return cast(getChildren());
    }

    /** Returns the number of items.
     */
    public int getItemCount() {
        return getItems().size();
    }

    /** Returns the item at the specified index.
     */
    public Comboitem getItemAtIndex(int index) {
        return getItems().get(index);
    }

    /** Appends an item.
     */
    public Comboitem appendItem(String label) {
        final Comboitem item = new Comboitem(label);
        item.setParent(this);
        return item;
    }

    /**  Removes the child item in the list box at the given index.
     * @return the removed item.
     */
    public Comboitem removeItemAt(int index) {
        final Comboitem item = getItemAtIndex(index);
        removeChild(item);
        return item;
    }

    /** Returns the selected item.
     * @since 2.4.0
     */
    public Comboitem getSelectedItem() {
        syncValueToSelection();
        return _selItem;
    }

    /**  Deselects the currently selected items and selects the given item.
     * <p>Note: if the label of comboitem has the same more than one, the first
     * comboitem will be selected at client side, it is a limitation of {@link Combobox}
     * and it is different from {@link Listbox}.</p>
     * @since 3.0.2
     */
    public void setSelectedItem(Comboitem item) {
        if (item != null && item.getParent() != this)
            throw new UiException("Not a child: " + item);

        if (item != _selItem) {
            _selItem = item;
            if (item != null) {
                setValue(item.getLabel());
                smartUpdate("selectedItemUuid_", item.getUuid());
            } else {
                //Bug#2919037: don't call setRawValue(), or the error message will be cleared
                if (_value != null && !"".equals(_value)) {
                    _value = "";
                    smartUpdate("value", coerceToString(_value));
                }
            }
            _lastCkVal = getValue();
        }
    }

    /** Deselects the currently selected items and selects
     * the item with the given index.
     * <p>Note: if the label of comboitem has the same more than one, the first
     * comboitem will be selected at client side, it is a limitation of {@link Combobox}
     * and it is different from {@link Listbox}.</p>
     * @since 3.0.2
     */
    public void setSelectedIndex(int jsel) {
        if (jsel >= getItemCount())
            throw new UiException("Out of bound: " + jsel + " while size=" + getItemCount());
        if (jsel < -1)
            jsel = -1;
        setSelectedItem(jsel >= 0 ? getItemAtIndex(jsel) : null);
        //Bug#2919037: setSelectedIndex(-1) shall unselect even with constraint
    }

    /** Returns the index of the selected item, or -1 if not selected.
     * @since 3.0.1
     */
    public int getSelectedIndex() {
        syncValueToSelection();
        return _selItem != null ? _selItem.getIndex() : -1;
    }

    /**
     * @return the width of the popup of this component
     * @since 8.0.3
     */
    public String getPopupWidth() {
        return _popupWidth;
    }

    /**
     * Sets the width of the popup of this component.
     * If the input is a percentage, the popup width will be calculated by multiplying the width of this component with the percentage.
     * (e.g. if the input string is 130%, and the width of this component is 300px, the popup width will be 390px = 300px * 130%)
     * Others will be set directly.
     * @param popupWidth the width of the popup of this component
     * @since 8.0.3
     */
    public void setPopupWidth(String popupWidth) {
        if (!Objects.equals(popupWidth, _popupWidth)) {
            _popupWidth = popupWidth;
            smartUpdate("popupWidth", popupWidth);
        }
    }

    //-- super --//
    public void setMultiline(boolean multiline) {
        if (multiline)
            throw new UnsupportedOperationException("Combobox doesn't support multiline");
    }

    public void setRows(int rows) {
        if (rows != 1)
            throw new UnsupportedOperationException("Combobox doesn't support multiple rows, " + rows);
    }

    public Object getExtraCtrl() {
        return new ExtraCtrl();
    }

    /** A utility class to implement {@link #getExtraCtrl}.
     * It is used only by component developers.
     *
     * <p>If a component requires more client controls, it is suggested to
     * override {@link #getExtraCtrl} to return an instance that extends from
     * this class.
     */
    protected class ExtraCtrl extends Textbox.ExtraCtrl implements Blockable {
        public boolean shallBlock(AuRequest request) {
            // B50-3316103: special case of readonly component: do not block onChange and onSelect
            final String cmd = request.getCommand();
            if (Events.ON_OPEN.equals(cmd))
                return false;
            return isDisabled() || (isReadonly() && Events.ON_CHANGING.equals(cmd))
                    || !Components.isRealVisible(Combobox.this);
        }
    }

    private void syncSelectionToModel() {
        if (_model != null) {
            List<Object> selObjs = new ArrayList<Object>();
            if (_selItem != null)
                selObjs.add(getElementAt(_selItem.getIndex()));
            getSelectableModel().setSelection(selObjs);
        }
    }

    /**
     * Sets the iconSclass name of this Combobox.
     * @param iconSclass String
     * @since 8.6.2
     */
    public void setIconSclass(String iconSclass) {
        if (!Objects.equals(_iconSclass, iconSclass)) {
            _iconSclass = iconSclass;
            smartUpdate("iconSclass", iconSclass);
        }
    }

    /**
     * Returns the iconSclass name of this Combobox.
     * @return the iconSclass name
     * @since 8.6.2
     */
    public String getIconSclass() {
        return _iconSclass;
    }

    // super
    public String getZclass() {
        return _zclass == null ? "z-combobox" : _zclass;
    }

    protected void renderProperties(org.zkoss.zk.ui.sys.ContentRenderer renderer) throws java.io.IOException {
        super.renderProperties(renderer);

        render(renderer, "autodrop", _autodrop);
        if (!_autocomplete)
            renderer.render("autocomplete", false);
        if (!_btnVisible)
            renderer.render("buttonVisible", false);
        if (_selItem != null)
            renderer.render("selectedItemUuid_", _selItem.getUuid());
        if (_popupWidth != null)
            renderer.render("popupWidth", _popupWidth);
        if (_emptySearchMessage != null)
            renderer.render("emptySearchMessage", _emptySearchMessage);
        if (!_instantSelect)
            renderer.render("instantSelect", false);
        if (!ICON_SCLASS.equals(_iconSclass))
            renderer.render("iconSclass", _iconSclass);

        // handle open state here instead of send AuInvoke for Stateless
        if (_open)
            renderer.render("open", true);
    }

    /** Processes an AU request.
     *
     * <p>Default: in addition to what are handled by {@link Textbox#service},
     * it also handles onOpen and onSelect.
     * @since 5.0.0
     */
    public void service(org.zkoss.zk.au.AuRequest request, boolean everError) {
        final String cmd = request.getCommand();
        if (cmd.equals(Events.ON_OPEN)) {
            OpenEvent evt = OpenEvent.getOpenEvent(request);
            _open = evt.isOpen();
            Events.postEvent(evt);
        } else if (cmd.equals(Events.ON_SELECT)) {
            final Set<Comboitem> prevSelectedItems = new LinkedHashSet<Comboitem>();
            Comboitem prevSeld = (Comboitem) request.getDesktop()
                    .getComponentByUuidIfAny((String) request.getData().get("prevSeld"));
            // ZK-2089: should skip when selected item is null
            if (prevSeld != null)
                prevSelectedItems.add(prevSeld);
            SelectEvent<Comboitem, Object> evt = SelectEvent.getSelectEvent(request,
                    new SelectEvent.SelectedObjectHandler<Comboitem>() {
                        public Set<Object> getObjects(Set<Comboitem> items) {
                            if (items == null || items.isEmpty() || _model == null)
                                return null;
                            Set<Object> objs = new LinkedHashSet<Object>();
                            // ZK-5047: we cannot use index here (if it's ListSubModel case)
                            if (_model instanceof ListSubModel) {
                                for (Comboitem i : items) {
                                    objs.add(i.getValue());
                                }
                            } else {
                                for (Comboitem i : items) {
                                    objs.add(_model.getElementAt(i.getIndex()));
                                }
                            }
                            return objs;
                        }

                        public Set<Comboitem> getPreviousSelectedItems() {
                            return prevSelectedItems;
                        }

                        // in single selection, getPreviousSelectedItems() is same as getPreviousSelectedItems()
                        public Set<Comboitem> getUnselectedItems() {
                            return getPreviousSelectedItems();
                        }

                        public Set<Object> getPreviousSelectedObjects() {
                            Set<Comboitem> items = getPreviousSelectedItems();
                            if (_model == null || items.size() < 1)
                                return null;
                            else {
                                Set<Object> s = new LinkedHashSet<Object>();
                                s.add(_model.getElementAt(((Comboitem) items.iterator().next()).getIndex()));
                                return s;
                            }
                        }

                        // in single selection, getUnselectedObjects() is same as getPreviousSelectedObjects()
                        public Set<Object> getUnselectedObjects() {
                            return getPreviousSelectedObjects();
                        }
                    });
            Comboitem oldSel = _selItem;
            Set<Comboitem> selItems = evt.getSelectedItems();
            _selItem = selItems != null && !selItems.isEmpty() ? (Comboitem) selItems.iterator().next() : null;
            if (getModel() instanceof ListSubModel && _selItem == null && ((List) request.getData().get("items")).size() != 0) {
                _selItem = oldSel; // selItem might be missing when new items are created in the same AU, sync back
                if (selItems != null) {
                    selItems.add(oldSel);
                }
            }
            _lastCkVal = getValue(); //onChange is sent before onSelect

            syncSelectionToModel();
            //ZK-1987: Combobox item selection rely items label string
            String val = _lastCkVal;
            if (oldSel != null && !oldSel.equals(_selItem) && oldSel.getLabel().equals(val))
                Events.postEvent(new InputEvent(Events.ON_CHANGE, this, val, val));
            Events.postEvent(evt);
        } else if (cmd.equals(Events.ON_CHANGE)) {
            super.service(request, everError);
            // Bug ZK-1492: synchronize the input value to selection
            syncValueToSelection();
        } else
            super.service(request, everError);
    }

    //-- Component --//
    public void beforeChildAdded(Component newChild, Component refChild) {
        if (!(newChild instanceof Comboitem))
            throw new UiException("Unsupported child for Combobox: " + newChild);
        super.beforeChildAdded(newChild, refChild);
    }

    /** Childable. */
    protected boolean isChildable() {
        return true;
    }

    public void onChildAdded(Component child) {
        super.onChildAdded(child);
        _syncItemIndicesLater = true;
        smartUpdate("repos", true);
    }

    public void onChildRemoved(Component child) {
        super.onChildRemoved(child);
        _syncItemIndicesLater = true;
        if (child == _selItem) {
            // Bug B60-ZK-1202.zul
            _selItem = null;
            schedSyncValueToSelection();
        }
        smartUpdate("repos", true);
    }

    /*package*/ void syncItemIndices() { //called by Comboitem
        if (_syncItemIndicesLater) {
            _syncItemIndicesLater = false;
            int j = 0;
            for (final Comboitem item : getItems())
                item.setIndexDirectly(j++);
        }
    }

    private void syncValueToSelection() {
        final String value = getValue();
        if (!Objects.equals(_lastCkVal, value)) {
            _lastCkVal = value;
            _selItem = null;
            for (final Comboitem item : getItems()) {
                if (Objects.equals(value, item.getLabel())) {
                    _selItem = item;
                    break;
                }
            }
            syncSelectionToModel();
        }
    }

    /*package*/ void schedSyncValueToSelection() {
        _lastCkVal = null;
    }

    /*package*/ Comboitem getSelectedItemDirectly() {
        return _selItem;
    }

    //Cloneable//
    @SuppressWarnings("rawtypes")
    public Object clone() {
        final Combobox clone = (Combobox) super.clone();
        clone._selItem = null;
        clone.schedSyncValueToSelection();
        if (clone._model != null) {
            if (clone._model instanceof ComponentCloneListener) {
                final ListModel model = (ListModel) ((ComponentCloneListener) clone._model).willClone(clone);
                if (model != null)
                    clone._model = model;
            }
            clone._dataListener = null;
            clone._eventListener = null;
            clone.initDataListener();
        }
        return clone;
    }

    //    Serializable//
    private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException {
        s.defaultReadObject();

        schedSyncValueToSelection();
        if (_model != null) {
            initDataListener();

            // Map#Entry cannot be serialized, we have to restore them
            if (_model instanceof ListModelMap) {
                for (final Comboitem item : getItems()) {
                    item.setValue(getElementAt(item.getIndex()));
                }
            }
        }
    }

    public void sessionWillPassivate(Page page) {
        super.sessionWillPassivate(page);
        willPassivate(_model);
        willPassivate(_renderer);
    }

    public void sessionDidActivate(Page page) {
        super.sessionDidActivate(page);
        didActivate(_model);
        didActivate(_renderer);
    }

    //--ComponentCtrl--//
    private static HashMap<String, PropertyAccess> _properties = new HashMap<String, PropertyAccess>(3);

    static {
        _properties.put("buttonVisible", new BooleanPropertyAccess() {
            public void setValue(Component cmp, Boolean value) {
                ((Combobox) cmp).setButtonVisible(value);
            }

            public Boolean getValue(Component cmp) {
                return ((Combobox) cmp).isButtonVisible();
            }
        });
        _properties.put("autocomplete", new BooleanPropertyAccess() {
            public void setValue(Component cmp, Boolean value) {
                ((Combobox) cmp).setAutocomplete(value);
            }

            public Boolean getValue(Component cmp) {
                return ((Combobox) cmp).isAutocomplete();
            }
        });
        _properties.put("autodrop", new BooleanPropertyAccess() {
            public void setValue(Component cmp, Boolean value) {
                ((Combobox) cmp).setAutodrop(value);
            }

            public Boolean getValue(Component cmp) {
                return ((Combobox) cmp).isAutodrop();
            }
        });
        _properties.put("instantSelect", new BooleanPropertyAccess() {
            public void setValue(Component cmp, Boolean value) {
                ((Combobox) cmp).setInstantSelect(value);
            }

            public Boolean getValue(Component cmp) {
                return ((Combobox) cmp).isInstantSelect();
            }
        });
    }

    public PropertyAccess getPropertyAccess(String prop) {
        PropertyAccess pa = _properties.get(prop);
        if (pa != null)
            return pa;
        return super.getPropertyAccess(prop);
    }

    public void onPageAttached(Page newpage, Page oldpage) {
        super.onPageAttached(newpage, oldpage);
        if (_model != null) {
            postOnInitRender(null);
            if (_dataListener != null) {
                _model.removeListDataListener(_dataListener);
                _model.addListDataListener(_dataListener);
            }
        }
    }

    public void onPageDetached(Page page) {
        super.onPageDetached(page);
        if (_model != null && _dataListener != null)
            _model.removeListDataListener(_dataListener);
    }
}