195 lines
7.8 KiB
JavaScript
195 lines
7.8 KiB
JavaScript
(function (root, factory) {
|
|
const api = factory();
|
|
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
else root.createMobileFindWorkNavigation = api;
|
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
|
'use strict';
|
|
|
|
const stages = ['discover', 'review', 'fit'];
|
|
|
|
return function createMobileFindWorkNavigation(options) {
|
|
const document = options.document;
|
|
const buttons = options.buttons || Object.fromEntries(stages.map(name =>
|
|
[name, document?.querySelector('[data-find-work-stage="' + name + '"]')]
|
|
));
|
|
const sections = options.sections || Object.fromEntries(stages.map(name =>
|
|
[name, document?.getElementById('find-work-' + name + '-stage')]
|
|
));
|
|
const history = options.history;
|
|
const eventTarget = options.eventTarget;
|
|
const onStageChange = options.onStageChange || (() => {});
|
|
const listeners = new Map();
|
|
let current = 'discover';
|
|
let selectedCount = 0;
|
|
let recovery = false;
|
|
let started = false;
|
|
|
|
function escape(value) {
|
|
return String(value || '').replace(/[&<>"']/g, character =>
|
|
({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' })[character]
|
|
);
|
|
}
|
|
|
|
function renderReview() {
|
|
const reviewList = options.reviewList || document?.getElementById('find-work-review-list');
|
|
const reviewSummary = options.reviewSummary || document?.getElementById('find-work-review-summary');
|
|
if (!reviewList) return;
|
|
const items = options.controller?.selectedItems?.() || options.getSelectedItems?.() || [];
|
|
const planning = options.todayWork?.planning?.();
|
|
const today = options.todayWork ? {
|
|
limit:options.todayWork.limit, count:options.todayWork.read().length,
|
|
capacityMinutes:planning.capacity_minutes, estimates:planning.estimates,
|
|
} : options.getTodayState?.() || {};
|
|
const remainingSlots = Math.max(0, (Number(today.limit) || 0) - (Number(today.count) || 0));
|
|
const planned = Object.values(today.estimates || {}).reduce((sum, minutes) => sum + minutes, 0);
|
|
const remainingMinutes = today.capacityMinutes === null || today.capacityMinutes === undefined ? null :
|
|
Math.max(0, today.capacityMinutes - planned);
|
|
const formatMinutes = minutes => [Math.floor(minutes / 60) ? Math.floor(minutes / 60) + 'h' : '',
|
|
minutes % 60 ? minutes % 60 + 'm' : ''].filter(Boolean).join(' ') || '0m';
|
|
if (reviewSummary) reviewSummary.textContent = items.length + ' selected · ' + remainingSlots +
|
|
' Today slot' + (remainingSlots === 1 ? '' : 's') + ' open' +
|
|
(remainingMinutes === null ? '' : ' · ' + formatMinutes(remainingMinutes) + ' available') + '.';
|
|
reviewList.innerHTML = items.map(item => {
|
|
const id = String(item.repository || '') + '#' + String(item.number || '');
|
|
return '<article class="find-work-review-item"><div><span class="small">' + escape(id) +
|
|
'</span><strong>' + escape(item.title || 'Untitled work') + '</strong></div>' +
|
|
'<button type="button" data-find-work-review-remove="' + escape(id) + '">Remove</button></article>';
|
|
}).join('');
|
|
reviewList.querySelectorAll('[data-find-work-review-remove]').forEach(button =>
|
|
button.addEventListener('click', () => {
|
|
const item = items.find(candidate => String(candidate.repository || '') + '#' +
|
|
String(candidate.number || '') === button.dataset.findWorkReviewRemove);
|
|
if (item) (options.controller?.toggleSelection || options.toggleSelection)?.(item);
|
|
})
|
|
);
|
|
}
|
|
|
|
function allowed(name) {
|
|
return stages.includes(name) && (name === 'discover' || selectedCount > 0 || recovery);
|
|
}
|
|
|
|
function paint(name, focus = false, notify = true) {
|
|
if (!allowed(name)) name = 'discover';
|
|
current = name;
|
|
stages.forEach(stage => {
|
|
const button = buttons[stage];
|
|
const section = sections[stage];
|
|
if (button) {
|
|
button.disabled = stage !== 'discover' && selectedCount === 0 && !recovery;
|
|
if (stage === name) button.setAttribute('aria-current', 'step');
|
|
else button.removeAttribute('aria-current');
|
|
}
|
|
if (section) section.hidden = stage !== name;
|
|
});
|
|
if (name === 'review') renderReview();
|
|
if (name === 'fit') options.openFit?.(options.controller?.selectedItems?.() || []);
|
|
if (notify) onStageChange(name);
|
|
if (focus) sections[name]?.focus?.();
|
|
return name;
|
|
}
|
|
|
|
function stateFor(name) {
|
|
const state = { ...(history?.state || {}) };
|
|
if (name === 'discover') delete state.findWorkStage;
|
|
else state.findWorkStage = name;
|
|
return state;
|
|
}
|
|
|
|
function go(name) {
|
|
if (!allowed(name) || name === current) return false;
|
|
const currentIndex = stages.indexOf(current);
|
|
const nextIndex = stages.indexOf(name);
|
|
if (nextIndex < currentIndex && history?.go) {
|
|
history.go(nextIndex - currentIndex);
|
|
return true;
|
|
}
|
|
if (nextIndex === currentIndex - 1 && history?.back) {
|
|
history.back();
|
|
return true;
|
|
}
|
|
history?.pushState?.(stateFor(name), '');
|
|
paint(name, true);
|
|
return true;
|
|
}
|
|
|
|
function onPopState(event) {
|
|
if (event.state?.taskOverlay !== 'find') return;
|
|
paint(event.state?.findWorkStage || 'discover', true);
|
|
}
|
|
|
|
function sync(state = {}) {
|
|
selectedCount = Math.max(0, Number(state.selectedCount) || 0);
|
|
recovery = state.recovery === true;
|
|
if (!allowed(current)) paint('discover');
|
|
else paint(current, false, false);
|
|
}
|
|
|
|
function open(hasRecovery) {
|
|
if (options.controller) {
|
|
selectedCount = options.controller.selection().count;
|
|
recovery = hasRecovery === true;
|
|
}
|
|
const restored = history?.state?.taskOverlay === 'find' ? history.state.findWorkStage : null;
|
|
return paint(allowed(restored) ? restored : 'discover');
|
|
}
|
|
|
|
function complete() {
|
|
selectedCount = 0;
|
|
recovery = false;
|
|
history?.replaceState?.(stateFor('discover'), '');
|
|
return paint('discover');
|
|
}
|
|
|
|
function back() {
|
|
if (current === 'discover') return false;
|
|
history?.back?.();
|
|
return true;
|
|
}
|
|
|
|
return {
|
|
start() {
|
|
if (started) return;
|
|
started = true;
|
|
stages.forEach(name => {
|
|
const button = buttons[name];
|
|
if (!button) return;
|
|
const listener = event => { event.preventDefault(); go(name); };
|
|
listeners.set(button, listener);
|
|
button.addEventListener('click', listener);
|
|
});
|
|
if (document) {
|
|
const bind = (id, action) => {
|
|
const button = document.getElementById(id);
|
|
if (!button) return;
|
|
const listener = event => { event.preventDefault(); action(); };
|
|
listeners.set(button, listener);
|
|
button.addEventListener('click', listener);
|
|
};
|
|
bind('select-find-work', () => { complete(); options.controller?.startSelection?.(); });
|
|
bind('cancel-find-work-selection', () => { complete(); options.controller?.cancelSelection?.(); });
|
|
bind('claim-selected-work', () => go('review'));
|
|
bind('continue-find-work-fit', () => go('fit'));
|
|
bind('back-find-work-discover', back);
|
|
bind('cancel-find-work-estimates', back);
|
|
bind('close-find-work', () => history?.go?.(-(stages.indexOf(current) + 1)));
|
|
}
|
|
eventTarget?.addEventListener?.('popstate', onPopState);
|
|
paint('discover');
|
|
},
|
|
stop() {
|
|
listeners.forEach((listener, button) => button.removeEventListener('click', listener));
|
|
listeners.clear();
|
|
eventTarget?.removeEventListener?.('popstate', onPopState);
|
|
started = false;
|
|
},
|
|
sync,
|
|
open,
|
|
go,
|
|
back,
|
|
complete,
|
|
renderReview,
|
|
stage: () => current,
|
|
};
|
|
};
|
|
});
|