import { ReactiveCache } from '/imports/reactiveCache'; import { TAPi18n } from '/imports/i18n'; import dragscroll from '@wekanteam/dragscroll'; const subManager = new SubsManager(); const { calculateIndex } = Utils; const swimlaneWhileSortingHeight = 150; BlazeComponent.extendComponent({ onCreated() { this.isBoardReady = new ReactiveVar(false); // The pattern we use to manually handle data loading is described here: // https://kadira.io/academy/meteor-routing-guide/content/subscriptions-and-data-management/using-subs-manager // XXX The boardId should be readed from some sort the component "props", // unfortunatly, Blaze doesn't have this notion. this.autorun(() => { const currentBoardId = Session.get('currentBoard'); if (!currentBoardId) return; const handle = subManager.subscribe('board', currentBoardId, false); Tracker.nonreactive(() => { Tracker.autorun(() => { this.isBoardReady.set(handle.ready()); }); }); }); }, onlyShowCurrentCard() { return Utils.isMiniScreen() && Utils.getCurrentCardId(true); }, goHome() { FlowRouter.go('home'); }, }).register('board'); BlazeComponent.extendComponent({ onCreated() { Meteor.subscribe('tableVisibilityModeSettings'); this.showOverlay = new ReactiveVar(false); this.draggingActive = new ReactiveVar(false); this._isDragging = false; // Used to set the overlay this.mouseHasEnterCardDetails = false; // fix swimlanes sort field if there are null values const currentBoardData = Utils.getCurrentBoard(); const nullSortSwimlanes = currentBoardData.nullSortSwimlanes(); if (nullSortSwimlanes.length > 0) { const swimlanes = currentBoardData.swimlanes(); let count = 0; swimlanes.forEach(s => { Swimlanes.update(s._id, { $set: { sort: count, }, }); count += 1; }); } // fix lists sort field if there are null values const nullSortLists = currentBoardData.nullSortLists(); if (nullSortLists.length > 0) { const lists = currentBoardData.lists(); let count = 0; lists.forEach(l => { Lists.update(l._id, { $set: { sort: count, }, }); count += 1; }); } }, onRendered() { // Accessibility: Focus management for popups and menus function focusFirstInteractive(container) { if (!container) return; // Find first focusable element const focusable = container.querySelectorAll('button, [role="button"], a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); for (let i = 0; i < focusable.length; i++) { if (!focusable[i].disabled && focusable[i].offsetParent !== null) { focusable[i].focus(); break; } } } // Observe for new popups/menus and set focus const popupObserver = new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { mutation.addedNodes.forEach(function(node) { if (node.nodeType === 1 && (node.classList.contains('popup') || node.classList.contains('modal') || node.classList.contains('menu'))) { setTimeout(function() { focusFirstInteractive(node); }, 10); } }); }); }); popupObserver.observe(document.body, { childList: true, subtree: true }); // Remove tabindex from non-interactive elements (e.g., user abbreviations, labels) document.querySelectorAll('.user-abbreviation, .user-label, .card-header-label, .edit-label, .private-label').forEach(function(el) { if (el.hasAttribute('tabindex')) { el.removeAttribute('tabindex'); } }); // Add a toggle button for keyboard shortcuts accessibility if (!document.getElementById('wekan-shortcuts-toggle')) { const toggleContainer = document.createElement('div'); toggleContainer.id = 'wekan-shortcuts-toggle'; toggleContainer.style.position = 'fixed'; toggleContainer.style.top = '10px'; toggleContainer.style.right = '10px'; toggleContainer.style.zIndex = '1000'; toggleContainer.style.background = '#fff'; toggleContainer.style.border = '2px solid #005fcc'; toggleContainer.style.borderRadius = '6px'; toggleContainer.style.padding = '8px 12px'; toggleContainer.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)'; toggleContainer.style.fontSize = '16px'; toggleContainer.style.color = '#005fcc'; toggleContainer.setAttribute('role', 'region'); toggleContainer.setAttribute('aria-label', 'Keyboard Shortcuts Settings'); toggleContainer.innerHTML = ` `; document.body.appendChild(toggleContainer); const checkbox = document.getElementById('shortcuts-toggle-checkbox'); checkbox.addEventListener('change', function(e) { window.toggleWekanShortcuts(e.target.checked); }); } // Ensure toggle-buttons, color choices, reactions, renaming, and calendar controls are focusable and have ARIA roles document.querySelectorAll('.js-toggle').forEach(function(el) { el.setAttribute('tabindex', '0'); el.setAttribute('role', 'button'); el.setAttribute('aria-label', 'Toggle'); }); document.querySelectorAll('.js-color-choice').forEach(function(el) { el.setAttribute('tabindex', '0'); el.setAttribute('role', 'button'); el.setAttribute('aria-label', 'Choose color'); }); document.querySelectorAll('.js-reaction').forEach(function(el) { el.setAttribute('tabindex', '0'); el.setAttribute('role', 'button'); el.setAttribute('aria-label', 'React'); }); document.querySelectorAll('.js-rename-swimlane').forEach(function(el) { el.setAttribute('tabindex', '0'); el.setAttribute('role', 'button'); el.setAttribute('aria-label', 'Rename swimlane'); }); document.querySelectorAll('.js-rename-list').forEach(function(el) { el.setAttribute('tabindex', '0'); el.setAttribute('role', 'button'); el.setAttribute('aria-label', 'Rename list'); }); document.querySelectorAll('.fc-button').forEach(function(el) { el.setAttribute('tabindex', '0'); el.setAttribute('role', 'button'); }); // Set the language attribute on the element for accessibility document.documentElement.lang = TAPi18n.getLanguage(); // Ensure the accessible name for the board view switcher matches the visible label "Swimlanes" // This fixes WCAG 2.5.3: Label in Name const swimlanesSwitcher = this.$('.js-board-view-swimlanes'); if (swimlanesSwitcher.length) { swimlanesSwitcher.attr('aria-label', swimlanesSwitcher.text().trim() || 'Swimlanes'); } // Add a highly visible focus indicator and improve contrast for interactive elements if (!document.getElementById('wekan-accessible-focus-style')) { const style = document.createElement('style'); style.id = 'wekan-accessible-focus-style'; style.innerHTML = ` /* Focus indicator */ button:focus, [role="button"]:focus, a:focus, input:focus, select:focus, textarea:focus, .dropdown-menu:focus, .js-board-view-swimlanes:focus, .js-add-card:focus { outline: 3px solid #005fcc !important; outline-offset: 2px !important; background-color: #e6f0ff !important; } /* Input borders */ input, textarea, select { border: 2px solid #222 !important; } /* Plus icon for adding a new card */ .js-add-card { color: #005fcc !important; /* dark blue for contrast */ cursor: pointer; outline: none; } .js-add-card[tabindex] { outline: none; } /* Hamburger menu */ .fa-bars, .icon-hamburger { color: #222 !important; } /* Grey icons in card detail header */ .card-detail-header .fa, .card-detail-header .icon { color: #444 !important; } /* Grey operating elements in card detail */ .card-detail .fa, .card-detail .icon { color: #444 !important; } /* Blue bar in checklists */ .checklist-progress-bar { background-color: #005fcc !important; } /* Green checkmark in checklists */ .checklist .fa-check { color: #007a33 !important; } /* X-Button and arrow button in menus */ .close, .fa-arrow-left, .icon-arrow-left { color: #005fcc !important; } /* Cross icon to move boards */ .js-move-board { color: #005fcc !important; } /* Current date background */ .current-date { background-color: #005fcc !important; color: #fff !important; } `; document.head.appendChild(style); } // Ensure plus/add elements are focusable and have ARIA roles document.querySelectorAll('.js-add-card').forEach(function(el) { el.setAttribute('tabindex', '0'); el.setAttribute('role', 'button'); el.setAttribute('aria-label', 'Add new card'); }); const boardComponent = this; const $swimlanesDom = boardComponent.$('.js-swimlanes'); $swimlanesDom.sortable({ tolerance: 'pointer', appendTo: '.board-canvas', helper(evt, item) { const helper = $(`
`); helper.append(item.clone()); // Also grab the list of lists of cards const list = item.next(); helper.append(list.clone()); return helper; }, items: '.swimlane:not(.placeholder)', placeholder: 'swimlane placeholder', distance: 7, start(evt, ui) { const listDom = ui.placeholder.next('.js-swimlane'); const parentOffset = ui.item.parent().offset(); ui.placeholder.height(ui.helper.height()); EscapeActions.executeUpTo('popup-close'); listDom.addClass('moving-swimlane'); boardComponent.setIsDragging(true); ui.placeholder.insertAfter(ui.placeholder.next()); boardComponent.origPlaceholderIndex = ui.placeholder.index(); // resize all swimlanes + headers to be a total of 150 px per row // this could be achieved by setIsDragging(true) but we want immediate // result ui.item .siblings('.js-swimlane') .css('height', `${swimlaneWhileSortingHeight - 26}px`); // set the new scroll height after the resize and insertion of // the placeholder. We want the element under the cursor to stay // at the same place on the screen ui.item.parent().get(0).scrollTop = ui.placeholder.get(0).offsetTop + parentOffset.top - evt.pageY; }, beforeStop(evt, ui) { const parentOffset = ui.item.parent().offset(); const siblings = ui.item.siblings('.js-swimlane'); siblings.css('height', ''); // compute the new scroll height after the resize and removal of // the placeholder const scrollTop = ui.placeholder.get(0).offsetTop + parentOffset.top - evt.pageY; // then reset the original view of the swimlane siblings.removeClass('moving-swimlane'); // and apply the computed scrollheight ui.item.parent().get(0).scrollTop = scrollTop; }, stop(evt, ui) { // To attribute the new index number, we need to get the DOM element // of the previous and the following card -- if any. const prevSwimlaneDom = ui.item.prevAll('.js-swimlane').get(0); const nextSwimlaneDom = ui.item.nextAll('.js-swimlane').get(0); const sortIndex = calculateIndex(prevSwimlaneDom, nextSwimlaneDom, 1); $swimlanesDom.sortable('cancel'); const swimlaneDomElement = ui.item.get(0); const swimlane = Blaze.getData(swimlaneDomElement); Swimlanes.update(swimlane._id, { $set: { sort: sortIndex.base, }, }); boardComponent.setIsDragging(false); }, sort(evt, ui) { // get the mouse position in the sortable const parentOffset = ui.item.parent().offset(); const cursorY = evt.pageY - parentOffset.top + ui.item.parent().scrollTop(); // compute the intended index of the placeholder (we need to skip the // slots between the headers and the list of cards) const newplaceholderIndex = Math.floor( cursorY / swimlaneWhileSortingHeight, ); let destPlaceholderIndex = (newplaceholderIndex + 1) * 2; // if we are scrolling far away from the bottom of the list if (destPlaceholderIndex >= ui.item.parent().get(0).childElementCount) { destPlaceholderIndex = ui.item.parent().get(0).childElementCount - 1; } // update the placeholder position in the DOM tree if (destPlaceholderIndex !== ui.placeholder.index()) { if (destPlaceholderIndex < boardComponent.origPlaceholderIndex) { ui.placeholder.insertBefore( ui.placeholder .siblings() .slice(destPlaceholderIndex - 2, destPlaceholderIndex - 1), ); } else { ui.placeholder.insertAfter( ui.placeholder .siblings() .slice(destPlaceholderIndex - 1, destPlaceholderIndex), ); } } }, }); this.autorun(() => { // Always reset dragscroll on view switch dragscroll.reset(); if (Utils.isTouchScreenOrShowDesktopDragHandles()) { $swimlanesDom.sortable({ handle: '.js-swimlane-header-handle', }); } else { $swimlanesDom.sortable({ handle: '.swimlane-header', }); } // Disable drag-dropping if the current user is not a board member $swimlanesDom.sortable( 'option', 'disabled', !ReactiveCache.getCurrentUser()?.isBoardAdmin(), ); }); // If there is no data in the board (ie, no lists) we autofocus the list // creation form by clicking on the corresponding element. const currentBoard = Utils.getCurrentBoard(); if (Utils.canModifyBoard() && currentBoard.lists().length === 0) { boardComponent.openNewListForm(); } dragscroll.reset(); Utils.setBackgroundImage(); }, notDisplayThisBoard() { let allowPrivateVisibilityOnly = TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly'); let currentBoard = Utils.getCurrentBoard(); if (allowPrivateVisibilityOnly !== undefined && allowPrivateVisibilityOnly.booleanValue && currentBoard.permission == 'public') { return true; } return false; }, isViewSwimlanes() { const currentUser = ReactiveCache.getCurrentUser(); if (currentUser) { return (currentUser.profile || {}).boardView === 'board-view-swimlanes'; } else { return ( window.localStorage.getItem('boardView') === 'board-view-swimlanes' ); } }, hasSwimlanes() { return Utils.getCurrentBoard().swimlanes().length > 0; }, isViewLists() { const currentUser = ReactiveCache.getCurrentUser(); if (currentUser) { return (currentUser.profile || {}).boardView === 'board-view-lists'; } else { return window.localStorage.getItem('boardView') === 'board-view-lists'; } }, isViewCalendar() { const currentUser = ReactiveCache.getCurrentUser(); if (currentUser) { return (currentUser.profile || {}).boardView === 'board-view-cal'; } else { return window.localStorage.getItem('boardView') === 'board-view-cal'; } }, isVerticalScrollbars() { const user = ReactiveCache.getCurrentUser(); return user && user.isVerticalScrollbars(); }, openNewListForm() { if (this.isViewSwimlanes()) { // The form had been removed in 416b17062e57f215206e93a85b02ef9eb1ab4902 // this.childComponents('swimlane')[0] // .childComponents('addListAndSwimlaneForm')[0] // .open(); } else if (this.isViewLists()) { this.childComponents('listsGroup')[0] .childComponents('addListForm')[0] .open(); } }, events() { return [ { // XXX The board-overlay div should probably be moved to the parent // component. mouseup() { if (this._isDragging) { this._isDragging = false; } }, 'click .js-empty-board-add-swimlane': Popup.open('swimlaneAdd'), }, ]; }, // XXX Flow components allow us to avoid creating these two setter methods by // exposing a public API to modify the component state. We need to investigate // best practices here. setIsDragging(bool) { this.draggingActive.set(bool); }, scrollLeft(position = 0) { const swimlanes = this.$('.js-swimlanes'); swimlanes && swimlanes.animate({ scrollLeft: position, }); }, scrollTop(position = 0) { const swimlanes = this.$('.js-swimlanes'); swimlanes && swimlanes.animate({ scrollTop: position, }); }, }).register('boardBody'); // Accessibility: Allow users to enable/disable keyboard shortcuts window.wekanShortcutsEnabled = true; window.toggleWekanShortcuts = function(enabled) { window.wekanShortcutsEnabled = !!enabled; }; // Example: Wrap your character key shortcut handler like this document.addEventListener('keydown', function(e) { // Example: "W" key shortcut (replace with your actual shortcut logic) if (!window.wekanShortcutsEnabled) return; if (e.key === 'w' || e.key === 'W') { // ...existing shortcut logic... // e.g. open swimlanes view, etc. } }); // Keyboard accessibility for card actions (favorite, archive, duplicate, etc.) document.addEventListener('keydown', function(e) { if (!window.wekanShortcutsEnabled) return; // Only proceed if focus is on a card action element const active = document.activeElement; if (active && active.classList.contains('js-card-action')) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); active.click(); } // Move card up/down with arrow keys if (e.key === 'ArrowUp') { e.preventDefault(); if (active.dataset.cardId) { Meteor.call('moveCardUp', active.dataset.cardId); } } if (e.key === 'ArrowDown') { e.preventDefault(); if (active.dataset.cardId) { Meteor.call('moveCardDown', active.dataset.cardId); } } } // Make plus/add elements keyboard accessible if (active && active.classList.contains('js-add-card')) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); active.click(); } } // Keyboard move for cards (alternative to drag & drop) if (active && active.classList.contains('js-move-card')) { if (e.key === 'ArrowUp') { e.preventDefault(); if (active.dataset.cardId) { Meteor.call('moveCardUp', active.dataset.cardId); } } if (e.key === 'ArrowDown') { e.preventDefault(); if (active.dataset.cardId) { Meteor.call('moveCardDown', active.dataset.cardId); } } } // Ensure move card buttons are focusable and have ARIA roles document.querySelectorAll('.js-move-card').forEach(function(el) { el.setAttribute('tabindex', '0'); el.setAttribute('role', 'button'); el.setAttribute('aria-label', 'Move card'); }); // Make toggle-buttons, color choices, reactions, and X-buttons keyboard accessible if (active && (active.classList.contains('js-toggle') || active.classList.contains('js-color-choice') || active.classList.contains('js-reaction') || active.classList.contains('close'))) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); active.click(); } } // Prevent scripts from removing focus when received if (active) { active.addEventListener('focus', function(e) { // Do not remove focus // No-op: This prevents F55 failure }, { once: true }); } // Make swimlane/list renaming keyboard accessible if (active && (active.classList.contains('js-rename-swimlane') || active.classList.contains('js-rename-list'))) { if (e.key === 'Enter') { e.preventDefault(); active.click(); } } // Calendar navigation buttons if (active && active.classList.contains('fc-button')) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); active.click(); } } }); BlazeComponent.extendComponent({ onRendered() { // Set the language attribute on the element for accessibility document.documentElement.lang = TAPi18n.getLanguage(); this.autorun(function () { $('#calendar-view').fullCalendar('refetchEvents'); }); }, calendarOptions() { return { id: 'calendar-view', defaultView: 'month', editable: true, selectable: true, timezone: 'local', weekNumbers: true, header: { left: 'title today prev,next', center: 'agendaDay,listDay,timelineDay agendaWeek,listWeek,timelineWeek month,listMonth', right: '', }, buttonText: { prev: TAPi18n.__('calendar-previous-month'), next: TAPi18n.__('calendar-next-month'), }, ariaLabel: { prev: TAPi18n.__('calendar-previous-month'), next: TAPi18n.__('calendar-next-month'), }, // height: 'parent', nope, doesn't work as the parent might be small height: 'auto', /* TODO: lists as resources: https://fullcalendar.io/docs/vertical-resource-view */ navLinks: true, nowIndicator: true, businessHours: { // days of week. an array of zero-based day of week integers (0=Sunday) dow: [1, 2, 3, 4, 5], // Monday - Friday start: '8:00', end: '18:00', }, locale: TAPi18n.getLanguage(), events(start, end, timezone, callback) { const currentBoard = Utils.getCurrentBoard(); const events = []; const pushEvent = function (card, title, start, end, extraCls) { start = start || card.startAt; end = end || card.endAt; title = title || card.title; const className = (extraCls ? `${extraCls} ` : '') + (card.color ? `calendar-event-${card.color}` : ''); events.push({ id: card._id, title, start, end: end || card.endAt, allDay: Math.abs(end.getTime() - start.getTime()) / 1000 === 24 * 3600, url: FlowRouter.path('card', { boardId: currentBoard._id, slug: currentBoard.slug, cardId: card._id, }), className, }); }; currentBoard .cardsInInterval(start.toDate(), end.toDate()) .forEach(function (card) { pushEvent(card); }); currentBoard .cardsDueInBetween(start.toDate(), end.toDate()) .forEach(function (card) { pushEvent( card, `${card.title} ${TAPi18n.__('card-due')}`, card.dueAt, new Date(card.dueAt.getTime() + 36e5), ); }); events.sort(function (first, second) { return first.id > second.id ? 1 : -1; }); callback(events); }, eventResize(event, delta, revertFunc) { let isOk = false; const card = ReactiveCache.getCard(event.id); if (card) { card.setEnd(event.end.toDate()); isOk = true; } if (!isOk) { revertFunc(); } }, eventDrop(event, delta, revertFunc) { let isOk = false; const card = ReactiveCache.getCard(event.id); if (card) { // TODO: add a flag for allDay events if (!event.allDay) { // https://github.com/wekan/wekan/issues/2917#issuecomment-1236753962 //card.setStart(event.start.toDate()); //card.setEnd(event.end.toDate()); card.setDue(event.start.toDate()); isOk = true; } } if (!isOk) { revertFunc(); } }, select: function (startDate) { const currentBoard = Utils.getCurrentBoard(); const currentUser = ReactiveCache.getCurrentUser(); const modalElement = document.createElement('div'); modalElement.classList.add('modal', 'fade'); modalElement.setAttribute('tabindex', '-1'); modalElement.setAttribute('role', 'dialog'); modalElement.innerHTML = `