diff --git a/autocomplete/autocomplete.py b/autocomplete/autocomplete.py index 964878e..6febd97 100644 --- a/autocomplete/autocomplete.py +++ b/autocomplete/autocomplete.py @@ -53,11 +53,15 @@ class Meta: item_value: The column string or DeferredAttribute that will be used as the item value. (Defaults to the PK) - item_label: The column string or DeferredAttribute that will be + item_label: The column string or DeferredAttribute that will be used as the item's label and used for searches. Defaults to the first CharField, or the first field if no CharFields exist. + lookup: Lookup method used for search. This text will be added to + item_label property to perform searches. + Defaults to 'icontains'. + For more fine-grained control over what items are available, you can also override the `get_items` function. @@ -75,6 +79,9 @@ class Meta: placeholder (str or None): The placeholder text used on the component Defaults to None. + indicator (bool): If enabled will display a search indicator. + Defaults to False + required (bool): If set the control is marked as required. no_result_text (str): The string displayed when no results are found. @@ -113,6 +120,9 @@ class Meta: # If set to True the HTML control will be marked as required required = False + # If enabled an indicator will be displayed while waiting for a network response + indicator = False + # The minimum search length to perform a search and show the dropdown. minimum_search_length = 3 @@ -134,6 +144,9 @@ class Meta: # Used internally to reference the `label` field returned by `get_items` _item_label = "label" + # Used internally as the field lookup for model searches in `get_items` + _lookup = "icontains" + @classmethod def url_dispatcher(cls, route): """Return the url pattern required for the component to function. @@ -233,7 +246,7 @@ def _get_and_verify_model(cls, meta): raise ImproperlyConfigured(f"Error loading '{meta.model}'") from exp elif not isinstance(meta.model, models.base.ModelBase): raise ImproperlyConfigured( - "Meta.model should be an object or string in the format \"app.model\"" + 'Meta.model should be an object or string in the format "app.model"' ) return meta.model @@ -279,6 +292,19 @@ def _get_and_verify_item_label(cls, meta): return all_fields[0].name + @classmethod + def _get_and_verify_lookup(cls, meta): + """Get the lookup value""" + if hasattr(meta, "lookup"): + if not isinstance(meta.lookup, str): + raise ImproperlyConfigured( + "If Meta.lookup is defined it must be a field lookup string" + ) + + return meta.lookup + + return 'icontains' + @classmethod def verify_config(cls): """Verify that the component is correctly configured. @@ -295,6 +321,7 @@ def verify_config(cls): cls.Meta.model = cls._get_and_verify_model(cls.Meta) cls._item_value = cls._get_and_verify_item_value(cls.Meta) cls._item_label = cls._get_and_verify_item_label(cls.Meta) + cls._lookup = cls._get_and_verify_lookup(cls.Meta) @classmethod def get_route_name(cls): @@ -331,7 +358,7 @@ class is not specified at all, the overridden method is expected to """ items = None if search is not None: - search_dict = {self._item_label + "__icontains": search} + search_dict = {f"{self._item_label}__{self._lookup}": search} # pylint: disable=no-member items = self.Meta.model.objects.filter(**search_dict) @@ -433,6 +460,8 @@ def put(self, request, method): template.render( { "name": self.name, + "search": "", + "indicator": self.indicator, "required": self.required, "no_result_text": self.no_result_text, "narrow_search_text": self.narrow_search_text, @@ -494,9 +523,7 @@ def get(self, request, method): items_selected = request.GET.getlist(self.name) if method == "component": - template = loader.get_template( - "autocomplete/component.html" - ) + template = loader.get_template("autocomplete/component.html") selected_options = self.map_items(self.get_items(values=items_selected)) return HttpResponse( @@ -504,6 +531,7 @@ def get(self, request, method): { "name": self.name, "required": self.required, + "indicator": self.indicator, "route_name": self.get_route_name(), "label": self.label, "placeholder": self.placeholder, @@ -518,9 +546,7 @@ def get(self, request, method): ) if method == "items": - template = loader.get_template( - "autocomplete/item_list.html" - ) + template = loader.get_template("autocomplete/item_list.html") search = request.GET.get("search", "") show = len(search) >= self.minimum_search_length items = ( @@ -528,17 +554,19 @@ def get(self, request, method): ) total_results = len(items) if self.max_results is not None and len(items) > self.max_results: - items = items[:self.max_results] + items = items[: self.max_results] return HttpResponse( template.render( { "name": self.name, "required": self.required, + "indicator": self.indicator, "no_result_text": self.no_result_text, "narrow_search_text": self.narrow_search_text, "route_name": self.get_route_name(), "show": show, + "search": search, "items": list(items), "total_results": total_results, }, diff --git a/autocomplete/static/autocomplete/css/autocomplete.css b/autocomplete/static/autocomplete/css/autocomplete.css index 1b9b84c..715f34b 100644 --- a/autocomplete/static/autocomplete/css/autocomplete.css +++ b/autocomplete/static/autocomplete/css/autocomplete.css @@ -1,9 +1,34 @@ -.phac_aspc_form_autocomplete { +/* .phac_aspc_form_autocomplete { position: relative; display: inline-block; vertical-align: middle; user-select: none; width: 100%; +} */ + +.phac_aspc_form_autocomplete { + display: block; + width: 100%; + padding: .375rem 2.25rem 0.375rem 0.75rem; + -moz-padding-start: calc(0.75rem - 3px); + font-weight: 400; + line-height: 1.5; + color: #212529; + background-color: #fff; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right .75rem center; + background-size: 16px 12px; + border: 1px solid #ced4da; + border-radius: .375rem; + transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out; + appearance: none; +} + +.phac_aspc_form_autocomplete:focus { + border-color: #86b7fe; + outline: 0; + box-shadow: 0 0 0 0.25rem rgb(13 110 253 / 25%); } .phac_aspc_form_autocomplete * { @@ -17,22 +42,15 @@ padding: 0 5px; width: 100%; height: auto; - border: 1px solid #aaa; - background-color: #fff; - background-image: linear-gradient(#eee 1%, #fff 15%); - cursor: text; + cursor: text; + display: flex; + flex-wrap: wrap; } .phac_aspc_form_autocomplete ul.ac_container li { - float: left; list-style: none; } -.phac_aspc_form_autocomplete ul.ac_container li > ul { - padding: 0; - float: left; -} - .phac_aspc_form_autocomplete ul.ac_container li.chip { position: relative; margin: 3px 5px 3px 0; @@ -85,6 +103,8 @@ margin: 0; padding: 0; white-space: nowrap; + flex: 1; + display: flex; } .phac_aspc_form_autocomplete ul.ac_container li input.textinput { @@ -99,6 +119,7 @@ font-size: 100%; line-height: normal; border-radius: 0; + flex: 1; } .phac_aspc_form_autocomplete .ac_required_input { @@ -198,6 +219,11 @@ border: 0; } +.phac_aspc_form_autocomplete .results .item .highlight { + font-weight: 600; + text-decoration: underline; +} + .phac_aspc_form_autocomplete .results .item:focus, .phac_aspc_form_autocomplete .results .item:hover { color: #1e2125; diff --git a/autocomplete/static/autocomplete/js/autocomplete.js b/autocomplete/static/autocomplete/js/autocomplete.js index f54ed9c..3fd0cc1 100644 --- a/autocomplete/static/autocomplete/js/autocomplete.js +++ b/autocomplete/static/autocomplete/js/autocomplete.js @@ -1,9 +1,12 @@ -function phac_aspc_autocomplete_blur_handler(name, sync=false) { +function phac_aspc_autocomplete_blur_handler(event, name, sync=false) { + // Handler responsible for blur events + // Will remove the results when focus is not longer on the component, and update + // the textbox value when multiselect is false requestAnimationFrame(function () { const parent = document.getElementById(`${name}__container`); if (!parent.contains(document.activeElement)) { - var el = document.getElementById(name + '__textinput'); - var data_el = document.getElementById(name + '__data'); + const el = document.getElementById(name + '__textinput'); + const data_el = document.getElementById(name + '__data'); if (!sync) { el.value = ''; } else { @@ -13,3 +16,44 @@ function phac_aspc_autocomplete_blur_handler(name, sync=false) { } }); } + +function phac_aspc_autocomplete_keydown_handler(event) { + // Handler responsible for keyboard navigation (up and down arrows, esc key) + const whereTo = (element, down=true, skip_element=true) => { + if (!element) return null; + const dir = down ? + elem => elem.nextElementSibling : elem => elem.previousElementSibling; + + let el = skip_element ? dir(element) : element; + while (el) { + if (el.getAttribute('href')) return el; + el = dir(el); + } + return null; + } + + if (event.keyCode === 27) { + event.target.blur(); + } else if (event.keyCode === 40 && event.target.tagName.toUpperCase() === 'INPUT') { + // down arrow on text element + const container = event.target.closest('.phac_aspc_form_autocomplete'); + const first = container.querySelector('.results a:first-child'); + const next = whereTo(first, true, false); + if (next) next.focus(); + return false; + } else if (event.keyCode === 40) { + // down arrow on item + const next = whereTo(event.target); + if (next) next.focus(); + return false; + } else if ( + event.keyCode === 38 && + event.target.tagName.toUpperCase() !== 'INPUT' + ) { + // up arrow on item + const prev = whereTo(event.target, false); + if (prev) prev.focus(); + return false; + } + return true; +} \ No newline at end of file diff --git a/autocomplete/templates/autocomplete/ac_container.html b/autocomplete/templates/autocomplete/ac_container.html new file mode 100644 index 0000000..29ed42a --- /dev/null +++ b/autocomplete/templates/autocomplete/ac_container.html @@ -0,0 +1,22 @@ +{% comment %} +This draws the list of selected items as chips as well as the text input box +{% endcomment %} +