@webwriter/timeline
Version:
Create/learn with a digital timeline and test your knowledge.
200 lines (168 loc) • 6.62 kB
text/typescript
import { localized, msg } from "@lit/localize";
import { LitElementWw } from "@webwriter/lit";
import { css, html, PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import LOCALIZE from "../../localization/generated";
import { TimelineDate, timelineDateConverter } from "./timeline-date";
export class DateInput extends LitElementWw {
protected localize = LOCALIZE;
static styles = css`
:host {
display: inline-block;
position: relative;
overflow: hidden;
}
input {
border: none;
background: none;
padding: 0;
font: inherit;
outline: none;
}
input:disabled {
color: black;
/* https://stackoverflow.com/questions/262158/disabled-input-text-color-on-ios */
opacity: 1;
-webkit-text-fill-color: black;
}
input::placeholder {
color: var(--sl-color-gray-500);
}
/* Use an invisible span to measure the length of the text */
span {
position: absolute;
left: 0;
opacity: 0;
z-index: -1;
/* Prevent wrapping and ensure spaces are counted */
white-space: pre;
}
`;
private measureElement = createRef<HTMLSpanElement>();
private inputElement = createRef<HTMLInputElement>();
private resizeObserver?: ResizeObserver;
accessor value: TimelineDate | null = null;
private accessor internalValue = "";
accessor disabled: boolean = false;
accessor optional: boolean = false;
accessor placeholder: string = "";
accessor lang: string = "en-US";
private localizeErrorMessage(key: string): string {
switch (key) {
case "INVALID_YEAR":
return msg("Invalid year");
case "INVALID_MONTH":
return msg("Invalid month");
case "INVALID_DAY":
return msg("Invalid day");
case "YEAR_ZERO_INVALID":
return msg("Year zero does not exist");
case "INVALID_FORMAT":
return msg("Invalid format");
default:
return msg("Invalid date");
}
}
private onInputFocus(event: InputEvent) {
this.internalValue = this.value?.toEuropeanString() ?? "";
this.updateComplete.then(() => this.inputElement.value?.select());
}
private blurCausedByKeydown = false;
private onInputKeydown(event: KeyboardEvent) {
if (event.key !== "Enter") return;
event.preventDefault();
if (this.optional && this.internalValue.trim() === "") {
this.value = null;
this.blurCausedByKeydown = true;
this.inputElement.value?.blur();
return;
}
try {
this.value = TimelineDate.fromEuropeanString(this.internalValue);
this.inputElement.value?.setCustomValidity("");
this.blurCausedByKeydown = true;
this.inputElement.value?.blur();
} catch (e) {
this.inputElement.value?.setCustomValidity(this.localizeErrorMessage((e as Error).message));
this.inputElement.value?.reportValidity();
}
}
private onInputBlur(event: Event) {
// If the blur was caused by pressing Enter, we have already handled it
if (this.blurCausedByKeydown) {
this.blurCausedByKeydown = false;
return;
}
event.preventDefault();
if (this.optional && this.internalValue.trim() === "") {
this.value = null;
return;
}
try {
this.value = TimelineDate.fromEuropeanString(this.internalValue);
} catch (e) {
// Even if the date is invalid, revert to the last valid date on blur
this.internalValue = this.value?.toLocalizedString(this.lang) ?? "";
}
}
private resizeInput() {
if (!this.inputElement.value || !this.measureElement.value) return;
this.inputElement.value.style.width = `${this.measureElement.value.offsetWidth + 1}px`;
}
connectedCallback(): void {
super.connectedCallback();
this.resizeObserver = new ResizeObserver(() => this.resizeInput());
this.resizeObserver.observe(this);
}
disconnectedCallback(): void {
this.resizeObserver?.disconnect();
super.disconnectedCallback();
}
protected updated(_changedProperties: PropertyValues): void {
if (_changedProperties.has("value") || _changedProperties.has("lang")) {
this.internalValue = this.value?.toLocalizedString(this.lang) ?? "";
// Send update event if the value actually changed
const oldValue = _changedProperties.get("value") as TimelineDate | null;
const valueNotChanged =
(oldValue === null && this.value === null) ||
(oldValue && this.value && oldValue.compare(this.value) === 0);
if (!valueNotChanged) {
this.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
}
}
if (_changedProperties.has("internalValue") || _changedProperties.has("placeholder")) {
this.resizeInput();
}
}
render() {
return html`<span ${ref(this.measureElement)}>${this.internalValue || this.placeholder}</span
><input
${ref(this.inputElement)}
type="text"
placeholder=${this.placeholder}
.value=${this.internalValue}
?disabled=${this.disabled}
=${this.onInputFocus}
=${(e: Event) => {
this.internalValue = (e.target as HTMLInputElement).value;
// Required, otherwise the validation popup would re-appear on every keystroke
this.inputElement.value?.setCustomValidity("");
}}
=${this.onInputKeydown}
=${this.onInputBlur}
/>`;
}
}