1/** Kompakter Zeitraumwaehler mit Schnellwahl und gemeinsam bestaetigten Datumsfeldern. */ 2import { createElement } from '../../../../../core/utils/index.js'; 3import { Icon } from '../../../../hierarchy-tracking/presentation/components/atoms/Icon.js'; 4import { datumsbereich, schnelleZeitraeume, zeitraumTitel, tabAusMenue } from './zeitraum.js'; 5 6const FOKUSSIERBAR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; 7 8let folge = 0; 9export class DateRangePicker { 10 #element; 11 #startDate; 12 #endDate; 13 #onChange; 14 #startInput; 15 #endInput; 16 #knopf; 17 #titel; 18 #menue; 19 #fehler; 20 #offen = false; 21 #schnell = []; 22 23 constructor(props = {}) { 24 this.#startDate = props.startDate || null; 25 this.#endDate = props.endDate || null; 26 this.#onChange = props.onChange || (() => {}); 27 const id = `rv-zeitraum-${++folge}`; 28 this.#titel = createElement('span', { className: 'rv-zeitraum-titel' }); 29 this.#knopf = createElement('button', { 30 type: 'button', className: 'rv-zeitraum-knopf', 'aria-expanded': 'false', 31 'aria-controls': id, 'aria-haspopup': 'dialog', onclick: () => this.#offen ? this.#schliesse() : this.#oeffne(), 32 }, [new Icon({ name: 'calendar', size: 16 }).element, this.#titel, new Icon({ name: 'chevronDown', size: 12 }).element]); 33 const feld = (name) => createElement('input', { 34 type: 'date', className: 'rv-zeitraum-datum', 'aria-label': name, 'aria-describedby': `${id}-fehler`, 35 oninput: () => { this.#fehler.hidden = true; }, 36 }); 37 this.#startInput = feld('Von'); 38 this.#endInput = feld('Bis'); 39 this.#fehler = createElement('p', { id: `${id}-fehler`, className: 'rv-zeitraum-fehler', role: 'alert', hidden: true }); 40 const schnell = schnelleZeitraeume().map(opt => { 41 const knopf = createElement('button', { 42 type: 'button', className: 'rv-zeitraum-schnell', 43 onclick: () => this.#uebernehme(opt.start, opt.end), 44 }, [opt.label]); 45 this.#schnell.push({ knopf, ...opt }); 46 return knopf; 47 }); 48 this.#menue = createElement('form', { 49 id, className: 'rv-zeitraum-menue', role: 'dialog', 'aria-label': 'Zeitraum wählen', 50 onsubmit: e => { e.preventDefault(); this.#anwenden(); }, 51 }, [ 52 createElement('div', { className: 'rv-menue-titel' }, ['Zeitraum wählen']), 53 createElement('div', { className: 'rv-zeitraum-schnellwahlen' }, schnell), 54 createElement('div', { className: 'rv-zeitraum-felder' }, [ 55 createElement('label', {}, ['Von', this.#startInput]), 56 createElement('label', {}, ['Bis', this.#endInput]), 57 ]), 58 this.#fehler, 59 createElement('button', { type: 'submit', className: 'rv-zeitraum-anwenden' }, ['Zeitraum übernehmen']), 60 ]); 61 this.#element = createElement('div', { className: 'rv-zeitraum' }, [this.#knopf]); 62 this.#aktualisiere(); 63 } 64 65 #anwenden() { 66 try { 67 const { startDate, endDate } = datumsbereich(this.#startInput.value, this.#endInput.value); 68 this.#uebernehme(startDate, endDate); 69 } catch (fehler) { 70 this.#fehler.textContent = fehler.message; 71 this.#fehler.hidden = false; 72 this.#endInput.focus(); 73 } 74 } 75 76 #uebernehme(start, end) { 77 this.setRange(start, end); 78 this.#schliesse(true); 79 this.#onChange({ startDate: start, endDate: end }); 80 } 81 82 #format(date) { 83 return date ? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` : ''; 84 } 85 86 #aktualisiere() { 87 this.#startInput.value = this.#format(this.#startDate); 88 this.#endInput.value = this.#format(this.#endDate); 89 this.#titel.textContent = zeitraumTitel(this.#startDate, this.#endDate); 90 this.#knopf.setAttribute('aria-label', `Zeitraum: ${this.#titel.textContent}`); 91 this.#knopf.title = this.#titel.textContent; 92 for (const opt of this.#schnell) { 93 const aktiv = this.#format(opt.start) === this.#startInput.value && this.#format(opt.end) === this.#endInput.value; 94 opt.knopf.setAttribute('aria-pressed', String(aktiv)); 95 } 96 } 97 98 #oeffne() { 99 this.#aktualisiere(); // Nicht bestaetigte Eingaben verwerfen. 100 this.#fehler.hidden = true; 101 this.#offen = true; 102 this.#knopf.setAttribute('aria-expanded', 'true'); 103 document.body.append(this.#menue); 104 const r = this.#knopf.getBoundingClientRect(); 105 const breite = Math.min(340, window.innerWidth - 24); 106 this.#menue.style.width = `${breite}px`; 107 this.#menue.style.maxHeight = `${window.innerHeight - 24}px`; 108 this.#menue.style.left = `${Math.max(12, Math.min(r.left, window.innerWidth - breite - 12))}px`; 109 const hoehe = this.#menue.getBoundingClientRect().height; 110 this.#menue.style.top = `${Math.max(12, Math.min(r.bottom + 8, window.innerHeight - hoehe - 12))}px`; 111 document.addEventListener('pointerdown', this.#aussen, true); 112 document.addEventListener('focusin', this.#aussen); 113 document.addEventListener('keydown', this.#taste, true); 114 window.addEventListener('resize', this.#umbruch); 115 window.addEventListener('scroll', this.#rollen, true); 116 this.#menue.querySelector('[aria-pressed="true"]')?.focus(); 117 if (!this.#menue.contains(document.activeElement)) this.#startInput.focus(); 118 } 119 120 #aussen = e => { 121 if (!this.#menue.contains(e.target) && !this.#element.contains(e.target)) this.#schliesse(); 122 }; 123 #taste = e => { 124 if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); this.#schliesse(true); return; } 125 if (e.key !== 'Tab') return; 126 // TAB AN DEN RAENDERN DES MENUES (D04-13, seit 5.314): zurueck auf den 127 // Knopf bzw. hinter ihn - nicht ans Seitenende, wo das Menue haengt. 128 const felder = [...this.#menue.querySelectorAll(FOKUSSIERBAR)]; 129 const ziel = tabAusMenue({ 130 erstes: felder[0], letztes: felder[felder.length - 1], aktiv: document.activeElement, shift: e.shiftKey, 131 }); 132 if (!ziel) return; 133 e.preventDefault(); 134 this.#schliesse(ziel === 'knopf'); 135 if (ziel === 'danach') this.#fokusHinterKnopf(); 136 }; 137 138 /** Das naechste fokussierbare Element nach dem Knopf - wie ohne Menue. */ 139 #fokusHinterKnopf() { 140 const alle = [...document.querySelectorAll(FOKUSSIERBAR)] 141 .filter((el) => el.offsetParent !== null || el === this.#knopf); 142 const naechstes = alle[alle.indexOf(this.#knopf) + 1]; 143 (naechstes || this.#knopf).focus({ preventScroll: true }); 144 } 145 #umbruch = () => this.#schliesse(); 146 #rollen = e => { if (!this.#menue.contains(e.target)) this.#schliesse(); }; 147 148 #schliesse(fokus = false) { 149 this.#offen = false;
150 this.#knopf.setAttribute('aria-expanded', 'false'); 151 this.#menue.remove(); 152 document.removeEventListener('pointerdown', this.#aussen, true); 153 document.removeEventListener('focusin', this.#aussen); 154 document.removeEventListener('keydown', this.#taste, true); 155 window.removeEventListener('resize', this.#umbruch); 156 window.removeEventListener('scroll', this.#rollen, true); 157 if (fokus) this.#knopf.focus({ preventScroll: true }); 158 } 159 160 get element() { return this.#element; } 161 get startDate() { return this.#startDate; } 162 get endDate() { return this.#endDate; } 163 setRange(startDate, endDate) { 164 this.#startDate = startDate; 165 this.#endDate = endDate; 166 this.#aktualisiere(); 167 } 168 destroy() { this.#schliesse(); } 169}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.