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 %} + diff --git a/autocomplete/templates/autocomplete/chip.html b/autocomplete/templates/autocomplete/chip.html index e59b153..c669817 100644 --- a/autocomplete/templates/autocomplete/chip.html +++ b/autocomplete/templates/autocomplete/chip.html @@ -10,7 +10,7 @@ hx-vals='{"remove": true, "item": "{{ item.value|escapejs }}"}' hx-include="#{{ route_name }}" hx-swap="delete" - tabindex="-1" + href="#" >
{# Hidden input elements used to maintain the component's state #} {# and used when submitting forms #} @@ -25,21 +24,7 @@ {% endif %} - + {% include "./ac_container.html" %}
{# This code snippet loads the required CSS and JS if not already loaded. #} @@ -56,10 +41,10 @@ if (!Array.from(document.querySelectorAll('link')).map(s => s.href).includes(ln.href)) { document.getElementsByTagName('head')[0].appendChild(ln); } - document.querySelector('#{{ route_name }}__container .ac_container').addEventListener( + document.querySelector('#{{ route_name }}__container').addEventListener( 'click', function(evt) { - if (evt.target === this) { + if (evt.target === this || evt.target.classList.contains('ac_container')) { document.getElementById('{{ route_name }}__textinput').focus(); } } diff --git a/autocomplete/templates/autocomplete/item.html b/autocomplete/templates/autocomplete/item.html index 3270cc4..273571e 100644 --- a/autocomplete/templates/autocomplete/item.html +++ b/autocomplete/templates/autocomplete/item.html @@ -2,8 +2,9 @@ - {{ item.label }} + {{ item.label|search_highlight:search }} {% if toggle is not None %} @@ -23,12 +24,7 @@ {% include "./values.html" %} {% if multiselect %} -
- {% include "./chip_list.html" with items=toggle %} -
+ {% include "./ac_container.html" with selected_items=toggle %} {% else %} diff --git a/autocomplete/templatetags/autocomplete.py b/autocomplete/templatetags/autocomplete.py index aca5ebb..99c0e84 100644 --- a/autocomplete/templatetags/autocomplete.py +++ b/autocomplete/templatetags/autocomplete.py @@ -5,18 +5,38 @@ from django import template from django import urls -from django.utils.html import format_html from django.utils.http import urlencode +from django.utils.html import escape, format_html from django.template import loader from django.template.defaultfilters import stringfilter +from django.utils.safestring import mark_safe register = template.Library() + @register.filter @stringfilter def make_id(value): """Generate an ID given a string, to use as element IDs in HTML""" - return hashlib.sha1(value.encode('utf-8')).hexdigest() + return hashlib.sha1(value.encode("utf-8")).hexdigest() + + +@register.filter() +@stringfilter +def search_highlight(value, search): + """Surround the section of text matching the search with a classed span""" + if search == "": + return value + try: + pos = value.lower().index(search.lower()) + start = escape(value[:pos]) + match = escape(value[pos:pos+len(search)]) + end = escape(value[pos+len(search):]) + return mark_safe(f"{start}{match}{end}") + except ValueError: + pass + return value + @register.simple_tag def autocomplete(name, selected=None): @@ -33,20 +53,23 @@ def autocomplete(name, selected=None): Defaults to None """ - options_selected = ','.join( - [str(x) for x in selected]) if selected is not None else '' + options_selected = ( + ",".join([str(x) for x in selected]) if selected is not None else "" + ) - url = urls.reverse(name, kwargs={'method': 'component'}) + url = urls.reverse(name, kwargs={"method": "component"}) parameter = urlencode({name: options_selected}) get_url = f"{url}?{parameter}" - return format_html(( - "
" - "
" - )) + return format_html( + ( + "
' + "
" + ) + ) @register.simple_tag @@ -59,8 +82,8 @@ def autocomplete_head(bootstrap=False): bootstrap Set to true if the bootstrap css should be loaded from cdn Defaults to False. """ - return loader.get_template('autocomplete/head.html', using="django").render( - {'bootstrap': bootstrap} + return loader.get_template("autocomplete/head.html", using="django").render( + {"bootstrap": bootstrap} ) @@ -81,11 +104,11 @@ def autocomplete_scripts(context, bootstrap=False, htmx=False, htmx_csrf=False): Defaults to False. """ - return loader.get_template('autocomplete/scripts.html', using="django"). \ - render({ - 'csrf_token': context.get('csrf_token', ''), - 'bootstrap': bootstrap, - 'htmx': htmx, - 'htmx_csrf': htmx_csrf, + return loader.get_template("autocomplete/scripts.html", using="django").render( + { + "csrf_token": context.get("csrf_token", ""), + "bootstrap": bootstrap, + "htmx": htmx, + "htmx_csrf": htmx_csrf, } ) diff --git a/autocomplete/widgets.py b/autocomplete/widgets.py index 57e77c6..81b26e3 100644 --- a/autocomplete/widgets.py +++ b/autocomplete/widgets.py @@ -15,6 +15,7 @@ class Autocomplete(Widget): options (dict): See [autocomplete.py](../autocomplete.py) for more info label (str) required (bool) Defaults to false + indicator (bool) Defaults to false placeholder (str) no_result_text (str) Defaults to "No results found." narrow_search_text (str) Defaults to @@ -25,6 +26,7 @@ class Autocomplete(Widget): model (str) item_value (str) item_label (str) + lookup (str) get_items (func) """ @@ -43,6 +45,7 @@ def __init__( config = { 'name': name, 'required': opts.get('required', None), + 'indicator': opts.get('indicator', None), 'route_name': opts.get('route_name', None), 'label': opts.get('label', None), 'placeholder': opts.get('placeholder', None), @@ -62,6 +65,8 @@ def __init__( mdl_config["item_value"] = item_value if item_label := opts.get('item_label', None): mdl_config["item_label"] = item_label + if lookup := opts.get('lookup', None): + mdl_config["lookup"] = lookup config["Meta"] = type("Meta", (object,), mdl_config) else: @@ -100,6 +105,7 @@ def get_context(self, name, value, attrs): ) context['name'] = self.a_c.name + context['indicator'] = self.a_c.indicator context['required'] = self.a_c.required context['route_name'] = self.a_c.get_route_name() context['label'] = self.a_c.label