Skip to content

Commit

Permalink
feat: Added keyboard navigation support
Browse files Browse the repository at this point in the history
  • Loading branch information
lucbelliveau committed Dec 13, 2022
1 parent eafcaf6 commit 44d15c7
Show file tree
Hide file tree
Showing 11 changed files with 205 additions and 74 deletions.
48 changes: 38 additions & 10 deletions autocomplete/autocomplete.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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):
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -494,16 +523,15 @@ 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(
template.render(
{
"name": self.name,
"required": self.required,
"indicator": self.indicator,
"route_name": self.get_route_name(),
"label": self.label,
"placeholder": self.placeholder,
Expand All @@ -518,27 +546,27 @@ 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 = (
self.map_items(self.get_items(search), items_selected) if show else []
)
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,
},
Expand Down
48 changes: 37 additions & 11 deletions autocomplete/static/autocomplete/css/autocomplete.css
Original file line number Diff line number Diff line change
@@ -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 * {
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -99,6 +119,7 @@
font-size: 100%;
line-height: normal;
border-radius: 0;
flex: 1;
}

.phac_aspc_form_autocomplete .ac_required_input {
Expand Down Expand Up @@ -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;
Expand Down
50 changes: 47 additions & 3 deletions autocomplete/static/autocomplete/js/autocomplete.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
}
22 changes: 22 additions & 0 deletions autocomplete/templates/autocomplete/ac_container.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{% comment %}
This draws the list of selected items as chips as well as the text input box
{% endcomment %}
<ul
class="ac_container"
id="{{ route_name }}_ac_container"
hx-swap-oob="{{ route_name }}_ac_container"
>
{% if multiselect %}
{% include "./chip_list.html" with items=selected_items %}
{% endif %}
<li class="input">
{% include "./textinput.html" %}
</li>
{% if indicator %}
<li class="search-indicator">
<div class="htmx-indicator">
{% include "./indicator-icon.html" %}
</div>
</li>
{% endif %}
</ul>
2 changes: 1 addition & 1 deletion autocomplete/templates/autocomplete/chip.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
hx-vals='{"remove": true, "item": "{{ item.value|escapejs }}"}'
hx-include="#{{ route_name }}"
hx-swap="delete"
tabindex="-1"
href="#"
>
<svg focusable="false" aria-hidden="true" viewBox="0 0 24 24">
<path
Expand Down
2 changes: 0 additions & 2 deletions autocomplete/templates/autocomplete/chip_list.html
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
{% comment %}
This template renders the list of selected items, or "chips".
{% endcomment %}
<ul>
{% for item in items %}
{% include "./chip.html" %}
{% endfor %}
</ul>
21 changes: 3 additions & 18 deletions autocomplete/templates/autocomplete/component.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
<div
id="{{ route_name }}__container"
class="phac_aspc_form_autocomplete"
hx-include="unset"
>
{# Hidden input elements used to maintain the component's state #}
{# and used when submitting forms #}
Expand All @@ -25,21 +24,7 @@
<label for="{{ route_name }}__textinput" class="form-label">{{ label }}</label>
{% endif %}

<ul class="ac_container">
{% if multiselect %}
<li id="{{ route_name }}__selected_items">
{% include "./chip_list.html" with items=selected_items %}
</li>
{% endif %}
<li class="input">
{% include "./textinput.html" %}
</li>
<li class="search-indicator">
<div class="htmx-indicator">
{% include "./indicator-icon.html" %}
</div>
</li>
</ul>
{% include "./ac_container.html" %}
<div class="dropdown-menu" id="{{ route_name }}__items"></div>
</div>
{# This code snippet loads the required CSS and JS if not already loaded. #}
Expand All @@ -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();
}
}
Expand Down
Loading

0 comments on commit 44d15c7

Please sign in to comment.