ctucx.git: trainsearch

web based trip-planner, fork of https://cyberchaos.dev/yuka/trainsearch

commit 09c294302b87b1e0bade0f49dab8f51b9e033a31
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
src/searchView.js
|
272
+++++++++++++++++++++++++++++++++++--------------------------------------------
M
static/style.css
|
56
+++++++++++++++++++++++---------------------------------
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;
 	}