Use arrow keys to change duration (#5495)

This commit is contained in:
Kevin Papst
2025-05-27 13:07:29 +02:00
committed by GitHub
parent e69ac6968b
commit e2a1146670
8 changed files with 155 additions and 192 deletions

View File

@@ -51,6 +51,8 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
if (this._duration !== undefined) {
this._duration.removeEventListener('change', this._durationListener);
delete this._durationListener;
this._duration.removeEventListener('keydown', this._durationKeyListener);
delete this._durationKeyListener;
delete this._duration;
}
@@ -110,11 +112,13 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
this._beginListener = () => this._changedBegin();
this._endListener = () => this._changedEnd();
this._durationListener = () => this._changedDuration();
this._durationKeyListener = (event) => this._changeDurationOnKeypress(event);
this._beginDate.addEventListener('change', this._beginListener);
this._beginTime.addEventListener('change', this._beginListener);
this._endTime.addEventListener('change', this._endListener);
this._duration.addEventListener('change', this._durationListener);
this._duration.addEventListener('keydown', this._durationKeyListener);
if (this._duration !== null && this._durationToggle !== null) {
this._durationToggleListener = () => {
@@ -315,11 +319,11 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
if (begin === null && end === null) {
const newBegin = DateTime.now();
this._applyDateToField(newBegin, this._beginDate, this._beginTime);
this._applyDateToField(newBegin.plus({seconds: seconds}), null, this._endTime);
this._addSecondsToEndDate(newBegin, seconds);
} else if (begin === null && end !== null) {
this._applyDateToField(end.minus({seconds: seconds}), this._beginDate, this._beginTime);
} else if (begin !== null && seconds >= 0) {
this._applyDateToField(begin.plus({seconds: seconds}), null, this._endTime);
this._addSecondsToEndDate(begin, seconds);
}
}
@@ -367,7 +371,23 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
*/
_getParsedDuration()
{
return this.getDateUtils().parseDuration(this._duration.value.toUpperCase());
return this.getDateUtils().parseDuration(this._duration.value);
}
/**
* @param {DateTime} dateTime
* @param {int} seconds
* @private
*/
_addSecondsToEndDate(dateTime, seconds)
{
// if the duration is longer than one day, the end field should be empty
// so kimai can calculate it after submitting the data from start + duration
if (seconds < 86400) {
this._applyDateToField(dateTime.plus({seconds: seconds}), null, this._endTime);
} else {
this._endTime.value = '';
}
}
/**
@@ -390,4 +410,122 @@ export default class KimaiTimesheetForm extends KimaiFormPlugin {
timeField.value = this.getDateUtils().format(timeField.dataset['format'], dateTime);
}
/**
* @param {KeyboardEvent} event
* @private
*/
_changeDurationOnKeypress(event)
{
switch (event.key) {
case 'ArrowUp':
case 'ArrowDown':
case 'PageUp':
case 'PageDown':
case 'Home':
case 'End':
this._setDurationAsString(this._getParsedDuration());
break;
default:
return; // Ignore other keys
}
this._changeTimeOnKeypress(event, this._duration, 99999, this._durationListener);
}
/**
* This method helps the user to change a duration field with simple keyboard interaction:
* - Read the current duration from the given timeField input in format HH:MM (no seconds)
* - Change the duration based on the rules below
* - Write the new duration back to the field
* - If the field is empty or invalid it uses 00:00 as start-time
* - Duration cannot exceed maxtime (which is given in minutes)
* - Duration cannot drop below 00:00
* - Read the position of the cursor and decide whether to increase minutes or hours: if the cursor is in the hour section (before the colon) change hours, if the cursor is in the minute section (after the colon) change minutes
* - It reads the pressed key from the given KeyboardEvent and changes the duration accordingly to the rules below
*
* Rules to apply when a key is pressed:
* - ArrowUp key to increase the duration (either 5 minutes or 1 hour, depending on the cursor position)
* - ArrowDown key to decrease the duration (either 5 minutes or 1 hour, depending on the cursor position)
* - PageUp key to increase the duration by 1 hour
* - PageDown key to decrease the duration by 1 hour
* - Home key to set the duration to 08:00
* - End key to set the duration to 00:00
* - all other keys are ignored
*
* @param {KeyboardEvent} event
* @param {HTMLElement} timeField
* @param {int} maxTime
* @param {function} changeCallback
* @private
*/
_changeTimeOnKeypress(event, timeField, maxTime, changeCallback)
{
// Parse current value or default to 00:00
let value = timeField.value || '00:00';
let [hours, minutes] = value.split(':').map(Number);
if (isNaN(hours)) { hours = 0; }
if (isNaN(minutes)) { minutes = 0; }
// Cursor position: before or after colon
const cursorPos = timeField.selectionStart || 0;
const colonPos = value.indexOf(':');
const inHour = cursorPos <= colonPos;
// Helper to clamp values
const clamp = (h, m) => {
let total = h * 60 + m;
if (total < 0) { total = 0; }
if (total > maxTime) { total = maxTime; }
h = Math.floor(total / 60);
m = total % 60;
return [h, m];
};
switch (event.key) {
case 'ArrowUp':
if (inHour) {
[hours, minutes] = clamp(hours + 1, minutes);
} else {
[hours, minutes] = clamp(hours, minutes + 5);
}
break;
case 'ArrowDown':
if (inHour) {
[hours, minutes] = clamp(hours - 1, minutes);
} else {
[hours, minutes] = clamp(hours, minutes - 5);
}
break;
case 'PageUp':
[hours, minutes] = clamp(hours + 1, minutes);
event.preventDefault();
break;
case 'PageDown':
[hours, minutes] = clamp(hours - 1, minutes);
event.preventDefault();
break;
case 'Home':
// TODO this should use the configured working time for today
hours = 8;
minutes = 0;
event.preventDefault();
break;
case 'End':
hours = 0;
minutes = 0;
event.preventDefault();
break;
default:
return; // Ignore other keys
}
// Format and set value
timeField.value = `${hours}:${minutes.toString().padStart(2, '0')}`;
// trigger update of linked fields
changeCallback(timeField);
// Move cursor to original position if possible
setTimeout(() => {
timeField.setSelectionRange(cursorPos, cursorPos);
}, 0);
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,173 +0,0 @@
/*!
* Bootstrap v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
/*!
*
* litepicker.umd.js
* Litepicker v2.0.12 (https://github.com/wakirin/Litepicker)
* Package: litepicker (https://www.npmjs.com/package/litepicker)
* License: MIT (https://github.com/wakirin/Litepicker/blob/master/LICENCE.md)
* Copyright 2019-2021 Rinat G.
*
* Hash: 504eef9c08cb42543660
*
*/
/*!
*
* plugins/mobilefriendly.js
* Litepicker v2.0.12 (https://github.com/wakirin/Litepicker)
* Package: litepicker (https://www.npmjs.com/package/litepicker)
* License: MIT (https://github.com/wakirin/Litepicker/blob/master/LICENCE.md)
* Copyright 2019-2021 Rinat G.
*
* Hash: b9a648207aabe31b2912
*
*/
/*!
* [KIMAI] KimaiAPI: easy access to API methods
*/
/*!
* [KIMAI] KimaiActiveRecords: responsible to display the users active records
*/
/*!
* [KIMAI] KimaiAjaxModalForm
*
* allows to assign the given selector to any element, which then is used as click-handler:
* opening a modal with the content from the URL given in the elements 'data-href' or 'href' attribute
*/
/*!
* [KIMAI] KimaiAlert: notifications for Kimai
*/
/*!
* [KIMAI] KimaiAlternativeLinks
*
* allows to assign the given selector to any element, which then is used as click-handler
* redirecting to the URL given in the elements 'data-href' or 'href' attribute
*/
/*!
* [KIMAI] KimaiColor: handle colors
*/
/*!
* [KIMAI] KimaiConfiguration: handling all configuration and runtime settings
*/
/*!
* [KIMAI] KimaiContainer
*
* ServiceContainer for Kimai
*/
/*!
* [KIMAI] KimaiContextMenu: help to create, position and display context menus
*/
/*!
* [KIMAI] KimaiDatatable: handles functionality for the datatable
*/
/*!
* [KIMAI] KimaiDatatableColumnView: manages the visibility of data-table columns in cookies
*/
/*!
* [KIMAI] KimaiDatePicker: single date selects (currently unused)
*/
/*!
* [KIMAI] KimaiDateRangePicker: activate the (daterange picker) compound field in toolbar
*/
/*!
* [KIMAI] KimaiDateUtils: responsible for handling date specific tasks
*/
/*!
* [KIMAI] KimaiEditTimesheetForm: responsible for the most important form in the application
*/
/*!
* [KIMAI] KimaiEscape: sanitize strings
*/
/*!
* [KIMAI] KimaiEvent: helper to trigger events
*/
/*!
* [KIMAI] KimaiForm: basic functions for all forms
*/
/*!
* [KIMAI] KimaiFormPlugin: base class for all none ID plugin that handle forms
*/
/*!
* [KIMAI] KimaiFormSelect: enhanced functionality for HTMLSelectElement
*/
/*!
* [KIMAI] KimaiLoader: bootstrap the application and all plugins
*/
/*!
* [KIMAI] KimaiMultiUpdateForm: handle the multi update checkbox list and form
*/
/*!
* [KIMAI] KimaiPaginatedBoxWidget: handles box widgets that have a pagination
*/
/*!
* [KIMAI] KimaiPlugin: base class for all plugins
*/
/*!
* [KIMAI] KimaiRecentActivities: responsible to reload the users recent activities
*/
/*!
* [KIMAI] KimaiReducedClickHandler: abstract class
*/
/*!
* [KIMAI] KimaiReloadPageWidget: a simple helper to reload the page on events
*/
/*!
* [KIMAI] KimaiStorage: simple wrapper to handle localStorage access
*/
/*!
* [KIMAI] KimaiThemeInitializer: initialize theme functionality
*/
/*!
* [KIMAI] KimaiToolbar: some event listener to handle the toolbar/data-table filter, toolbar and navigation
*/
/*!
* [KIMAI] KimaiTranslation: handling translation strings
*/
/*!
* [KIMAI] KimaiUser: information about the current user
*/
/*!
* [KIMAI] Notification: notifications for Kimai
*/
/*!
* [KIMAI] Wrapper class for loading Kimai app in browser script scope
*/

View File

@@ -3,10 +3,10 @@
"app": {
"js": [
"/build/runtime.6c399d29.js",
"/build/app.7719f352.js"
"/build/app.26740f92.js"
],
"css": [
"/build/app.899af573.css"
"/build/app.0416ea92.css"
]
},
"app-rtl": {
@@ -15,7 +15,7 @@
"/build/app-rtl.7a875ca7.js"
],
"css": [
"/build/app-rtl.bb694a34.css"
"/build/app-rtl.0848906b.css"
]
},
"export-pdf": {
@@ -72,10 +72,10 @@
},
"integrity": {
"/build/runtime.6c399d29.js": "sha384-/rm616f12czi8l/27GvWXtb3g608vJZf2XTUKxqCRI4tsa2vUHP+BW90edTok5zC",
"/build/app.7719f352.js": "sha384-lj0mGChJMeCxDtjDy96KBKaW5E61RIqYc2+3RlVz+wcsIroUZxMApq0PsW8QcKFN",
"/build/app.899af573.css": "sha384-8FV7dixselI1s8FeZCs88+w6hVO9bJ7U1Ai12BBPE7O8bQOxqkhKsWHsCD+WCtdn",
"/build/app.26740f92.js": "sha384-HapsoomsQcRK4QxBGYW0bZhGuDiYxFvCQVcpK5bO2MwFd8StKhncAzK9fbCVlauM",
"/build/app.0416ea92.css": "sha384-JAIO6+B/vmV8IlpsmQ+zkfO4JsvdKTMsZZaDg6EMlZxkMZ5K3cpEK0mo24D/Wgkh",
"/build/app-rtl.7a875ca7.js": "sha384-T7gLI61h9dGeMgzo63vKu4GiDOeLPct9zSUHrceNbhSwIdUmSSNoZ1+d7fKhJJ4/",
"/build/app-rtl.bb694a34.css": "sha384-0ANmqhe0IP4aUvWOWo/KZUgAqueE4hH/ZU0EREtgrtKXWM/JPMmRFBEXk8iZTEcd",
"/build/app-rtl.0848906b.css": "sha384-O26Xw3P/NSea5iT6lt5v2RaZ6+jP06hf9vYD2pUJbkFSCLGU1iGXnobP25dWjUs/",
"/build/export-pdf.395749ab.js": "sha384-3Hjvmu4FC/0dhHnR8kyRBU7k2xMNy1lxBpGgOkrw8PxXnwyQDM8/5bQmkJbjVT1+",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.773af9c4.js": "sha384-QmMqYJ0RP2WOYU7D4lLzKCBFDL6vCvZHQc7wf8K8N+hdCuTJwq1P0GuY81f30/SY",

View File

@@ -1,7 +1,7 @@
{
"build/app.css": "/build/app.899af573.css",
"build/app.js": "/build/app.7719f352.js",
"build/app-rtl.css": "/build/app-rtl.bb694a34.css",
"build/app.css": "/build/app.0416ea92.css",
"build/app.js": "/build/app.26740f92.js",
"build/app-rtl.css": "/build/app-rtl.0848906b.css",
"build/app-rtl.js": "/build/app-rtl.7a875ca7.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.395749ab.js",

View File

@@ -2717,9 +2717,9 @@ __metadata:
linkType: hard
"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001359, caniuse-lite@npm:^1.0.30001587, caniuse-lite@npm:^1.0.30001669":
version: 1.0.30001687
resolution: "caniuse-lite@npm:1.0.30001687"
checksum: 10/0b6a064d5df185ec60b842dba5a27d2c54a66967b7f89571bfd0a8256f0863b1f2a910da6a19ed1b8f534bedf0663cae90309a4a6899bba2286205d459b32f95
version: 1.0.30001718
resolution: "caniuse-lite@npm:1.0.30001718"
checksum: 10/e172a4c156f743cc947e659f353ad9edb045725cc109a02cc792dcbf98569356ebfa4bb4356e3febf87427aab0951c34c1ee5630629334f25ae6f76de7d86fd0
languageName: node
linkType: hard