@webwriter/timeline
Version:
Create/learn with a digital timeline and test your knowledge.
265 lines (228 loc) • 9.51 kB
text/typescript
import { localized, msg } from "@lit/localize";
import SlIconButton from "@shoelace-style/shoelace/dist/components/icon-button/icon-button.component.js";
import SlIcon from "@shoelace-style/shoelace/dist/components/icon/icon.component.js";
import SlTooltip from "@shoelace-style/shoelace/dist/components/tooltip/tooltip.component.js";
import { LitElementWw } from "@webwriter/lit";
import ExclamationCircleIcon from "bootstrap-icons/icons/exclamation-circle.svg";
import TrashIcon from "bootstrap-icons/icons/trash.svg";
import { css, html, nothing, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { createRef, ref } from "lit/directives/ref.js";
import LOCALIZE from "../../localization/generated";
import { DateInput } from "../util/date-input.component";
import { TimelineDate, timelineDateConverter } from "../util/timeline-date";
/**
* A single event in a `webwriter-timeline` component. Should not be used independently.
* As children, it must contain both a `webwriter-timeline-event-title` and a `webwriter-timeline-event-details` element,
* which contain the title and details of the event, respectively.
*/
()
("webwriter-timeline-event")
export class WebWriterTimelineEventWidget extends LitElementWw {
protected localize = LOCALIZE;
/** @internal */
static scopedElements = {
"sl-icon-button": SlIconButton,
"sl-icon": SlIcon,
"sl-tooltip": SlTooltip,
"date-input": DateInput,
};
static styles = css`
:host {
width: 100%;
display: contents ;
}
.dot {
width: 100%;
aspect-ratio: 1 / 1;
}
.dot.pulse::before {
animation: dot-pulse 600ms ease;
}
.dot::before {
content: "";
display: block;
margin: 0 auto;
margin-top: 9px;
height: 10px;
width: 10px;
border-radius: 50%;
background-color: black;
outline: 4px solid white;
/* Ensures that the dot is above the timeline line */
position: relative;
z-index: 1;
}
@keyframes dot-pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
background-color: var(--sl-color-primary-600);
transform: scale(1.4);
}
100% {
transform: scale(1);
opacity: 1;
}
}
input {
background: unset;
padding: 0;
border: none;
font: inherit;
display: block;
outline: none ;
}
.controls {
display: flex;
gap: var(--sl-spacing-x-small);
align-items: center;
}
sl-icon {
margin: auto;
color: var(--sl-color-neutral-600);
}
sl-icon-button::part(base) {
margin: calc(var(--sl-spacing-x-small) * -1) 0;
}
.spacer {
flex: 1;
}
.gray-out {
color: var(--sl-color-gray-500);
}
.show-on-focus {
opacity: 0;
}
:hover .show-on-focus,
:focus-within .show-on-focus {
opacity: 1;
}
.hide {
display: none;
}
`;
private get isInEditView() {
return this.contentEditable === "true" || this.contentEditable === "";
}
private dotElement = createRef<HTMLElement>();
/**
* The (start) date of the event.
* Must be formatted as an ISO 8601 date as "YYYY", "YYYY-MM", or "YYYY-MM-DD".
* Any year BCE must be represented with a negative year number, with year 0 representing 1 BCE, -1 representing 2 BCE, and so on.
*/
({ type: TimelineDate, attribute: true, reflect: true, converter: timelineDateConverter })
accessor date: TimelineDate | null = null;
/**
* The end date of the event, should be after the start date.
* Must be formatted as an ISO 8601 date as "YYYY", "YYYY-MM", or "YYYY-MM-DD".
* Any year BCE must be represented with a negative year number, with year 0 representing 1 BCE, -1 representing 2 BCE, and so on.
*/
({ type: TimelineDate, attribute: true, reflect: true, converter: timelineDateConverter })
accessor endDate: TimelineDate | null = null;
()
private accessor titleEmpty: boolean = true;
private titleElement = null;
private titleMutationObserver = new MutationObserver(() => this.checkIfTitleIsEmpty());
private checkIfTitleIsEmpty() {
this.titleEmpty = this.titleElement?.textContent === "";
}
private onSlotChange(event: Event) {
this.titleMutationObserver.disconnect();
this.titleElement = (event.target as HTMLSlotElement)
.assignedElements()
.find((e) => e.tagName === "WEBWRITER-TIMELINE-EVENT-TITLE");
if (this.titleElement) {
this.checkIfTitleIsEmpty();
this.titleMutationObserver.observe(this.titleElement, { childList: true });
}
}
/** @internal */
async showMovedAnimation() {
const dot = this.dotElement.value;
if (!dot) return;
dot.classList.remove("pulse");
// Trigger reflow to restart the animation
void dot.offsetWidth;
dot.classList.add("pulse");
const removePulse = () => {
dot.classList.remove("pulse");
dot.removeEventListener("animationend", removePulse);
};
dot.addEventListener("animationend", removePulse);
}
protected firstUpdated(_changedProperties: PropertyValues): void {
// Check if either date attribute is not yet obfuscated
// If so, set it to a clone to force an update which will obfuscate the attribute value
if (this.date && !this.getAttribute("date").startsWith("$")) {
this.date = this.date.clone();
}
if (this.endDate && !this.getAttribute("enddate")?.startsWith("$")) {
this.endDate = this.endDate.clone();
}
}
get isIncomplete() {
return this.titleEmpty || !this.date;
}
render() {
// Do not render invalid events when not in edit view
// However, we still need to mount the slot to be able to observe changes
if (!this.isInEditView && this.isIncomplete)
return html`<slot @slotchange=${this.onSlotChange} class="hide"></slot>`;
return html`
<div class="dot" ${ref(this.dotElement)}></div>
<div>
<div class="controls">
<date-input
lang=${this.lang || "en-US"}
placeholder=${msg("Date")}
.value=${this.date}
?disabled=${!this.isInEditView}
@change=${(e: Event) => {
if (this.date === (e.target as DateInput).value) return;
this.date = (e.target as DateInput).value;
// Notify the webwriter-timeline widget that the date has changed
// so it can reorder the events.
this.updateComplete.then(() =>
this.dispatchEvent(new CustomEvent("date-changed", { bubbles: true, composed: true })),
);
}}
></date-input>
${this.isInEditView || !!this.endDate
? html`<span class=${classMap({ "gray-out": !this.endDate, "show-on-focus": !this.endDate })}>
—
</span>
<date-input
lang=${this.lang || "en-US"}
class=${classMap({ "show-on-focus": !this.endDate })}
placeholder=${msg("End date")}
.value=${this.endDate}
?disabled=${!this.isInEditView}
optional
@change=${(event: Event) => {
this.endDate = (event.target as DateInput).value;
}}
></date-input>`
: nothing}
<span class="spacer"></span>
${this.isIncomplete
? html`<sl-tooltip content=${msg("An event requires a date and a title")} placement="bottom">
<sl-icon src=${ExclamationCircleIcon}></sl-icon>
</sl-tooltip>`
: nothing}
${this.isInEditView
? html`<sl-icon-button
src=${TrashIcon}
label=${msg("Delete event")}
@click=${() => this.remove()}
></sl-icon-button>`
: nothing}
</div>
<slot @slotchange=${this.onSlotChange}></slot>
</div>
`;
}
}