From 3c040f344682a2d3801db2d072611520a3e0dfc1 Mon Sep 17 00:00:00 2001 From: Luc Belliveau Date: Thu, 9 Feb 2023 14:42:19 +0000 Subject: [PATCH] feat!: Component is now WCAG 2.1 compliant BREAKING CHANGE: `no_result_text` and `narrow_search_text` replaced by the `custom_strings` dictionary. Release-As: 0.6.0 --- .vscode/settings.json | 3 +- MANIFEST.in | 1 + README.md | 32 ++ autocomplete/autocomplete.py | 44 ++- autocomplete/locale/en/LC_MESSAGES/django.po | 50 +++ autocomplete/locale/fr/LC_MESSAGES/django.po | 50 +++ .../static/autocomplete/css/autocomplete.css | 70 ++-- .../static/autocomplete/js/autocomplete.js | 315 +++++++++++++----- .../templates/autocomplete/component.html | 103 +++++- autocomplete/templates/autocomplete/item.html | 34 +- .../templates/autocomplete/item_list.html | 27 +- .../strings/available_results.html | 6 + .../autocomplete/strings/item_selected.html | 2 + .../autocomplete/strings/more_results.html | 6 + .../autocomplete/strings/multiselect.html | 1 + .../autocomplete/strings/no_results.html | 2 + .../strings/nothing_selected.html | 2 + .../templates/autocomplete/textinput.html | 26 +- .../templates/autocomplete/values.html | 2 +- autocomplete/templatetags/autocomplete.py | 28 +- autocomplete/widgets.py | 1 - requirements.txt | 1 + setup.cfg | 2 + setup.py | 50 ++- tests/app/ac_test/__init__.py | 0 tests/app/ac_test/ac_controls.py | 108 ++++++ tests/app/ac_test/admin.py | 3 + tests/app/ac_test/apps.py | 6 + tests/app/ac_test/forms.py | 110 ++++++ tests/app/ac_test/migrations/0001_initial.py | 55 +++ tests/app/ac_test/migrations/__init__.py | 0 tests/app/ac_test/models.py | 13 + tests/app/ac_test/templates/index.html | 129 +++++++ tests/app/ac_test/tests.py | 3 + tests/app/ac_test/views.py | 32 ++ tests/app/app/__init__.py | 0 tests/app/app/asgi.py | 16 + tests/app/app/settings.py | 125 +++++++ tests/app/app/urls.py | 26 ++ tests/app/app/wsgi.py | 16 + tests/app/manage.py | 30 ++ 41 files changed, 1376 insertions(+), 154 deletions(-) create mode 100644 autocomplete/locale/en/LC_MESSAGES/django.po create mode 100644 autocomplete/locale/fr/LC_MESSAGES/django.po create mode 100644 autocomplete/templates/autocomplete/strings/available_results.html create mode 100644 autocomplete/templates/autocomplete/strings/item_selected.html create mode 100644 autocomplete/templates/autocomplete/strings/more_results.html create mode 100644 autocomplete/templates/autocomplete/strings/multiselect.html create mode 100644 autocomplete/templates/autocomplete/strings/no_results.html create mode 100644 autocomplete/templates/autocomplete/strings/nothing_selected.html create mode 100644 tests/app/ac_test/__init__.py create mode 100644 tests/app/ac_test/ac_controls.py create mode 100644 tests/app/ac_test/admin.py create mode 100644 tests/app/ac_test/apps.py create mode 100644 tests/app/ac_test/forms.py create mode 100644 tests/app/ac_test/migrations/0001_initial.py create mode 100644 tests/app/ac_test/migrations/__init__.py create mode 100644 tests/app/ac_test/models.py create mode 100644 tests/app/ac_test/templates/index.html create mode 100644 tests/app/ac_test/tests.py create mode 100644 tests/app/ac_test/views.py create mode 100644 tests/app/app/__init__.py create mode 100644 tests/app/app/asgi.py create mode 100644 tests/app/app/settings.py create mode 100644 tests/app/app/urls.py create mode 100644 tests/app/app/wsgi.py create mode 100755 tests/app/manage.py diff --git a/.vscode/settings.json b/.vscode/settings.json index ca7a224..d69ce88 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,6 +7,7 @@ "python.linting.enabled": true, "python.formatting.provider": "black", "cSpell.words": [ - "htmx" + "htmx", + "textinput" ] } \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index 0d1c84d..0497848 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,3 +2,4 @@ include LICENSE include README.md recursive-include autocomplete/templates * recursive-include autocomplete/static * +recursive-include autocomplete/locale * diff --git a/README.md b/README.md index 404bf04..30c335a 100644 --- a/README.md +++ b/README.md @@ -101,3 +101,35 @@ This Django app provides a client-side autocomplete component powered by ``` + +## Customization + +### Strings + +The strings listed in the table below can be overriden by creating the appropriate +template in your own project, matching the `autocomplete/strings/{name}.html` pattern. +By default all strings are available in both French and English. + +| Name | Description | Default English | Default French | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------- | +| no_results | Text displayed when no results are found. | No results found. | Aucun résultat trouvé. | +| more_results | When `max_results` is set, text displayed when there are additional results available. | Displaying maximum {{ count }} out of {{ total_results }} results. | Affichage maximum de {{ count }} résultats sur {{ total_results }}. | +| available_results | Text anounced to sceen readers when results are available. If max_results is set, the more_results text is spoken instead. | {{ count }} results available. | {{ count }} résultats disponibles. | +| nothing_selected | Text anounced to screen readers when there are no selections. | Nothing selected. | Rien de sélectionné. | + +Individual instances can override strings by providing a dictionary of `custom_strings`. + +```python + class GetItemsMultiAutoComplete(HTMXAutoComplete): + name = "members" + multiselect = True + custom_strings = { + "no_results": "no results text", + "more_results": _("More results text") + } + + class Meta: + model = Person + + +``` diff --git a/autocomplete/autocomplete.py b/autocomplete/autocomplete.py index 3983788..a12c42b 100644 --- a/autocomplete/autocomplete.py +++ b/autocomplete/autocomplete.py @@ -91,9 +91,17 @@ class Meta: frontend, or None for all. Defaults to None. - narrow_search_text (str): Text to display when the results are cut off due to - max_results. - Default: "Narrow your search for more results." + custom_strings (dict): Dictionary containing custom strings to use for this + instance. Available keys are: + - no_results The string displayed when no + results are found. + - more_results Text to display when the results + are cut off due to max_results. + - available_results Text anounced to sceen readers + when results are available. If + max_results is set, the + more_results text is spoken + instead. minimum_search_length (int): The minimum search length to perform a search and show the dropdown. @@ -138,11 +146,8 @@ class Meta: # Values in this set are stripped from any toggle operation. strip_values = set(["undefined"]) - # Text to display when the results are cut off due to max_results. - narrow_search_text = "Narrow your search for more results" - - # The text displayed when no results are found. - no_result_text = "No results found." + # String overrides + custom_strings = dict() # If True will allow the user to select multiple items multiselect = False @@ -501,16 +506,18 @@ def sort_items(item): print("ERROR: Requested item to toggle not found.") return HttpResponseNotFound() - if not self.multiselect: - for item in items: - item["selected"] = False - if target_item.get("selected"): items.remove(target_item) target_item["selected"] = False else: target_item["selected"] = True + if not self.multiselect: + for item in items: + if item != target_item: + item["selected"] = False + + template = loader.get_template("autocomplete/item.html") return HttpResponse( template.render( @@ -520,12 +527,12 @@ def sort_items(item): "indicator": self.indicator, "placeholder": self.placeholder, "required": self.required, - "no_result_text": self.no_result_text, - "narrow_search_text": self.narrow_search_text, + "custom_strings": self.custom_strings, "route_name": self.get_route_name(), "component_id": self.get_component_id(override_component_id), "multiselect": self.multiselect, "values": list(self.item_values(items, True)), + "item_as_list": [target_item], "item": target_item, "toggle": items, "swap_oob": data.get("remove", False), @@ -581,6 +588,8 @@ def get(self, request, method): items (dict[]): List of items """ items_selected = request.GET.getlist(self.name) + if items_selected == [""]: + items_selected = [] override_component_id = request.GET.get("component_id", "") @@ -602,8 +611,7 @@ def get(self, request, method): "multiselect": self.multiselect, "values": list(self.item_values(selected_options)), "selected_items": list(selected_options), - "no_result_text": self.no_result_text, - "narrow_search_text": self.narrow_search_text, + "custom_strings": self.custom_strings, }, request, ) @@ -627,8 +635,8 @@ def get(self, request, method): "required": self.required, "placeholder": self.placeholder, "indicator": self.indicator, - "no_result_text": self.no_result_text, - "narrow_search_text": self.narrow_search_text, + "custom_strings": self.custom_strings, + "multiselect": self.multiselect, "route_name": self.get_route_name(), "component_id": self.get_component_id(override_component_id), "show": show, diff --git a/autocomplete/locale/en/LC_MESSAGES/django.po b/autocomplete/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..a692722 --- /dev/null +++ b/autocomplete/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,50 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-02-08 20:30+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: templates/autocomplete/strings/available_results.html:2 +#, python-format +msgid "%(count)s result available." +msgid_plural "%(count)s results available." +msgstr[0] "" +msgstr[1] "" + +#: templates/autocomplete/strings/item_selected.html:2 +#, python-format +msgid "%(item)s selected," +msgstr "" + +#: templates/autocomplete/strings/more_results.html:2 +#, python-format +msgid "Displaying maximum %(count)s out of %(total_results)s results." +msgid_plural "Displaying maximum %(count)s out of %(total_results)s results." +msgstr[0] "" +msgstr[1] "" + +#: templates/autocomplete/strings/multiselect.html:2 +msgid "multiselect" +msgstr "" + +#: templates/autocomplete/strings/no_results.html:2 +msgid "No results found." +msgstr "" + +#: templates/autocomplete/strings/nothing_selected.html:2 +msgid "Nothing selected." +msgstr "" diff --git a/autocomplete/locale/fr/LC_MESSAGES/django.po b/autocomplete/locale/fr/LC_MESSAGES/django.po new file mode 100644 index 0000000..8a710dd --- /dev/null +++ b/autocomplete/locale/fr/LC_MESSAGES/django.po @@ -0,0 +1,50 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-02-08 20:30+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: templates/autocomplete/strings/available_results.html:2 +#, python-format +msgid "%(count)s result available." +msgid_plural "%(count)s results available." +msgstr[0] "%(count)s résultat disponible" +msgstr[1] "%(count)s résultats disponibles" + +#: templates/autocomplete/strings/item_selected.html:2 +#, python-format +msgid "%(item)s selected," +msgstr "%(item)s sélectionné" + +#: templates/autocomplete/strings/more_results.html:2 +#, python-format +msgid "Displaying maximum %(count)s out of %(total_results)s results." +msgid_plural "Displaying maximum %(count)s out of %(total_results)s results." +msgstr[0] "Affichage maximum de %(count)s résultat sur %(total_results)s." +msgstr[1] "Affichage maximum de %(count)s résultats sur %(total_results)s." + +#: templates/autocomplete/strings/multiselect.html:2 +msgid "multiselect" +msgstr "sélection multiple" + +#: templates/autocomplete/strings/no_results.html:2 +msgid "No results found." +msgstr "Aucun résultat trouvé." + +#: templates/autocomplete/strings/nothing_selected.html:2 +msgid "Nothing selected." +msgstr "Rien de sélectionné." diff --git a/autocomplete/static/autocomplete/css/autocomplete.css b/autocomplete/static/autocomplete/css/autocomplete.css index 93a64f3..6bcc0bb 100644 --- a/autocomplete/static/autocomplete/css/autocomplete.css +++ b/autocomplete/static/autocomplete/css/autocomplete.css @@ -1,11 +1,3 @@ -/* .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%; @@ -24,19 +16,14 @@ transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out; appearance: none; min-height: 38px; + position: relative; } -.phac_aspc_form_autocomplete.disabled { +.phac_aspc_form_autocomplete_focus_ring.disabled .phac_aspc_form_autocomplete { background-color: #e9ecef; background-image: 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 * { box-sizing: border-box; } @@ -48,7 +35,6 @@ padding: 0; width: 100%; height: auto; - cursor: text; display: flex; flex-wrap: wrap; } @@ -75,7 +61,7 @@ cursor: default; } -.phac_aspc_form_autocomplete.disabled ul.ac_container li.chip { +.phac_aspc_form_autocomplete_focus_ring.disabled .phac_aspc_form_autocomplete ul.ac_container li.chip { padding: 3px 5px 3px 5px; } @@ -127,13 +113,13 @@ outline: 0; border: 0 !important; background: transparent !important; - color: #6c757d; box-shadow: none; font-size: 100%; line-height: normal; border-radius: 0; flex: 1; max-width: 100%; + cursor: pointer; } .phac_aspc_form_autocomplete .ac_required_input { @@ -197,9 +183,9 @@ .phac_aspc_form_autocomplete .results { max-height: 240px; + max-width: 100%; position: absolute; - overflow-x: hidden; - overflow-y: auto; + overflow: auto; z-index: 1000; display: none; min-width: 10rem; @@ -222,7 +208,7 @@ .phac_aspc_form_autocomplete .results .item { display: block; width: 100%; - padding: 0.25rem 1rem; + padding: 2px 1rem 2px 1rem; clear: both; font-weight: 400; color: #212529; @@ -238,20 +224,52 @@ text-decoration: underline; } -.phac_aspc_form_autocomplete .results .item:focus, +.phac_aspc_form_autocomplete_focus_ring .live_info { + border: 0!important; + clip: rect(0 0 0 0)!important; + height: 1px!important; + margin: -1px!important; + overflow: hidden!important; + padding: 0!important; + position: absolute!important; + width: 1px!important; +} + +.phac_aspc_form_autocomplete_focus_ring.disabled { + cursor: inherit; +} + +.phac_aspc_form_autocomplete_focus_ring { + cursor: pointer; + padding: 4px; +} + +.phac_aspc_form_autocomplete_focus_ring.active { + border: 2px solid #000; + padding: 2px; +} +.phac_aspc_form_autocomplete_focus_ring.active > div { + color: #1e2125; + background-color: #e9ecef; +} + +.phac_aspc_form_autocomplete .results .item.hasFocus, .phac_aspc_form_autocomplete .results .item:hover { color: #1e2125; background-color: #e9ecef; } -.phac_aspc_form_autocomplete .results .item.selected:focus, +.phac_aspc_form_autocomplete .results .item.selected.hasFocus, .phac_aspc_form_autocomplete .results .item.selected:hover { - color: #ccc; - background-color: initial; + color: #1e2125; + background-color: #eca9a9; +} + +.phac_aspc_form_autocomplete .results .item.hasFocus { + outline: 2px solid #000; } .phac_aspc_form_autocomplete .results .item.selected { - cursor: default; display: list-item; color: #ccc; } diff --git a/autocomplete/static/autocomplete/js/autocomplete.js b/autocomplete/static/autocomplete/js/autocomplete.js index 8e139cb..c33f1ba 100644 --- a/autocomplete/static/autocomplete/js/autocomplete.js +++ b/autocomplete/static/autocomplete/js/autocomplete.js @@ -1,12 +1,49 @@ -function phac_aspc_autocomplete_blur_handler(event, name, sync=false, item=false) { + +function phac_aspc_autocomplete_clear_focus(container, activate_ring) { + const hasFocus = container.querySelectorAll('.hasFocus'); + for (const el of hasFocus) { + el.classList.remove('hasFocus'); + } + + const el = container.querySelector('.textinput'); + el.removeAttribute('aria-activedescendant'); + + if (activate_ring) { + container.closest('.phac_aspc_form_autocomplete_focus_ring') + .classList.add('active'); + container.querySelector('.textinput').focus(); + } else { + container.closest('.phac_aspc_form_autocomplete_focus_ring') + .classList.remove('active'); + } +} + +function phac_aspc_autocomplete_hide_results(container) { + const results = container.querySelector('.results'); + const el = container.querySelector('.textinput'); + el.setAttribute('aria-expanded', false); + results.classList.remove('show'); +} + +phac_aspc_autocomplete_blur_skip = {} +function phac_aspc_autocomplete_blur_handler(event, name, sync = false, item = false) { // Handler responsible for blur events - // Will remove the results when focus is not longer on the component, and update + // Will remove the results when focus is no longer on the component, and update // the box value when multiselect is false requestAnimationFrame(function () { const parent = document.getElementById(`${name}__container`); + const id = parent.getAttribute('id'); + if (phac_aspc_autocomplete_blur_skip[id]) return false; if (!parent.contains(document.activeElement)) { // Focus has left the component + // Reset the component's state + phac_aspc_autocomplete_closed[id] = false; + if (phac_aspc_autocomplete_keyup_debounce[id]) { + clearTimeout(phac_aspc_autocomplete_keyup_debounce[id]) + phac_aspc_autocomplete_keyup_debounce[id] = false; + } + // Get reference to box const el = document.getElementById(name + '__textinput'); @@ -15,182 +52,306 @@ function phac_aspc_autocomplete_blur_handler(event, name, sync=false, item=false // Set the text input value const data_el = document.getElementById(name + '__data'); - if (!sync) { + if (!sync) { el.value = ''; } else { el.value = data_el.getAttribute('data-phac-aspc-autocomplete'); } - - // Reset focus back to box if a menu item triggered the blur - if (item) el.focus(); + phac_aspc_autocomplete_set_initial_value(parent, true); // Get reference to list of results const results = document.getElementById(name + '__items'); + // Get reference to aria live area + const live = document.getElementById(name + '__info'); + // Test if HTMX is currently in the process of swapping if (results.classList.contains('htmx-swapping')) { // To ensure the results are hidden, wait for HTMX to finish, then hide. results.addEventListener( - 'htmx:afterSettle', () => results.classList.remove('show') + 'htmx:afterSettle', () => { + phac_aspc_autocomplete_hide_results(parent); + } ); } // Hide the results - results.classList.remove('show'); + phac_aspc_autocomplete_hide_results(parent); + + // Clear the live info + live.innerHTML = ''; // Change the min-width of the text input back to the (small) default parent.querySelector('.textinput') .parentElement.classList.remove('ac-active'); + + // Ensure no elements remain 'focused', and set focus to input + phac_aspc_autocomplete_clear_focus(parent, item); } }); } +function phac_aspc_autocomplete_item_click_handler(event) { + const container = event.target.closest('.phac_aspc_form_autocomplete'); + const results = container.querySelector('.results'); + const open = results && results.classList.contains('show'); + if (open) { + phac_aspc_autocomplete_clear_focus(container, true); + phac_aspc_autocomplete_hide_results(container); + } + return true; +} + +function phac_aspc_autocomplete_focus_handler(event) { + const container = event.target.closest('.phac_aspc_form_autocomplete'); + phac_aspc_autocomplete_clear_focus(container, true); + phac_aspc_autocomplete_set_initial_value(container); + setTimeout(() => { + // Announce selected items to screen readers. (if any) + const info = container.querySelector('.live_info'); + info.innerHTML += ' '; + }, 100); +} + +const phac_aspc_autocomplete_initial_value = {}; +function phac_aspc_autocomplete_set_initial_value(container, reset = false) { + const id = container.getAttribute('id'); + const el = container.querySelector('.textinput'); + if (reset) { + phac_aspc_autocomplete_initial_value[id] = undefined; + return; + } + if (phac_aspc_autocomplete_initial_value[id] === undefined) { + phac_aspc_autocomplete_initial_value[id] = el.value; + } +} + +phac_aspc_autocomplete_closed = {}; +function phac_aspc_autocomplete_click_handler(event) { + if (event.target.classList.contains('item')) return true; + const container = event.target.closest('.phac_aspc_form_autocomplete'); + const id = container.getAttribute('id'); + const results = container.querySelector('.results'); + const open = results && results.classList.contains('show'); + const text_box = container.querySelector('.textinput'); + + phac_aspc_autocomplete_set_initial_value(container); + phac_aspc_autocomplete_clear_focus(container, true); + + phac_aspc_autocomplete_closed[id] = open; + if (open) { + phac_aspc_autocomplete_hide_results(container); + } else { + text_box.dispatchEvent(new Event('phac_aspc_autocomplete_trigger')); + } + return false; +} + +const phac_aspc_autocomplete_keyup_debounce = {}; +function phac_aspc_autocomplete_keyup_handler(event) { + if (event.keyCode === 13) return false; + const debounce = phac_aspc_autocomplete_keyup_debounce; + const value = phac_aspc_autocomplete_initial_value; + const elem = event.target; + + const container = elem.closest('.phac_aspc_form_autocomplete'); + const id = container.getAttribute('id'); + + phac_aspc_autocomplete_set_initial_value(container); + + if (debounce[id]) { + clearTimeout(debounce[id]); + debounce[id] = false; + } + + const v = elem.value; + + debounce[id] = setTimeout(() => { + if (!phac_aspc_autocomplete_closed[id] && v != value[id]) { + elem.dispatchEvent(new Event('phac_aspc_autocomplete_trigger')); + } else if ( + phac_aspc_autocomplete_closed[id] && + v != value[id] && + v == '' + ) { + phac_aspc_autocomplete_closed[id] = false; + } + value[id] = v; + }, 250); + return true; +} + const phac_aspc_autocomplete_keydown_debounce = {}; function phac_aspc_autocomplete_keydown_handler(event) { if (event.target.classList.contains('textinput') && event.keyCode > 47) { // Expands the min-width of text input to a reasonable size when typing event.target.parentElement.classList.add('ac-active'); } else if (event.target.classList.contains('textinput') && event.keyCode === 8 - && event.target.value.length === 1) { + && event.target.value.length === 1) { // Shrinks the min-width of text input back to the (small) default if // the text input is empty due to backspacing event.target.parentElement.classList.remove('ac-active'); } // Handler responsible for keyboard navigation (up, down, esc and backspace) const debounce = phac_aspc_autocomplete_keydown_debounce; - const whereTo = (element, down=true, skip_element=true, count=1) => { + const whereTo = (container, down = true, skip_element = true, count = 1) => { // This function determines which element should receive focus + // TODO: bug with down + if (!container) return null; + const results = container.querySelector('.results'); + let element = container.querySelector('.hasFocus'); + const must_skip = Boolean(element); + const fallback = down ? results.querySelector('a:first-child') + : results.querySelector('a:last-child'); + if (!element) element = fallback; if (!element) return null; const dir = down ? elem => elem.nextElementSibling : elem => elem.previousElementSibling; - let el = skip_element ? dir(element) : element; - let last_el = el; + let el = skip_element && must_skip ? dir(element) : element; let counter = count; while (el && counter > 0) { if (el.getAttribute('href')) { if (counter === 1) return el; - last_el = el; } if (counter !== 1) counter -= 1; el = dir(el); } - if (last_el && counter > 0) return last_el; + if (counter > 0) return fallback; return null; } - const focusWhenResultsShown = (container, timeout) => { + const switchFocus = (element, container) => { + phac_aspc_autocomplete_clear_focus(container); + const el = container.querySelector('.textinput'); + el.setAttribute('aria-activedescendant', element.getAttribute('id')); + element.classList.add('hasFocus'); + element.scrollIntoView({ block: 'nearest' }) + } + const selectFocusedItem = (container) => { + const item = container.querySelector('.hasFocus'); + if (item) { + item.dispatchEvent(new Event('click')); + } + return item; + } + const focusWhenResultsShown = (container, timeout, up) => { // This function uses polling to wait for the results to be shown before // moving focus. + const id = container.getAttribute('id'); const results = container.querySelector('.results'); if (!results || !results.classList.contains('show')) { if (timeout > 0) { - if (debounce[container.getAttribute('id')]) - clearTimeout(debounce[container.getAttribute('id')]); - debounce[container.getAttribute('id')] = + if (debounce[id]) + clearTimeout(debounce[id]); + debounce[id] = setTimeout( - () => focusWhenResultsShown(container, timeout - 100), + () => focusWhenResultsShown(container, timeout - 100, up), 100 ); } return false; } - debounce[container.getAttribute('id')] = undefined; - const first = results.querySelector('a:first-child'); - const next = whereTo(first, true, false); - if (next) next.focus(); + debounce[id] = undefined; + phac_aspc_autocomplete_closed[id] = false; + if (up) { + const prev = whereTo(container, false); + if (prev) switchFocus(prev, container); + } else { + const next = whereTo(container, true, false); + if (next) switchFocus(next, container); + } } - const getPageSize = (container, element) => { + const getPageSize = (container) => { const r1 = container.getBoundingClientRect(); - const r2 = element.getBoundingClientRect(); + const r2 = container.querySelector('.item').getBoundingClientRect(); return Math.floor((r1.bottom - r1.top) / (r2.bottom - r2.top)); } const container = event.target.closest('.phac_aspc_form_autocomplete'); const results = container.querySelector('.results'); + const id = container.getAttribute('id'); + + phac_aspc_autocomplete_set_initial_value(container); + if (event.keyCode === 27) { - // Escape key on text input or item + // Escape key if (results && results.classList.contains('show')) { - results.classList.remove('show'); - } else if (event.target.tagName.toUpperCase() === 'INPUT') { + phac_aspc_autocomplete_clear_focus(container, true); + phac_aspc_autocomplete_hide_results(container); + phac_aspc_autocomplete_closed[id] = true; + } else { event.target.value = ''; } - if (event.target.tagName.toUpperCase() !== 'INPUT') { - container.querySelector('.textinput').focus(); + } else if (event.keyCode === 13) { + // Enter key + if (results && results.classList.contains('show')) { + selectFocusedItem(container); + phac_aspc_autocomplete_clear_focus(container, true); + phac_aspc_autocomplete_hide_results(container); } + return false; } else if ( - event.target.tagName.toUpperCase() === 'INPUT' && event.keyCode === 8 && event.target.value.length === 0 ) { // Backspace key on text input const chip = container.querySelectorAll('.chip a'); if (chip.length > 0) chip[chip.length - 1].dispatchEvent(new Event('click')); - } else if (event.target.tagName.toUpperCase() !== 'INPUT' && event.keyCode === 36) { - // Home key - if (results) { - const top = whereTo( - results.querySelector('a:first-child'), - true, - false - ); - if (top) top.focus(); - return false; - } - } else if (event.target.tagName.toUpperCase() !== 'INPUT' && event.keyCode === 35) { - // End key - if (results) { - const bottom = whereTo( - results.querySelector('a:last-child'), - false, - false - ); - if (bottom) bottom.focus(); - return false; - } - } else if (event.target.tagName.toUpperCase() !== 'INPUT' && event.keyCode === 33) { + } else if (event.keyCode === 33) { // Page up key if (results) { const prev = whereTo( - event.target, + container, false, true, - getPageSize(results, event.target) + getPageSize(results) ); - if (prev) prev.focus(); + if (prev) switchFocus(prev, container); return false; } - } else if (event.target.tagName.toUpperCase() !== 'INPUT' && event.keyCode === 34) { + } else if (event.keyCode === 34) { // Page down key if (results) { const next = whereTo( - event.target, + container, true, true, - getPageSize(results, event.target) + getPageSize(results) ); - if (next) next.focus(); + if (next) switchFocus(next, container); return false; } - } else if (event.keyCode === 40 && event.target.tagName.toUpperCase() === 'INPUT') { - // down arrow on text element - // Open the results if they are not shown - if (!results || !results.classList.contains('show')) - event.target.dispatchEvent(new Event('input')); - focusWhenResultsShown(container, 3000); - return false; } else if (event.keyCode === 40) { - // down arrow on item - const next = whereTo(event.target); - if (next) next.focus(); + // down arrow + // Open the results if they are not shown + if (!results || !results.classList.contains('show')) { + event.target.dispatchEvent(new Event('phac_aspc_autocomplete_trigger')); + if (event.altKey) { + phac_aspc_autocomplete_closed[id] = false; + } else { + focusWhenResultsShown(container, 3000); + } + } else { + const next = whereTo(container); + if (next) switchFocus(next, container); + } return false; - } else if ( - event.keyCode === 38 && - event.target.tagName.toUpperCase() !== 'INPUT' - ) { + } else if (event.keyCode === 38) { // up arrow on item - const prev = whereTo(event.target, false); - if (prev) prev.focus(); + // Open the results if they are not shown + if (!results || !results.classList.contains('show')) { + event.target.dispatchEvent(new Event('phac_aspc_autocomplete_trigger')); + if (event.altKey) { + phac_aspc_autocomplete_closed[id] = false; + } else { + focusWhenResultsShown(container, 3000, true); + } + } else { + const prev = whereTo(container, false); + if (prev) switchFocus(prev, container); + } return false; } + phac_aspc_autocomplete_clear_focus(container, true); return true; } diff --git a/autocomplete/templates/autocomplete/component.html b/autocomplete/templates/autocomplete/component.html index d6c62ca..da52eb9 100644 --- a/autocomplete/templates/autocomplete/component.html +++ b/autocomplete/templates/autocomplete/component.html @@ -1,24 +1,26 @@ {% load static %} +{% load autocomplete %} {% comment %} This is the main component template that creates the basic HTML structure. {% endcomment %} -
+
+ > {# Hidden input elements used to maintain the component's state #} {# and used when submitting forms #}
- {% include "./values.html" %} + {% include "./values.html" %}
{# Data element used to store component state data #} {% if label is not None %} @@ -26,7 +28,28 @@ {% endif %} {% include "./ac_container.html" %} -
+
+ {# This region provides contextual information for screen readers #} +
+ {% if selected_items|length > 0 %} + {% for item in selected_items %} + {% with item=item.label %} + {% use_string "item_selected" custom_strings %} + {% endwith %} + {% endfor %} + {% else %} + {% use_string "nothing_selected" custom_strings %} + {% endif %} +
+
{# This code snippet loads the required CSS and JS if not already loaded. #} diff --git a/autocomplete/templates/autocomplete/item.html b/autocomplete/templates/autocomplete/item.html index f3f7c5d..712e21a 100644 --- a/autocomplete/templates/autocomplete/item.html +++ b/autocomplete/templates/autocomplete/item.html @@ -1,16 +1,16 @@ {% load autocomplete %} {% include "./values.html" %} +
+ {% if toggle|length > 0 %} + {% for item in toggle %} + {% with item=item.label %} + {% use_string "item_selected" custom_strings %} + {% endwith %} + {% endfor %} + {% else %} + {% use_string "nothing_selected" custom_strings %} + {% endif %} +
+ {% if multiselect %} {% include "./ac_container.html" with selected_items=toggle %} {% else %} - + > + {% else %} + {% include "./textinput.html" with swap_oob=True %} + + {% endif %} {% for item in toggle %} {% if not item.selected %} {% include "autocomplete/item.html" with toggle=None swap_oob=True %} diff --git a/autocomplete/templates/autocomplete/item_list.html b/autocomplete/templates/autocomplete/item_list.html index db2d5da..1fc8b45 100644 --- a/autocomplete/templates/autocomplete/item_list.html +++ b/autocomplete/templates/autocomplete/item_list.html @@ -1,13 +1,34 @@ -
+{% load autocomplete %} +
{% for item in items %} {% include "./item.html" %} {% endfor %} {% if not items %} - {{ no_result_text }} + + {% use_string "no_results" custom_strings %} + {% endif %} {% if items|length != total_results %}
- {{ narrow_search_text }} + + {% use_string "more_results" custom_strings %} +
{% endif %}
+ +
+ {% if items|length != total_results %} + {% use_string "more_results" custom_strings %} + {% else %} + {% use_string "available_results" custom_strings %} + {% endif %} +
diff --git a/autocomplete/templates/autocomplete/strings/available_results.html b/autocomplete/templates/autocomplete/strings/available_results.html new file mode 100644 index 0000000..3336de4 --- /dev/null +++ b/autocomplete/templates/autocomplete/strings/available_results.html @@ -0,0 +1,6 @@ +{% load i18n %} +{% blocktranslate trimmed with count=items|length count counter=items|length %} +{{ count }} result available. +{% plural %} +{{ count }} results available. +{% endblocktranslate %} diff --git a/autocomplete/templates/autocomplete/strings/item_selected.html b/autocomplete/templates/autocomplete/strings/item_selected.html new file mode 100644 index 0000000..6ce45fc --- /dev/null +++ b/autocomplete/templates/autocomplete/strings/item_selected.html @@ -0,0 +1,2 @@ +{% load i18n %} +{% blocktranslate trimmed %}{{ item }} selected,{% endblocktranslate %} diff --git a/autocomplete/templates/autocomplete/strings/more_results.html b/autocomplete/templates/autocomplete/strings/more_results.html new file mode 100644 index 0000000..93ee7e7 --- /dev/null +++ b/autocomplete/templates/autocomplete/strings/more_results.html @@ -0,0 +1,6 @@ +{% load i18n %} +{% blocktranslate trimmed with count=items|length count counter=items|length %} +Displaying maximum {{ count }} out of {{ total_results }} results. +{% plural %} +Displaying maximum {{ count }} out of {{ total_results }} results. +{% endblocktranslate %} \ No newline at end of file diff --git a/autocomplete/templates/autocomplete/strings/multiselect.html b/autocomplete/templates/autocomplete/strings/multiselect.html new file mode 100644 index 0000000..bc3f575 --- /dev/null +++ b/autocomplete/templates/autocomplete/strings/multiselect.html @@ -0,0 +1 @@ +{% load i18n %}{% translate "multiselect" %} \ No newline at end of file diff --git a/autocomplete/templates/autocomplete/strings/no_results.html b/autocomplete/templates/autocomplete/strings/no_results.html new file mode 100644 index 0000000..5a50381 --- /dev/null +++ b/autocomplete/templates/autocomplete/strings/no_results.html @@ -0,0 +1,2 @@ +{% load i18n %} +{% translate "No results found." %} diff --git a/autocomplete/templates/autocomplete/strings/nothing_selected.html b/autocomplete/templates/autocomplete/strings/nothing_selected.html new file mode 100644 index 0000000..d15085d --- /dev/null +++ b/autocomplete/templates/autocomplete/strings/nothing_selected.html @@ -0,0 +1,2 @@ +{% load i18n %} +{% translate "Nothing selected." %} \ No newline at end of file diff --git a/autocomplete/templates/autocomplete/textinput.html b/autocomplete/templates/autocomplete/textinput.html index bd290c2..96199b3 100644 --- a/autocomplete/templates/autocomplete/textinput.html +++ b/autocomplete/templates/autocomplete/textinput.html @@ -2,12 +2,24 @@ +{% endif %} +{% if disabled %} + {% endif %} \ No newline at end of file diff --git a/autocomplete/templates/autocomplete/values.html b/autocomplete/templates/autocomplete/values.html index c11e181..b8a2ed4 100644 --- a/autocomplete/templates/autocomplete/values.html +++ b/autocomplete/templates/autocomplete/values.html @@ -4,5 +4,5 @@ {% endfor %} {% if required and values|length == 0 %} - + {% endif %} \ No newline at end of file diff --git a/autocomplete/templatetags/autocomplete.py b/autocomplete/templatetags/autocomplete.py index 99c0e84..bba9a4b 100644 --- a/autocomplete/templatetags/autocomplete.py +++ b/autocomplete/templatetags/autocomplete.py @@ -30,14 +30,36 @@ def search_highlight(value, search): 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}") + 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(takes_context=True) +def use_string(context, name, strings): + """ + Loads the string from a template or via the variable dict `strings` if the `name` + key is defined within. This allows strings to be overriden in 2 ways, either by + user defined templates which will override *all* instances, or via the + `custom_strings` property of the Autocomplete instance which allows individual + customization. + + When `name` is not found in `strings`, the template name becomes: + + autocomplete/strings/{name}.html + + """ + if name in strings: + return strings[name] + + return loader.get_template( + f"autocomplete/strings/{name}.html", using="django" + ).render(context.flatten()) + + @register.simple_tag def autocomplete(name, selected=None): """ diff --git a/autocomplete/widgets.py b/autocomplete/widgets.py index d294149..791581e 100644 --- a/autocomplete/widgets.py +++ b/autocomplete/widgets.py @@ -5,7 +5,6 @@ from .autocomplete import HTMXAutoComplete - class Autocomplete(Widget): """ Django forms compatible autocomplete widget diff --git a/requirements.txt b/requirements.txt index b8428ea..8d82744 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ black==22.12.0 Django==4.1.4 +Faker==16.6.1 pylint==2.15.9 pylint-django==2.5.3 diff --git a/setup.cfg b/setup.cfg index fe4932c..94da9c3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,3 +29,5 @@ packages = find: python_requires = >=3.8 install_requires = Django >= 4.1 +setup_requires = + Django >= 4.1 diff --git a/setup.py b/setup.py index fc1f76c..cb8d311 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,51 @@ from setuptools import setup +from setuptools.command.install_lib import install_lib as _install_lib +from setuptools.command.sdist import sdist as _sdist +from distutils.command.build import build as _build +from distutils.cmd import Command -setup() \ No newline at end of file + +class compile_translations(Command): + description = 'compile message catalogs to MO files via django compilemessages' + user_options = [] + + def initialize_options(self): + pass + + def finalize_options(self): + pass + + def run(self): + import os + import sys + from django.core.management.commands.compilemessages import Command + c = Command() + curdir = os.getcwd() + os.chdir(os.path.realpath('autocomplete')) + c.handle(locale=[], exclude=[], ignore_patterns=[], fuzzy=False, verbosity=0) + os.chdir(curdir) + + +class build(_build): + sub_commands = [('compile_translations', None)] + _build.sub_commands + + +class install_lib(_install_lib): + def run(self): + self.run_command('compile_translations') + _install_lib.run(self) + +class sdist(_sdist): + def run(self): + self.run_command('compile_translations') + _sdist.run(self) + + +setup( + cmdclass={ + 'build': build, + 'install_lib': install_lib, + 'sdist': sdist, + 'compile_translations': compile_translations + } +) \ No newline at end of file diff --git a/tests/app/ac_test/__init__.py b/tests/app/ac_test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/app/ac_test/ac_controls.py b/tests/app/ac_test/ac_controls.py new file mode 100644 index 0000000..8ab3ed3 --- /dev/null +++ b/tests/app/ac_test/ac_controls.py @@ -0,0 +1,108 @@ +from autocomplete import HTMXAutoComplete + +from .models import Person + +data = [ + {"value": "1", "label": "Newsome Instruments Ltd"}, + {"value": "2", "label": "Dixon Refrigeration Ltd"}, + {"value": "3", "label": "Quirke Skips Ltd"}, + {"value": "4", "label": "Talisman Costume Hire Ltd"}, + {"value": "5", "label": "Hallworth Carpenters Ltd"}, + {"value": "6", "label": "Peel Building Materials Ltd"}, + {"value": "7", "label": "Ainsley Meat Wholesalers Ltd"}, + {"value": "8", "label": "Earl Recreational Vehicles Ltd"}, + {"value": "9", "label": "Crocket Footwear Ltd"}, + {"value": "10", "label": "Ashton Plant Hire Ltd"}, + {"value": "11", "label": "Valley Home Services Ltd"}, + {"value": "12", "label": "Keaton Engineering Ltd"}, + {"value": "13", "label": "Edwards Developments Ltd"}, + {"value": "14", "label": "Leary Car Repairs Ltd"}, + {"value": "15", "label": "Yeoman Fitness Products Ltd"}, + {"value": "16", "label": "Crocket Office Furniture Ltd"}, + {"value": "17", "label": "Walcott Beauty Treatments Ltd"}, + {"value": "18", "label": "Malloney Tractors Ltd"}, + {"value": "19", "label": "Sterling Fruit Importers Ltd"}, + {"value": "20", "label": "Tattershall Car Repairs Ltd"}, + {"value": "21", "label": "Lyons Reproductions Ltd"}, + {"value": "22", "label": "Elgar Freight Ltd"}, + {"value": "23", "label": "West Point Luxury Cars Ltd"}, + {"value": "24", "label": "Summit Printing Ltd"}, + {"value": "25", "label": "Alexander Lab Equipment Ltd"}, + {"value": "26", "label": "Goodacre Camping Supplies Ltd"}, + {"value": "27", "label": "Hunt Footwear Ltd"}, + {"value": "28", "label": "Eckard Printing Ltd"}, + {"value": "29", "label": "Fisher Design Ltd"}, + {"value": "30", "label": "Grady,Fine Accountancy Services Ltd"}, + {"value": "31", "label": "Reeve Locksmiths Ltd"}, + {"value": "32", "label": "Eagle Skips Ltd"}, + {"value": "33", "label": "Adkinson Demolition Ltd"}, + {"value": "34", "label": "Pendrick Skips Ltd"}, + {"value": "35", "label": "Jarvis Agency Ltd"}, + {"value": "36", "label": "East View Automations Ltd"}, + {"value": "37", "label": "Mack Fabrics Ltd"}, + {"value": "38", "label": "Bainbridge Construction Ltd"}, + {"value": "39", "label": "North Side Camping Supplies Ltd"}, + {"value": "40", "label": "Gatley Furniture Ltd"}, + {"value": "41", "label": "Addler Disposal Ltd"}, + {"value": "42", "label": "Cromwell Cleaners Ltd"}, + {"value": "43", "label": "Craft Building Materials Ltd"}, + {"value": "44", "label": "Knowles Showrooms Ltd"}, + {"value": "45", "label": "Allen Luxury Cars Ltd"}, + {"value": "46", "label": "Thistlemoor Builders Ltd"}, + {"value": "47", "label": "Valley Kitchens Ltd"}, + {"value": "48", "label": "Dobson Cosmetics Ltd"}, + {"value": "49", "label": "West View Hire Cars Ltd"}, + {"value": "50", "label": "Bentley Meat Wholesalers Ltd"}, +] + + +class GetItemsAutoComplete(HTMXAutoComplete): + name = "getitems" + minimum_search_length = 0 + + def get_items(self, search=None, values=None): + if values: + return list(filter(lambda x: x.get("value") in values, data)) + + if search: + return list(filter(lambda x: x.get("label").startswith(search), data)) + + if search == "": + return list(data) + + return [] + + +class ModelAutoComplete(HTMXAutoComplete): + name = "model" + minimum_search_length = 0 + + class Meta: + model = Person + + +class GetItemsMultiAutoComplete(HTMXAutoComplete): + name = "getitems_multi" + multiselect = True + minimum_search_length = 0 + + def get_items(self, search=None, values=None): + if values: + return list(filter(lambda x: x.get("value") in values, data)) + + if search: + return list(filter(lambda x: x.get("label").startswith(search), data)) + + if search == "": + return list(data) + + return [] + + +class ModelMultiAutoComplete(HTMXAutoComplete): + name = "model_multi" + multiselect = True + minimum_search_length = 0 + + class Meta: + model = Person diff --git a/tests/app/ac_test/admin.py b/tests/app/ac_test/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/tests/app/ac_test/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/tests/app/ac_test/apps.py b/tests/app/ac_test/apps.py new file mode 100644 index 0000000..d3a8339 --- /dev/null +++ b/tests/app/ac_test/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AcTestConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "ac_test" diff --git a/tests/app/ac_test/forms.py b/tests/app/ac_test/forms.py new file mode 100644 index 0000000..03896d8 --- /dev/null +++ b/tests/app/ac_test/forms.py @@ -0,0 +1,110 @@ +""" +Form objects used to test the autocomplete's widget interface +""" +from django import forms +from autocomplete import widgets + +from .models import Person, Team +from .ac_controls import data + + +class SingleFormGetItem(forms.Form): + """Form used for single select using get_items""" + + @staticmethod + def get_items(search=None, values=None): + """Example function used to provide list of options to widget + + Args: + search (str, optional): Search string. Defaults to None. + values (str[], optional): Values to return. Defaults to None. + + Returns: + dict[]: List of dictionaries with value and label keys. + """ + if values: + return list(filter(lambda x: x.get("value") in values, data)) + + return list( + filter(lambda x: x.get("label").lower().startswith(search.lower()), data) + ) + + name = forms.CharField() + company = forms.CharField( + widget=widgets.Autocomplete( + name="company", options=dict(get_items=get_items, minimum_search_length=0) + ) + ) + + +class SingleFormModel(forms.ModelForm): + """Single select example form using a model""" + + class Meta: + """Meta class that configures the form""" + + model = Team + fields = ["name", "members"] + widgets = { + "members": widgets.Autocomplete( + name="members", options=dict(model=Person, minimum_search_length=0) + ) + } + + +class MultipleFormGetItem(forms.Form): + """Form used for multiple select using get_items""" + + @staticmethod + def get_items(search=None, values=None): + """Example function used to provide list of options to widget + + Args: + search (str, optional): Search string. Defaults to None. + values (str[], optional): Values to return. Defaults to None. + + Returns: + dict[]: List of dictionaries with value and label keys. + """ + items = None + if search is not None: + items = Person.objects.filter(name__startswith=search) + + if values is not None: + items = Person.objects.filter(id__in=values) + + return [{"label": x.name, "value": x.id} for x in items] + + name = forms.CharField() + members = forms.CharField( + widget=widgets.Autocomplete( + name="members", + options=dict( + multiselect=True, + get_items=get_items, + route_name="multi_members", + minimum_search_length=0, + ), + ) + ) + + +class MultipleFormModel(forms.ModelForm): + """Multiple select example form using a model""" + + class Meta: + """Meta class that configures the form""" + + model = Team + fields = ["name", "members"] + widgets = { + "members": widgets.Autocomplete( + name="members", + options=dict( + multiselect=True, + model=Person, + route_name="multi_model_members", + minimum_search_length=0, + ), + ) + } diff --git a/tests/app/ac_test/migrations/0001_initial.py b/tests/app/ac_test/migrations/0001_initial.py new file mode 100644 index 0000000..d1c2e35 --- /dev/null +++ b/tests/app/ac_test/migrations/0001_initial.py @@ -0,0 +1,55 @@ +# Generated by Django 4.1.4 on 2023-02-07 16:53 + +from django.db import migrations, models + +from faker import Faker + +def generate_names(apps, schema_editor): + faker = Faker() + Person = apps.get_model("ac_test", "Person") + for x in range(0, 1000): + p = Person() + p.name = faker.name() + p.save() + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Person", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=60)), + ], + ), + migrations.CreateModel( + name="Team", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=60)), + ("members", models.ManyToManyField(to="ac_test.person")), + ], + ), + migrations.RunPython(generate_names), + ] diff --git a/tests/app/ac_test/migrations/__init__.py b/tests/app/ac_test/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/app/ac_test/models.py b/tests/app/ac_test/models.py new file mode 100644 index 0000000..7f779c5 --- /dev/null +++ b/tests/app/ac_test/models.py @@ -0,0 +1,13 @@ +from django.db import models + +class Person(models.Model): + name = models.CharField(max_length=60) + + def __str__(self): + return self.name + + +class Team(models.Model): + name = models.CharField(max_length=60) + members = models.ManyToManyField(Person) + diff --git a/tests/app/ac_test/templates/index.html b/tests/app/ac_test/templates/index.html new file mode 100644 index 0000000..ab685ab --- /dev/null +++ b/tests/app/ac_test/templates/index.html @@ -0,0 +1,129 @@ +{% load autocomplete %} +{% load static %} + + + + + + Bootstrap demo + + + + + +
+

Autocomplete HTMX Test cases

+
+
+

Using form widgets

+
+
+
+
Using model, single select
+
+
+ {% csrf_token %} + + {{ single_form_model.as_table }} +
+
+
+
+
+
+
+
Using get_items, single select
+
+ + {{ single_form_get_item.as_table }} +
+
+
+
+
+
+
+
+
Using model, multiselect
+
+
+ {% csrf_token %} + + {{ multi_form_model.as_table }} +
+ +
+
+
+
+
+
+
Using get_items, multiselect
+
+ + {{ multi_form_get_item.as_table }} +
+
+
+
+
+

Using template tags

+
+
+
+
Using model
+
+ {% autocomplete "model" %} +
+
+
+
+
+
Using get_items
+
+ {% autocomplete "getitems" %} +
+
+
+
+
+
+
+
Using model, multiselect
+
+ {% autocomplete "model_multi" %} +
+
+
+
+
+
Using get_items, multiselect
+
+ {% autocomplete "getitems_multi" %} +
+
+
+
+
+
+ + + + + diff --git a/tests/app/ac_test/tests.py b/tests/app/ac_test/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/tests/app/ac_test/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/tests/app/ac_test/views.py b/tests/app/ac_test/views.py new file mode 100644 index 0000000..c766016 --- /dev/null +++ b/tests/app/ac_test/views.py @@ -0,0 +1,32 @@ +from django.template import loader +from django.http import HttpResponse + +from . import ac_controls +from .forms import ( + SingleFormGetItem, + SingleFormModel, + MultipleFormGetItem, + MultipleFormModel, +) + + +def index(request): + print(request.POST) + template = loader.get_template("index.html") + single_form_get_item = SingleFormGetItem({"name": "Team Pickle", "company": [2]}) + single_form_model = SingleFormModel({"name": "Team Pickles", "members": [1]}) + multi_form_get_item = MultipleFormGetItem( + {"name": "Team Pickle", "members": [1, 2, 3, 21]} + ) + multi_form_model = MultipleFormModel(request.POST or None) + return HttpResponse( + template.render( + { + "single_form_model": single_form_model, + "single_form_get_item": single_form_get_item, + "multi_form_get_item": multi_form_get_item, + "multi_form_model": multi_form_model, + }, + request, + ) + ) diff --git a/tests/app/app/__init__.py b/tests/app/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/app/app/asgi.py b/tests/app/app/asgi.py new file mode 100644 index 0000000..f29ff9b --- /dev/null +++ b/tests/app/app/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for app project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") + +application = get_asgi_application() diff --git a/tests/app/app/settings.py b/tests/app/app/settings.py new file mode 100644 index 0000000..dfa60af --- /dev/null +++ b/tests/app/app/settings.py @@ -0,0 +1,125 @@ +""" +Django settings for app project. + +Generated by 'django-admin startproject' using Django 4.1.4. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.1/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-kg=9#&k%henl@#1vs*bvda&=xfx^h$fr+m)fss680o1&4-pf9l" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "autocomplete", + "ac_test", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "app.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "app.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/4.1/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.1/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.1/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/tests/app/app/urls.py b/tests/app/app/urls.py new file mode 100644 index 0000000..16dc89a --- /dev/null +++ b/tests/app/app/urls.py @@ -0,0 +1,26 @@ +"""app URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path + +from autocomplete import HTMXAutoComplete +from ac_test import views + +urlpatterns = [ + path("admin/", admin.site.urls), + path("", views.index, name="index"), + *HTMXAutoComplete.url_dispatcher("ac"), +] diff --git a/tests/app/app/wsgi.py b/tests/app/app/wsgi.py new file mode 100644 index 0000000..03af5d2 --- /dev/null +++ b/tests/app/app/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for app project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") + +application = get_wsgi_application() diff --git a/tests/app/manage.py b/tests/app/manage.py new file mode 100755 index 0000000..64555bd --- /dev/null +++ b/tests/app/manage.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + sys.path.append( + os.path.realpath( + os.path.join( + os.path.dirname(os.path.abspath(__file__)), + '..', '..' + ) + ) + ) + main()