commit 09c294302b87b1e0bade0f49dab8f51b9e033a31
parent 1703eb1419f63338a1a22bef1dc55de9406858d9
Author: Katja (ctucx) <git@ctu.cx>
Date: Sat, 25 Jan 2025 10:04:12 +0100
parent 1703eb1419f63338a1a22bef1dc55de9406858d9
Author: Katja (ctucx) <git@ctu.cx>
Date: Sat, 25 Jan 2025 10:04:12 +0100
searchView.js: improve input and suggestion logic dramaticly
2 files changed, 145 insertions(+), 183 deletions(-)
M
|
272
+++++++++++++++++++++++++++++++++++--------------------------------------------
diff --git a/src/searchView.js b/src/searchView.js @@ -9,6 +9,7 @@ import { showAlertModal, showSelectModal, showLoader, hideOverlay} from './overl import { showSettings } from './settingsView.js'; import { client } from './hafas_client'; +let numEnter = 0; const suggestions = { from: {}, via: {}, @@ -63,28 +64,28 @@ const iconFor = id => { const searchTemplate = (journeysHistory) => html` <div class="searchView"> <h1 class="title center">TrainSearch</h1> - <form class="column" onsubmit="return false;"> + <form class="column" id="form"> <div class="row nowrap"> - <label for="from">${t('from')}:</label> - <input type="text" name="from" id="from" placeholder="${t('from')}" value="${fromValue}" autocomplete="off" @focus=${startTyping} @blur=${stopTyping} @keyup=${onKeyup} @keydown=${onKeydown} required> - <div class="button icon-arrow2" id="viaButton" title="Via" @click=${toggleVia}></div> + <input type="text" name="from" id="from" title="${t('from')}" placeholder="${t('from')}" value="${fromValue}" autocomplete="off" + @focus=${focusHandler} @blur=${blurHandler} @keydown=${keydownHandler} @keyup=${keyupHandler} @input=${loadSuggestions} required> + <div class="button icon-arrow2" id="viaButton" title="${t('via')}" @click=${toggleVia}></div> </div> - <div class="suggestions" id="fromSuggestions"></div> + <div class="suggestions" id="fromSuggestions" @mouseover=${mouseOverHandler} @mouseout=${mouseOutHandler}></div> <div class="row nowrap hidden" id="viaRow"> - <label for="via">${t('via')}:</label> - <input type="text" name="via" id="via" placeholder="${t('via')}" value="${viaValue}" autocomplete="off" @focus=${startTyping} @blur=${stopTyping} @keyup=${onKeyup} @keydown=${onKeydown} required> + <input type="text" name="via" id="via" title="${t('via')}" placeholder="${t('via')}" value="${viaValue}" autocomplete="off" + @focus=${focusHandler} @blur=${blurHandler} @keydown=${keydownHandler} @keyup=${keyupHandler} @input=${loadSuggestions}> <div class="button icon-arrow2 invisible"></div> </div> - <div class="suggestions" id="viaSuggestions"></div> + <div class="suggestions" id="viaSuggestions" @mouseover=${mouseOverHandler} @mouseout=${mouseOutHandler}></div> <div class="row nowrap"> - <label for="to">${t('to')}:</label> - <input type="text" name="to" id="to" placeholder="${t('to')}" value="${toValue}" autocomplete="off" @focus=${startTyping} @blur=${stopTyping} @keyup=${onKeyup} @keydown=${onKeydown} required> + <input type="text" name="to" id="to" title="${t('to')}" placeholder="${t('to')}" value="${toValue}" autocomplete="off" + @focus=${focusHandler} @blur=${blurHandler} @keydown=${keydownHandler} @keyup=${keyupHandler} @input=${loadSuggestions} required> <div class="button icon-swap" title="${t('swap')}" @click=${swapFromTo}></div> </div> - <div class="suggestions" id="toSuggestions"></div> + <div class="suggestions" id="toSuggestions" @mouseover=${mouseOverHandler} @mouseout=${mouseOutHandler}></div> <div class="row"> <div class="selector"> @@ -94,38 +95,32 @@ const searchTemplate = (journeysHistory) => html` <label for="arrival">${t('arrival')}</label> </div> - <label for="date">${t('date')}:</label> - <input type="date" name="date" id="date" value="${dateValue}" placeholder="${t('date')}" pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}" @keydown=${onKeypressSubmit} required> - - <label for="time"><br>${t('time')}:</label> - <input type="time" name="time" id="time" value="${timeValue}" placeholder="${t('time')}" pattern="[0-9]{2}:[0-9]{2}" @keydown=${onKeypressSubmit} required> + <input type="date" name="date" id="date" title="${t('date')}" value="${dateValue}" placeholder="${t('date')}" pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}" required> + <input type="time" name="time" id="time" title="${t('time')}" value="${timeValue}" placeholder="${t('time')}" pattern="[0-9]{2}:[0-9]{2}" required> </div> <div class="row"> - <span class="hidden">${t('products')}:</span> <div class="selector rectangular"> ${client.profile.products.map(prod => html` <input type="checkbox" id="${prod.id}" name="${prod.id}" checked> - <label class="${iconFor(prod.id)}" for="${prod.id}" title="${prod.name}">${prod.name}<br></label> + <label class="${iconFor(prod.id)}" for="${prod.id}" title="${t('product')}: ${prod.name}"></label> `)} </div> - - <span class="hidden">${t('accessibility')}:</span> <div class="selector rectangular"> <input type="radio" id="accessibilityNone" name="accessibility" value="none" ?checked=${settings.accessibility === 'none'}> - <label class="icon-walk-fast" for="accessibilityNone" title="${t('access_none')}">${t('access_none')}<br></label> + <label class="icon-walk-fast" for="accessibilityNone" title="${t('accessibility')}: ${t('access_none')}"></label> <input type="radio" id="accessibilityPartial" name="accessibility" value="partial" ?checked=${settings.accessibility === 'partial'}> - <label class="icon-walk" for="accessibilityPartial" title="${t('access_partial')}">${t('access_partial')}<br></label> + <label class="icon-walk" for="accessibilityPartial" title="${t('accessibility')}: ${t('access_partial')}"></label> <input type="radio" id="accessibilityComplete" name="accessibility" value="complete" ?checked=${settings.accessibility === 'complete'}> - <label class="icon-weelchair" for="accessibilityComplete" title="${t('access_full')}">${t('access_full')}<br></label> + <label class="icon-weelchair" for="accessibilityComplete" title="${t('accessibility')}: ${t('access_full')}"></label> </div> <div class="filler"></div> <div class="button icon-settings" title="${t('settings')}" @click=${showSettings}></div> - <div class="button go" tabindex="0" id="go" @click=${search}>${t('search')}</div> + <button type="submit" tabindex="0" id="go" class="go" >${t('search')}</button> </div> ${journeysHistory.length ? html` @@ -177,6 +172,8 @@ export const searchView = async () => { if (viaValue !== '') toggleVia('show'); + ElementById('form').addEventListener("submit", search); + ElementById('from').focus(); for (const [product, enabled] of Object.entries(settings.products)) { @@ -187,7 +184,9 @@ export const searchView = async () => { } }; -export const search = async (requestId) => { +export const search = async (event) => { + event.preventDefault(); + await modifySettings(settings => { settings.products = readProductSelection(settings); settings.accessibility = document.querySelector('input[name="accessibility"]:checked').value; @@ -201,11 +200,11 @@ export const search = async (requestId) => { let to = ''; currDate = new Date(); - fromValue = ElementById('from').value; - viaValue = ElementById('via').value; - toValue = ElementById('to').value; - dateValue = ElementById('date').value; - timeValue = ElementById('time').value; + fromValue = ElementById('from').value.trim(); + viaValue = ElementById('via').value.trim(); + toValue = ElementById('to').value.trim(); + dateValue = ElementById('date').value.trim(); + timeValue = ElementById('time').value.trim(); isArrival = ElementById('arrival').checked; @@ -221,7 +220,7 @@ export const search = async (requestId) => { return false; } } else { - dateValue = currDate.getFullYear()+'-'+padZeros(currDate.getMonth()+1)+'-'+padZeros(currDate.getDate()); + dateValue = `${currDate.getFullYear()}-${padZeros(currDate.getMonth()+1)}-${padZeros(currDate.getDate())}`; } @@ -232,7 +231,7 @@ export const search = async (requestId) => { return false; } } else { - timeValue = padZeros(currDate.getHours())+':'+padZeros(currDate.getMinutes()); + timeValue = `${padZeros(currDate.getHours())}:${padZeros(currDate.getMinutes())}`; } const split_date = dateValue.split('-'); @@ -316,59 +315,6 @@ export const search = async (requestId) => { return false; }; -const suggestionsTemplate = (data, inputId) => html` - <div @mouseover=${mouseOverSuggestions} @mouseout=${stopMouseOverSuggestions}> - ${data.map(element => html` - <p class="suggestion" @click=${() => setSuggestion(encodeURI(JSON.stringify(element)), inputId)}>${formatName(element)}</p> - `)} - </div> -`; - -const loadSuggestions = async (e) => { - const value = e.target.value; - - suggestions[e.target.id] = {}; - - const results = value ? await Promise.all([ - (async () => { - const ds100Result = await ds100Reverse(value); - if (ds100Result !== null) { - return await client.locations(ds100Result, {'results': 1}); - } - return []; - }) (), - client.locations(value, {'results': 10}) - ]) : [[], []]; - - const data = results[0].concat(results[1]); - - render(suggestionsTemplate(data, e.target.id), ElementById(e.target.id+'Suggestions')); -}; - -export const setSuggestion = (data, inputId) => { - if (typeof data === 'string') { - data = JSON.parse(decodeURI(data)); - } - - ElementById(inputId).value = formatName(data); - suggestions[inputId] = data; - - if (inputId === 'from') { - ElementById('fromSuggestions').classList.remove('mouseover'); - ElementById('to').focus(); - - if (!elementHidden(ElementById('via'))) { - ElementById('via').focus(); - } - } else if (inputId === 'via') { - ElementById('viaSuggestions').classList.remove('mouseover'); - ElementById('to').focus(); - } else if (inputId === 'to') { - ElementById('toSuggestions').classList.remove('mouseover'); - ElementById('to').blur(); - } -}; - export const toggleHistory = () => { const historyElement = ElementById('history'); const buttonElement = ElementById('historyButton'); @@ -429,101 +375,127 @@ export const readProductSelection = settings => { return productsMap; }; -const stopTyping = (e) => ElementById(e.target.id+'Suggestions').classList.remove('typing'); -const startTyping = (e) => { - ElementById(e.target.id+'Suggestions').classList.add('typing'); +const showSuggestions = (id) => { + numEnter = 0; + showElement(ElementById(`${id}Suggestions`)); +}; +const hideSuggestions = (id) => { + const suggestionsElement = ElementById(`${id}Suggestions`); - if (e.target.id == 'from') ElementById('toSuggestions').classList.remove('mouseover'); - if (e.target.id == 'to') ElementById('fromSuggestions').classList.remove('mouseover'); + hideElement(suggestionsElement); + suggestionsElement.classList.remove('mouseover'); }; +const suggestionsTemplate = (data, inputId) => data.map((element, index) => html` + <p id="${index == 0 ? inputId+'Selected' : ''}" class="suggestion" @click=${() => setSuggestion(encodeURI(JSON.stringify(element)), inputId)}>${formatName(element)}</p> +`); + +const loadSuggestions = async (event) => { + const elementId = event.target.id; + const elementValue = event.target.value.trim(); + + suggestions[elementId] = {}; -const mouseOverSuggestions = (e) => { - let el = e.target; - let i = 0; + if (elementValue === '') return; + + const results = elementValue ? await Promise.all([ + (async () => { + const ds100Result = await ds100Reverse(elementValue); + if (ds100Result !== null) return await client.locations(ds100Result, {'results': 1}); + return []; + }) (), + client.locations(elementValue, {'results': 10}) + ]) : [[], []]; - while (i++ < 10 && el.id !== 'fromSuggestions' && el.id !== 'viaSuggestions' && el.id !== 'toSuggestions') el = el.parentElement; + const data = results[0].concat(results[1]); - el.classList.add('mouseover'); + render(suggestionsTemplate(data, elementId), ElementById(`${elementId}Suggestions`)); }; -const stopMouseOverSuggestions = (e) => { - let el = e.target; - let i = 0; +export const setSuggestion = (data, inputId) => { + if (typeof data === 'string') { + data = JSON.parse(decodeURI(data)); + } - while (i++ < 10 && el.id !== 'fromSuggestions' && el.id !== 'viaSuggestions'&& el.id !== 'toSuggestions') el = el.parentElement; + hideSuggestions(inputId); - el.classList.remove('mouseover'); + ElementById(inputId).value = formatName(data); + suggestions[inputId] = data; }; -const onKeyup = (e) => { - const keyCode = e.which || e.keyCode; - const forbiddeKeys = [13, 38, 40]; +const mouseOverHandler = (event) => event.target.parentElement.classList.add('mouseover'); +const mouseOutHandler = (event) => event.target.parentElement.classList.remove('mouseover'); - if (!forbiddeKeys.includes(keyCode)) { - loadSuggestions(e); - } +const focusHandler = (event) => showSuggestions(event.target.id); +const blurHandler = (event) => { + if (!ElementById(`${event.target.id}Suggestions`).classList.contains('mouseover')) hideSuggestions(event.target.id); }; +const keyupHandler = (event) => { + const eventElement = event.target; -const onKeydown = (e) => { - const keyCode = e.which || e.keyCode; + if (event.key !== 'Enter') return true; - if (keyCode == 13) { // enter - if (!ElementById('selected')) { - document.querySelector(`#${e.target.id}Suggestions>div>:first-child`).click(); - if (e.target.id === 'to') ElementById('go').click(); - } else { - const element = ElementById('selected'); - element.id = ''; - element.click(); - } - return false; - } + if (numEnter === 2 && eventElement.value === formatName(suggestions[eventElement.id])) { + numEnter = 0; + switch (eventElement.id) { + case 'from': + ElementById('to').focus(); - if (keyCode == 40) { // keyup - if (!ElementById('selected')) { - document.querySelector(`#${e.target.id}Suggestions>div>:first-child`).id = 'selected'; - } else { - const currentElement = ElementById('selected'); - let nextElement = currentElement.nextElementSibling; + if (!elementHidden(ElementById('via'))) { + ElementById('via').focus(); + } + break; - if (nextElement == null) nextElement = document.querySelector(`#${e.target.id}Suggestions>div>:first-child`); + case 'via': + ElementById('to').focus(); + break; - currentElement.id = ''; - nextElement.id = 'selected'; + case 'to': + hideSuggestions(eventElement.id); + ElementById('go').click(); + break; } - - return false; } +}; - if (keyCode == 38) { // keydown - if (!ElementById('selected')) { - document.querySelector(`#${e.target.id}Suggestions>div>:first-child`).id = 'selected'; - } else { - const currentElement = ElementById('selected'); - let previousElement = currentElement.previousElementSibling; +const keydownHandler = (event) => { + const eventElement = event.target; + const firstSuggestionElement = document.querySelector(`#${eventElement.id}Suggestions>p:first-child`); + const lastSuggestionElement = document.querySelector(`#${eventElement.id}Suggestions>p:last-child`); + const selectedElement = ElementById(`${eventElement.id}Selected`); - if (previousElement == null) previousElement = document.querySelector(`#${e.target.id}Suggestions>div>:last-child`); + if (selectedElement === null) return true; - currentElement.id = ''; - previousElement.id = 'selected'; - } + if (event.key === 'Enter') { + event.preventDefault(); + selectedElement.click(); + numEnter++; + return true; + }; - return false; + if (elementHidden(ElementById(`${eventElement.id}Suggestions`))) { + showSuggestions(eventElement.id); + return true; } - return true; -}; + switch (event.key) { + case 'Tab': + hideSuggestions(eventElement.id); + break; -const onKeypressSubmit = (e) => { - const keyCode = e.which || e.keyCode; + case 'ArrowUp': + case 'ArrowDown': + event.preventDefault(); - if (keyCode == 13) { // enter - ElementById('go').click(); - return false; - } + if (event.shiftKey) break; + + let nextElement = selectedElement.nextElementSibling ?? firstSuggestionElement; + if (event.key === 'ArrowUp') nextElement = selectedElement.previousElementSibling ?? lastSuggestionElement; - return true; + selectedElement.id = ''; + nextElement.id = `${eventElement.id}Selected`; + break; + } };
diff --git a/static/style.css b/static/style.css @@ -254,10 +254,6 @@ header { form { color: white; - label[for=from], label[for=via], label[for=to], label[for=date], label[for=time] { - display: none; - } - #time, #date { flex-grow: 1; } @@ -272,7 +268,7 @@ header { padding: 0; } - .button.go, + button.go, .button.icon-settings { height: 32px; } @@ -286,24 +282,23 @@ header { padding: 3px; } - .button.go { + button.go { display: flex; align-items: center; font-size: 20px; padding: 8px; } - .button.go::after { + button.go::after { width: 24px; height: 24px; margin-left: 5px; - content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24"><path d="M12 2 4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" fill="green"/></svg>'); + content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2 4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" fill="green"/></svg>'); } } .suggestions { position: relative; - display: none; overflow: visible; z-index: 999; height: 0; @@ -327,16 +322,13 @@ header { background-color: #d3d3d3; } - #selected { + #fromSelected, + #viaSelected, + #toSelected { background-color: #bfbfbf !important; } } - .suggestions.typing, - .suggestions.mouseover { - display: block; - } - .history { margin-top: 8px; overflow: hidden; @@ -520,7 +512,7 @@ header { .header { justify-content: space-between; - background-color: #5a5a5a; + background-color: #33691E; color: white; padding: 15px; @@ -641,26 +633,25 @@ button.color:hover, .button.color:hover { margin-top: 2px; } - label.icon-ice, - label.icon-ic, - label.icon-icice, - label.icon-dzug, - label.icon-regional { - font-style: italic; - } - - label.icon-tram:after, - label.icon-bus:after, - label.icon-ferry:after, - label.icon-taxi:after { - font-size: 0.6rem; - } - div:not(:last-child), label:not(:last-child) { border-right: 1px solid #bbb; } + .icon-ice, + .icon-ic, + .icon-icice, + .icon-dzug, + .icon-regional { + font-style: italic; + } + + .icon-tram:after, + .con-bus:after, + .icon-ferry:after, + .icon-taxi:after { + font-size: 0.6rem; + } .icon-ice:after { content: 'ICE'; } .icon-ic:after { content: 'IC'; } @@ -680,7 +671,6 @@ button.color:hover, .button.color:hover { width: 32px; padding: 3px; font-weight: bold; - font-size: 0; overflow: hidden; } @@ -689,7 +679,7 @@ button.color:hover, .button.color:hover { flex: unset !important; } - .button.go { + button.go { flex-basis: 100%; justify-content: center; }