Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Item Interactions #11

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
10 changes: 8 additions & 2 deletions examples/random.html
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,20 @@
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) {
if (typeof utilization === "undefined") return "100%";
return `${100 * utilization}%`;
}

function handleEvent(e) {
console.log(e);
}

const template = html`
<mutti-timeline>
<mutti-timeline viewportPadding="150">
${map(
tracks,
(track) => html`
Expand All @@ -78,6 +82,8 @@
start=${item.startDate}
end=${item.endDate}
scale=${item.utilization}
@action=${handleEvent}
@delete=${handleEvent}
>
<span>${item.children}</span>
<small>
Expand Down
82 changes: 75 additions & 7 deletions src/components/item.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { css, html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { customElement, property, state } from "lit/decorators.js";
import { cameraProp } from "../controllers/camera-controller.js";
import { ItemInteractionsController } from "../controllers/item-interactions-controller.js";
import {
PointerController,
PointerHandler,
} from "../controllers/pointer-controller.js";
import { MuttiDate } from "../core/date.js";
import { ItemChangeEvent, ItemFocusEvent } from "../core/events.js";
import { varX } from "../core/properties.js";

/** Custom CSS property names that are related to items. */
Expand All @@ -10,6 +16,7 @@ export const itemProp = {
length: "--mutti-item-length",
nowOffset: "--mutti-item-now-offset",
subTrack: "--mutti-item-sub-track",
dragOffset: "--mutti-item-drag-offset",
};

const styles = css`
Expand All @@ -27,7 +34,10 @@ const styles = css`
height: calc(100% * ${varX(itemProp.scale)});
width: calc(${varX(cameraProp.dayWidth)} * ${varX(itemProp.length)});
transform: translateX(
calc(${varX(cameraProp.dayWidth)} * ${varX(itemProp.nowOffset)})
calc(
${varX(itemProp.dragOffset)} +
(${varX(cameraProp.dayWidth)} * ${varX(itemProp.nowOffset)})
)
);
grid-row-start: ${varX(itemProp.subTrack)};
grid-row-end: ${varX(itemProp.subTrack)};
Expand All @@ -37,20 +47,78 @@ const styles = css`
@customElement("mutti-item")
export class MuttiItemElement extends LitElement {
static override styles = styles;
private interactionController: ItemInteractionsController;
private pointerController: PointerController;

readonly role = "gridcell";
override slot = "item";
override tabIndex = 0;

@state() dragOffset = 0;

@property({ type: Number }) scale = 1;
@property({ converter: MuttiDate.converter }) start = MuttiDate.now;
@property({ converter: MuttiDate.converter }) end = MuttiDate.from(
this.start,
7
);
@property({ converter: MuttiDate.converter, reflect: true }) start =
MuttiDate.now;
@property({ converter: MuttiDate.converter, reflect: true }) end =
MuttiDate.from(this.start, 7);
@property({ type: Number, attribute: false }) subTrack = 1;

constructor() {
super();
this.interactionController = new ItemInteractionsController(this);
this.pointerController = new PointerController(this, {
disabled: true,
stopPropagation: true,
moveHandler: this.handleMove,
doneHandler: this.handleMoveDone,
});

this.addEventListener(
"focus",
() => (this.pointerController.disabled = false)
);
this.addEventListener(
"blur",
() => (this.pointerController.disabled = true)
);
}

private handleMove: PointerHandler = (_, delta) => {
this.dragOffset += delta.x;
};

private handleMoveDone: PointerHandler = () => {
// TODO: Parsing the value from CSS properties feels a bit "hacky" (but is quite fast).
// We should consider upgrading the backbone of the timeline with a lit context
// to pass global state around. However, it should not introduce additional
// lit renders when e.g. the offset updates.
const dayWidth = parseFloat(
getComputedStyle(this).getPropertyValue(cameraProp.dayWidth)
);
const newStart = MuttiDate.from(this.start, this.dragOffset / dayWidth);
const newEnd = MuttiDate.from(this.end, this.dragOffset / dayWidth);
this.dragOffset = 0;

const shouldContinue = this.dispatchEvent(
new ItemChangeEvent(newStart, newEnd)
);

if (!shouldContinue) return;
this.start = newStart;
this.end = newEnd;
};

override focus(): void {
const shouldContinue = this.dispatchEvent(
new ItemFocusEvent(this.start, this.end)
);
if (shouldContinue) super.focus({ preventScroll: true });
}

protected override willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has("dragOffset")) {
this.style.setProperty(itemProp.dragOffset, `${this.dragOffset}px`);
}
if (changedProperties.has("subTrack")) {
this.style.setProperty(itemProp.subTrack, this.subTrack.toString());
}
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
Loading