__init__.py
1 # SPDX-License-Identifier: MIT 2 # 3 # Copyright (c) 2021 The Anvil Extras project team members listed at 4 # https://github.com/anvilistas/anvil-extras/graphs/contributors 5 # 6 # This software is published at https://github.com/anvilistas/anvil-extras 7 8 import anvil.js as _js 9 from anvil import HtmlPanel as _HtmlPanel 10 from anvil.js.window import document as _document 11 12 from ..popover import pop, popover 13 from ..utils._component_helpers import _css_length, _html_injector, _spacing_property 14 from ._anvil_designer import MultiSelectDropDownTemplate 15 from .DropDown import DropDown 16 from .Option import Option 17 18 __version__ = "3.1.0" 19 20 _css = """ 21 .anvil-role-ae-ms-btn > button { 22 display: flex; 23 align-items: center; 24 justify-content: space-between; 25 width: 100%; 26 } 27 28 .anvil-role-ae-ms-btn > button > span { 29 text-overflow: ellipsis; 30 white-space: nowrap !important; 31 overflow: hidden; 32 } 33 34 /* dropdown */ 35 .ae-ms-dd { 36 display: flex; 37 flex-direction: column; 38 height: 100%; 39 } 40 41 .ae-ms-select-all button > span { 42 text-wrap: nowrap !important; 43 } 44 45 .ae-ms-options { 46 min-height: 0; 47 } 48 49 .ae-ms-options:focus-visible { 50 outline: none; 51 } 52 .ae-ms-options > div { 53 height: 100% 54 } 55 .ae-ms-options ul { 56 display: flex; 57 flex-direction: column; 58 height: 100%; 59 overflow: scroll; 60 } 61 62 .ae-ms-options a[data-disabled] { 63 opacity: 0.5; 64 cursor: not-allowed; 65 pointer-events: none; 66 } 67 .ae-ms-options a[data-disabled] * { 68 pointer-events: none; 69 } 70 71 .ae-ms-options div[data-divider] { 72 height: 1px; 73 margin: 9px 0; 74 overflow: hidden; 75 background-color: #e5e5e5; 76 } 77 78 .ae-ms-options .anvil-panel-section-gutter, 79 .ae-ms-options .flow-panel-gutter, 80 .ae-ms-options .anvil-flow-panel-gutter, 81 .ae-ms-options .anvil-panel-col, 82 .ae-ms-options .flow-panel-item, 83 .ae-ms-options .anvil-flow-panel-item { 84 margin: 0 !important; 85 } 86 .ae-ms-options .anvil-panel-col, 87 .ae-ms-options .flow-panel-item, 88 .ae-ms-options .anvil-flow-panel-item { 89 padding: 0 !important; 90 } 91 .ae-ms-options .flow-panel-gutter, 92 .ae-ms-options .anvil-flow-panel-gutter { 93 gap: 8px; 94 } 95 96 .ae-ms-options a.anvil-role-ae-ms-option { 97 color: var(--ae-ms-option-text, #333333); 98 padding: 2px 0; 99 } 100 101 .ae-ms-options a.anvil-role-ae-ms-option:hover:not(.anvil-role-ae-ms-option-active) { 102 background-color: var(--ae-ms-option-bg-hover, #e8e8e8); 103 } 104 105 .ae-ms-options a.anvil-role-ae-ms-option-active { 106 background-color: var(--ae-ms-option-bg-active, #337ab7); 107 color: var(--ae-ms-option-text-active, #fff); 108 } 109 110 .anvil-role-ae-ms-option-label span { 111 white-space: nowrap !important; 112 padding: 0 !important; 113 } 114 115 .anvil-role-ae-ms-option-subtext span { 116 white-space: nowrap !important; 117 font-size: 80%; 118 padding: 0 !important; 119 color: var(--ae-ms-option-subtext, #777); 120 } 121 .anvil-role-ae-ms-option-active .anvil-role-ae-ms-option-subtext span { 122 color: var(--ae-ms-option-subtext-active, rgba(255,255,255,.5)); 123 } 124 125 """ 126 _html_injector.css(_css) 127 128 # TODO 129 # - [x] add support for dividers 130 # - [x] add support for icons 131 # - [x] add support for subtext 132 # - [x] add support for title 133 # - [x] add support for disabled 134 # - [x] make the dropwdown conatiner a compnent in it's own right - might even be a custom component 135 # - [x] add support for select all buttons 136 # - [x] we probably need to wrap the lp in a container and add the button to that 137 # - [x] only works in multiple mode 138 # - [x] add arrow key support 139 # - [x] active role 140 # - [x] when filtering is enabled - ensure the arrow keys work 141 # - needs a different approach - can't just do the focus thing 142 # - [x] add tab key support 143 # - [x] Consider whether we need the underlying select and options (probably not) 144 # - No 145 # - [x] search box support 146 # - [x] focus the search box when the search box is added to the page 147 # - [x] ensure visible works as expected - i.e. the popover should be hidden when we're not visible 148 # - [x] support single select dropdown 149 # - when an option is selected all other options are hidden 150 # - This might be why it's useful to use the underlying option component, because then the browser is responsible for this logic 151 # - [x] support the various width options 152 # - [ ] fit needs to also change the width of the dd to the max 153 # - [x] support the selected text option 154 # - [x] support the placeholder text 155 # - [x] styling and roles 156 # - [ ] go through each property and make sure it's working as expected 157 # - [ ] test multi select inside a popover 158 # - [ ] stop the focus within when enable fitler is false and we hit an arrow key 159 160 _defaults = { 161 "align": "left", 162 "placeholder": "None Selected", 163 "enable_filtering": False, 164 "multiple": True, 165 "enabled": True, 166 "items": None, 167 "spacing_below": "small", 168 "spacing_above": "small", 169 "enable_select_all": False, 170 "width": "", 171 "visible": True, 172 } 173 174 175 def _props_property(prop, setter): 176 def getprop(self): 177 return self._props[prop] 178 179 def setprop(self, val): 180 self._props[prop] = val 181 setter(self, val) 182 183 return property(getprop, setprop, None, prop) 184 185 186 class MultiSelectDropDown(MultiSelectDropDownTemplate): 187 def __init__(self, **properties): 188 self._init = False 189 self._dom_node = _js.get_dom_node(self) 190 self._invalid = [] 191 self._options = [] 192 self._total = 0 193 self._props = props = _defaults | properties 194 props["items"] = props["items"] or [] 195 196 self._dd_width = 0 197 self._dd = DropDown() 198 self._dd.add_event_handler("change", self._change) 199 popover( 200 self._select_btn, 201 self._dd, 202 placement="bottom-start", 203 arrow=False, 204 delay=0, 205 animation=False, 206 trigger="manual", 207 max_width="fit-content", 208 ) 209 210 selected = props.pop("selected", ()) 211 212 self.init_components(**props) 213 self.set_event_handler("x-popover-init", self._mk_popover) 214 self.set_event_handler("x-popover-destroy", self._mk_popover) 215 self._dd.set_event_handler( 216 "x-popover-show", lambda **e: self.raise_event("opened") 217 ) 218 self._dd.set_event_handler( 219 "x-popover-hide", lambda **e: self.raise_event("closed") 220 ) 221 222 self._init = True 223 self.selected = selected 224 225 def format_selected_text(self, count, total): 226 if count > 3: 227 return f"{count} items selected" 228 return ", ".join(opt.title or opt.key for opt in self._options if opt.selected) 229 230 ##### PROPERTIES ##### 231 @property 232 def width(self): 233 return self._props.get("width") 234 235 @width.setter 236 def width(self, val): 237 self._props["width"] = val 238 self._dd._dom_node.style.minWidth = "" 239 240 if val == "auto": 241 self._select_btn.width = self._dd_width 242 elif val == "fit": 243 self._select_btn.width = "fit-content" 244 self._dd._dom_node.style.minWidth = "" 245 elif not val: 246 self._select_btn.width = 220 247 self._dd._dom_node.style.minWidth = "192px" 248 else: 249 self._select_btn.width = val 250 self._dd._dom_node.style.minWidth = _css_length(val) 251 # We might want to change this 252 # but if we do we need the btn to be on the screen to calculate the width 253 # and we'd probably need to have a resize observer to change the width of the _dd element 254 255 @property 256 def align(self): 257 return self._select_btn.align 258 259 @align.setter 260 def align(self, val): 261 self._select_btn.align = val 262 263 @property 264 def items(self): 265 return self._props["items"] 266 267 @items.setter 268 def items(self, value): 269 self._props["items"] = value 270 self._close() 271 selected = self.selected + self._invalid 272 273 options = Option.from_items(value) 274 275 self._dd.options = self._options = options 276 self._calc_dd_width() 277 self._total = sum(1 for opt in options if not opt.is_divider) 278 self.selected = selected 279 if self._init: 280 self.width = self.width 281 282 @property 283 def selected_keys(self): 284 return [opt.key for opt in self._options if opt.selected] 285 286 @property 287 def selected(self): 288 return [opt.value for opt in self._options if opt.selected] 289 290 @selected.setter 291 def selected(self, values): 292 if not self._init: 293 return 294 295 FOUND = object() 296 297 if not isinstance(values, (list, tuple)): 298 values = [values] 299 else: 300 values = list(values) 301 302 multiple = self.multiple 303 first = True 304 305 for opt in self._options: 306 try: 307 idx = values.index(opt.value) 308 except ValueError: 309 opt.selected = False 310 else: 311 values[idx] = FOUND 312 opt.selected = True if multiple else first 313 first = False 314 315 self._invalid = [val for val in values if val is not FOUND] 316 self._change(raise_event=False) 317 318 @property 319 def placeholder(self): 320 return self._props.get("placeholder", "") 321 322 @placeholder.setter 323 def placeholder(self, val): 324 self._props["placeholder"] = val 325 if not self.selected_keys: 326 self._select_btn.text = val 327 328 @property 329 def enabled(self): 330 return self._select_btn.enabled 331 332 @enabled.setter 333 def enabled(self, val): 334 self._select_btn.enabled = val 335 if not val: 336 self._close() 337 338 @property 339 def enable_filtering(self): 340 return self._dd.enable_filtering 341 342 @enable_filtering.setter 343 def enable_filtering(self, val): 344 self._dd.enable_filtering = val 345 346 @property 347 def multiple(self): 348 return self._dd.multiple 349 350 @multiple.setter 351 def multiple(self, val): 352 self._dd.multiple = val 353 self.selected = self.selected 354 355 @property 356 def enable_select_all(self): 357 return self._dd.enable_select_all 358 359 @enable_select_all.setter 360 def enable_select_all(self, val): 361 self._dd.enable_select_all = val 362 363 tag = _HtmlPanel.tag 364 visible = _HtmlPanel.visible 365 spacing_above = _spacing_property("above") 366 spacing_below = _spacing_property("below") 367 368 ##### PRIVATE Functions ##### 369 370 def _calc_dd_width(self): 371 dd_node = self._dd._dom_node 372 width = dd_node.style.width 373 min_width = dd_node.style.minWidth 374 dd_node.style.width = "fit-content" 375 _document.body.appendChild(dd_node) 376 self._dd_width = dd_node.offsetWidth + 28 377 dd_node.remove() 378 dd_node.style.width = width 379 dd_node.style.minWidth = min_width 380 381 def _change(self, raise_event=True, **event_args): 382 keys = self.selected_keys 383 if not keys: 384 self._select_btn.text = self.placeholder 385 else: 386 self._select_btn.text = self.format_selected_text(len(keys), self._total) 387 if raise_event: 388 self.raise_event("change") 389 390 def _mk_popover(self, init_node, **event_args): 391 init_node(self._dd) 392 393 def _open(self, **e): 394 if not pop(self._select_btn, "shown"): 395 pop(self._select_btn, "show") 396 # invalidate these since the user has interacted with the component 397 self._invalid = [] 398 399 def _close(self, **e): 400 if pop(self._select_btn, "shown"): 401 pop(self._select_btn, "hide") 402 403 def _toggle(self, **e): 404 if not pop(self._select_btn, "shown"): 405 self._open() 406 else: 407 self._close()