UNPKG

@webwriter/timeline

Version:

Create/learn with a digital timeline and test your knowledge.

246 lines (217 loc) 10.3 kB
import { localized, msg } from "@lit/localize"; import SlButton from "@shoelace-style/shoelace/dist/components/button/button.component.js"; import SlIcon from "@shoelace-style/shoelace/dist/components/icon/icon.component.js"; import SlRadioGroup from "@shoelace-style/shoelace/dist/components/radio-group/radio-group.component.js"; import SlRadio from "@shoelace-style/shoelace/dist/components/radio/radio.component.js"; import SlTabGroup from "@shoelace-style/shoelace/dist/components/tab-group/tab-group.component.js"; import SlTabPanel from "@shoelace-style/shoelace/dist/components/tab-panel/tab-panel.component.js"; import SlTab from "@shoelace-style/shoelace/dist/components/tab/tab.component.js"; import "@shoelace-style/shoelace/dist/themes/light.css"; import { LitElementWw } from "@webwriter/lit"; import ExclamationCircleIcon from "bootstrap-icons/icons/exclamation-circle.svg"; import EyeSlashIcon from "bootstrap-icons/icons/eye-slash.svg"; import { css, html, nothing, PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { createRef, ref } from "lit/directives/ref.js"; import LOCALIZE from "../localization/generated"; import { QuizContainer, QuizEvent } from "./quiz/quiz-container.component"; import { TimelineContainer } from "./timeline/timeline-container.component"; import type { WebWriterTimelineEventWidget } from "./timeline/webwriter-timeline-event.widget"; import { TimelineDate } from "./util/timeline-date"; /** * Displays a timeline with events and a quiz based on those events. * * As children, it should only contain `<webwriter-timeline-event>` elements in order * to function properly. Any other children may lead to unexpected behavior. */ @localized() @customElement("webwriter-timeline") export class WebWriterTimelineWidget extends LitElementWw { protected localize = LOCALIZE; /** @internal */ static scopedElements = { "sl-button": SlButton, "sl-icon": SlIcon, "sl-radio-group": SlRadioGroup, "sl-radio": SlRadio, "sl-tab-group": SlTabGroup, "sl-tab-panel": SlTabPanel, "sl-tab": SlTab, "timeline-container": TimelineContainer, "quiz-container": QuizContainer, }; static styles = css` :host { display: block; width: 100%; } sl-icon { margin-left: var(--sl-spacing-x-small); } :host(:not([contenteditable="true"]):not([contenteditable=""])) aside { display: none; } sl-tab-panel::part(base) { padding: 0; } .hide { display: none; } .timeline-empty { display: flex; gap: var(--sl-spacing-small); align-items: center; } `; private get isInEditView() { return this.contentEditable === "true" || this.contentEditable === ""; } private tabGroupRef = createRef<SlTabGroup>(); /** * Which panels are enabled for the reader: * - "timeline": only the timeline panel * - "quiz": only the quiz panel * - "timeline+quiz": both panels with tabs */ @property({ type: String, reflect: true, attribute: "panels" }) accessor enabledPanels: "timeline" | "quiz" | "timeline+quiz" = "timeline+quiz"; @state() private accessor eventsForQuiz: QuizEvent[] | null = null; private updateEventsForQuiz() { this.eventsForQuiz = Array.from(this.children) .filter((child) => { if (!(child instanceof HTMLElement) || child.tagName !== "WEBWRITER-TIMELINE-EVENT") return false; const event = child as WebWriterTimelineEventWidget; const titleElement = event.querySelector("webwriter-timeline-event-title"); return event.date && titleElement && titleElement.textContent.trim() !== ""; }) .map((event: WebWriterTimelineEventWidget) => ({ id: event.id, titleHtml: event.querySelector("webwriter-timeline-event-title").innerHTML.trim(), date: event.date, endDate: event.endDate, })); } private addEvent(event: CustomEvent) { event.stopPropagation(); const newEvent = document.createElement("webwriter-timeline-event"); this.appendChild(newEvent); } private dateChanged(event: Event) { event.stopPropagation(); // When the date of an event changes, we need to reorder the events. const target = event.target as WebWriterTimelineEventWidget; let oldPos = Array.from(this.children).indexOf(target); let inserted = false; for (const child of this.children) { if ("date" in child && child.date) { // Move the target before the first event with a date greater than the target's date // (i.e. at the end of all events with a date less than or equal to the target's date) if (target.date.compare(child.date as TimelineDate) < 0) { this.insertBefore(target, child); inserted = true; break; } } else { // Once we reach an event without a date, we can insert the target before it // as all previous events have a date less than or equal to the target's date this.insertBefore(target, child); inserted = true; break; } } // If no event was found with a date greater than the target's date, append it to the end if (!inserted) this.appendChild(target); if (oldPos !== Array.from(this.children).indexOf(target)) { // For some reason, the element instance actually changes during reordering, // so we need to use a timeout and search for the updated instance to trigger the animation. setTimeout(() => { const updatedTarget = Array.from(this.children).find( (child) => child.id === target.id, ) as WebWriterTimelineEventWidget; updatedTarget?.showMovedAnimation(); }, 0); } } protected firstUpdated(_changedProperties: PropertyValues): void { // It is not entirely clear why this is necessary, but without this, // no tab is selected when the widget is first rendered in export mode. this.tabGroupRef.value?.updateComplete.then(() => this.tabGroupRef.value?.show("timeline")); } private Options() { // Shoelace's radio group only syncs the value to the checked state of the radio buttons if <sl-radio> is // defined inside the global customElements registry, see // https://github.com/shoelace-style/shoelace/blob/v2.20.1/src/components/radio-group/radio-group.component.ts#L238 // Since we are using scoped elements, we need to manually set the checked state of the radio buttons const PanelRadioOption = (value: string, label: string) => html`<sl-radio value=${value} .checked=${this.enabledPanels === value}>${label}</sl-radio>`; return html`<aside part="options"> <sl-radio-group .value=${this.enabledPanels} @sl-change=${(e: CustomEvent) => (this.enabledPanels = (e.target as SlRadioGroup).value as "timeline" | "quiz" | "timeline+quiz")} help-text=${msg("Which panels will be visible to readers")} > <!-- prettier-ignore --> ${PanelRadioOption("timeline+quiz", msg("Timeline and Quiz"))} ${PanelRadioOption("timeline", msg("Timeline only"))} ${PanelRadioOption("quiz", msg("Quiz only"))} </sl-radio-group> </aside>`; } private Quiz() { return html`<quiz-container lang=${this.lang} .events=${this.eventsForQuiz || []}></quiz-container>`; } private Timeline() { return html`<timeline-container lang=${this.lang} ?edit-view=${this.isInEditView} @add-event=${this.addEvent} @date-changed=${this.dateChanged} @slotchange=${() => this.updateEventsForQuiz()} > <slot></slot> </timeline-container>`; } private PanelIcon(panelName: string) { if (!this.enabledPanels.includes(panelName)) { return html`<sl-icon src=${EyeSlashIcon} label=${msg("(disabled)")}></sl-icon>`; } else { return nothing; } } private TabGroup() { if (!this.isInEditView) { // During reader view, display a warning if there are no events instead of showing a empty timeline or quiz, // as this is likely a mistake by the author. if (this.eventsForQuiz?.length === 0) { return html`<div class="timeline-empty"> <sl-icon src=${ExclamationCircleIcon}></sl-icon> ${msg("Timeline is empty.")} </div>`; } if (this.enabledPanels === "timeline") { return this.Timeline(); } else if (this.enabledPanels === "quiz") { // We still need to mount the slot to get a list of events for the quiz return html`<slot class="hide" @slotchange=${() => this.updateEventsForQuiz()}></slot>${this.Quiz()}`; } } return html`<sl-tab-group ${ref(this.tabGroupRef)} tabindex="0" activation="manual" @sl-tab-show=${(event: CustomEvent) => { if (event.detail.name === "quiz") this.updateEventsForQuiz(); }} > <sl-tab slot="nav" panel="timeline">${msg("Timeline")}${this.PanelIcon("timeline")}</sl-tab> <sl-tab slot="nav" panel="quiz">${msg("Quiz")}${this.PanelIcon("quiz")}</sl-tab> <sl-tab-panel name="timeline">${this.Timeline()}</sl-tab-panel> <sl-tab-panel name="quiz">${this.Quiz()}</sl-tab-panel> </sl-tab-group>`; } render() { return html`${this.Options()}${this.TabGroup()}`; } }