Skip to content

Commit

Permalink
feat: handle focus events for 2D timeline keyboard navigation
Browse files Browse the repository at this point in the history
refs: #4
  • Loading branch information
christoph-fricke committed Aug 17, 2022
1 parent 2dbfbcb commit 8a0c93a
Show file tree
Hide file tree
Showing 5 changed files with 216 additions and 45 deletions.
4 changes: 2 additions & 2 deletions examples/random.html
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -58,7 +58,7 @@
}

const template = html`
<mutti-timeline>
<mutti-timeline viewportPadding="150">
${map(
tracks,
(track) => html`
Expand Down
41 changes: 37 additions & 4 deletions src/components/timeline.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 <mutti-track> 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`
Expand Down
179 changes: 145 additions & 34 deletions src/components/track.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -30,61 +31,171 @@ const styles = css`
}
`;

type SubTracks = MuttiItemElement[][];
type ItemPosition = { subTrack: number; position: number };
type ItemPositionMap = Map<MuttiItemElement, ItemPosition>;

@customElement("mutti-track")
export class MuttiTrackElement extends LitElement {
static override styles = styles;

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<MuttiItemElement>(
(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 <mutti-timeline> 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 {
Expand Down
19 changes: 19 additions & 0 deletions src/controllers/camera-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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":
Expand Down
18 changes: 13 additions & 5 deletions src/core/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down

0 comments on commit 8a0c93a

Please sign in to comment.