diff options
author | Hugo Hörnquist <hugo@lysator.liu.se> | 2021-12-20 22:09:57 +0100 |
---|---|---|
committer | Hugo Hörnquist <hugo@lysator.liu.se> | 2021-12-20 22:09:57 +0100 |
commit | d75ebbab2a414fe1a9a09d703a3bc7be782f1f1e (patch) | |
tree | 0de4f1c17afd6fbefbafc3a0a8a91bc85cb30355 /static/components | |
parent | Document testrunner syntax. (diff) | |
parent | Documentation updates for util. (diff) | |
download | calp-d75ebbab2a414fe1a9a09d703a3bc7be782f1f1e.tar.gz calp-d75ebbab2a414fe1a9a09d703a3bc7be782f1f1e.tar.xz |
Merge Javascript rewrite.
Diffstat (limited to 'static/components')
-rw-r--r-- | static/components/changelog.ts | 49 | ||||
-rw-r--r-- | static/components/date-time-input.ts | 121 | ||||
-rw-r--r-- | static/components/edit-rrule.ts | 75 | ||||
-rw-r--r-- | static/components/input-list.ts | 122 | ||||
-rw-r--r-- | static/components/popup-element.ts | 198 | ||||
-rw-r--r-- | static/components/tab-group-element.ts | 178 | ||||
-rw-r--r-- | static/components/vevent-block.ts | 99 | ||||
-rw-r--r-- | static/components/vevent-description.ts | 59 | ||||
-rw-r--r-- | static/components/vevent-dl.ts | 35 | ||||
-rw-r--r-- | static/components/vevent-edit.ts | 167 | ||||
-rw-r--r-- | static/components/vevent.ts | 70 |
11 files changed, 1173 insertions, 0 deletions
diff --git a/static/components/changelog.ts b/static/components/changelog.ts new file mode 100644 index 00000000..831e4ced --- /dev/null +++ b/static/components/changelog.ts @@ -0,0 +1,49 @@ +import { makeElement } from '../lib' +import { ComponentVEvent } from './vevent' +import { VEvent } from '../vevent' + +export { VEventChangelog } + +class VEventChangelog extends ComponentVEvent { + + readonly ul: HTMLElement + + constructor(uid?: string) { + super(uid); + + this.ul = makeElement('ul'); + } + + connectedCallback() { + this.replaceChildren(this.ul); + } + + redraw(data: VEvent) { + /* TODO only redraw what is needed */ + let children = [] + for (let el of data._changelog) { + let msg = ''; + switch (el.type) { + case 'property': + msg += `change ${el.name}: ` + msg += `from "${el.from}" to "${el.to}"` + break; + case 'calendar': + if (el.from === null && el.to === null) { + msg += '???' + } else if (el.from === null) { + msg += `set calendar to "${atob(el.to!)}"` + } else if (el.to === null) { + msg += `Remove calendar "${atob(el.from)}"` + } else { + msg += `Change calendar from "${atob(el.from)}" to "${atob(el.to)}"` + } + break; + } + + children.push(makeElement('li', { textContent: msg })); + } + + this.ul.replaceChildren(...children) + } +} diff --git a/static/components/date-time-input.ts b/static/components/date-time-input.ts new file mode 100644 index 00000000..a6d5df18 --- /dev/null +++ b/static/components/date-time-input.ts @@ -0,0 +1,121 @@ +export { DateTimeInput } + +import { makeElement, parseDate } from '../lib' + + +/* '<date-time-input />' */ +class DateTimeInput extends /* HTMLInputElement */ HTMLElement { + + readonly time: HTMLInputElement; + readonly date: HTMLInputElement; + + constructor() { + super(); + + this.date = makeElement('input', { + type: 'date' + }) as HTMLInputElement + + this.time = makeElement('input', { + type: 'time', + disabled: this.dateonly + }) as HTMLInputElement + } + + connectedCallback() { + /* This can be in the constructor for chromium, but NOT firefox... + Vivaldi 4.3.2439.63 stable + Mozilla Firefox 94.0.1 + */ + /* + https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#boolean_attributes + https://developer.mozilla.org/en-US/docs/Web/API/Element/getAttribute + */ + this.replaceChildren(this.date, this.time) + } + + static get observedAttributes() { + return ['dateonly'] + } + + attributeChangedCallback(name: string, _: string | null, to: string | null): void { + switch (name) { + case 'dateonly': + if (to == null) { + this.time.disabled = false + } else { + if (to == '' || to == name) { + this.time.disabled = true; + } else { + throw new TypeError(`Invalid value for attribute dateonly: ${to}`) + } + } + break; + } + } + + get dateonly(): boolean { + return this.hasAttribute('dateonly'); + } + + set dateonly(b: boolean) { + if (b) { + this.setAttribute('dateonly', ""); + } else { + this.removeAttribute('dateonly'); + } + } + + set value(date: Date) { + let [d, t] = date.format("~L~Y-~m-~dT~H:~M:~S").split('T'); + // console.log(d, t); + this.date.value = d; + this.time.value = t; + + this.dateonly = date.dateonly; + } + + get value(): Date { + let dt; + let date = this.date.value; + if (this.dateonly) { + dt = parseDate(date); + dt.dateonly = true; + } else { + let time = this.time.value; + dt = parseDate(date + 'T' + time) + dt.dateonly = false; + } + return dt; + } + + get stringValue(): string { + if (this.dateonly) { + return this.value.format("~Y-~m-~d") + } else { + return this.value.format("~Y-~m-~dT~H:~M:~S") + } + } + + set stringValue(new_value: Date | string) { + // console.log('Setting date'); + let date, time, dateonly = false; + if (new_value instanceof Date) { + date = new_value.format("~L~Y-~m-~d"); + time = new_value.format("~L~H:~M:~S"); + dateonly = new_value.dateonly; + } else { + [date, time] = new_value.split('T') + } + this.dateonly = dateonly; + this.date.value = date; + this.time.value = time; + } + + addEventListener(type: string, proc: ((e: Event) => void)) { + if (type != 'input') throw "Only input supported"; + + this.date.addEventListener(type, proc); + this.time.addEventListener(type, proc); + } +} diff --git a/static/components/edit-rrule.ts b/static/components/edit-rrule.ts new file mode 100644 index 00000000..a361bdee --- /dev/null +++ b/static/components/edit-rrule.ts @@ -0,0 +1,75 @@ +export { EditRRule } + +import { ComponentVEvent } from './vevent' +import { VEvent } from '../vevent' +import { vcal_objects } from '../globals' + +import { RecurrenceRule } from '../vevent' + +/* <vevent-edit-rrule/> + Tab for editing the recurrence rule of a component +*/ +class EditRRule extends ComponentVEvent { + + constructor(uid?: string) { + super(uid); + + if (!this.template) { + throw 'vevent-edit-rrule template required'; + } + + let frag = this.template.content.cloneNode(true) as DocumentFragment + let body = frag.firstElementChild! + this.replaceChildren(body); + + for (let el of this.querySelectorAll('[name]')) { + el.addEventListener('input', () => { + // console.log(this); + let data = vcal_objects.get(this.uid)!; + let rrule = data.getProperty('rrule') + if (!rrule) { + console.warn('RRUle missing from object'); + return; + } + rrule = rrule as RecurrenceRule + + console.log(el.getAttribute('name'), (el as any).value); + rrule[el.getAttribute('name')!] = (el as any).value; + data.setProperty('rrule', rrule); + + }); + } + } + + connectedCallback() { + this.redraw(vcal_objects.get(this.uid)!) + } + + redraw(data: VEvent) { + + let rrule = data.getProperty('rrule') + if (!rrule) return; + rrule = rrule as RecurrenceRule + + for (let el of this.querySelectorAll('[name]')) { + + /* + el ought to be one of the tag types: + <input/>, <input-list/>, <select/>, and <date-time-input/> + Which all have `name` and `value` fields, allowing the code + below to work. + */ + + let name = el.getAttribute('name') + if (!name) { + console.warn(`Input without name, ${el}`) + continue + } + + let value: any = rrule[name]; + if (value) + (el as any).value = value; + } + } + +} diff --git a/static/components/input-list.ts b/static/components/input-list.ts new file mode 100644 index 00000000..c31066da --- /dev/null +++ b/static/components/input-list.ts @@ -0,0 +1,122 @@ +export { InputList } + +/* This file replaces input_list.js */ + +/* + TODO allow each item to be a larger unit, possibly containing multiple input + fields. +*/ +class InputList extends HTMLElement { + + el: HTMLInputElement; + + private _listeners: [string, (e: Event) => void][] = []; + + constructor() { + super(); + this.el = this.children[0].cloneNode(true) as HTMLInputElement; + } + + connectedCallback() { + for (let child of this.children) { + child.remove(); + } + this.addInstance(); + } + + createInstance(): HTMLInputElement { + let new_el = this.el.cloneNode(true) as HTMLInputElement + let that = this; + new_el.addEventListener('input', function() { + /* TODO .value is empty both if it's actually empty, but also + for invalid input. Check new_el.validity, and new_el.validationMessage + */ + if (new_el.value === '') { + let sibling = (this.previousElementSibling || this.nextElementSibling) + /* Only remove ourselves if we have siblings + Otherwise we just linger */ + if (sibling) { + this.remove(); + (sibling as HTMLInputElement).focus(); + } + } else { + if (!this.nextElementSibling) { + that.addInstance(); + // window.setTimeout(() => this.focus()) + this.focus(); + } + } + }); + + for (let [type, proc] of this._listeners) { + new_el.addEventListener(type, proc); + } + + return new_el; + } + + addInstance() { + let new_el = this.createInstance(); + this.appendChild(new_el); + } + + get value(): any[] { + let value_list = [] + for (let child of this.children) { + value_list.push((child as any).value); + } + if (value_list[value_list.length - 1] === '') { + value_list.pop(); + } + return value_list + } + + set value(new_value: any[]) { + + let all_equal = true; + for (let i = 0; i < this.children.length; i++) { + let sv = (this.children[i] as any).value + all_equal + &&= (sv == new_value[i]) + || (sv === '' && new_value[i] == undefined) + } + if (all_equal) return; + + /* Copy our current input elements into a dictionary. + This allows us to only create new elements where needed + */ + let values = new Map; + for (let child of this.children) { + values.set((child as HTMLInputElement).value, child); + } + + let output_list: HTMLInputElement[] = [] + for (let value of new_value) { + let element; + /* Only create element if needed */ + if ((element = values.get(value))) { + output_list.push(element) + /* clear dictionary */ + values.set(value, false); + } else { + let new_el = this.createInstance(); + new_el.value = value; + output_list.push(new_el); + } + } + /* final, trailing, element */ + output_list.push(this.createInstance()); + + this.replaceChildren(...output_list); + } + + addEventListener(type: string, proc: ((e: Event) => void)) { + // if (type != 'input') throw "Only input supported"; + + this._listeners.push([type, proc]) + + for (let child of this.children) { + child.addEventListener(type, proc); + } + } +} diff --git a/static/components/popup-element.ts b/static/components/popup-element.ts new file mode 100644 index 00000000..35c966ac --- /dev/null +++ b/static/components/popup-element.ts @@ -0,0 +1,198 @@ +export { PopupElement, setup_popup_element } + +import { VEvent } from '../vevent' +import { find_block, vcal_objects } from '../globals' + +import { ComponentVEvent } from './vevent' + +import { remove_event } from '../server_connect' + +/* <popup-element /> */ +class PopupElement extends ComponentVEvent { + + /* The popup which is the "selected" popup. + /* Makes the popup last hovered over the selected popup, moving it to + * the top, and allowing global keyboard bindings to affect it. */ + static activePopup: PopupElement | null = null; + + constructor(uid?: string) { + super(uid); + + /* TODO populate remaining (??) */ + + let obj = vcal_objects.get(this.uid); + if (obj && obj.calendar) { + this.dataset.calendar = obj.calendar; + } + + /* Makes us the active popup */ + this.addEventListener('mouseover', () => { + if (PopupElement.activePopup) { + PopupElement.activePopup.removeAttribute('active'); + } + PopupElement.activePopup = this; + this.setAttribute('active', 'active'); + }) + } + + redraw(data: VEvent) { + if (data.calendar) { + /* The CSS has hooks on [data-calendar], meaning that this can + (and will) change stuff */ + this.dataset.calendar = data.calendar; + } + + } + + connectedCallback() { + let template = document.getElementById('popup-template') as HTMLTemplateElement + let body = (template.content.cloneNode(true) as DocumentFragment).firstElementChild!; + + let uid = this.uid; + + /* nav bar */ + let nav = body.getElementsByClassName("popup-control")[0] as HTMLElement; + bind_popup_control(nav); + + let close_btn = body.querySelector('.popup-control .close-button') as HTMLButtonElement + close_btn.addEventListener('click', () => this.visible = false); + + let maximize_btn = body.querySelector('.popup-control .maximize-button') as HTMLButtonElement + maximize_btn.addEventListener('click', () => this.maximize()); + + let remove_btn = body.querySelector('.popup-control .remove-button') as HTMLButtonElement + remove_btn.addEventListener('click', () => remove_event(uid)); + /* end nav bar */ + + this.replaceChildren(body); + } + + static get observedAttributes() { + return ['visible']; + } + + attributeChangedCallback(name: string, oldValue?: string, newValue?: string) { + switch (name) { + case 'visible': + this.onVisibilityChange() + break; + } + } + + get visible(): boolean { + return this.hasAttribute('visible'); + } + + set visible(isVisible: boolean) { + if (isVisible) { + this.setAttribute('visible', 'visible'); + } else { + this.removeAttribute('visible'); + } + } + + private onVisibilityChange() { + + /* TODO better way to find root */ + let root; + switch (window.VIEW) { + case 'week': + root = document.getElementsByClassName("days")[0]; + break; + case 'month': + default: + root = document.body; + break; + } + + let element = find_block(this.uid) as HTMLElement | null + /* start <X, Y> sets offset between top left corner + of event in calendar and popup. 10, 10 soo old + event is still visible */ + let offsetX = 10, offsetY = 10; + while (element !== root && element !== null) { + offsetX += element.offsetLeft; + offsetY += element.offsetTop; + element = element.offsetParent as HTMLElement; + } + this.style.left = offsetX + "px"; + this.style.top = offsetY + "px"; + + /* Reset width and height to initial, to save user if they have resized + it to something weird */ + let el = this.firstElementChild as HTMLElement; + el.style.removeProperty('width'); + el.style.removeProperty('height'); + } + + maximize() { + /* TODO this assumes that popups are direct decendant of their parent, + which they really ought to be */ + let parent = this.parentElement!; + let el = this.firstElementChild as HTMLElement + /* TODO offsetParent.scrollLeft places us "fullscreen" according to the currently + scrolled viewport. But is this the correct way to do it? How does it work for + month views */ + this.style.left = `${this.offsetParent!.scrollLeft + 10}px`; + this.style.top = '10px'; + /* 5ex is width of tab labels */ + el.style.width = `calc(${parent.clientWidth - 20}px - 5ex)` + el.style.height = `${parent.clientHeight - 20}px` + } +} + +/* Create a new popup element for the given VEvent, and ready it for editing the + event. Used when creating event (through the frontend). + The return value can safely be ignored. +*/ +function setup_popup_element(ev: VEvent): PopupElement { + let uid = ev.getProperty('uid'); + let popup = new PopupElement(uid); + ev.register(popup); + /* TODO propper way to find popup container */ + (document.querySelector('.days') as Element).appendChild(popup); + let tabBtn = popup.querySelector('[role="tab"][title="Redigera"]') as HTMLButtonElement + tabBtn.click() + let tab = document.getElementById(tabBtn.getAttribute('aria-controls')!)! + let input = tab.querySelector('input[name="summary"]') as HTMLInputElement + popup.visible = true; + input.select(); + return popup; +} + +/* + Given the navbar of a popup, make it dragable. + */ +function bind_popup_control(nav: HTMLElement) { + + // if (!nav.closest('popup-element')) { + // console.log(nav); + // throw TypeError('not a popup container'); + // } + + nav.addEventListener('mousedown', function(e) { + /* Ignore mousedown on children */ + if (e.target != nav) return; + nav.style.cursor = "grabbing"; + nav.dataset.grabbed = "true"; + nav.dataset.grabPoint = e.clientX + ";" + e.clientY; + // let popup = nav.closest(".popup-container"); + let popup = nav.closest("popup-element") as HTMLElement; + nav.dataset.startPoint = popup.offsetLeft + ";" + popup.offsetTop; + }) + window.addEventListener('mousemove', function(e) { + if (nav.dataset.grabbed) { + let [x, y] = nav.dataset.grabPoint!.split(";").map(Number); + let [startX, startY] = nav.dataset.startPoint!.split(";").map(Number); + // let popup = nav.closest(".popup-container"); + let popup = nav.closest("popup-element") as HTMLElement; + + popup.style.left = startX + (e.clientX - x) + "px"; + popup.style.top = startY + (e.clientY - y) + "px"; + } + }); + window.addEventListener('mouseup', function() { + nav.dataset.grabbed = ""; + nav.style.cursor = ""; + }); +} diff --git a/static/components/tab-group-element.ts b/static/components/tab-group-element.ts new file mode 100644 index 00000000..05cac7d2 --- /dev/null +++ b/static/components/tab-group-element.ts @@ -0,0 +1,178 @@ +import { ComponentVEvent } from './vevent' +import { makeElement, gensym } from '../lib' +import { EditRRule } from './edit-rrule' +import { VEvent, isRedrawable } from '../vevent' +import { vcal_objects } from '../globals' + +export { TabGroupElement } + +/* Lacks a template, since it's trivial + The initial children of this element all becomes tabs, each child may have + the datapropertys 'label' and 'title' set, where label is what is shown in + the tab bar, and title is the hower text. + + All additions and removals of tabs MUST go through addTab and removeTab! + + Information about how tabs should work from an accesability standpoint can be + found here: + https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/Tab_Role + + <tab-group/> +*/ +class TabGroupElement extends ComponentVEvent { + + readonly menu: HTMLElement; + + tabs: HTMLElement[] = []; + tabLabels: HTMLElement[] = []; + + constructor(uid?: string) { + super(uid); + + this.menu = makeElement('menu', {}, { + role: 'tablist', + 'aria-label': 'Simple Tabs', + }) + } + + redraw(data: VEvent) { + /* Update our tabset to match data:s having or not having of rrule, + but do nothing if we already match */ + let rrule_tab = this.has_rrule_tab() + if (data.getProperty('rrule')) { + if (!this.has_rrule_tab()) { + /* Note that EditRRule register itself to be updated on changes + to the event */ + this.addTab(new EditRRule(data.getProperty('uid')), + "↺", "Upprepningar"); + } + } else { + if (rrule_tab) this.removeTab(rrule_tab as HTMLElement); + } + + /* TODO is there any case where we want to propagate the draw to any of + our tabs? or are all our tabs independent? */ + } + + connectedCallback() { + /* All pre-added children should become tabs, but start with removing + them and storing them for later */ + let originalChildren: HTMLElement[] = []; + while (this.firstChild) { + originalChildren.push(this.removeChild(this.firstChild) as HTMLElement); + } + + /* Add our tab label menu */ + this.appendChild(this.menu); + + /* Re-add our initial children, but as proper tab elements */ + for (let child of originalChildren) { + this.addTab(child); + } + + /* redraw might add or remove tabs depending on our data, so call it here */ + this.redraw(vcal_objects.get(this.uid)!); + + /* All tabs should now be ready, focus the first one */ + if (this.tabLabels.length > 0) { + this.tabLabels[0].setAttribute('tabindex', '0'); + this.tabLabels[0].click(); + } + + } /* end connectedCallback */ + + addTab(child: HTMLElement, label?: string, title?: string) { + + /* First character of text is a good a guess as any for our label, + but still defaut to '?' if no text is found */ + label = label || child.dataset.label || (child.textContent + '?')[0]; + title = title || child.dataset.title || ''; + + let tab_id = gensym('tab_content_'); + let label_id = gensym('tab_label_'); + + let tabLabel = makeElement('button', { + textContent: label, + }, { + role: 'tab', + id: label_id, + tabindex: -1, + title: title, + 'aria-selected': false, + 'aria-controls': tab_id, + }) + + let tabContainer = makeElement('article', {}, { + id: tab_id, + role: 'tabpanel', + tabindex: 0, + hidden: 'hidden', + 'aria-labeledby': label_id, + }) + + tabContainer.replaceChildren(child); + this.tabs.push(tabContainer); + this.appendChild(tabContainer); + + this.tabLabels.push(tabLabel); + this.menu.appendChild(tabLabel); + + tabLabel.addEventListener('click', () => this.tabClickedCallback(tabLabel)); + + this.style.setProperty('--tabcount', '' + this.tabs.length); + } + + removeTab(tab: HTMLElement) { + let id = tab.getAttribute('aria-labeledby')! + let label = document.getElementById(id) + if (label) { + if (label.ariaSelected === 'true') { + this.tabLabels[0].click(); + } + this.tabLabels = this.tabLabels.filter(el => el !== label) + label.remove(); + } + /* remove tab */ + this.tabs = this.tabs.filter(el => el !== tab) + this.removeChild(tab); + if (tab.firstChild) { + let child = tab.firstChild as HTMLElement; + if (isRedrawable(child)) { + vcal_objects.get(this.uid)?.unregister(child) + } + } + + this.style.setProperty('--tabcount', '' + this.tabs.length); + } + + /* TODO replace querySelectors here with our already saved references */ + tabClickedCallback(tab: Element) { + + /* hide all tab panels */ + for (let tabcontent of this.querySelectorAll('[role="tabpanel"]')) { + tabcontent.setAttribute('hidden', 'true'); + } + /* unselect all (selected) tab handles */ + for (let item of this.querySelectorAll('[aria-selected="true"]')) { + item.setAttribute('aria-selected', 'false'); + } + /* re-select ourselves */ + tab.setAttribute('aria-selected', 'true'); + + /* unhide our target tab */ + this.querySelector('#' + tab.getAttribute('aria-controls'))! + .removeAttribute('hidden') + } + + + /* returns our rrule tab if we have one */ + has_rrule_tab(): Element | false { + for (let child of this.children) { + if ((child.firstChild! as HTMLElement).tagName.toLowerCase() === 'vevent-edit-rrule') { + return child; + } + } + return false; + } + +} diff --git a/static/components/vevent-block.ts b/static/components/vevent-block.ts new file mode 100644 index 00000000..8cf61d30 --- /dev/null +++ b/static/components/vevent-block.ts @@ -0,0 +1,99 @@ +export { ComponentBlock } + +import { ComponentVEvent } from './vevent' +import { VEvent } from '../vevent' +import { parseDate, to_local } from '../lib' + + +/* <vevent-block /> + + A grahpical block in the week view. +*/ +class ComponentBlock extends ComponentVEvent { + constructor(uid?: string) { + super(uid); + + if (!this.template) { + throw 'vevent-block template required'; + } + + this.addEventListener('click', () => { + let uid = this.uid + /* TODO is it better to find the popup through a query selector, or + by looking through all registered components of a VEvent? */ + let popup = document.querySelector(`popup-element[data-uid="${uid}"]`) + if (popup === null) throw new Error('no popup for uid ' + uid); + popup.toggleAttribute('visible'); + }); + } + + redraw(data: VEvent) { + let body = (this.template!.content.cloneNode(true) as DocumentFragment).firstElementChild!; + + for (let el of body.querySelectorAll('[data-property]')) { + if (!(el instanceof HTMLElement)) continue; + let p = el.dataset.property!; + let d, fmt; + if ((d = data.getProperty(p))) { + if ((fmt = el.dataset.fmt)) { + el.textContent = d.format(fmt); + } else { + el.textContent = d; + } + } else switch (p.toLowerCase()) { + /* We lack that property, but might want to set a default here */ + case 'summary': + el.textContent = 'Ny händelse' + break; + } + } + + this.replaceChildren(body); + + /* -------------------------------------------------- */ + + if (window.VIEW === 'week') { + let p; + if ((p = data.getProperty('dtstart'))) { + let c = this.closest('.event-container') as HTMLElement + let start = parseDate(c.dataset.start!).getTime() + let end = parseDate(c.dataset.end!).getTime(); + // console.log(p); + let pp = to_local(p).getTime() + let result = 100 * (Math.min(end, Math.max(start, pp)) - start) / (end - start) + "%" + if (c.classList.contains('longevents')) { + this.style.left = result + } else { + this.style.top = result + } + // console.log('dtstart', p); + } + if ((p = data.getProperty('dtend'))) { + // console.log('dtend', p); + let c = this.closest('.event-container') as HTMLElement + let start = parseDate(c.dataset.start!).getTime() + let end = parseDate(c.dataset.end!).getTime(); + let pp = to_local(p).getTime() + let result = 100 - (100 * (Math.min(end, Math.max(start, pp)) - start) / (end - start)) + "%" + if (c.classList.contains('longevents')) { + this.style.width = 'unset'; + this.style.right = result; + } else { + this.style.height = 'unset'; + this.style.bottom = result; + } + } + } + + if (data.calendar) { + this.dataset.calendar = data.calendar; + } + + if (data.getProperty('rrule') !== undefined) { + let rep = this.getElementsByClassName('repeating') + if (rep && rep.length !== 0) { + (rep[0] as HTMLElement).innerText = '↺' + } + } + } +} diff --git a/static/components/vevent-description.ts b/static/components/vevent-description.ts new file mode 100644 index 00000000..4d81d6b3 --- /dev/null +++ b/static/components/vevent-description.ts @@ -0,0 +1,59 @@ +export { ComponentDescription } + +import { VEvent } from '../vevent' +import { ComponentVEvent } from './vevent' +import { makeElement } from '../lib' + +/* + <vevent-description /> +*/ +class ComponentDescription extends ComponentVEvent { + + constructor(uid?: string) { + super(uid); + if (!this.template) { + throw 'vevent-description template required'; + } + } + + redraw(data: VEvent) { + // update ourselves from template + + let body = (this.template!.content.cloneNode(true) as DocumentFragment).firstElementChild!; + + for (let el of body.querySelectorAll('[data-property]')) { + if (!(el instanceof HTMLElement)) continue; + let p = el.dataset.property!; + let d, fmt; + if ((d = data.getProperty(p))) { + switch (p.toLowerCase()) { + case 'categories': + for (let item of d) { + let q = encodeURIComponent( + `(member "${item}" (or (prop event (quote CATEGORIES)) (quote ())))`) + el.appendChild(makeElement('a', { + textContent: item, + href: `/search/?q=${q}`, + })) + } + break; + default: + if ((fmt = el.dataset.fmt)) { + el.textContent = d.format(fmt); + } else { + el.textContent = d; + } + } + } + } + + let repeating = body.getElementsByClassName('repeating')[0] as HTMLElement + if (data.getProperty('rrule')) { + repeating.classList.remove('hidden'); + } else { + repeating.classList.add('hidden'); + } + + this.replaceChildren(body); + } +} diff --git a/static/components/vevent-dl.ts b/static/components/vevent-dl.ts new file mode 100644 index 00000000..a792c07f --- /dev/null +++ b/static/components/vevent-dl.ts @@ -0,0 +1,35 @@ +export { VEventDL } + +import { ComponentVEvent } from './vevent' +import { VEvent } from '../vevent' +import { makeElement } from '../lib' + +import { RecurrenceRule } from '../vevent' + +/* <vevent-dl /> */ +class VEventDL extends ComponentVEvent { + redraw(obj: VEvent) { + let dl = buildDescriptionList( + Array.from(obj.boundProperties) + .map(key => [key, obj.getProperty(key)])) + this.replaceChildren(dl); + } +} + +function buildDescriptionList(data: [string, any][]): HTMLElement { + let dl = document.createElement('dl'); + for (let [key, val] of data) { + dl.appendChild(makeElement('dt', { textContent: key })) + let fmtVal: string = val; + if (val instanceof Date) { + fmtVal = val.format( + val.dateonly + ? '~Y-~m-~d' + : '~Y-~m-~dT~H:~M:~S'); + } else if (val instanceof RecurrenceRule) { + fmtVal = JSON.stringify(val.to_jcal()) + } + dl.appendChild(makeElement('dd', { textContent: fmtVal })) + } + return dl; +} diff --git a/static/components/vevent-edit.ts b/static/components/vevent-edit.ts new file mode 100644 index 00000000..ee368296 --- /dev/null +++ b/static/components/vevent-edit.ts @@ -0,0 +1,167 @@ +export { ComponentEdit } + +import { ComponentVEvent } from './vevent' +import { InputList } from './input-list' +import { DateTimeInput } from './date-time-input' + +import { vcal_objects } from '../globals' +import { VEvent, RecurrenceRule } from '../vevent' +import { create_event } from '../server_connect' +import { to_boolean } from '../lib' + +/* <vevent-edit /> + Edit form for a given VEvent. Used as the edit tab of popups. +*/ +class ComponentEdit extends ComponentVEvent { + + constructor(uid?: string) { + super(uid); + + if (!this.template) { + throw 'vevent-edit template required'; + } + + let frag = this.template.content.cloneNode(true) as DocumentFragment + let body = frag.firstElementChild! + this.replaceChildren(body); + } + + connectedCallback() { + + /* Edit tab is rendered here. It's left blank server-side, since + it only makes sense to have something here if we have javascript */ + + let data = vcal_objects.get(this.uid) + + if (!data) { + throw `Data missing for uid ${this.dataset.uid}.` + } + + + // return; + + /* Handle calendar dropdown */ + for (let el of this.getElementsByClassName('calendar-selection')) { + for (let opt of el.getElementsByTagName('option')) { + opt.selected = false; + } + if (data.calendar) { + (el as HTMLSelectElement).value = data.calendar; + } + + el.addEventListener('change', (e) => { + let v = (e.target as HTMLSelectElement).selectedOptions[0].value + let obj = vcal_objects.get(this.uid)! + obj.calendar = v; + }); + } + + this.redraw(data); + + // for (let el of this.getElementsByClassName("interactive")) { + for (let el of this.querySelectorAll("[data-property]")) { + // console.log(el); + el.addEventListener('input', (e) => { + let obj = vcal_objects.get(this.uid) + // console.log(el, e); + if (obj === undefined) { + throw 'No object with uid ' + this.uid + } + if (!(el instanceof HTMLInputElement + || el instanceof DateTimeInput + || el instanceof HTMLTextAreaElement + || el instanceof InputList + )) { + console.log(el, 'not an HTMLInputElement'); + return; + } + // console.log(`obj[${el.dataset.property!}] = `, el.value); + obj.setProperty( + el.dataset.property!, + el.value) + }); + } + + let wholeday_ = this.querySelector('[name="wholeday"]') + if (wholeday_) { + let wholeday = wholeday_ as HTMLInputElement + + if (data.getProperty('dtstart')?.dateonly) { + wholeday.checked = true; + } + + wholeday.addEventListener('click', () => { + let chk = wholeday.checked + let start = data!.getProperty('dtstart') + let end = data!.getProperty('dtend') + start.dateonly = chk + end.dateonly = chk + data!.setProperty('dtstart', start); + data!.setProperty('dtend', end); + }); + } + + let has_repeats_ = this.querySelector('[name="has_repeats"]') + if (has_repeats_) { + let has_repeats = has_repeats_ as HTMLInputElement; + + has_repeats.addEventListener('click', () => { + /* TODO unselecting and reselecting this checkbox deletes all entered data. + Cache it somewhere */ + if (has_repeats.checked) { + vcal_objects.get(this.uid)!.setProperty('rrule', new RecurrenceRule()) + } else { + /* TODO is this a good way to remove a property ? */ + vcal_objects.get(this.uid)!.setProperty('rrule', undefined) + } + }) + } + + let submit = this.querySelector('form') as HTMLFormElement + submit.addEventListener('submit', (e) => { + console.log(submit, e); + create_event(vcal_objects.get(this.uid)!); + + e.preventDefault(); + return false; + }); + } + + redraw(data: VEvent) { + /* We only update our fields, instead of reinstansiating + ourselves from the template, in hope that it's faster */ + + + for (let el of this.querySelectorAll("[data-property]")) { + if (!(el instanceof HTMLElement)) continue; + let p = el.dataset.property!; + let d: any; + if ((d = data.getProperty(p))) { + /* + https://stackoverflow.com/questions/57157830/how-can-i-specify-the-sequence-of-running-nested-web-components-constructors + */ + window.setTimeout(() => { + /* NOTE Some specific types might require special formatting + here. But due to my custom components implementing custom + `.value' procedures, we might not need any special cases + here */ + /* Technically we just want to cast to HTMLElement with + value field here, but multiple types implement it + sepparately, and no common interface exist */ + (el as HTMLInputElement).value = d; + }); + } + } + + let el = this.querySelector('[name="has_repeats"]') + if (el) { + (el as HTMLInputElement).checked = to_boolean(data.getProperty('rrule')) + } + + if (data.calendar) { + for (let el of this.getElementsByClassName('calendar-selection')) { + (el as HTMLSelectElement).value = data.calendar; + } + } + } +} diff --git a/static/components/vevent.ts b/static/components/vevent.ts new file mode 100644 index 00000000..b72cda90 --- /dev/null +++ b/static/components/vevent.ts @@ -0,0 +1,70 @@ +export { ComponentVEvent } + +import { vcal_objects } from '../globals' +import { VEvent } from '../vevent' + +/* Root component for all events which content is closely linked to a +@code{VEvent} object + +Lacks an accompaning tag, and shouldn't be directly instanciated. +*/ +abstract class ComponentVEvent extends HTMLElement { + + template: HTMLTemplateElement | null + uid: string + + constructor(uid?: string) { + super(); + this.template = document.getElementById(this.tagName) as HTMLTemplateElement | null + + let real_uid; + + // console.log(this.tagName); + if (uid) { + // console.log('Got UID directly'); + real_uid = uid; + } else { + /* I know that this case is redundant, it's here if we don't want to + look up the tree later */ + if (this.dataset.uid) { + // console.log('Had UID as direct attribute'); + real_uid = this.dataset.uid; + } else { + let el = this.closest('[data-uid]') + if (el) { + // console.log('Found UID higher up in the tree'); + real_uid = (el as HTMLElement).dataset.uid + } else { + throw "No parent with [data-uid] set" + } + } + } + + if (!real_uid) { + console.warn(this.outerHTML); + throw `UID required` + } + + // console.log(real_uid); + this.uid = real_uid; + this.dataset.uid = real_uid; + + vcal_objects.get(this.uid)?.register(this); + + /* We DON'T have a redraw here in the general case, since the + HTML rendered server-side should be fine enough for us. + Those that need a direct rerendering (such as the edit tabs) + should take care of that some other way */ + } + + connectedCallback() { + let uid = this.dataset.uid + if (uid) { + let v = vcal_objects.get(uid) + if (v) this.redraw(v); + } + } + + abstract redraw(data: VEvent): void + +} |