Skip to content

Commit

Permalink
Support size argument on Select widget (#1546)
Browse files Browse the repository at this point in the history
* Support size argument on Select widget

* Fixed flake

* Hide arrow

* Factor out SingleSelectBase

* Declare base class as abstract
  • Loading branch information
philippjfr committed Sep 17, 2020
1 parent 06489aa commit c759b8f
Show file tree
Hide file tree
Showing 7 changed files with 208 additions and 21 deletions.
21 changes: 20 additions & 1 deletion examples/reference/widgets/Select.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"The ``Select`` widget allows selecting a ``value`` from a list or dictionary of ``options`` by selecting it from a dropdown menu. It falls into the broad category of single-value, option-selection widgets that provide a compatible API and include the [``RadioBoxGroup``](RadioBoxGroup.ipynb), [``AutocompleteInput``](AutocompleteInput.ipynb) and [``DiscreteSlider``](DiscreteSlider.ipynb) widgets.\n",
"The ``Select`` widget allows selecting a ``value`` from a list or dictionary of ``options`` by selecting it from a dropdown menu or selection area. It falls into the broad category of single-value, option-selection widgets that provide a compatible API and include the [``RadioBoxGroup``](RadioBoxGroup.ipynb), [``AutocompleteInput``](AutocompleteInput.ipynb) and [``DiscreteSlider``](DiscreteSlider.ipynb) widgets.\n",
"\n",
"For more information about listening to widget events and laying out widgets refer to the [widgets user guide](../../user_guide/Widgets.ipynb). Alternatively you can learn how to build GUIs by declaring parameters independently of any specific widgets in the [param user guide](../../user_guide/Param.ipynb). To express interactivity entirely using Javascript without the need for a Python server take a look at the [links user guide](../../user_guide/Param.ipynb).\n",
"\n",
Expand All @@ -25,6 +25,7 @@
"##### Core\n",
"\n",
"* **``options``** (list or dict): A list or dictionary of options to select from\n",
"* **``size``** (int): Declares how many options are displayed at the same time. If set to 1 displays options as dropdown otherwise displays scrollable area.\n",
"* **``value``** (object): The current value; must be one of the option values\n",
"\n",
"##### Display\n",
Expand Down Expand Up @@ -62,6 +63,24 @@
"select.value"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Instead of a dropdown menu we can also select one option from a list by rendering multiple options at once using the `size` parameter:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"select_area = pn.widgets.Select(name='Select', options=['Biology', 'Chemistry', 'Physics'], size=3)\n",
"\n",
"select_area"
]
},
{
"cell_type": "markdown",
"metadata": {},
Expand Down
4 changes: 3 additions & 1 deletion panel/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@
from .location import Location # noqa
from .markup import JSON, HTML # noqa
from .state import State # noqa
from .widgets import Audio, FileDownload, Player, Progress, Video, VideoStream # noqa
from .widgets import ( # noqa
Audio, FileDownload, Player, Progress, SingleSelect, Video, VideoStream
)
1 change: 1 addition & 0 deletions panel/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export {MathJax} from "./mathjax"
export {Player} from "./player"
export {PlotlyPlot} from "./plotly"
export {Progress} from "./progress"
export {SingleSelect} from "./singleselect"
export {State} from "./state"
export {VegaPlot} from "./vega"
export {Video} from "./video"
Expand Down
115 changes: 115 additions & 0 deletions panel/models/singleselect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {select, option} from "@bokehjs/core/dom"
import {isString} from "@bokehjs/core/util/types"
import * as p from "@bokehjs/core/properties"

import {InputWidget, InputWidgetView} from "@bokehjs/models/widgets/input_widget"
import {bk_input} from "@bokehjs/styles/widgets/inputs"

export class SingleSelectView extends InputWidgetView {
model: SingleSelect

protected select_el: HTMLSelectElement

connect_signals(): void {
super.connect_signals()
this.connect(this.model.properties.value.change, () => this.render_selection())
this.connect(this.model.properties.options.change, () => this.render())
this.connect(this.model.properties.name.change, () => this.render())
this.connect(this.model.properties.title.change, () => this.render())
this.connect(this.model.properties.size.change, () => this.render())
this.connect(this.model.properties.disabled.change, () => this.render())
}

render(): void {
super.render()

const options = this.model.options.map((opt) => {
let value, _label
if (isString(opt))
value = _label = opt
else
[value, _label] = opt

return option({value}, _label)
})

this.select_el = select({
multiple: false,
class: bk_input,
name: this.model.name,
disabled: this.model.disabled,
}, options)
this.select_el.style.backgroundImage = 'none';

this.select_el.addEventListener("change", () => this.change_input())
this.group_el.appendChild(this.select_el)

this.render_selection()
}

render_selection(): void {
const selected = this.model.value

for (const el of this.el.querySelectorAll('option'))
if (el.value === selected)
el.selected = true

// Note that some browser implementations might not reduce
// the number of visible options for size <= 3.
this.select_el.size = this.model.size
}

change_input(): void {
const is_focused = this.el.querySelector('select:focus') != null

let value = null
for (const el of this.el.querySelectorAll('option')) {
if (el.selected) {
value = el.value
break
}
}

this.model.value = value
super.change_input()
// Restore focus back to the <select> afterwards,
// so that even if python on_change callback is invoked,
// focus remains on <select> and one can seamlessly scroll
// up/down.
if (is_focused)
this.select_el.focus()
}
}

export namespace SingleSelect {
export type Attrs = p.AttrsOf<Props>

export type Props = InputWidget.Props & {
value: p.Property<string|null>
options: p.Property<(string | [string, string])[]>
size: p.Property<number>
}
}

export interface SingleSelect extends SingleSelect.Attrs {}

export class SingleSelect extends InputWidget {
properties: SingleSelect.Props
__view_type__: SingleSelectView

constructor(attrs?: Partial<SingleSelect.Attrs>) {
super(attrs)
}

static __module__ = "panel.models.widgets"

static init_SingleSelect(): void {
this.prototype.default_view = SingleSelectView

this.define<SingleSelect.Props>({
value: [ p.String, "" ],
options: [ p.Array, [] ],
size: [ p.Number, 4 ], // 4 is the HTML default
})
}
}
26 changes: 25 additions & 1 deletion panel/models/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
from __future__ import absolute_import, division, unicode_literals

from bokeh.core.enums import ButtonType
from bokeh.core.properties import Int, Float, Override, Enum, Any, Bool, Dict, String

from bokeh.core.properties import (
Int, Float, Override, Enum, Any, Bool, Dict, String, List, Either, Tuple
)
from bokeh.models.layouts import HTMLBox
from bokeh.models.widgets import InputWidget, Widget

Expand Down Expand Up @@ -38,6 +41,27 @@ class Player(Widget):
height = Override(default=250)


class SingleSelect(InputWidget):
''' Single-select 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 = String(help="Initial or selected value.")

size = Int(default=4, help="""
The number of visible options in the dropdown list. (This uses the
``select`` HTML element's ``size`` attribute. Some browsers might not
show less than 3 options.)
""")


class Audio(HTMLBox):

loop = Bool(False, help="""Whether the audio should loop""")
Expand Down
4 changes: 2 additions & 2 deletions panel/widgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@
)
from .misc import Audio, FileDownload, Progress, VideoStream # noqa
from .player import DiscretePlayer, Player # noqa
from .slider import (# noqa
from .slider import ( # noqa
DateSlider, DateRangeSlider, DiscreteSlider, FloatSlider,
IntSlider, IntRangeSlider, RangeSlider
)
from .select import (# noqa
from .select import ( # noqa
AutocompleteInput, CheckBoxGroup, CheckButtonGroup, CrossSelector,
MultiChoice, MultiSelect, RadioButtonGroup, RadioBoxGroup, Select,
ToggleGroup
Expand Down
58 changes: 42 additions & 16 deletions panel/widgets/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
AutocompleteInput as _BkAutocompleteInput, CheckboxGroup as _BkCheckboxGroup,
CheckboxButtonGroup as _BkCheckboxButtonGroup, MultiSelect as _BkMultiSelect,
RadioButtonGroup as _BkRadioButtonGroup, RadioGroup as _BkRadioBoxGroup,
Select as _BkSelect, MultiChoice as _BkMultiChoice)
Select as _BkSelect, MultiChoice as _BkMultiChoice
)

from ..layout import Column, VSpacer
from ..models import SingleSelect as _BkSingleSelect
from ..util import as_unicode, isIn, indexOf
from .base import Widget, CompositeWidget
from .button import _ButtonBase, Button
Expand Down Expand Up @@ -45,22 +47,23 @@ def _items(self):
return OrderedDict(zip(self.labels, self.values))


class Select(SelectBase):

class SingleSelectBase(SelectBase):

value = param.Parameter(default=None)

_supports_embed = True

_widget_type = _BkSelect
__abstract = True

def __init__(self, **params):
super(Select, self).__init__(**params)
super(SingleSelectBase, self).__init__(**params)
values = self.values
if self.value is None and None not in values and values:
self.value = values[0]

def _process_param_change(self, msg):
msg = super(Select, self)._process_param_change(msg)
msg = super(SingleSelectBase, self)._process_param_change(msg)
labels, values = self.labels, self.values
unique = len(set(self.unicode_values)) == len(labels)
if 'value' in msg:
Expand Down Expand Up @@ -95,7 +98,7 @@ def unicode_values(self):
return [as_unicode(v) for v in self.values]

def _process_property_change(self, msg):
msg = super(Select, self)._process_property_change(msg)
msg = super(SingleSelectBase, self)._process_property_change(msg)
if 'value' in msg:
if not self.values:
pass
Expand All @@ -121,14 +124,37 @@ def _get_embed_state(self, root, values=None, max_opts=3):
lambda x: x.value, 'value', 'cb_obj.value')


class _MultiSelectBase(Select):
class Select(SingleSelectBase):

size = param.Integer(default=1, bounds=(1, None), doc="""
Declares how many options are displayed at the same time.
If set to 1 displays options as dropdown otherwise displays
scrollable area.""")

@property
def _widget_type(self):
return _BkSelect if self.size == 1 else _BkSingleSelect

def __init__(self, **params):
super(Select, self).__init__(**params)
if self.size == 1:
self.param.size.constant = True

def _process_param_change(self, msg):
msg = super(Select, self)._process_param_change(msg)
if msg.get('size') == 1:
msg.pop('size')
return msg


class _MultiSelectBase(SingleSelectBase):

value = param.List(default=[])

_supports_embed = False

def _process_param_change(self, msg):
msg = super(Select, self)._process_param_change(msg)
msg = super(SingleSelectBase, self)._process_param_change(msg)
labels, values = self.labels, self.values
if 'value' in msg:
msg['value'] = [labels[indexOf(v, values)] for v in msg['value']
Expand All @@ -141,7 +167,7 @@ def _process_param_change(self, msg):
return msg

def _process_property_change(self, msg):
msg = super(Select, self)._process_property_change(msg)
msg = super(SingleSelectBase, self)._process_property_change(msg)
if 'value' in msg:
labels = self.labels
msg['value'] = [self._items[v] for v in msg['value']
Expand Down Expand Up @@ -196,7 +222,7 @@ class AutocompleteInput(Widget):
_rename = {'name': 'title', 'options': 'completions'}


class _RadioGroupBase(Select):
class _RadioGroupBase(SingleSelectBase):

_supports_embed = False

Expand All @@ -209,7 +235,7 @@ class _RadioGroupBase(Select):
__abstract = True

def _process_param_change(self, msg):
msg = super(Select, self)._process_param_change(msg)
msg = super(SingleSelectBase, self)._process_param_change(msg)
values = self.values
if 'active' in msg:
value = msg['active']
Expand All @@ -228,7 +254,7 @@ def _process_param_change(self, msg):
return msg

def _process_property_change(self, msg):
msg = super(Select, self)._process_property_change(msg)
msg = super(SingleSelectBase, self)._process_property_change(msg)
if 'value' in msg:
index = msg['value']
if index is None:
Expand Down Expand Up @@ -269,7 +295,7 @@ class RadioBoxGroup(_RadioGroupBase):



class _CheckGroupBase(Select):
class _CheckGroupBase(SingleSelectBase):

value = param.List(default=[])

Expand All @@ -284,7 +310,7 @@ class _CheckGroupBase(Select):
__abstract = True

def _process_param_change(self, msg):
msg = super(Select, self)._process_param_change(msg)
msg = super(SingleSelectBase, self)._process_param_change(msg)
values = self.values
if 'active' in msg:
msg['active'] = [indexOf(v, values) for v in msg['active']
Expand All @@ -297,7 +323,7 @@ def _process_param_change(self, msg):
return msg

def _process_property_change(self, msg):
msg = super(Select, self)._process_property_change(msg)
msg = super(SingleSelectBase, self)._process_property_change(msg)
if 'value' in msg:
values = self.values
msg['value'] = [values[a] for a in msg['value']]
Expand All @@ -320,7 +346,7 @@ class CheckBoxGroup(_CheckGroupBase):



class ToggleGroup(Select):
class ToggleGroup(SingleSelectBase):
"""This class is a factory of ToggleGroup widgets.
A ToggleGroup is a group of widgets which can be switched 'on' or 'off'.
Expand Down

0 comments on commit c759b8f

Please sign in to comment.