aboutsummaryrefslogtreecommitdiff
path: root/static/ts/components
diff options
context:
space:
mode:
Diffstat (limited to 'static/ts/components')
-rw-r--r--static/ts/components/changelog.ts49
-rw-r--r--static/ts/components/date-jump.ts40
-rw-r--r--static/ts/components/date-time-input.ts119
-rw-r--r--static/ts/components/edit-rrule.ts75
-rw-r--r--static/ts/components/input-list.ts120
-rw-r--r--static/ts/components/popup-element.ts201
-rw-r--r--static/ts/components/slider.ts101
-rw-r--r--static/ts/components/tab-group-element.ts184
-rw-r--r--static/ts/components/vevent-block.ts99
-rw-r--r--static/ts/components/vevent-description.ts38
-rw-r--r--static/ts/components/vevent-dl.ts35
-rw-r--r--static/ts/components/vevent-edit.ts179
-rw-r--r--static/ts/components/vevent.ts69
13 files changed, 1309 insertions, 0 deletions
diff --git a/static/ts/components/changelog.ts b/static/ts/components/changelog.ts
new file mode 100644
index 00000000..d08f7cb3
--- /dev/null
+++ b/static/ts/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/ts/components/date-jump.ts b/static/ts/components/date-jump.ts
new file mode 100644
index 00000000..fd3908ae
--- /dev/null
+++ b/static/ts/components/date-jump.ts
@@ -0,0 +1,40 @@
+export { DateJump }
+
+/* Replace backend-driven [today] link with frontend, with one that
+ gets correctly set in the frontend. Similarly, update the go to
+ specific date button into a link which updates wheneven the date
+ form updates.
+*/
+class DateJump extends HTMLElement {
+
+ readonly golink: HTMLAnchorElement;
+ readonly input: HTMLInputElement;
+
+ constructor() {
+ super();
+
+ this.golink = document.createElement('a')
+ this.golink.classList.add('btn');
+ this.golink.textContent = "➔"
+ this.input = document.createElement('input')
+ this.input.type = 'date';
+ }
+
+ connectedCallback() {
+
+ /* Form is just here so the css works out */
+ let form = document.createElement('form');
+ form.replaceChildren(this.input, this.golink);
+ this.replaceChildren(form);
+
+ this.input.onchange = () => {
+ let date = this.input.valueAsDate!.format('~Y-~m-~d');
+ this.golink.href = `${date}.html`
+ }
+
+ let now = (new Date).format("~Y-~m-~d")
+ this.input.value = now;
+ /* onchange isn't triggered by manually setting the value */
+ this.golink.href = `${now}.html`
+ }
+}
diff --git a/static/ts/components/date-time-input.ts b/static/ts/components/date-time-input.ts
new file mode 100644
index 00000000..20e9a505
--- /dev/null
+++ b/static/ts/components/date-time-input.ts
@@ -0,0 +1,119 @@
+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").split('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) {
+ 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/ts/components/edit-rrule.ts b/static/ts/components/edit-rrule.ts
new file mode 100644
index 00000000..a361bdee
--- /dev/null
+++ b/static/ts/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/ts/components/input-list.ts b/static/ts/components/input-list.ts
new file mode 100644
index 00000000..0afd4999
--- /dev/null
+++ b/static/ts/components/input-list.ts
@@ -0,0 +1,120 @@
+export { InputList }
+
+/*
+ TODO allow each item to be a larger unit, possibly containing multiple input
+ fields.
+*/
+class InputList extends HTMLElement {
+
+ el: HTMLInputElement;
+
+ #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/ts/components/popup-element.ts b/static/ts/components/popup-element.ts
new file mode 100644
index 00000000..458f543c
--- /dev/null
+++ b/static/ts/components/popup-element.ts
@@ -0,0 +1,201 @@
+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, _?: string, newValue?: string) {
+ switch (name) {
+ case 'visible':
+ if (newValue !== null)
+ /* Only run resize code when showing the popup */
+ 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() {
+ console.log('here');
+
+ /* 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"][data-originaltitle="Edit"]') 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/ts/components/slider.ts b/static/ts/components/slider.ts
new file mode 100644
index 00000000..48abc91b
--- /dev/null
+++ b/static/ts/components/slider.ts
@@ -0,0 +1,101 @@
+export { SliderInput }
+
+import { makeElement } from '../lib'
+
+const dflt = {
+ min: 0,
+ max: 100,
+ step: 1,
+}
+
+type Attribute = 'min' | 'max' | 'step'
+
+class SliderInput extends HTMLElement {
+
+ /* value a string since javascript kind of expects that */
+ #value = "0";
+ min = 0;
+ max = 100;
+ step = 1;
+
+ readonly slider: HTMLInputElement;
+ readonly textIn: HTMLInputElement;
+
+ constructor(min?: number, max?: number, step?: number, value?: number) {
+ super();
+
+ this.min = min || parseFloat(this.getAttribute('min') || "" + dflt['min']);
+ this.max = max || parseFloat(this.getAttribute('max') || "" + dflt['max']);
+ this.step = step || parseFloat(this.getAttribute('step') || "" + dflt['step']);
+ // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/range#value
+ const defaultValue
+ = (this.max < this.min)
+ ? this.min
+ : this.min + (this.max - this.min) / 2;
+
+ this.slider = makeElement('input', {
+ type: 'range',
+ min: this.min,
+ max: this.max,
+ step: this.step,
+ value: this.value,
+ }) as HTMLInputElement
+ this.textIn = makeElement('input', {
+ type: 'number',
+ min: this.min,
+ max: this.max,
+ step: this.step,
+ value: this.value,
+ }) as HTMLInputElement
+
+ this.slider.addEventListener('input', e => this.propagate(e));
+ this.textIn.addEventListener('input', e => this.propagate(e));
+
+ /* MUST be after sub components are bound */
+ this.value = "" + (value || this.getAttribute('value') || defaultValue);
+ }
+
+ connectedCallback() {
+ this.replaceChildren(this.slider, this.textIn);
+ }
+
+
+ static get observedAttributes(): Attribute[] {
+ return ['min', 'max', 'step']
+ }
+
+ attributeChangedCallback(name: Attribute, _?: string, to?: string): void {
+ if (to) {
+ this.slider.setAttribute(name, to);
+ this.textIn.setAttribute(name, to);
+ } else {
+ this.slider.removeAttribute(name);
+ this.textIn.removeAttribute(name);
+ }
+ this[name] = parseFloat(to || "" + dflt[name])
+ }
+
+ propagate(e: Event) {
+ this.value = (e.target as HTMLInputElement).value;
+ if (e instanceof InputEvent && this.oninput) {
+ this.oninput(e);
+ }
+ }
+
+ set value(value: string) {
+ this.slider.value = value;
+ this.textIn.value = value;
+ this.#value = value;
+ }
+
+ get value(): string {
+ return this.#value;
+ }
+
+ /* TODO do we want to implement this?
+ * oninput directly on the component already works
+ * /
+ addEventListener(type: string, proc: ((e: Event) => void)) {
+ }
+ */
+}
diff --git a/static/ts/components/tab-group-element.ts b/static/ts/components/tab-group-element.ts
new file mode 100644
index 00000000..e90997e9
--- /dev/null
+++ b/static/ts/components/tab-group-element.ts
@@ -0,0 +1,184 @@
+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('div', {}, {
+ 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 extra_attributes = {};
+ /* Used to target a tab by name */
+ if (child.dataset.originaltitle) {
+ extra_attributes = { 'data-originaltitle': child.dataset.originaltitle }
+ }
+
+ 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,
+ ...extra_attributes,
+ })
+
+ let tabContainer = makeElement('div', {}, {
+ id: tab_id,
+ role: 'tabpanel',
+ tabindex: 0,
+ hidden: 'hidden',
+ 'aria-labelledby': 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-labelledby')!
+ 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', 'hidden');
+ }
+ /* 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! instanceof EditRRule) {
+ return child;
+ }
+ }
+ return false;
+ }
+
+}
diff --git a/static/ts/components/vevent-block.ts b/static/ts/components/vevent-block.ts
new file mode 100644
index 00000000..9bbb8e7e
--- /dev/null
+++ b/static/ts/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.length !== 0) {
+ (rep[0] as HTMLElement).innerText = '↺'
+ }
+ }
+ }
+}
diff --git a/static/ts/components/vevent-description.ts b/static/ts/components/vevent-description.ts
new file mode 100644
index 00000000..b44185e7
--- /dev/null
+++ b/static/ts/components/vevent-description.ts
@@ -0,0 +1,38 @@
+export { ComponentDescription }
+
+import { VEvent } from '../vevent'
+import { ComponentVEvent } from './vevent'
+import { format } from '../formatters'
+
+/*
+ <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;
+ format(el, data, el.dataset.property!);
+ }
+
+ 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/ts/components/vevent-dl.ts b/static/ts/components/vevent-dl.ts
new file mode 100644
index 00000000..a792c07f
--- /dev/null
+++ b/static/ts/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/ts/components/vevent-edit.ts b/static/ts/components/vevent-edit.ts
new file mode 100644
index 00000000..e3b5d105
--- /dev/null
+++ b/static/ts/components/vevent-edit.ts
@@ -0,0 +1,179 @@
+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, gensym } 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);
+
+ let data = vcal_objects.get(this.uid)
+ if (!data) {
+ throw `Data missing for uid ${this.dataset.uid}.`
+ }
+
+ for (let el of this.querySelectorAll('[data-label]')) {
+ let label = document.createElement('label');
+ let id = el.id || gensym('input');
+ el.id = id;
+ label.htmlFor = id;
+ label.textContent = (el as HTMLElement).dataset.label!;
+ el.parentElement!.insertBefore(label, el);
+ }
+
+ /* Handle calendar dropdown */
+ for (let el of this.querySelectorAll('select.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;
+ });
+ }
+
+
+ // for (let el of this.getElementsByClassName("interactive")) {
+ for (let el of this.querySelectorAll("[data-property]")) {
+ // console.log(el);
+ el.addEventListener('input', () => {
+ let obj = vcal_objects.get(this.uid)
+ 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;
+ }
+ 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;
+ });
+ }
+
+ 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}.`
+ }
+
+ this.redraw(data);
+
+ // return;
+ }
+
+ 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/ts/components/vevent.ts b/static/ts/components/vevent.ts
new file mode 100644
index 00000000..7487cbb6
--- /dev/null
+++ b/static/ts/components/vevent.ts
@@ -0,0 +1,69 @@
+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
+ uid: string
+
+ constructor(uid?: string) {
+ super();
+ this.template = document.getElementById(this.tagName.toLowerCase()) as HTMLTemplateElement | undefined
+
+ let real_uid;
+
+ 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
+
+}