diff --git a/assets/js/__tests__/search.spec.ts b/assets/js/__tests__/search.spec.ts
new file mode 100644
index 00000000..abc033b5
--- /dev/null
+++ b/assets/js/__tests__/search.spec.ts
@@ -0,0 +1,99 @@
+import { $ } from '../utils/dom';
+import { assertNotNull } from '../utils/assert';
+import { setupSearch } from '../search';
+import { setupTagListener } from '../tagsinput';
+
+const formData = `
`;
+
+describe('Search form help', () => {
+ beforeAll(() => {
+ setupSearch();
+ setupTagListener();
+ });
+
+ let input: HTMLInputElement;
+ let prependAnchor: HTMLAnchorElement;
+ let idAnchor: HTMLAnchorElement;
+ let favesAnchor: HTMLAnchorElement;
+ let helpNumeric: HTMLDivElement;
+ let subjectSpan: HTMLElement;
+
+ beforeEach(() => {
+ document.body.innerHTML = formData;
+
+ input = assertNotNull($('input'));
+ prependAnchor = assertNotNull($('a[data-search-prepend]'));
+ idAnchor = assertNotNull($('a[data-search-add="id.lte:10"]'));
+ favesAnchor = assertNotNull($('a[data-search-add="my:faves"]'));
+ helpNumeric = assertNotNull($('[data-search-help="numeric"]'));
+ subjectSpan = assertNotNull($('span', helpNumeric));
+ });
+
+ it('should add text to input field', () => {
+ idAnchor.click();
+ expect(input.value).toBe('id.lte:10');
+
+ favesAnchor.click();
+ expect(input.value).toBe('id.lte:10, my:faves');
+ });
+
+ it('should focus and select text in input field when requested', () => {
+ idAnchor.click();
+ expect(input).toHaveFocus();
+ expect(input.selectionStart).toBe(7);
+ expect(input.selectionEnd).toBe(9);
+ });
+
+ it('should highlight subject name when requested', () => {
+ expect(helpNumeric).toHaveClass('hidden');
+ idAnchor.click();
+ expect(helpNumeric).not.toHaveClass('hidden');
+ expect(subjectSpan).toHaveTextContent('Numeric ID');
+ });
+
+ it('should not focus and select text in input field when unavailable', () => {
+ favesAnchor.click();
+ expect(input).not.toHaveFocus();
+ expect(input.selectionStart).toBe(8);
+ expect(input.selectionEnd).toBe(8);
+ });
+
+ it('should not highlight subject name when unavailable', () => {
+ favesAnchor.click();
+ expect(helpNumeric).toHaveClass('hidden');
+ });
+
+ it('should prepend to empty input', () => {
+ prependAnchor.click();
+ expect(input.value).toBe('-');
+ });
+
+ it('should prepend to single input', () => {
+ input.value = 'a';
+ prependAnchor.click();
+ expect(input.value).toBe('-a');
+ });
+
+ it('should prepend to comma-separated input', () => {
+ input.value = 'a,b';
+ prependAnchor.click();
+ expect(input.value).toBe('a,-b');
+ });
+
+ it('should prepend to comma and space-separated input', () => {
+ input.value = 'a, b';
+ prependAnchor.click();
+ expect(input.value).toBe('a, -b');
+ });
+});
diff --git a/assets/js/search.js b/assets/js/search.js
deleted file mode 100644
index 50733fd9..00000000
--- a/assets/js/search.js
+++ /dev/null
@@ -1,45 +0,0 @@
-import { $, $$ } from './utils/dom';
-import { addTag } from './tagsinput';
-
-function showHelp(subject, type) {
- $$('[data-search-help]').forEach(helpBox => {
- if (helpBox.getAttribute('data-search-help') === type) {
- $('.js-search-help-subject', helpBox).textContent = subject;
- helpBox.classList.remove('hidden');
- } else {
- helpBox.classList.add('hidden');
- }
- });
-}
-
-function prependToLast(field, value) {
- const separatorIndex = field.value.lastIndexOf(',');
- const advanceBy = field.value[separatorIndex + 1] === ' ' ? 2 : 1;
- field.value =
- field.value.slice(0, separatorIndex + advanceBy) + value + field.value.slice(separatorIndex + advanceBy);
-}
-
-function selectLast(field, characterCount) {
- field.focus();
-
- field.selectionStart = field.value.length - characterCount;
- field.selectionEnd = field.value.length;
-}
-
-function executeFormHelper(e) {
- const searchField = $('.js-search-field');
- const attr = name => e.target.getAttribute(name);
-
- attr('data-search-add') && addTag(searchField, attr('data-search-add'));
- attr('data-search-show-help') && showHelp(e.target.textContent, attr('data-search-show-help'));
- attr('data-search-select-last') && selectLast(searchField, parseInt(attr('data-search-select-last'), 10));
- attr('data-search-prepend') && prependToLast(searchField, attr('data-search-prepend'));
-}
-
-function setupSearch() {
- const form = $('.js-search-form');
-
- form && form.addEventListener('click', executeFormHelper);
-}
-
-export { setupSearch };
diff --git a/assets/js/search.ts b/assets/js/search.ts
new file mode 100644
index 00000000..eff8d98a
--- /dev/null
+++ b/assets/js/search.ts
@@ -0,0 +1,85 @@
+import { assertNotNull, assertNotUndefined } from './utils/assert';
+import { $, $$, showEl, hideEl } from './utils/dom';
+import { delegate, leftClick } from './utils/events';
+import { addTag } from './tagsinput';
+
+function focusAndSelectLast(field: HTMLInputElement, characterCount: number) {
+ field.focus();
+ field.selectionStart = field.value.length - characterCount;
+ field.selectionEnd = field.value.length;
+}
+
+function prependToLast(field: HTMLInputElement, value: string) {
+ // Find the last comma in the input and advance past it
+ const separatorIndex = field.value.lastIndexOf(',');
+ const advanceBy = field.value[separatorIndex + 1] === ' ' ? 2 : 1;
+
+ // Insert the value string at the new location
+ field.value = [
+ field.value.slice(0, separatorIndex + advanceBy),
+ value,
+ field.value.slice(separatorIndex + advanceBy),
+ ].join('');
+}
+
+function getAssociatedData(target: HTMLElement) {
+ const form = assertNotNull(target.closest('form'));
+ const input = assertNotNull($('.js-search-field', form));
+ const helpBoxes = $$('[data-search-help]', form);
+
+ return { input, helpBoxes };
+}
+
+function showHelp(helpBoxes: HTMLDivElement[], typeName: string, subject: string) {
+ for (const helpBox of helpBoxes) {
+ // Get the subject name span
+ const subjectName = assertNotNull($('.js-search-help-subject', helpBox));
+
+ // Take the appropriate action for this help box
+ if (helpBox.dataset.searchHelp === typeName) {
+ subjectName.textContent = subject;
+ showEl(helpBox);
+ } else {
+ hideEl(helpBox);
+ }
+ }
+}
+
+function onSearchAdd(_event: Event, target: HTMLAnchorElement) {
+ // Load form
+ const { input, helpBoxes } = getAssociatedData(target);
+
+ // Get data for this link
+ const addValue = assertNotUndefined(target.dataset.searchAdd);
+ const showHelpValue = assertNotUndefined(target.dataset.searchShowHelp);
+ const selectLastValue = target.dataset.searchSelectLast;
+
+ // Add the tag
+ addTag(input, addValue);
+
+ // Show associated help, if available
+ showHelp(helpBoxes, showHelpValue, assertNotNull(target.textContent));
+
+ // Select last characters, if requested
+ if (selectLastValue) {
+ focusAndSelectLast(input, Number(selectLastValue));
+ }
+}
+
+function onSearchPrepend(_event: Event, target: HTMLAnchorElement) {
+ // Load form
+ const { input } = getAssociatedData(target);
+
+ // Get data for this link
+ const prependValue = assertNotUndefined(target.dataset.searchPrepend);
+
+ // Prepend
+ prependToLast(input, prependValue);
+}
+
+export function setupSearch() {
+ delegate(document, 'click', {
+ 'form.js-search-form a[data-search-add][data-search-show-help]': leftClick(onSearchAdd),
+ 'form.js-search-form a[data-search-prepend]': leftClick(onSearchPrepend),
+ });
+}