Compare commits

...
Author SHA1 Message Date
Johann Hoffmann 74d4361828 Merge branch 'feature-77588/AnwesenheitenSprint252' into feature-83680/AnwFehlminuten 2026-10-08 16:24:18 +02:00
Johann Hoffmann 57d37e01a3 only make those anw_user_entries editable which come from LE's the user is allowed to change by isAdminOrTeachesLE function; also send the correct le_id of the different le_ids of detail component to backend, not the selected one of main view; simplify the entschuldigungsmanagement filter options and only have the "alle offenen Entschuldigungen unabhängig vom gewählten Zeitraum" button available which comes with a comprehensive tooltip in simple language what that button actually does since it can be confusing to interpret which timespan and which dataset is being delivered here. 💯💥 2026-10-08 16:22:28 +02:00
Johann Hoffmann ec74a28a73 changed verspätung status wording to fehlminuten at every occurance; added a begründung text input field to the FehlminutenDialog.js with 2 buttons for the most common cases "Verspätung" and "Früher gegangen"; 2026-10-08 12:54:12 +02:00
Johann Hoffmann a73673cd94 Merge branch 'feature-77588/AnwesenheitenSprint252' into feature-83680/AnwFehlminuten 2026-10-07 19:31:14 +02:00
Johann Hoffmann 0428c27bef fix delete dialog handling; new multiselect mode is now hidden behind a "Gesamtansicht" Button Switch; in Gesamtansicht remove Kontrolldatum select & Alle anzeigen and default to all LV-Teile selected; fix multiselect darkmode theme; align all ? tooltips to be the same component and enhanced that component with delayed sticky animation, then moved that to the core so we can ALL have fancy sticky tooltips; checked over existing phrasen for spelling mistakes; bunch more css adjustments; fixed a possible bug in getAnwForUid where only uid was verified and not person_id; renamed timeline tab and enabled it for students aswell -> configurable; 🤓; 2026-10-07 19:10:43 +02:00
Johann Hoffmann bff4b39b59 Merge branch 'feature-77588/AnwesenheitenSprint252' into feature-83680/AnwFehlminuten 2026-10-01 12:17:02 +02:00
Johann Hoffmann b4fc8dfd45 status entry in legende for missing entry aka "-" 2026-10-01 12:03:34 +02:00
Johann Hoffmann 9f5db6899d Merge branch 'feature-77588/AnwesenheitenSprint252' into feature-83680/AnwFehlminuten 2026-09-30 15:30:18 +02:00
Johann Hoffmann 614274f3e1 darkmode stylecheck & adjustments across anw extension; ent management UX changes: datepicker labels, counts for filtered/total/offen/akzeptiert/abgelehnt + a button to just load all open excuse note applications no matter the timespan; added more clear visualization for multi stg students inside student uid col, studiengang col, orgform col and edit entschuldigung modal; fixed a bug in edit entschuldigung modal never showing to upload datum oopsie; 2026-09-30 15:07:45 +02:00
Johann Hoffmann 830521c3fa base fehlminuten feature implemented; WIP testing edge cases; 2026-09-30 09:42:55 +02:00
Johann Hoffmann c82a6e4c44 phrasens 2026-09-28 13:31:13 +02:00
Johann Hoffmann 791ae73627 Merge branch 'master' into feature-77588/AnwesenheitenSprint252 2026-09-28 12:35:40 +02:00
Johann Hoffmann 283865aa32 Merge remote-tracking branch 'origin/feature-76994/EntEditByStudentErrorFix' into feature-77588/AnwesenheitenSprint252
# Conflicts:
#	system/phrasesupdate.php
2026-09-28 11:26:11 +02:00
Johann Hoffmann 2bb59c9f66 fix a tabulator issue with dynamic columns and presets; legacy le selection now also uses the legacy loading procedure instead of the new one fetching all le of the lva, with the flag on only the assigned le to the looged in lektor; added phrasen; 2026-09-25 13:47:51 +02:00
Johann Hoffmann 98ceeb862d not only log sophisticated upload errors into log table, also tell the user what went wrong with the upload as per codeigniter 3 upload class; 2026-09-23 15:49:03 +02:00
Johann Hoffmann 86a8350bdc fix phrasesupdate.php missing colon 2026-08-27 09:32:59 +02:00
Johann Hoffmann 943999cef9 adapt filter/tablePresets/tablePresetsModule to account for dynamic tables with a new presetColumns property. by default the preset holds all columns, if a scope is given it only holds those columns, their header filters and their sort; on apply the scope is merged into the current table columns so the dynamic ones keep their place; 2026-08-26 16:45:06 +02:00
Johann Hoffmann 6d9ce91302 Merge remote-tracking branch 'origin/master' into feature-77588/AnwesenheitenSprint252
# Conflicts:
#	system/phrasesupdate.php
2026-08-26 16:31:28 +02:00
Johann Hoffmann 7e01142016 anw widget sql scripts for standalone linklist and once utilizing fhc-calendar list view; also added anw link in lehreinheiten renderer modalContent as new row 2026-07-21 18:30:24 +02:00
Johann Hoffmann 04d90f53d3 adapt stundeplanlib expandObjectInformation when called by getEventsEmployee with an event called extendStundenplanData, which allows extensions like digi anw to plant their own data object inside the stundenplan events in order to conditionally render something like currently in the lehreinheiten calendarEvent.js renderer -> parameterized link to anw extension in order to avoid duplicating functionality across widgets when there is already one doing it very well; 2026-07-20 16:40:57 +02:00
Johann Hoffmann 7fdfa1481e new phrases 2026-07-09 16:00:42 +02:00
Johann Hoffmann 285a2921e4 code cleanup & refactoring lektor frontend/backend; transactions on important concurrent operations like deleteAnwesenheitskontrolle & getNewQRCode; show all flag saved/fetch from local storage; hardcoded strings -> phrasen; 2026-07-03 14:23:14 +02:00
14 changed files with 3363 additions and 107 deletions
+17 -2
View File
@@ -7,6 +7,7 @@ use \DateTime as DateTime;
use \DateTimeZone as DateTimeZone;
use \DateInterval as DateInterval;
use \DatePeriod as DatePeriod;
use CI3_Events as Events;
class StundenplanLib
{
@@ -143,7 +144,7 @@ class StundenplanLib
return $stundenplan_data;
$stundenplan_data = getData($stundenplan_data) ?? [];
$function_error = $this->expandObjectInformation($stundenplan_data);
$function_error = $this->expandObjectInformation($stundenplan_data, true);
if ($function_error)
return $function_error;
@@ -303,7 +304,7 @@ class StundenplanLib
return success($lektoren);
}
public function expandObjectInformation($data)
public function expandObjectInformation($data, $callExtensionsEvent = false)
{
$this->_ci =& get_instance();
@@ -392,6 +393,20 @@ class StundenplanLib
$item->lektor = $lektor_obj_array;
}
// info retrieved from extensions might not be necessary for some stundenplan use cases so the event
// will be gated behind this flag
if($callExtensionsEvent) {
Events::trigger(
'extendStundenplanData',
function & () use (&$data)
{
return $data;
}
);
}
}
public function fetchFerienTageEvents($start_date, $end_date, $studiengang_kz)
+3
View File
@@ -840,8 +840,11 @@ html {
color: var(--fhc-light);
}
/* the bootstrap opacity 0.5 reaches 2.9:1 on the blue header, 0.75 reaches 4.5:1 (WCAG 1.4.11) */
#cis-main .modal-header .btn-close{
filter: invert(1);
--bs-btn-close-opacity: 0.75;
--bs-btn-close-hover-opacity: 1;
}
#cis-main .modal-footer {
+22 -1
View File
@@ -121,8 +121,9 @@
background-color: #000;
}
/* contrast: #777 reaches 4.48:1 on the odd and 4:1 on the even rows (#f2f2f2), #6c6c6c 5.3:1 and 4.7:1 */
.tabulator-row.tabulator-unselectable:not(.tabulator-calcs) {
color: #777 !important;
color: #6c6c6c !important;
}
.tabulator-cell .btn {
@@ -156,3 +157,23 @@
color: #fff;
background: #1d68cd;
}
/* contrast (WCAG 1.4.11): tabulator_simple draws the inactive sort arrow in #bbb (1.9:1) and the
* placeholder in #ccc (1.6:1). #8f8f8f (3.2:1) is close to the grey of the active arrow, so the
* active arrow gets the primary color (6.8:1) */
.tabulator .tabulator-header .tabulator-col .tabulator-col-content .tabulator-col-sorter .tabulator-arrow,
.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="none"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
border-bottom-color: #8f8f8f;
}
.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="ascending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
border-bottom-color: var(--fhc-primary, #006095);
}
.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="descending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
border-top-color: var(--fhc-primary, #006095);
}
.tabulator .tabulator-tableholder .tabulator-placeholder .tabulator-placeholder-contents {
color: #6c757d;
}
+81
View File
@@ -0,0 +1,81 @@
/* the panel of js/directives/inViewTooltip.js. place() sets its place, max width and max height
inside the viewport, so neither a scrolling container nor a modal body clips it */
.fhc-in-view-tooltip {
--fhc-in-view-tooltip-progress: var(--fhc-blue-primary, #0d6efd);
position: fixed;
z-index: 1080;
display: flex;
flex-direction: column;
width: max-content;
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 0.375rem;
background-color: var(--bs-body-bg, #fff);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
color: var(--bs-body-color, #212529);
font-size: 0.875rem;
font-weight: normal;
line-height: 1.5;
text-align: left;
}
/* the dark primary reaches 2:1 on the dark panel, the highlight 3.9:1 */
html.dark .fhc-in-view-tooltip {
--fhc-in-view-tooltip-progress: var(--fhc-blue-primary-highlight, #6ea8fe);
}
/* the body scrolls, so the progress border stays on the panel edge */
.fhc-in-view-tooltip-body {
min-height: 0;
overflow-y: auto;
padding: 0.75rem 1rem;
white-space: pre-line; /* the text keeps its line breaks */
}
/* the hover timer of a sticky panel: after a pause the border fills up clockwise, then the panel sticks.
The 2 px stroke covers the 1 px border and 1 px inside it */
.fhc-in-view-tooltip-progress {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.fhc-in-view-tooltip-progress rect {
fill: none;
stroke: var(--fhc-in-view-tooltip-progress);
stroke-width: 2;
stroke-dasharray: var(--fhc-in-view-tooltip-perimeter);
stroke-dashoffset: var(--fhc-in-view-tooltip-perimeter);
}
.fhc-in-view-tooltip--holding .fhc-in-view-tooltip-progress rect {
animation: fhc-in-view-tooltip-hold var(--fhc-in-view-tooltip-hold-fill) ease-in var(--fhc-in-view-tooltip-hold-delay) forwards;
}
/* a click during the hover timer fills the rest of the border quickly */
.fhc-in-view-tooltip--pinned .fhc-in-view-tooltip-progress rect {
stroke-dashoffset: 0;
transition: stroke-dashoffset 0.15s linear;
}
@keyframes fhc-in-view-tooltip-hold {
to {
stroke-dashoffset: 0;
}
}
/* the ? icon of components/InViewHelp.js takes the color of its context, e.g. white in the blue
modal header. A class like text-muted overrides it */
.fhc-in-view-help-btn,
.fhc-in-view-help-btn:hover,
.fhc-in-view-help-btn:focus-visible,
.btn.fhc-in-view-help-btn:active {
color: inherit;
}
.fhc-in-view-help-btn {
line-height: 1;
text-decoration: none;
}
+154 -3
View File
@@ -188,6 +188,9 @@
--fhc-light: var(--fhc-white-50);
--fhc-cis-menu-lvl-1-color: var(--fhc-white-50);
/* input borders, 3:1 against the background and the tinted card headers (#f8f8f9) */
--fhc-input-border: #868e96;
}
:root.dark {
@@ -233,6 +236,8 @@
--fhc-dark: var(--fhc-black-50);
--fhc-light: var(--fhc-white-70);
/* input borders, 3:1 against the background */
--fhc-input-border: #7c8791;
}
:root.purple {
@@ -313,12 +318,13 @@ html.dark .border-primary {
border-color: var(--fhc-primary) !important;
}
/* fhc danger*/
/* fhc danger: --fhc-danger is a light red for text and borders on the dark background. White text
* on it reaches 1.2:1, the backgrounds use the dark red (6.5:1) */
html.dark .bg-danger,
html.dark .btn-danger,
html.dark .btn-outline-danger {
background-color: var(--fhc-danger) !important;
border-color: var(--fhc-danger) !important;
background-color: var(--fhc-red-60) !important;
border-color: var(--fhc-red-60) !important;
color: white;
}
@@ -327,6 +333,25 @@ html.dark .btn-danger:hover {
filter: brightness(90%);
}
/* the dark primevue theme draws p-button-danger (e.g. FhcAlert.confirmDelete) light pink with black text,
* use the dark red of .btn-danger */
html.dark .p-button.p-button-danger {
color: white;
background: var(--fhc-red-60);
border-color: var(--fhc-red-60);
}
html.dark .p-button.p-button-danger:enabled:hover,
html.dark .p-button.p-button-danger:enabled:active {
color: white;
background: var(--fhc-red-70);
border-color: var(--fhc-red-70);
}
html.dark .p-button.p-button-danger:enabled:focus {
box-shadow: 0 0 0 0.2rem rgba(225, 110, 120, 0.5);
}
html.dark .text-danger {
color: var(--fhc-danger) !important;
}
@@ -335,6 +360,55 @@ html.dark .border-danger {
border-color: var(--fhc-danger) !important;
}
/* bootstrap draws btn-outline-secondary in #6c757d: 4.3:1 on the light dialog footer (#f6f6f6).
* --fhc-gray-30 reaches 5.4:1 there, the white hover text 5.9:1 */
html.light .btn-outline-secondary {
--bs-btn-color: var(--fhc-gray-30);
--bs-btn-border-color: var(--fhc-gray-30);
--bs-btn-hover-bg: var(--fhc-gray-30);
--bs-btn-hover-border-color: var(--fhc-gray-30);
--bs-btn-active-bg: var(--fhc-gray-30);
--bs-btn-active-border-color: var(--fhc-gray-30);
}
/* bootstrap keeps #6c757d for btn-outline-secondary in the dark theme: 2.4:1 on the dark tables and dialogs */
html.dark .btn-outline-secondary {
--bs-btn-color: var(--fhc-white-90);
--bs-btn-border-color: var(--fhc-white-90);
--bs-btn-hover-color: var(--fhc-black-30);
--bs-btn-hover-bg: var(--fhc-white-90);
--bs-btn-hover-border-color: var(--fhc-white-90);
--bs-btn-active-color: var(--fhc-black-30);
--bs-btn-active-bg: var(--fhc-white-90);
--bs-btn-active-border-color: var(--fhc-white-90);
}
/* the primevue multiselect renders the check icon also in unchecked boxes and hides it by color:
* white on white in the light theme, but #151515 on #20262e in the dark theme */
html.dark .p-checkbox .p-checkbox-box:not(.p-highlight) .p-checkbox-icon {
color: transparent;
}
/* FhcAlert.confirm passes bootstrap classes to the primevue buttons. The primevue theme loads after
* bootstrap, its .p-button colors replace the .btn colors: every button blue */
.p-button.btn {
color: var(--bs-btn-color);
background: var(--bs-btn-bg);
border-color: var(--bs-btn-border-color);
}
.p-button.btn:enabled:hover {
color: var(--bs-btn-hover-color);
background: var(--bs-btn-hover-bg);
border-color: var(--bs-btn-hover-border-color);
}
.p-button.btn:enabled:active {
color: var(--bs-btn-active-color);
background: var(--bs-btn-active-bg);
border-color: var(--bs-btn-active-border-color);
}
html.dark .tabulator,
html.dark .tabulator .tabulator-header,
@@ -355,6 +429,52 @@ html.dark .tabulator .tabulator-header .tabulator-col:hover {
background-color: var(--fhc-tertiary) !important;
}
/* the active sort arrow of tabulator_simple (#666) reaches 2:1 on the dark header */
html.dark .tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="ascending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
border-bottom-color: var(--fhc-text);
}
html.dark .tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="descending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
border-top-color: var(--fhc-text);
}
@media (hover: hover) and (pointer: fine) {
html.dark .tabulator .tabulator-header .tabulator-col.tabulator-sortable .tabulator-col-content .tabulator-col-sorter.tabulator-col-sorter-element .tabulator-arrow:hover {
border-bottom-color: var(--fhc-text);
border-top-color: var(--fhc-text);
}
}
/* the group toggle arrow of tabulator_simple (#666) reaches 2:1 on the dark rows */
html.dark .tabulator .tabulator-row.tabulator-group:not(.tabulator-group-visible) .tabulator-arrow {
border-left-color: var(--fhc-text);
}
html.dark .tabulator .tabulator-row.tabulator-group.tabulator-group-visible .tabulator-arrow {
border-top-color: var(--fhc-text);
}
/* the footer stays white otherwise: the page buttons get the white button text of the dark color scheme */
html.dark .tabulator .tabulator-footer {
background-color: var(--fhc-tertiary);
border-top-color: var(--fhc-border);
color: var(--fhc-text);
}
html.dark .tabulator .tabulator-footer .tabulator-page {
background: transparent;
border-color: var(--fhc-white-90);
color: var(--fhc-text);
}
html.dark .tabulator .tabulator-footer .tabulator-page.active {
color: var(--fhc-red-30);
}
html.dark .tabulator .tabulator-tableholder .tabulator-placeholder .tabulator-placeholder-contents {
color: var(--fhc-white-90);
}
.p-paginator{
background-color: var(--fhc-background) !important;
}
@@ -376,4 +496,35 @@ html.dark .tabulator .tabulator-header .tabulator-col:hover {
.p-link:focus {
box-shadow: 0 0 0 0.2rem rgba(var(--fhc-primary), 0.5) !important;
}
/* input borders: 3:1 against the background (WCAG 1.4.11), the bootstrap, primevue and datepicker defaults
* reach 1.3 - 1.9:1. Focus and checked states of the libraries stay */
.form-control,
.form-select,
.form-check-input,
.p-inputtext,
.p-inputtext:enabled:hover:not(:focus),
.p-dropdown,
.p-dropdown:not(.p-disabled):hover:not(.p-focus),
.p-multiselect,
.p-multiselect:not(.p-disabled):hover:not(.p-focus),
.p-checkbox .p-checkbox-box,
.p-radiobutton .p-radiobutton-box {
border-color: var(--fhc-input-border, #868e96);
}
/* the datepicker css loads after the theme, html raises the specificity */
html .dp__theme_light,
html .dp__theme_dark {
--dp-border-color: var(--fhc-input-border, #868e96);
--dp-border-color-hover: var(--fhc-border-highlight, #495057);
}
/* the light datepicker (also in the dark cis4 theme) draws the days of other months in #c0c4cc (1.8:1),
* the icons in #959595 (3:1) and the selected day in #f8f5f5 on #1976d2 (4.2:1) */
html .dp__theme_light {
--dp-secondary-color: #6c6c6c;
--dp-icon-color: #6c757d;
--dp-primary-text-color: #fff;
}
+2 -2
View File
@@ -73,6 +73,7 @@ export default {
}
},
computed: {
// opacity 0.5 reaches 2.4:1 on the blue cis4 modal header, 0.75 reaches 3.6:1 (WCAG 1.4.11)
getExpandButtonStyles() {
const hovered = this.expandBtnHovered;
const focused = this.expandBtnFocused;
@@ -85,12 +86,11 @@ export default {
border: 0;
background: transparent;
font-size: 1em;
opacity: 0.5;
color: inherit;
cursor: pointer;
line-height: 1;
transition: opacity 0.15s ease;
opacity: ${focused ? '1' : hovered ? '0.75' : '0.5'};
opacity: ${focused || hovered ? '1' : '0.75'};
outline: ${focused ? '1px solid currentColor' : 'none'};
outline-offset: 2px;`
}
@@ -66,6 +66,24 @@ export default {
return "d-none d-xl-grid";
}
},
// link into the FHC-Core-Anwesenheiten tool
// digi_anw_data is attached per event by the extension's `extendStundenplanData` listener
anwLink() {
const d = this.event && this.event.digi_anw_data;
if (!d || !d.stg_kz || !d.lv_id || !d.sem_kurzbz)
return null;
const base = FHC_JS_DATA_STORAGE_OBJECT.app_root
+ FHC_JS_DATA_STORAGE_OBJECT.ci_router
+ '/extensions/FHC-Core-Anwesenheiten/';
return base
+ `?stg_kz=${encodeURIComponent(d.stg_kz)}`
+ `&sem=${encodeURIComponent(d.sem ?? '')}`
+ `&lvid=${encodeURIComponent(d.lv_id)}`
+ `&sem_kurzbz=${encodeURIComponent(d.sem_kurzbz)}`;
},
anwLinkTitle() {
return this.$p.t('global/anwKontrolleOeffnen');
},
},
template: /*html*/`
<div
@@ -96,6 +114,21 @@ export default {
... +{{ event.lektor.length - 3 }}
</span>
</div>
<a
v-if="anwLink"
:href="anwLink"
target="_blank"
rel="noopener"
class="d-flex align-items-center flex-shrink-0"
style="margin-right: 4px"
:title="anwLinkTitle"
:aria-label="anwLinkTitle"
draggable="false"
@click.stop
@mousedown.stop
>
<i class="fa fa-arrow-up-right-from-square fs-5" aria-hidden="true"></i>
</a>
</div>
`,
}
@@ -49,6 +49,20 @@ export default {
return this.event.ende;
}
return numberPadding(this.event.ende.getHours()) + ":" + numberPadding(this.event.ende.getMinutes());
},
// Deep link into the attendance tool; only present when the anw extension wrote into digi_anw_data
anwLink: function () {
const d = this.event && this.event.digi_anw_data;
if (!d || !d.stg_kz || !d.lv_id || !d.sem_kurzbz)
return null;
const base = FHC_JS_DATA_STORAGE_OBJECT.app_root
+ FHC_JS_DATA_STORAGE_OBJECT.ci_router
+ '/extensions/FHC-Core-Anwesenheiten/';
return base
+ `?stg_kz=${encodeURIComponent(d.stg_kz)}`
+ `&sem=${encodeURIComponent(d.sem ?? '')}`
+ `&lvid=${encodeURIComponent(d.lv_id)}`
+ `&sem_kurzbz=${encodeURIComponent(d.sem_kurzbz)}`;
}
},
watch: {
@@ -160,6 +174,16 @@ export default {
}}</th>
<td>{{event.organisationseinheit}}</td>
</tr>
<tr v-if="anwLink">
<th>{{
$p.t('global','anwKontrolle')?
$p.t('global','anwKontrolle')+':'
:''
}}</th>
<td>
<a :href="anwLink" target="_blank" rel="noopener" :aria-label="$p.t('global','anwKontrolleOeffnen')" :title="$p.t('global','anwKontrolleOeffnen')"><i class="fa fa-arrow-up-right-from-square me-1" style="color:#00649C" aria-hidden="true"></i></a>
</td>
</tr>
</tbody>
</table>
<lv-menu v-if="lvMenu.length && $route.name === 'MyLvPlan'" :containerStyles="['p-0']" :rowStyles="['m-0']" :menu="lvMenu" />
+34
View File
@@ -0,0 +1,34 @@
import InViewTooltip from '../directives/inViewTooltip.js';
/**
* ? icon with a help text in the in-view tooltip. The panel stays inside the viewport, also at the
* screen edge, in a modal and on a phone: a plain tooltip ran out of the screen there.
*
* Help texts are long, so the panel is sticky by default: it sticks after a hover of 1.4 s or a click,
* a tap toggles it on touch. sticky: false makes it a plain hover tooltip.
*
* The view needs public/css/components/inViewTooltip.css.
*
* Usage: <in-view-help :text="$p.t('category/phrase')" button-class="fs-5 text-body-secondary"></in-view-help>
*/
export default {
name: 'InViewHelp',
directives: {
tooltip: InViewTooltip
},
props: {
text: { type: String, default: '' },
sticky: { type: Boolean, default: true },
buttonClass: { type: [String, Array, Object], default: '' }
},
template: /*html*/ `
<button
type="button"
class="btn btn-link p-0 fhc-in-view-help-btn"
:class="buttonClass"
:aria-label="$p.t('ui/hilfe')"
v-tooltip="{ value: text, sticky }"
>
<i class="fa fa-circle-question" aria-hidden="true"></i>
</button>`
};
+20 -6
View File
@@ -106,6 +106,13 @@ export const CoreFilterCmpt = {
type: Array,
default: [],
},
// fields of the columns a preset is allowed to handle. Give the constant
// columns here if the table builds dynamic columns at runtime. An empty
// array gives the preset every column of the table.
presetColumns: {
type: Array,
default: [],
},
},
data: function () {
return {
@@ -942,6 +949,10 @@ export const CoreFilterCmpt = {
return res;
}, {});
},
isColumnInPresetScope(columnField) {
if (!this.$props.presetColumns.length) return true;
return this.$props.presetColumns.includes(columnField);
},
afterTablePresetApplied(preset) {
this.selectedFields = [...preset.displayedColumns];
this.appliedPreset = { ...preset };
@@ -973,6 +984,7 @@ export const CoreFilterCmpt = {
const displayedColumns = this.tabulator.columnManager.columns
.filter((column) => column.field !== "collapse")
.filter((column) => this.isColumnInPresetScope(column.field))
.filter((column) => column.visible)
.map((column) => column.field);
@@ -1029,12 +1041,13 @@ export const CoreFilterCmpt = {
});
const activeSort = this.tabulator.getSorters()[0];
const sort = activeSort
? {
column: activeSort.field,
direction: activeSort.dir,
}
: null;
const sort =
activeSort && this.isColumnInPresetScope(activeSort.field)
? {
column: activeSort.field,
direction: activeSort.dir,
}
: null;
if (
sort?.column !== this.appliedPreset.sort?.column ||
sort?.direction !== this.appliedPreset.sort?.direction
@@ -1148,6 +1161,7 @@ export const CoreFilterCmpt = {
:presetsId="$props.presetsId"
:tabulator="tabulator"
:generalPresets="$props.generalPresets"
:presetColumns="$props.presetColumns"
ref="tablePresets"
class="card-body collapse"
></table-presets>
@@ -26,6 +26,9 @@ export default {
presetsId: { type: String },
tabulator: { type: Object },
generalPresets: { type: Array, default: [] },
// fields of the columns the preset is allowed to handle. An empty array
// gives the preset every column of the table.
presetColumns: { type: Array, default: [] },
},
emits: ["tablePresetApplied"],
data: function () {
@@ -162,6 +165,10 @@ export default {
},
},
methods: {
isInPresetScope(columnField) {
if (!this.$props.presetColumns.length) return true;
return this.$props.presetColumns.includes(columnField);
},
getPresetIndex() {
return this.presetIndexBeingApplied;
},
@@ -227,9 +234,9 @@ export default {
}
},
generateNewPreset() {
const columns = this.tabulator.columnManager.columns.filter(
(column) => column.field !== "collapse",
);
const columns = this.tabulator.columnManager.columns
.filter((column) => column.field !== "collapse")
.filter((column) => this.isInPresetScope(column.field));
const displayedColumns = columns
.filter((column) => column.visible)
@@ -251,12 +258,13 @@ export default {
);
const activeSort = this.tabulator.getSorters()[0];
const sort = activeSort
? {
column: activeSort.field,
direction: activeSort.dir,
}
: null;
const sort =
activeSort && this.isInPresetScope(activeSort.field)
? {
column: activeSort.field,
direction: activeSort.dir,
}
: null;
this.newPreset = {
displayedColumns,
@@ -325,8 +333,11 @@ export default {
preset.displayedColumns.length;
preset.displayedColumns = preset.displayedColumns.filter(
(columnField) => {
return validColumns.some(
(column) => column.field === columnField,
return (
this.isInPresetScope(columnField) &&
validColumns.some(
(column) => column.field === columnField,
)
);
},
);
@@ -365,10 +376,11 @@ export default {
if (preset.sort) {
if (
!this.isInPresetScope(preset.sort.column) ||
!validColumns.some(
(column) =>
column.field === preset.sort.column &&
column.headerSort,
column.headerSort !== false,
)
) {
preset.sort = null;
@@ -407,7 +419,10 @@ export default {
window.localStorage["tabulatorPreset-" + this.$props.presetsId] =
preset.id ? "custom:" + preset.id : "general:" + preset.name;
this.$props.tabulator.modules.tablePresets.applyPreset(preset);
this.$props.tabulator.modules.tablePresets.applyPreset(
preset,
this.$props.presetColumns,
);
this.$emit("tablePresetApplied", { preset });
+352
View File
@@ -0,0 +1,352 @@
/**
* v-tooltip with a panel that stays in view, also at the screen edge, in a modal and on a phone.
*
* - The panel is fixed and stays inside the viewport. It opens below the anchor, above it when
* it fits only there or that side has more space. It grows away from the nearer viewport side.
* - On an anchor wider than the panel, e.g. a long table cell, it opens at the mouse, not at the anchor edge.
* - One panel at a time, appended to the body. A modal or a scrolling container cannot clip it.
* - A mouse hover or a keyboard focus opens it. Escape, a click outside or a scroll closes it.
*
* Sticky, for several sentences or a list: a short label is read before the hover pause ends, a long text is not.
* - After HOLD_DELAY_MS + HOLD_FILL_MS of hovering the panel sticks, the border fills up in the end.
* A click on the anchor makes it stick at once. Touch has no hover: a tap toggles the sticky panel.
* - A sticky panel stays until a click outside, Escape, a scroll or an opening bootstrap modal.
*
* The view needs public/css/components/inViewTooltip.css.
*
* Usage examples:
*
* 1) Directive, registered for the app or the component:
* app.directive('tooltip', InViewTooltip) or directives: { tooltip: InViewTooltip }
* <button v-tooltip="label"> a short label, hover only
* <span v-tooltip.sticky="longText"> several sentences or a list
* v-tooltip="{ value: text, disabled: bool, sticky: bool }", the modifier .top prefers the place above
*
* 2) Without a directive, e.g. a tabulator cell. The content is a text, a DOM node or a function:
* { event: 'cellMouseEnter', handler: (e, cell) => hoverTooltip(cell.getElement(), () => buildNode(cell), { delay: 300, sticky: true, closeOnClick: true }) },
* { event: 'cellMouseLeave', handler: (e, cell) => hideTooltip(cell.getElement()) }
*
* 3) A ? icon with a help text: components/InViewHelp.js
*/
// hover time until a sticky panel sticks: a pause without motion filters out a pointer that only passes by,
// then a short fill shows the pin. A long fill would draw the eye away from the text
const HOLD_DELAY_MS = 1000
const HOLD_FILL_MS = 400
const HIDE_DELAY_MS = 150 // the pointer can cross the gap from the anchor into the panel
const WATCH_MS = 250 // an anchor can leave the page without an unmount, e.g. in a hidden keep-alive tab
const PANEL_MAX_WIDTH = 512 // px
const PANEL_MARGIN = 16 // px between the panel and the viewport edge
const PANEL_GAP = 6 // px between the anchor and the panel
const PANEL_RADIUS = 5 // px, the inner radius of the panel border. The progress border follows it
const PANEL_POINTER_INSET = 16 // px, the horizontal padding of the panel: the text starts below the mouse
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
let active = null // the open panel, only one at a time
let pending = null // a delayed show
let panelCount = 0
// the last pointer. Its type tells a tap from a mouse also for the mouse events of tabulator, a tap fires
// emulated mouse events too: pointerover fires before them. Its place puts the panel next to the mouse
const pointer = { type: 'mouse', x: null, y: null }
const trackPointer = event => {
pointer.type = event.pointerType
pointer.x = event.clientX
pointer.y = event.clientY
}
for (const name of ['pointerover', 'pointermove', 'pointerdown'])
document.addEventListener(name, trackPointer, { capture: true, passive: true })
/**
* Opens the panel for an anchor element.
*
* content: a text, a DOM node or a function that returns one of them.
* options: prefer ('bottom' | 'top'), delay (ms), sticky (the hover timer pins the panel), pinned (sticks at once),
* closeOnClick (a click on the anchor closes the panel, e.g. a table cell with a click action)
*/
export function showTooltip(anchor, content, options = {}) {
cancelPending()
if (!options.delay) return open(anchor, content, options)
pending = { anchor, timer: setTimeout(() => { pending = null; open(anchor, content, options) }, options.delay) }
}
// for mouse events without a pointer type, e.g. the cell events of tabulator
export function hoverTooltip(anchor, content, options) {
if (pointer.type === 'mouse') showTooltip(anchor, content, options)
}
// closes the panel of the anchor after HIDE_DELAY_MS, a sticky panel stays
export function hideTooltip(anchor) {
if (pending?.anchor === anchor) cancelPending()
if (active?.anchor !== anchor || active.pinned) return
clearTimeout(active.hideTimer)
active.hideTimer = setTimeout(close, HIDE_DELAY_MS)
}
// closes the open panel, with an anchor only the panel of that anchor
export function closeTooltip(anchor) {
if (!anchor || pending?.anchor === anchor) cancelPending()
if (!anchor || active?.anchor === anchor) close()
}
function isTooltipPinned(anchor) {
return active?.anchor === anchor && active.pinned
}
// new content for an open panel, an empty text closes it
function updateTooltip(anchor, content) {
if (active?.anchor !== anchor) return
if (!content) return close()
render(active.panel, content)
place()
}
function open(anchor, content, { prefer = 'bottom', sticky = false, pinned = false, closeOnClick = false }) {
if (active?.anchor === anchor) {
clearTimeout(active.hideTimer)
if (pinned) pin()
return
}
close()
sticky = sticky || pinned
const panel = document.createElement('div')
panel.id = 'fhc-in-view-tooltip-' + ++panelCount
panel.className = 'fhc-in-view-tooltip'
panel.setAttribute('role', 'tooltip')
panel.innerHTML = '<div class="fhc-in-view-tooltip-body"></div>'
if (sticky) {
panel.style.setProperty('--fhc-in-view-tooltip-hold-delay', HOLD_DELAY_MS + 'ms')
panel.style.setProperty('--fhc-in-view-tooltip-hold-fill', HOLD_FILL_MS + 'ms')
panel.insertAdjacentHTML('beforeend', '<svg class="fhc-in-view-tooltip-progress" aria-hidden="true">'
+ '<rect width="100%" height="100%" rx="' + PANEL_RADIUS + '"></rect></svg>')
}
render(panel, content)
document.body.appendChild(panel)
// the pointer at the opening, while it is over the anchor. A keyboard focus has none
const rect = anchor.getBoundingClientRect()
const overAnchor = pointer.x !== null && pointer.x >= rect.left && pointer.x <= rect.right
&& pointer.y >= rect.top && pointer.y <= rect.bottom
active = {
anchor,
panel,
prefer,
pointerX: overAnchor ? pointer.x : null,
sticky,
closeOnClick,
pinned: false,
describedBy: anchor.getAttribute('aria-describedby'),
holdTimer: null,
hideTimer: null,
watchTimer: setInterval(() => {
if (!anchor.isConnected || !anchor.getClientRects().length) close()
}, WATCH_MS)
}
place()
anchor.setAttribute('aria-describedby', panel.id)
if (pinned) {
pin()
} else if (sticky) {
panel.classList.add('fhc-in-view-tooltip--holding')
active.holdTimer = setTimeout(pin, HOLD_DELAY_MS + HOLD_FILL_MS)
}
panel.addEventListener('pointerenter', onPanelEnter)
panel.addEventListener('pointerleave', onPanelLeave)
panel.addEventListener('click', onPanelClick)
document.addEventListener('pointerdown', onPointerDown, true)
document.addEventListener('keydown', onKeyDown, true)
// an anchor often opens a modal on click, the sticky panel must not cover it
document.addEventListener('show.bs.modal', close)
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', place)
}
function pin() {
if (!active || active.pinned || !active.sticky) return
clearTimeout(active.holdTimer)
clearTimeout(active.hideTimer)
active.pinned = true
active.panel.classList.remove('fhc-in-view-tooltip--holding')
active.panel.classList.add('fhc-in-view-tooltip--pinned')
}
function close() {
if (!active) return
const { anchor, panel, describedBy } = active
clearTimeout(active.holdTimer)
clearTimeout(active.hideTimer)
clearInterval(active.watchTimer)
active = null
panel.remove()
if (describedBy) anchor.setAttribute('aria-describedby', describedBy)
else anchor.removeAttribute('aria-describedby')
document.removeEventListener('pointerdown', onPointerDown, true)
document.removeEventListener('keydown', onKeyDown, true)
document.removeEventListener('show.bs.modal', close)
window.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', place)
}
function cancelPending() {
if (!pending) return
clearTimeout(pending.timer)
pending = null
}
function render(panel, content) {
if (typeof content === 'function') content = content()
const body = panel.querySelector('.fhc-in-view-tooltip-body')
if (content instanceof Node) body.replaceChildren(content)
else body.textContent = content
}
function place() {
if (!active) return
const { anchor, panel, prefer } = active
const rect = anchor.getBoundingClientRect()
const viewWidth = document.documentElement.clientWidth
const viewHeight = document.documentElement.clientHeight
// measure the natural size first
panel.style.maxWidth = Math.min(PANEL_MAX_WIDTH, viewWidth - 2 * PANEL_MARGIN) + 'px'
panel.style.maxHeight = ''
panel.style.left = '0px'
panel.style.top = '0px'
panel.style.bottom = ''
const width = panel.offsetWidth
const height = panel.offsetHeight
const space = {
bottom: viewHeight - rect.bottom - PANEL_GAP - PANEL_MARGIN,
top: rect.top - PANEL_GAP - PANEL_MARGIN
}
const other = prefer === 'top' ? 'bottom' : 'top'
let side = prefer
if (space[prefer] < height) side = space[other] >= height || space[other] > space[prefer] ? other : prefer
// a panel at least as wide as the anchor starts at the anchor edge, grows away from the nearer viewport side
// and spans the whole anchor. On a wider anchor that edge can be far from the mouse: the text starts below
// the pointer, the clamp moves the panel to the left only at the right viewport edge
const { pointerX } = active
let left
if (pointerX === null || width >= rect.width) left = rect.left + rect.width / 2 < viewWidth / 2 ? rect.left : rect.right - width
else left = pointerX - PANEL_POINTER_INSET
panel.style.left = clamp(left, PANEL_MARGIN, viewWidth - PANEL_MARGIN - width) + 'px'
panel.style.maxHeight = space[side] + 'px'
if (side === 'bottom') {
panel.style.top = rect.bottom + PANEL_GAP + 'px'
} else {
panel.style.top = ''
panel.style.bottom = viewHeight - rect.top + PANEL_GAP + 'px'
}
if (!active.sticky) return
// the dash of the progress border is as long as the rounded rect along the inner edge of the 1 px border.
// Rounded up, else a gap stays at its start: clientWidth drops the fraction of the width
const box = panel.getBoundingClientRect()
const perimeter = 2 * (box.width + box.height - 4) - (8 - 2 * Math.PI) * PANEL_RADIUS
panel.style.setProperty('--fhc-in-view-tooltip-perimeter', Math.ceil(perimeter) + 1)
}
function onPanelEnter() {
cancelPending()
clearTimeout(active.hideTimer)
}
function onPanelLeave(event) {
if (event.pointerType === 'mouse') hideTooltip(active.anchor)
}
// a click into the panel closes it, a text selection keeps it
function onPanelClick() {
if (!String(window.getSelection())) close()
}
function onPointerDown(event) {
const { anchor, panel, closeOnClick } = active
if (panel.contains(event.target)) return
if (anchor.contains(event.target) && !closeOnClick) return
cancelPending()
close()
}
// Escape closes only the panel, not a modal around the anchor
function onKeyDown(event) {
if (event.key !== 'Escape') return
event.stopPropagation()
close()
}
// the panel is fixed, a scroll moves the anchor away from it. A scroll inside the panel keeps it
function onScroll(event) {
if (!active.panel.contains(event.target)) close()
}
// reads the binding: "text" or { value, disabled, sticky }, the modifiers .sticky and .top
function read(state, binding) {
const value = binding.value
const isObject = value && typeof value === 'object'
state.text = (isObject ? (value.disabled ? '' : value.value) : value) ?? ''
state.sticky = !!(binding.modifiers.sticky || isObject && value.sticky)
state.prefer = binding.modifiers.top ? 'top' : 'bottom'
}
export default {
mounted(el, binding) {
const state = el._fhcInViewTooltip = { pointerType: 'mouse' }
read(state, binding)
const show = options => {
if (state.text) showTooltip(el, state.text, { prefer: state.prefer, sticky: state.sticky, ...options })
}
state.listeners = {
pointerenter: event => { if (event.pointerType === 'mouse') show() },
pointerleave: event => { if (event.pointerType === 'mouse') hideTooltip(el) },
pointerdown: event => { state.pointerType = event.pointerType },
// a mouse click or a key pins a sticky panel at once, a tap toggles it
click: () => {
if (!state.sticky) return
if (state.pointerType !== 'mouse' && isTooltipPinned(el)) closeTooltip(el)
else show({ pinned: true })
state.pointerType = 'mouse' // a key click has no pointerdown
},
focusin: event => { if (event.target.matches(':focus-visible')) show() },
focusout: () => hideTooltip(el)
}
// the click captures: it pins the panel before the click action of the anchor runs, so a modal
// that this action opens closes the panel again. The pointer events must not capture the children
for (const [name, listener] of Object.entries(state.listeners)) el.addEventListener(name, listener, name === 'click')
},
updated(el, binding) {
const state = el._fhcInViewTooltip
const text = state.text
read(state, binding)
if (state.text !== text) updateTooltip(el, state.text)
},
beforeUnmount(el) {
const state = el._fhcInViewTooltip
closeTooltip(el)
for (const [name, listener] of Object.entries(state.listeners)) el.removeEventListener(name, listener, name === 'click')
delete el._fhcInViewTooltip
}
}
@@ -8,27 +8,36 @@ export default class TablePresetsModule extends Module {
super(table);
}
applyPreset(preset) {
let columnsConfig = this.table.options.columns;
let reorderedColumnsConfig = [];
preset.displayedColumns.forEach((columnField) => {
let columnConfig = columnsConfig.find(
(column) => column.field === columnField,
);
if (!columnConfig) return;
columnConfig.visible = true;
reorderedColumnsConfig.push(columnConfig);
});
let hiddenColumnsConfig = columnsConfig
.filter((column) => !preset.displayedColumns.includes(column.field))
.map((column) => {
column.visible = false;
return column;
// presetColumns limits the preset to the given column fields. A table with dynamic
// columns gives the fields of its constant columns here.
applyPreset(preset, presetColumns = []) {
if (presetColumns.length) {
this.applyColumnsInScope(preset, presetColumns);
} else {
let columnsConfig = this.table.options.columns;
let reorderedColumnsConfig = [];
preset.displayedColumns.forEach((columnField) => {
let columnConfig = columnsConfig.find(
(column) => column.field === columnField,
);
if (!columnConfig) return;
columnConfig.visible = true;
reorderedColumnsConfig.push(columnConfig);
});
this.table.setColumns(
reorderedColumnsConfig.concat(hiddenColumnsConfig),
);
let hiddenColumnsConfig = columnsConfig
.filter(
(column) =>
!preset.displayedColumns.includes(column.field),
)
.map((column) => {
column.visible = false;
return column;
});
this.table.setColumns(
reorderedColumnsConfig.concat(hiddenColumnsConfig),
);
}
this.table.clearHeaderFilter();
if (preset.headerFilters) {
@@ -61,4 +70,44 @@ export default class TablePresetsModule extends Module {
);
}
}
// The preset orders and hides the columns of the scope only. A column outside the
// scope keeps its position, because the table does not show it in every state.
applyColumnsInScope(preset, presetColumns) {
const displayedColumns = preset.displayedColumns ?? [];
// a displayed column takes the rank of the preset. A column that the preset
// hides has no rank and keeps the current order at the end of its block.
const rank = (columnConfig) => {
const index = displayedColumns.indexOf(columnConfig.field);
return index === -1 ? displayedColumns.length : index;
};
let columnsConfig = [];
let scopeBlock = [];
// the sort runs for each block of neighbouring scope columns. This way a scope
// column never moves over a column outside the scope.
const addScopeBlock = () => {
scopeBlock.sort((a, b) => rank(a) - rank(b));
columnsConfig = columnsConfig.concat(scopeBlock);
scopeBlock = [];
};
this.table.getColumnDefinitions().forEach((columnConfig) => {
if (!presetColumns.includes(columnConfig.field)) {
addScopeBlock();
columnsConfig.push(columnConfig);
return;
}
columnConfig.visible = displayedColumns.includes(
columnConfig.field,
);
scopeBlock.push(columnConfig);
});
addScopeBlock();
this.table.setColumns(columnsConfig);
}
}
+2524 -60
View File
File diff suppressed because it is too large Load Diff