From 8a0c93a9eddc21a60d32e30d22b39648fb250849 Mon Sep 17 00:00:00 2001 From: Christoph Fricke Date: Wed, 17 Aug 2022 15:31:05 +0200 Subject: [PATCH] feat: handle focus events for 2D timeline keyboard navigation refs: #4 --- examples/random.html | 4 +- src/components/timeline.ts | 41 +++++- src/components/track.ts | 179 ++++++++++++++++++++++----- src/controllers/camera-controller.ts | 19 +++ src/core/date.ts | 18 ++- 5 files changed, 216 insertions(+), 45 deletions(-) diff --git a/examples/random.html b/examples/random.html index 02ded30..98a61ed 100644 --- a/examples/random.html +++ b/examples/random.html @@ -45,7 +45,7 @@ import { buildTrack, listOf, seed } from "../dist/test-utils/builder.js"; const root = document.querySelector("#root"); const usedSeed = seed(); - const tracks = listOf(buildTrack, { min: 15, max: 100 }); + const tracks = listOf(buildTrack, { min: 15, max: 15 }); const itemSum = tracks.flatMap((track) => track.items).length; function getUtilizationPercentage(utilization) { @@ -58,7 +58,7 @@ } const template = html` - + ${map( tracks, (track) => html` diff --git a/src/components/timeline.ts b/src/components/timeline.ts index e04b6c0..862f482 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,10 +1,12 @@ -import { css, html, LitElement, TemplateResult } from "lit"; +import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; import { CameraController, ZoomDetailLevel, } from "../controllers/camera-controller.js"; +import { FocusChangeEvent } from "../core/events.js"; import "./heading.js"; +import { MuttiTrackElement } from "./track.js"; const styles = css` :host { @@ -27,13 +29,44 @@ export class MuttiTimelineElement extends LitElement { static override styles = styles; readonly role = "grid"; + private cameraController: CameraController; @property() override lang = document.documentElement.lang || navigator.language; + @property({ type: Number }) viewportPadding = 100; - private cameraController = new CameraController(this, { - initialDayOffset: 100, - }); + constructor() { + super(); + this.cameraController = new CameraController(this, { + initialDayOffset: 100, + }); + this.addEventListener(FocusChangeEvent.type, this.handleFocusChange); + } + + protected override willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("viewportPadding")) { + this.cameraController.updateConfig({ + viewportPadding: this.viewportPadding, + }); + } + } + + private handleFocusChange = (e: FocusChangeEvent) => { + // The that first handled the event is not able to go further + // up or down, so the event should be passed to the previous/next track. + const track = e.composedPath().find(this.isMuttiTrack); + let next: Element | null | undefined; + do { + if (e.where === "up") next = (next ?? track)?.previousElementSibling; + else next = (next ?? track)?.nextElementSibling; + } while (next && !this.isMuttiTrack(next)); + + next?.focusOnRelevantSubTrack(e); + }; + + private isMuttiTrack(value: unknown): value is MuttiTrackElement { + return value instanceof MuttiTrackElement; + } protected override render(): TemplateResult { return html` diff --git a/src/components/track.ts b/src/components/track.ts index 678360f..4ff5a5b 100644 --- a/src/components/track.ts +++ b/src/components/track.ts @@ -1,6 +1,7 @@ import { css, html, LitElement, TemplateResult } from "lit"; import { customElement } from "lit/decorators.js"; import { cameraProp } from "../controllers/camera-controller.js"; +import { FocusChangeEvent } from "../core/events.js"; import { varX, themeProp } from "../core/properties.js"; import { MuttiItemElement } from "./item.js"; @@ -30,6 +31,10 @@ const styles = css` } `; +type SubTracks = MuttiItemElement[][]; +type ItemPosition = { subTrack: number; position: number }; +type ItemPositionMap = Map; + @customElement("mutti-track") export class MuttiTrackElement extends LitElement { static override styles = styles; @@ -37,54 +42,160 @@ export class MuttiTrackElement extends LitElement { readonly role = "row"; override slot = "track"; + private subTracks: SubTracks = []; + private itemPositionMap: ItemPositionMap = new Map(); + + constructor() { + super(); + this.addEventListener(FocusChangeEvent.type, this.handleFocusChange); + } + protected override firstUpdated(): void { const children = Array.from(this.children); - const items = children.filter( - (c): c is MuttiItemElement => c instanceof MuttiItemElement - ); - this.collisionAvoidance(items); + const items = children.filter(this.isMuttiItem); + this.subTracks = this.orderItemsIntoSubTracks(items); + this.applySubTrackInfoToElements(this.subTracks); + this.fillPositionMap(this.itemPositionMap, this.subTracks); } - private collisionAvoidance(items: MuttiItemElement[]) { - const subTracks: MuttiItemElement[][] = []; - for (const item of items) { - const added = this.addToFittingTrack(item, subTracks); - if (!added) { - subTracks.push([item]); + /** Called by the with delegated {@link FocusChangeEvent}s. */ + public focusOnRelevantSubTrack(e: FocusChangeEvent): void { + const item = e.target; + if (!this.isMuttiItem(item)) return; + + const track = + (e.where === "down" ? this.subTracks.at(0) : this.subTracks.at(-1)) ?? []; + const next = this.getClosestItemFromList(item, track); + if (!next) return; + + this.scrollIntoView({ block: "center" }); + next.focus(); + } + + private handleFocusChange = (e: FocusChangeEvent) => { + const item = e.target; + if (!this.isMuttiItem(item)) return; + + const position = this.itemPositionMap.get(item); + if (!position) { + throw new Error("Item has not been mapped into a position!"); + } + + switch (e.where) { + case "left": { + e.stopPropagation(); + const next = this.subTracks[position.subTrack]?.[position.position - 1]; + next?.focus(); + return; + } + case "right": { + e.stopPropagation(); + const next = this.subTracks[position.subTrack]?.[position.position + 1]; + next?.focus(); + return; + } + case "up": { + const previousTrack = this.subTracks[position.subTrack - 1]; + if (!previousTrack) return; // Event will be delegated to the previous track by the timeline + e.stopPropagation(); + const next = this.getClosestItemFromList(item, previousTrack); + return next?.focus(); + } + case "down": { + const nextTrack = this.subTracks[position.subTrack + 1]; + if (!nextTrack) return; // Event will be delegated to the next track by the timeline + e.stopPropagation(); + const next = this.getClosestItemFromList(item, nextTrack); + return next?.focus(); } } + }; - this.style.setProperty(trackProp.subTracks, `${subTracks.length}`); - subTracks.forEach((track, index) => { - for (const item of track) { - item.subTrack = index + 1; + private orderItemsIntoSubTracks(items: MuttiItemElement[]): SubTracks { + const subTracks: SubTracks = []; + for (const item of items) { + let fits = false; + for (const track of subTracks) { + fits = this.isItemFittingIntoTrack(item, track); + if (!fits) continue; + track.push(item); + break; } - }); + if (!fits) subTracks.push([item]); + } + + for (const track of subTracks) { + track.sort((a, b) => { + const lessThan = a.start.isEarlierThan(b.start); + const greaterThan = a.start.isLaterThan(b.start); + if (lessThan) return -1; + if (greaterThan) return 1; + return 0; + }); + } + return subTracks; } - private addToFittingTrack( + private isItemFittingIntoTrack( item: MuttiItemElement, - tracks: MuttiItemElement[][] - ): boolean { - for (const track of tracks) { - const isFitting = track.every((trackItem) => { - const itemWithinTrackItem = - item.start.isWithinDays(trackItem.start, trackItem.end) || - item.end.isWithinDays(trackItem.start, trackItem.end); - const trackItemWithinItem = - trackItem.start.isWithinDays(item.start, item.end) || - trackItem.end.isWithinDays(item.start, item.end); - - return !itemWithinTrackItem && !trackItemWithinItem; - }); - if (isFitting) { - track.push(item); - return true; + track: MuttiItemElement[] + ) { + return track.every((trackItem) => { + const itemWithinTrackItem = + item.start.isWithinDays(trackItem.start, trackItem.end) || + item.end.isWithinDays(trackItem.start, trackItem.end); + const trackItemWithinItem = + trackItem.start.isWithinDays(item.start, item.end) || + trackItem.end.isWithinDays(item.start, item.end); + + return !itemWithinTrackItem && !trackItemWithinItem; + }); + } + + private isMuttiItem(value: unknown): value is MuttiItemElement { + return value instanceof MuttiItemElement; + } + + private getClosestItemFromList( + ref: MuttiItemElement, + items: MuttiItemElement[] + ): MuttiItemElement | undefined { + if (items.length === 0) return; + + const scoring = items.map((item) => { + const startToStart = Math.abs(item.start.getDaysUntil(ref.start)); + const startToEnd = Math.abs(item.start.getDaysUntil(ref.end)); + const endToStart = Math.abs(item.end.getDaysUntil(ref.start)); + const endToEnd = Math.abs(item.end.getDaysUntil(ref.end)); + return ( + Math.min(startToStart, startToEnd) + Math.min(endToStart, endToEnd) + ); + }); + const minIndex = scoring.indexOf(Math.min(...scoring)); + return items[minIndex]; + } + + private applySubTrackInfoToElements(subTracks: SubTracks) { + this.style.setProperty(trackProp.subTracks, `${subTracks.length}`); + subTracks.forEach((track, index) => + track.forEach((item) => (item.subTrack = index + 1)) + ); + } + + private fillPositionMap(map: ItemPositionMap, subTracks: SubTracks) { + /* eslint-disable @typescript-eslint/no-non-null-assertion */ + for (let subTrack = 0; subTrack < subTracks.length; subTrack++) { + for ( + let position = 0; + position < subTracks[subTrack]!.length; + position++ + ) { + const item = subTracks[subTrack]![position]!; + map.set(item, { subTrack, position }); } } - - return false; + /* eslint-enable @typescript-eslint/no-non-null-assertion */ } protected override render(): TemplateResult { diff --git a/src/controllers/camera-controller.ts b/src/controllers/camera-controller.ts index ad12720..a25f011 100644 --- a/src/controllers/camera-controller.ts +++ b/src/controllers/camera-controller.ts @@ -4,6 +4,7 @@ import { ResizeValueCallback, } from "@lit-labs/observers/resize_controller.js"; import { Camera, CameraConfig, ViewPort } from "../core/camera.js"; +import { ItemFocusEvent } from "../core/events.js"; export enum ZoomDetailLevel { Year = 0, @@ -55,8 +56,13 @@ export class CameraController implements ReactiveController { return this.camera.viewport; } + get updateConfig() { + return this.camera.updateConfig.bind(this.camera); + } + hostConnected() { document.addEventListener("keydown", this.handleKeydown); + this.host.addEventListener(ItemFocusEvent.type, this.handleItemFocus); this.host.addEventListener("pointerdown", this.handlePointerDown); this.host.addEventListener("pointermove", this.handlePointerMove); @@ -68,6 +74,7 @@ export class CameraController implements ReactiveController { hostDisconnected() { document.removeEventListener("keydown", this.handleKeydown); + this.host.removeEventListener(ItemFocusEvent.type, this.handleItemFocus); this.host.removeEventListener("pointerdown", this.handlePointerDown); this.host.removeEventListener("pointermove", this.handlePointerMove); @@ -102,6 +109,18 @@ export class CameraController implements ReactiveController { this.camera.changeViewport(contentSize.inlineSize, contentSize.blockSize); }; + private handleItemFocus = (e: ItemFocusEvent) => { + if (e.defaultPrevented) return; + + // Dates are plotted relative to today, which is positioned at the current offset. + // Therefore, they are converted to absolute positions on the timeline. + const start = this.camera.offset + e.start.getDaysFromNow() * this.dayWidth; + const end = this.camera.offset + e.end.getDaysFromNow() * this.dayWidth; + + this.camera.moveRangeIntoViewport(start, end); + this.setHostPropertiesAndUpdate(); + }; + private handleKeydown = (e: KeyboardEvent) => { switch (e.code) { case "KeyR": diff --git a/src/core/date.ts b/src/core/date.ts index cfa833b..be1ebdb 100644 --- a/src/core/date.ts +++ b/src/core/date.ts @@ -78,16 +78,24 @@ export class MuttiDate { return MuttiDate.now.getDaysUntil(this); } - public isLaterOrSameThan(date: MuttiDate): boolean { - return this.dateMS >= date.dateMS; + public isLaterThan(date: MuttiDate): boolean { + return this.dateMS > date.dateMS; } - public isEarlierOrSameThan(date: MuttiDate): boolean { - return this.dateMS <= date.dateMS; + public isEarlierThan(date: MuttiDate): boolean { + return this.dateMS < date.dateMS; + } + + public isSameDay(date: MuttiDate): boolean { + return this.dateMS === date.dateMS; } public isWithinDays(start: MuttiDate, end: MuttiDate): boolean { - return this.isLaterOrSameThan(start) && this.isEarlierOrSameThan(end); + return ( + (this.isLaterThan(start) && this.isEarlierThan(end)) || + this.isSameDay(start) || + this.isSameDay(end) + ); } public get isStartOfMonth(): boolean {