Repository: bokeh/bokeh

Issue:
Please replace MultiSelect widget with something more compact, effective and nicer looking
ORIGINALLY POSTED AS PANEL FEATURE REQUEST AT https://github.com/holoviz/panel/issues/874

#### My Pain

I know of experience that many of my dashboard have multiple multi-selections. 

The MultiSelect widget of panel is not compact so it takes up a lot of space.

Furthermore I find the MultiSelect not very nicely looking. I would like my dashboards to look appealing and fresh.

Futhermore I think navigating and selecting in the MultiSelect widget is slow as soon as you have to start scrolling in the MultiSelect

![image](https://user-images.githubusercontent.com/42288570/70967814-c2e29080-2097-11ea-97cd-e41d3b9e1bc7.png)

#### Solution

Implement compact, efficient and nicer looking MultiSelect. 

It should work as a dropdown with multiselect.

#### Additional Context

You can get inspiration from Dash, Streamlit and Tableau that all have a much more compact and modern looking widget.

![image](https://user-images.githubusercontent.com/42288570/70967921-240a6400-2098-11ea-9d99-cefe44ed27dc.png)

![image](https://user-images.githubusercontent.com/42288570/70967964-3d131500-2098-11ea-8991-3ff2f1c29fc8.png)

FYI. Tableau has both a more compact Dropdown and something similar to the MultiSelect.

Here it's used an that's where I have my evaluation from. You can find it in the Gallery at awesome-panel.org.

![kickstarter_dashboard](https://user-images.githubusercontent.com/42288570/70968057-72b7fe00-2098-11ea-8680-73de1f2bf843.gif)


Discussion:
@MarcSkovMadsen it would be more helpful if you point to a specific widget or widgets that you would like to be considered, and ideally, would be something you wish to collaborate on implementing within Bokeh. 
[Choices.js](https://github.com/jshjohnson/Choices) seems like the only option without a jQuery dependency.
I've now opened a PR with the widget [on Panel](https://github.com/holoviz/panel/pull/910). I'd be quite happy to move the widget to Bokeh though.
I think it would be a good addition to Bokeh

Resolving patch:
diff --git a/bokeh/models/widgets/inputs.py b/bokeh/models/widgets/inputs.py
--- a/bokeh/models/widgets/inputs.py
+++ b/bokeh/models/widgets/inputs.py
@@ -47,6 +47,7 @@
     'DatePicker',
     'FileInput',
     'InputWidget',
+    'MultiChoice',
     'MultiSelect',
     'PasswordInput',
     'Select',
@@ -240,6 +241,42 @@ class MultiSelect(InputWidget):
     """)
 
 
+class MultiChoice(InputWidget):
+    ''' MultiChoice widget.
+
+    '''
+
+    options = List(Either(String, Tuple(String, String)), help="""
+    Available selection options. Options may be provided either as a list of
+    possible string values, or as a list of tuples, each of the form
+    ``(value, label)``. In the latter case, the visible widget text for each
+    value will be corresponding given label.
+    """)
+
+    value = List(String, help="""
+    Initial or selected values.
+    """)
+
+    delete_button = Bool(default=True, help="""
+    Whether to add a button to remove a selected option.
+    """)
+
+    max_items = Int(default=None, help="""
+    The maximum number of items that can be selected.
+    """)
+
+    option_limit = Int(default=None, help="""
+    The number of choices that will be rendered in the dropdown.
+    """)
+
+    placeholder = String(default=None, help="""
+    A string that is displayed if not item is added.
+    """)
+
+    solid = Bool(default=True, help="""
+    Specify whether the choices should be solidly filled.""")
+
+
 class DatePicker(InputWidget):
     ''' Calendar-based date picker widget.
 


Test patch:
diff --git a/bokehjs/test/codebase/size.ts b/bokehjs/test/codebase/size.ts
--- a/bokehjs/test/codebase/size.ts
+++ b/bokehjs/test/codebase/size.ts
@@ -8,13 +8,13 @@ const build_dir = path.normalize(`${__dirname}/../..`) // build/test/codebase ->
 const LIMITS: {[key: string]: number} = {
   // es2017
   "js/bokeh.min.js":          670,
-  "js/bokeh-widgets.min.js":  160,
+  "js/bokeh-widgets.min.js":  242,
   "js/bokeh-tables.min.js":   270,
   "js/bokeh-api.min.js":      90,
   "js/bokeh-gl.min.js":       70,
   // legacy (es5)
   "js/legacy/bokeh.min.js":         800,
-  "js/legacy/bokeh-widgets.min.js": 165,
+  "js/legacy/bokeh-widgets.min.js": 252,
   "js/legacy/bokeh-tables.min.js":  270,
   "js/legacy/bokeh-api.min.js":      90,
   "js/legacy/bokeh-gl.min.js":       70,
diff --git a/tests/integration/widgets/test_multi_choice.py b/tests/integration/widgets/test_multi_choice.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/widgets/test_multi_choice.py
@@ -0,0 +1,129 @@
+#-----------------------------------------------------------------------------
+# Copyright (c) 2012 - 2017, Anaconda, Inc. All rights reserved.
+#
+# Powered by the Bokeh Development Team.
+#
+# The full license is in the file LICENSE.txt, distributed with this software.
+#-----------------------------------------------------------------------------
+
+#-----------------------------------------------------------------------------
+# Boilerplate
+#-----------------------------------------------------------------------------
+import pytest ; pytest
+
+#-----------------------------------------------------------------------------
+# Imports
+#-----------------------------------------------------------------------------
+
+# External imports
+from flaky import flaky
+from selenium.webdriver.common.keys import Keys
+
+# Bokeh imports
+from bokeh._testing.util.selenium import RECORD
+from bokeh.layouts import row
+from bokeh.models import (
+    Circle,
+    ColumnDataSource,
+    CustomAction,
+    CustomJS,
+    MultiChoice,
+    Plot,
+    Range1d,
+)
+
+#-----------------------------------------------------------------------------
+# Tests
+#-----------------------------------------------------------------------------
+
+pytest_plugins = (
+    "bokeh._testing.plugins.project",
+)
+
+def modify_doc(doc):
+    source = ColumnDataSource(dict(x=[1, 2], y=[1, 1], val=["a", "b"]))
+    plot = Plot(plot_height=400, plot_width=400, x_range=Range1d(0, 1), y_range=Range1d(0, 1), min_border=0)
+    plot.add_glyph(source, Circle(x='x', y='y', size=20))
+    plot.add_tools(CustomAction(callback=CustomJS(args=dict(s=source), code=RECORD("data", "s.data"))))
+    input_box = MultiChoice(css_classes=["foo"])
+    input_box.title = "title"
+    input_box.options = ["100001", "12344556", "12344557", "3194567289", "209374209374"]
+    input_box.value = ["12344556", "12344557"]
+    def cb(attr, old, new):
+        source.data['val'] = [old, new]
+    input_box.on_change('value', cb)
+    doc.add_root(row(input_box, plot))
+
+@pytest.mark.selenium
+class Test_MultiChoice(object):
+
+    def test_displays_multi_choice(self, bokeh_model_page) -> None:
+        text_input = MultiChoice(css_classes=["foo"], options = ["100001", "12344556", "12344557", "3194567289", "209374209374"])
+
+        page = bokeh_model_page(text_input)
+
+        el = page.driver.find_element_by_css_selector('.foo input')
+        assert el.get_attribute('type') == "text"
+
+        assert page.has_no_console_errors()
+
+    def test_displays_title(self, bokeh_model_page) -> None:
+        text_input = MultiChoice(title="title", css_classes=["foo"], options = ["100001", "12344556", "12344557", "3194567289", "209374209374"])
+
+        page = bokeh_model_page(text_input)
+
+        el = page.driver.find_element_by_css_selector('.foo label')
+        assert el.text == "title"
+        el = page.driver.find_element_by_css_selector('.foo input')
+        assert el.get_attribute('placeholder') == ""
+        assert el.get_attribute('type') == "text"
+
+        assert page.has_no_console_errors()
+
+    def test_displays_menu(self, bokeh_model_page) -> None:
+        text_input = MultiChoice(title="title", css_classes=["foo"], options = ["100001", "12344556", "12344557", "3194567289", "209374209374"])
+
+        page = bokeh_model_page(text_input)
+
+        el = page.driver.find_element_by_css_selector('.foo .choices__list--dropdown')
+        assert 'is-active' not in el.get_attribute('class')
+
+        # double click to highlight and overwrite old text
+        inp = page.driver.find_element_by_css_selector('.foo input')
+        inp.click()
+        assert 'is-active' in el.get_attribute('class')
+
+        inp.send_keys(Keys.ENTER)
+
+        selected = page.driver.find_element_by_css_selector('.foo .choices__list--multiple')
+        items = selected.find_elements_by_css_selector("div")
+        assert len(items) == 1
+
+        item = page.driver.find_element_by_css_selector('.foo .choices__list--multiple div.choices__item')
+        assert '100001' == item.get_attribute('data-value')
+
+        delete_button = page.driver.find_element_by_css_selector('.foo .choices__item button')
+        assert "Remove item: '100001'" == delete_button.get_attribute('aria-label')
+
+        delete_button.click()
+
+        selected = page.driver.find_element_by_css_selector('.foo .choices__list--multiple')
+        items = selected.find_elements_by_css_selector("div")
+        assert len(items) == 0
+
+        assert page.has_no_console_errors()
+
+    @flaky(max_runs=10)
+    def test_server_on_change_round_trip_on_enter(self, bokeh_server_page) -> None:
+        page = bokeh_server_page(modify_doc)
+
+        inp = page.driver.find_element_by_css_selector('.foo input')
+        inp.click()
+
+        inp.send_keys(Keys.ENTER)
+
+        button = page.driver.find_element_by_class_name("bk-toolbar-button-custom-action")
+        button.click()
+
+        results = page.results
+        assert results['data']['val'] == [['12344556', '12344557'], ['12344556', '12344557', '100001']]