1079 lines
37 KiB
JavaScript
1079 lines
37 KiB
JavaScript
//public DONE
|
|
function phac_aspc_autocomplete_trigger_change(container_id) {
|
|
setTimeout(() => {
|
|
const container = document.getElementById(container_id);
|
|
const el = container.querySelector('.textinput');
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}, 0)
|
|
}
|
|
//private DONE
|
|
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');
|
|
}
|
|
}
|
|
//private DONE
|
|
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 = {}
|
|
// public method DONE
|
|
function phac_aspc_autocomplete_blur_handler(event, name, sync = false, item = false) {
|
|
// Handler responsible for blur events
|
|
// Will remove the results when focus is no longer on the component, and update
|
|
// the <input> 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 <input> box
|
|
const el = document.getElementById(name + '__textinput');
|
|
|
|
// Abort active HTMX operations on the input box to avoid race conditions
|
|
htmx.trigger(el, 'htmx:abort');
|
|
|
|
// Set the text input value
|
|
const data_el = document.getElementById(name + '__data');
|
|
if (!sync) {
|
|
el.value = '';
|
|
} else {
|
|
el.value = data_el.getAttribute('data-phac-aspc-autocomplete');
|
|
}
|
|
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', () => {
|
|
phac_aspc_autocomplete_hide_results(parent);
|
|
}
|
|
);
|
|
}
|
|
// Hide the results
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
//public DONE
|
|
function phac_aspc_autocomplete_item_click_handler(event) {
|
|
// once an item is clicked, before HTMX swaps in new values
|
|
// focus on the text input and hide the results
|
|
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;
|
|
}
|
|
//public DONE
|
|
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 = {};
|
|
//private DONE
|
|
function phac_aspc_autocomplete_set_initial_value(container, reset = false) {
|
|
// stores current value of text input in variable
|
|
// to enable restoring this value if a new value is not selected
|
|
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 = {};
|
|
//public DONE
|
|
function phac_aspc_autocomplete_click_handler(event) {
|
|
if (event.target.classList.contains('item')){
|
|
// This never happens?
|
|
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 = {};
|
|
//public DONE
|
|
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 = {};
|
|
//public DONE
|
|
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) {
|
|
// 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 = (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 && must_skip ? dir(element) : element;
|
|
let counter = count;
|
|
while (el && counter > 0) {
|
|
if (el.getAttribute('href')) {
|
|
if (counter === 1) return el;
|
|
}
|
|
if (counter !== 1) counter -= 1;
|
|
el = dir(el);
|
|
}
|
|
if (counter > 0) return fallback;
|
|
return null;
|
|
}
|
|
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[id])
|
|
clearTimeout(debounce[id]);
|
|
debounce[id] =
|
|
setTimeout(
|
|
() => focusWhenResultsShown(container, timeout - 100, up),
|
|
100
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
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) => {
|
|
const r1 = container.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
|
|
if (results && results.classList.contains('show')) {
|
|
phac_aspc_autocomplete_clear_focus(container, true);
|
|
phac_aspc_autocomplete_hide_results(container);
|
|
phac_aspc_autocomplete_closed[id] = true;
|
|
} else {
|
|
event.target.value = '';
|
|
}
|
|
} 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.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.keyCode === 33) {
|
|
// Page up key
|
|
if (results) {
|
|
const prev = whereTo(
|
|
container,
|
|
false,
|
|
true,
|
|
getPageSize(results)
|
|
);
|
|
if (prev) switchFocus(prev, container);
|
|
return false;
|
|
}
|
|
} else if (event.keyCode === 34) {
|
|
// Page down key
|
|
if (results) {
|
|
const next = whereTo(
|
|
container,
|
|
true,
|
|
true,
|
|
getPageSize(results)
|
|
);
|
|
if (next) switchFocus(next, container);
|
|
return false;
|
|
}
|
|
} else if (event.keyCode === 40) {
|
|
// 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) {
|
|
// up arrow on item
|
|
// 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;
|
|
} else {
|
|
phac_aspc_autocomplete_closed[id] = false;
|
|
}
|
|
phac_aspc_autocomplete_clear_focus(container, true);
|
|
return true;
|
|
}
|
|
|
|
|
|
KEY_CODES = {
|
|
ENTER: 13,
|
|
ESCAPE: 27,
|
|
UP_ARROW: 38,
|
|
DOWN_ARROW: 40,
|
|
BACKSPACE: 8,
|
|
PAGE_UP: 33,
|
|
PAGE_DOWN: 34,
|
|
BEGINNING_OF_PRINTABLE_KEYS: 48,
|
|
}
|
|
|
|
class PhacAutocomplete {
|
|
/*
|
|
Instances are stateless, creation has zero side-effects
|
|
state is stored in static properties
|
|
|
|
instances don't cache anything, they never go stale either
|
|
|
|
A bit arbitrary what behavior goes in static vs instance methods
|
|
*/
|
|
|
|
componentId //only instance variable
|
|
|
|
static existingValuesById = {
|
|
/*
|
|
Existing values are stored
|
|
to reset the input to its previous value
|
|
if a new value is not selected
|
|
*/
|
|
|
|
}
|
|
static closedStateById = {
|
|
/*
|
|
TODO: wtf is this
|
|
*/
|
|
}
|
|
|
|
static debouncedKeyUpTimeoutsById = {
|
|
/*
|
|
here we store timeout references for debounced keyup events
|
|
we store them in case we need to clear them
|
|
*/
|
|
}
|
|
|
|
static debouncedKeyDownTimeoutsById = {
|
|
/*
|
|
here we store timeout references for debounced keydown events
|
|
we store them in case we need to clear them
|
|
*/
|
|
}
|
|
|
|
static blurSkipsById = {
|
|
// TODO idk what this does yet
|
|
}
|
|
|
|
constructor(componentId) {
|
|
this.componentId = componentId;
|
|
}
|
|
|
|
static getClosestComponentId(element){
|
|
const ac_root = element.closest('[data-autocomplete-root]');
|
|
if(!ac_root){
|
|
return null;
|
|
}
|
|
if(!isEnabledAcRoot(ac_root)){
|
|
return null;
|
|
}
|
|
return ac_root.getAttribute('data-autocomplete-componentId');
|
|
}
|
|
|
|
static getInstanceForElement(element){
|
|
const componentId = this.getClosestComponentId(element);
|
|
if(!componentId){
|
|
return null;
|
|
}
|
|
|
|
return new this(componentId);
|
|
|
|
}
|
|
|
|
|
|
static emitChangeEvent(containerId){
|
|
// imperatively emit 'change' event,
|
|
// in case consumer code is listening
|
|
setTimeout(() => {
|
|
const container = document.getElementById(containerId);
|
|
const el = container.querySelector('.textinput');
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}, 0)
|
|
}
|
|
|
|
static focusInputHandler(event){
|
|
const instance = this.getInstanceForElement(event.target);
|
|
|
|
instance.resetFocus(true);
|
|
instance.storeExistingValue();
|
|
setTimeout(() => {
|
|
// Announce selected items to screen readers. (if any)
|
|
const info = instance.getInfo();
|
|
info.innerHTML += ' ';
|
|
}, 100);
|
|
|
|
}
|
|
|
|
static inputClickHandler(event){
|
|
const instance = this.getInstanceForElement(event.target);
|
|
instance.storeExistingValue();
|
|
instance.resetFocus(true);
|
|
|
|
const isOpen = instance.getIsOpen();
|
|
if (isOpen) {
|
|
instance.hideResults();
|
|
} else {
|
|
instance.setIsClosed( false );
|
|
instance.triggerNewSearch();
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
static itemClickHandler(event){
|
|
const instance = this.getInstanceForElement(event.target);
|
|
if(instance.getIsOpen()){
|
|
instance.resetFocus(true);
|
|
instance.hideResults();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static inputFocusHandler(event){
|
|
const instance = this.getInstanceForElement(event.target);
|
|
instance.resetFocus(true);
|
|
instance.storeExistingValue();
|
|
setTimeout(() => {
|
|
// Announce selected items to screen readers. (if any)
|
|
const info = instance.getInfo();
|
|
info.innerHTML += ' ';
|
|
}, 100);
|
|
}
|
|
|
|
static keyUpHandler(event){
|
|
if (event.keyCode === KEY_CODES.ENTER) {
|
|
return false;
|
|
}
|
|
|
|
const instance = this.getInstanceForElement(event.target);
|
|
const componentId = instance.getComponentId();
|
|
|
|
const existingValue = instance.getExistingValue();
|
|
instance.storeExistingValue();
|
|
|
|
if( this.debouncedKeyUpTimeoutsById[componentId] ){
|
|
clearTimeout( this.debouncedKeyUpTimeoutsById[componentId] );
|
|
this.debouncedKeyUpTimeoutsById[componentId] = false;
|
|
}
|
|
|
|
const value = event.target.value;
|
|
|
|
const timeOut = setTimeout( () => {
|
|
const isClosedValue = instance.getIsClosedValue();
|
|
|
|
// if value has changed and it's still open, trigger new search
|
|
if( !isClosedValue && value != existingValue ){
|
|
instance.triggerNewSearch();
|
|
} else if (
|
|
// this probably isn't common?
|
|
// but if results are closed, and then user backspaces to empty
|
|
// reopen the results
|
|
isClosedValue &&
|
|
value != existingValue &&
|
|
value == ''
|
|
) {
|
|
instance.setIsClosed( false );
|
|
}
|
|
|
|
instance.storeExistingValue(value);
|
|
}, 250);
|
|
|
|
this.debouncedKeyUpTimeoutsById[componentId] = timeOut;
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
static keyDownHandler(event){
|
|
// this is complex
|
|
const keyCode = event.keyCode;
|
|
const instance = this.getInstanceForElement(event.target);
|
|
const componentId = instance.getComponentId();
|
|
|
|
if(!event.target.classList.contains('textinput')){
|
|
console.warn('keydown event on non textinput element in autocomplete component');
|
|
return true;
|
|
}
|
|
|
|
if(keyCode >= KEY_CODES.BEGINNING_OF_PRINTABLE_KEYS){
|
|
// Expands the min-width of text input to a reasonable size when typing
|
|
instance.getInput().parentElement.classList.add('ac-active');
|
|
} else if(keyCode === KEY_CODES.BACKSPACE && instance.getInput().value.length === 1){
|
|
// Shrinks the min-width of text input back to the (small) default if
|
|
// the text input is empty due to backspacing
|
|
|
|
instance.getInput().parentElement.classList.remove('ac-active');
|
|
}
|
|
|
|
|
|
const getWhatToFocus = (instance, down = true, skip_element = true, count = 1) => {
|
|
// this could be an instance method
|
|
|
|
// determine which element should receive focus
|
|
if(!instance){
|
|
return null;
|
|
}
|
|
|
|
const results = instance.getResultItems();
|
|
const currentlyFocused = instance.getContainer().querySelector('.hasFocus');
|
|
const mustSkip = Boolean(currentlyFocused);
|
|
const fallback = down ?
|
|
results.querySelector('a:first-child')
|
|
: results.querySelector('a:last-child');
|
|
let element = currentlyFocused || fallback;
|
|
if(!element){
|
|
return null;
|
|
}
|
|
const dir = down ?
|
|
elem => elem.nextElementSibling
|
|
: elem => elem.previousElementSibling;
|
|
|
|
let el = skip_element && mustSkip ? dir(element) : element;
|
|
let counter = count;
|
|
while(el && counter > 0){
|
|
if(el.getAttribute('href')){
|
|
if(counter === 1){
|
|
return el;
|
|
}
|
|
}
|
|
if(counter !== 1){
|
|
counter -= 1;
|
|
}
|
|
el = dir(el);
|
|
}
|
|
if(counter > 0){
|
|
return fallback;
|
|
}
|
|
return null;
|
|
|
|
}
|
|
|
|
const switchFocus = (element, instance) => {
|
|
// this could be an instance method
|
|
instance.resetFocus();
|
|
const input = instance.getInput();
|
|
input.setAttribute('aria-activedescendant', element.getAttribute('id'));
|
|
element.classList.add('hasFocus');
|
|
element.scrollIntoView({ block: 'nearest' })
|
|
}
|
|
|
|
const selectFocusedItem = (instance) => {
|
|
// this could be an instance method
|
|
const item = instance.getContainer().querySelector('.hasFocus');
|
|
if(item){
|
|
item.dispatchEvent(new Event('click'));
|
|
}
|
|
return item;
|
|
}
|
|
|
|
const focusWhenResultsShown = (instance, timeout, up) => {
|
|
// this could be an instance method
|
|
// uses polling to wait for the results to be shown before
|
|
// moving focus.
|
|
const componentId = instance.getComponentId();
|
|
const results = instance.getResultItems();
|
|
if(!results || !results.classList.contains('show')){
|
|
if(timeout > 0){
|
|
if( this.debouncedKeyDownTimeoutsById[componentId] ){
|
|
clearTimeout( this.debouncedKeyDownTimeoutsById[componentId] );
|
|
}
|
|
this.debouncedKeyDownTimeoutsById[componentId] =
|
|
setTimeout(
|
|
() => focusWhenResultsShown(instance, timeout - 100, up),
|
|
100
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
// TODO: investigate if this is the bug?
|
|
this.debouncedKeyDownTimeoutsById[componentId] = undefined;
|
|
instance.setIsClosed( false );
|
|
if(up){
|
|
const prev = getWhatToFocus(instance, false);
|
|
if(prev){
|
|
switchFocus(prev, instance);
|
|
}
|
|
} else {
|
|
const next = getWhatToFocus(instance, true, false);
|
|
if(next){
|
|
switchFocus(next, instance);
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
const getPageSize = (instance) => {
|
|
const container = instance.getContainer();
|
|
const exampleItem = container.querySelector('.item');
|
|
const r1 = container.getBoundingClientRect();
|
|
const r2 = exampleItem.getBoundingClientRect();
|
|
return Math.floor((r1.bottom - r1.top) / (r2.bottom - r2.top));
|
|
}
|
|
|
|
|
|
instance.storeExistingValue();
|
|
|
|
if(keyCode === KEY_CODES.ESCAPE){
|
|
if(instance.getIsOpen()){
|
|
instance.resetFocus(true);
|
|
instance.hideResults();
|
|
instance.setIsClosed( true );
|
|
} else {
|
|
event.target.value = '';
|
|
}
|
|
}
|
|
|
|
else if(keyCode === KEY_CODES.ENTER){
|
|
if(instance.getIsOpen()){
|
|
selectFocusedItem(instance);
|
|
instance.resetFocus(true);
|
|
instance.hideResults();
|
|
}
|
|
return false;
|
|
} else if (
|
|
keyCode === KEY_CODES.BACKSPACE && instance.getInput().value.length === 0
|
|
) {
|
|
const chips = instance.getChips();
|
|
if(chips.length > 0){
|
|
chips[chips.length - 1].querySelector('a').dispatchEvent(new Event('click'));
|
|
}
|
|
} else if(keyCode === KEY_CODES.PAGE_UP){
|
|
if(instance.getIsOpen()){
|
|
const prev = getWhatToFocus(
|
|
instance,
|
|
false,
|
|
true,
|
|
getPageSize(instance)
|
|
);
|
|
if(prev){
|
|
switchFocus(prev, instance);
|
|
}
|
|
return false;
|
|
}
|
|
} else if(keyCode === KEY_CODES.PAGE_DOWN){
|
|
|
|
if(instance.getIsOpen()){
|
|
const next = getWhatToFocus(
|
|
instance,
|
|
true,
|
|
true,
|
|
getPageSize(instance)
|
|
);
|
|
if(next){
|
|
switchFocus(next, instance);
|
|
}
|
|
return false;
|
|
}
|
|
} else if(keyCode === KEY_CODES.DOWN_ARROW){
|
|
// down arrow
|
|
// Open the results if they are not shown
|
|
if(!instance.getIsOpen()){
|
|
//event.target.dispatchEvent(new Event('phac_aspc_autocomplete_trigger'));
|
|
instance.triggerNewSearch();
|
|
if(event.altKey){
|
|
|
|
instance.setIsClosed( false );
|
|
}
|
|
else {
|
|
focusWhenResultsShown(instance, 3000);
|
|
}
|
|
} else {
|
|
const next = getWhatToFocus(instance);
|
|
if(next){
|
|
switchFocus(next, instance);
|
|
}
|
|
}
|
|
return false;
|
|
} else if(keyCode === KEY_CODES.UP_ARROW){
|
|
// up arrow on item
|
|
// Open the results if they are not shown
|
|
if(!instance.getIsOpen()){
|
|
instance.triggerNewSearch();
|
|
if(event.altKey){
|
|
instance.setIsClosed( false );
|
|
} else {
|
|
focusWhenResultsShown(instance, 3000, true);
|
|
}
|
|
} else {
|
|
const prev = getWhatToFocus(instance, false);
|
|
if(prev){
|
|
switchFocus(prev, instance);
|
|
}
|
|
}
|
|
return false;
|
|
} else {
|
|
instance.setIsClosed( false );
|
|
}
|
|
instance.resetFocus(true);
|
|
return true;
|
|
}
|
|
|
|
static blurHandler(event, name, sync=false, item=false){
|
|
// Handler responsible for blur events
|
|
// Will remove the results when focus is no longer on the component, and update
|
|
// the <input> box value when multiselect is false
|
|
const instance = this.getInstanceForElement(event.target);
|
|
const componentId = instance.getComponentId();
|
|
|
|
requestAnimationFrame( () => {
|
|
const container = instance.getContainer();
|
|
if(this.blurSkipsById[componentId]){
|
|
return false;
|
|
}
|
|
if( !container.contains(document.activeElement) ){
|
|
// Focus has left the component
|
|
|
|
// Reset the component's state
|
|
instance.setIsClosed( false );
|
|
if( this.debouncedKeyUpTimeoutsById[componentId] ){
|
|
clearTimeout( this.debouncedKeyUpTimeoutsById[componentId] )
|
|
this.debouncedKeyUpTimeoutsById[componentId] = false;
|
|
}
|
|
|
|
//also abort any active htmx operations
|
|
const input = instance.getInput();
|
|
htmx.trigger(input, 'htmx:abort');
|
|
|
|
// Set the text input value, using the syncronized 'data' element
|
|
if(!sync){
|
|
input.value = '';
|
|
} else {
|
|
const dataContainer = instance.getDataContainer();
|
|
input.value = dataContainer.getAttribute('data-phac-aspc-autocomplete');
|
|
}
|
|
instance.resetExistingValue();
|
|
|
|
const results = instance.getResultItems();
|
|
const live = instance.getInfo();
|
|
|
|
// 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', () => {
|
|
instance.hideResults();
|
|
}
|
|
);
|
|
}
|
|
// Hide the results
|
|
instance.hideResults();
|
|
|
|
// Clear the live info
|
|
live.innerHTML = '';
|
|
|
|
// Change the min-width of the text input back to the (small) default
|
|
instance.getInput().parentElement.classList.remove('ac-active');
|
|
|
|
// Ensure no elements remain 'focused', and set focus to input
|
|
instance.resetFocus(item);
|
|
}
|
|
});
|
|
}
|
|
|
|
//getters
|
|
getComponentId(){
|
|
return this.componentId;
|
|
}
|
|
getContainer(){
|
|
return document.getElementById(`${this.getComponentId()}__container`);
|
|
}
|
|
getInput(){
|
|
return this.getContainer().querySelector(`#${this.getComponentId()}__textinput`);
|
|
}
|
|
getInputWrapper(){
|
|
return this.getContainer().querySelector(`#${this.getComponentId()}`);
|
|
}
|
|
getResultItems(){
|
|
return this.getContainer().querySelector(`#${this.getComponentId()}__items`);
|
|
}
|
|
getInfo(){
|
|
return this.getContainer().querySelector(`#${this.getComponentId()}__info`);
|
|
}
|
|
getDataContainer(){
|
|
return this.getContainer().querySelector(`#${this.getComponentId()}__data`);
|
|
}
|
|
getFocusRing(){
|
|
return this.getContainer().closest('.phac_aspc_form_autocomplete_focus_ring');
|
|
}
|
|
getIsOpen(){
|
|
const results = this.getResultItems();
|
|
return results && results.classList.contains('show');
|
|
}
|
|
getIsMulti(){
|
|
return this.getContainer().hasAttribute('data-autocomplete-multiselect');
|
|
}
|
|
|
|
|
|
// behavioral methods
|
|
|
|
storeExistingValue(value=null){
|
|
|
|
if(value === null){
|
|
value = this.getInput().value;
|
|
}
|
|
this.constructor.existingValuesById[this.getComponentId()] = value;
|
|
}
|
|
|
|
getExistingValue(){
|
|
return this.constructor.existingValuesById[this.getComponentId()];
|
|
}
|
|
|
|
resetExistingValue(){
|
|
this.constructor.existingValuesById[this.getComponentId()] = undefined;
|
|
}
|
|
|
|
resetFocus(shouldFocusRing){
|
|
const hasFocus = this.getContainer().querySelectorAll('.hasFocus');
|
|
for (const el of hasFocus) {
|
|
el.classList.remove('hasFocus');
|
|
}
|
|
|
|
this.getInput().removeAttribute('aria-activedescendant');
|
|
if (shouldFocusRing) {
|
|
this.getFocusRing().classList.add('active');
|
|
this.getInput().focus();
|
|
} else {
|
|
this.getFocusRing().classList.remove('active');
|
|
}
|
|
|
|
}
|
|
|
|
getIsClosedValue(){
|
|
// whether the stored value indicates the results are closed
|
|
// Different than getIsOpen, which checks the live DOM state
|
|
return this.constructor.closedStateById[this.getComponentId()] || false;
|
|
}
|
|
|
|
|
|
setIsClosed(isClosed){
|
|
this.constructor.closedStateById[this.getComponentId()] = isClosed;
|
|
}
|
|
|
|
hideResults(){
|
|
this.getInput().setAttribute('aria-expanded', false);
|
|
this.getResultItems().classList.remove('show');
|
|
this.setIsClosed( true );
|
|
}
|
|
|
|
triggerNewSearch(){
|
|
this.getInput().dispatchEvent(new Event('phac_aspc_autocomplete_trigger'));
|
|
}
|
|
|
|
getSrDescription(){
|
|
return this.getContainer().querySelector(`#${this.getComponentId()}__sr_description`);
|
|
}
|
|
|
|
getChips(){
|
|
return this.getContainer().querySelectorAll(`#${this.getComponentId()}_ac_container li.chip`);
|
|
}
|
|
|
|
clear(){
|
|
this.getInput().value = '';
|
|
this.getInputWrapper().innerHTML = '';
|
|
this.getResultItems().innerHTML = '';
|
|
this.getInfo().innerHTML = '';
|
|
this.getDataContainer().removeAttribute('data-phac-aspc-autocomplete');
|
|
this.getChips().forEach(chip => chip.remove());
|
|
|
|
const sr = this.getSrDescription()
|
|
if (sr) {
|
|
sr.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
function isEnabledAcRoot(element){
|
|
if(!element){
|
|
return false;
|
|
}
|
|
if(!element.hasAttribute('data-autocomplete-root')){
|
|
return false;
|
|
}
|
|
if(element.hasAttribute('data-autocomplete-disabled')){
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function initializeGlobalAutocompleteListeners(){
|
|
|
|
const skipBlurOnEvents = ['mousedown', 'mouseup'];
|
|
|
|
for(const eventName of skipBlurOnEvents){
|
|
document.addEventListener(eventName, function(event){
|
|
const ac_root = event.target.closest('[data-autocomplete-root]');
|
|
if(!isEnabledAcRoot(ac_root)){
|
|
return;
|
|
}
|
|
const componentId = ac_root.getAttribute('data-autocomplete-componentId');
|
|
phac_aspc_autocomplete_blur_skip[componentId] = true;
|
|
});
|
|
}
|
|
|
|
|
|
document.addEventListener('click', function(event){
|
|
const target = event.target;
|
|
const ac_root = target.closest('[data-autocomplete-root]');
|
|
if(!isEnabledAcRoot(ac_root)){
|
|
return;
|
|
}
|
|
if (
|
|
target === ac_root ||
|
|
target.classList.contains('ac_container') ||
|
|
target.classList.contains('textinput')
|
|
){
|
|
return PhacAutocomplete.inputClickHandler(event);
|
|
}
|
|
});
|
|
|
|
|
|
document.body.addEventListener(
|
|
'htmx:afterSettle', (event) => {
|
|
const ac_root = event.detail.elt.closest('[data-autocomplete-root]');
|
|
if(!isEnabledAcRoot(ac_root)){
|
|
return;
|
|
}
|
|
const componentId = ac_root.getAttribute('data-autocomplete-componentId');
|
|
const toggleurl = ac_root.getAttribute('data-autocomplete-toggleurl');
|
|
|
|
if (event.detail.elt.getAttribute('id') === `${componentId}__items`) {
|
|
const shown = event.detail.elt.classList.contains('show');
|
|
const el = document.querySelector(`#${componentId}__textinput`);
|
|
el.setAttribute('aria-expanded', shown);
|
|
} else if (
|
|
event.detail.elt.getAttribute('id') === componentId &&
|
|
event.detail.pathInfo.requestPath === toggleurl
|
|
) {
|
|
PhacAutocomplete.emitChangeEvent(`${componentId}__container`);
|
|
}
|
|
}
|
|
);
|
|
document.body.addEventListener(
|
|
'htmx:oobAfterSwap', (event) => {
|
|
|
|
const instance = PhacAutocomplete.getInstanceForElement(event.detail.elt);
|
|
if(!instance){
|
|
return;
|
|
}
|
|
|
|
if (event.detail.elt.getAttribute('id') === instance.getInput().getAttribute('id')) {
|
|
setTimeout(() => {
|
|
if(instance){
|
|
// instance.storeExistingValue();
|
|
instance.resetExistingValue();
|
|
}
|
|
//phac_aspc_autocomplete_set_initial_value(document.querySelector(`#${componentId}__container`), true);
|
|
const input = instance.getInput()
|
|
input.selectionStart = input.selectionEnd = input.value.length;
|
|
}, 0)
|
|
}
|
|
}
|
|
);
|
|
document.body.addEventListener('htmx:configRequest', function(event) {
|
|
const ac_root = event.detail.elt.closest('[data-autocomplete-root]');
|
|
if(!ac_root){
|
|
return;
|
|
}
|
|
if (
|
|
ac_root.contains(event.detail.elt) &&
|
|
event.detail.path.endsWith('/toggle')
|
|
) {
|
|
// HTMX will include the entire form for non get requests, so we update
|
|
// it here. If the name is being used in multiple locations in the
|
|
// form, without this intervention it will not work as expected.
|
|
event.detail.parameters = {
|
|
...event.detail.parameters,
|
|
...htmx.values(event.detail.elt, 'get'),
|
|
}
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
initializeGlobalAutocompleteListeners();
|
|
|
|
|
|
|