diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
new file mode 100644
index 0000000..956c46e
--- /dev/null
+++ b/.github/workflows/checks.yml
@@ -0,0 +1,29 @@
+name: Code Checks
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/setup-node@v3
+ with:
+ node-version: 16
+ cache: "npm"
+ - run: npm ci
+ - run: npm run lint
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/setup-node@v3
+ with:
+ node-version: 16
+ cache: "npm"
+ - run: npm ci
+ - run: npm run test:cov
diff --git a/README.md b/README.md
index 875cd97..c75263c 100644
--- a/README.md
+++ b/README.md
@@ -16,18 +16,23 @@ After importing the package, the web components can be used in your page markup.
Track Name
- Item Content
+
+ Item Content
+
Item Content
Item Content
+
Track Name
Item Content
- Item Content
+
+ Item Content
+
Item Content
diff --git a/examples/random.html b/examples/random.html
index 98a61ed..0b64eb7 100644
--- a/examples/random.html
+++ b/examples/random.html
@@ -15,23 +15,27 @@
Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue",
sans-serif;
}
- .item {
+ mutti-static-item {
+ border-radius: 4px;
+ background-color: rgba(224, 255, 255, 0.6);
+ }
+ mutti-item {
cursor: pointer;
padding: 8px;
border-radius: 4px;
- background-color: lightsalmon;
+ background-color: rgba(255, 160, 122, 0.8);
}
- .item:focus {
+ mutti-item:focus {
outline: none;
- background-color: darksalmon;
+ background-color: rgba(233, 150, 122, 0.8);
}
- .label {
+ mutti-label {
padding: 4px;
width: 100px;
background-color: white;
border-right: 1px solid darkgray;
}
- .track {
+ mutti-track {
border-top: 1px solid darkgray;
border-bottom: 1px solid darkgray;
}
@@ -62,25 +66,31 @@
${map(
tracks,
(track) => html`
-
- ${track.children}
- ${map(
- track.items,
- (item) => html`
-
- ${item.children}
-
- ${getUtilizationPercentage(item.utilization)}
-
-
- `
+
+ ${track.children}
+ ${map(track.items, (item) =>
+ item.static
+ ? html`
+
+ `
+ : html`
+
+ ${item.children}
+
+ ${getUtilizationPercentage(item.utilization)}
+
+
+ `
)}
`
diff --git a/src/components/static-item.ts b/src/components/static-item.ts
new file mode 100644
index 0000000..84d045f
--- /dev/null
+++ b/src/components/static-item.ts
@@ -0,0 +1,78 @@
+import { css, html, LitElement, PropertyValues, TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { cameraProp } from "../controllers/camera-controller.js";
+import { MuttiDate } from "../core/date.js";
+import { varX } from "../core/properties.js";
+
+/** Custom CSS property names that are related to static items. */
+export const staticItemProp = {
+ length: "--mutti-static-item-length",
+ nowOffset: "--mutti-static-item-now-offset",
+};
+
+const styles = css`
+ :host {
+ box-sizing: border-box;
+ display: inline-block;
+ overflow: hidden;
+ white-space: nowrap;
+
+ pointer-events: none;
+ user-select: none;
+ -webkit-tap-highlight-color: transparent;
+ -webkit-user-select: none;
+
+ position: absolute;
+ height: 100%;
+ width: calc(${varX(cameraProp.dayWidth)} * ${varX(staticItemProp.length)});
+ transform: translateX(
+ calc(${varX(cameraProp.dayWidth)} * ${varX(staticItemProp.nowOffset)})
+ );
+ grid-row: 1 / -1; // Spans the whole track
+ }
+`;
+
+/**
+ * Static items are special items, which do not participate in collision avoidance
+ * and are not interactive for the user.
+ *
+ * Their default styling render them behind other `mutti-item`s.
+ */
+@customElement("mutti-static-item")
+export class MuttiStaticItemElement extends LitElement {
+ static override styles = styles;
+
+ readonly role = "gridcell";
+ override slot = "static-item";
+
+ @property({ converter: MuttiDate.converter }) start = MuttiDate.now;
+ @property({ converter: MuttiDate.converter }) end = MuttiDate.from(
+ this.start,
+ 7
+ );
+
+ protected override willUpdate(changedProperties: PropertyValues): void {
+ if (changedProperties.has("start") || changedProperties.has("end")) {
+ this.style.setProperty(
+ staticItemProp.length,
+ this.start.getDaysUntil(this.end).toString()
+ );
+ }
+ if (changedProperties.has("start")) {
+ this.style.setProperty(
+ staticItemProp.nowOffset,
+ this.start.getDaysFromNow().toString()
+ );
+ }
+ }
+
+ protected override render(): TemplateResult {
+ return html``;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "mutti-static-item": MuttiStaticItemElement;
+ }
+}
diff --git a/src/components/track.test.ts b/src/components/track.test.ts
index eef031e..44721a1 100644
--- a/src/components/track.test.ts
+++ b/src/components/track.test.ts
@@ -1,6 +1,8 @@
+import { screen } from "@testing-library/dom";
import { html, render } from "../test-utils/render";
import type { MuttiTrackElement } from "./track";
import "./track";
+import "./label";
describe("", () => {
it("should set its slot property to track", async () => {
@@ -17,5 +19,28 @@ describe("", () => {
);
expect(element).toHaveSlot("label");
expect(element).toHaveSlot("item");
+ expect(element).toHaveSlot("static-item");
+ });
+
+ it("should label itself with a containing ", async () => {
+ const { element } = await render(
+ html`Test`
+ );
+
+ const label = screen.getByText("Test");
+
+ expect(label.id).toBe("mutti-label-0");
+ expect(element.getAttribute("aria-labelledby")).toBe(label.id);
+ });
+
+ it("should preserve the id of its label if exists", async () => {
+ const { element } = await render(
+ html`Test`
+ );
+
+ const label = screen.getByText("Test");
+
+ expect(label.id).toBe("test");
+ expect(element.getAttribute("aria-labelledby")).toBe(label.id);
});
});
diff --git a/src/components/track.ts b/src/components/track.ts
index 4ff5a5b..7fead1f 100644
--- a/src/components/track.ts
+++ b/src/components/track.ts
@@ -4,6 +4,7 @@ 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";
+import { MuttiLabelElement } from "./label.js";
/** Custom CSS property names that are related to tracks. */
const trackProp = {
@@ -21,7 +22,7 @@ const styles = css`
.items-container {
box-sizing: border-box;
display: grid;
- grid-template-rows: repeat(${varX(trackProp.subTracks)}, 100px);
+ grid-template-rows: repeat(${varX(trackProp.subTracks, "1")}, 100px);
align-items: end;
gap: ${varX(themeProp.itemGap, "4px")};
padding: ${varX(themeProp.itemGap, "4px")} 0;
@@ -53,12 +54,27 @@ export class MuttiTrackElement extends LitElement {
protected override firstUpdated(): void {
const children = Array.from(this.children);
+ const label = children.find(
+ (c): c is MuttiLabelElement => c instanceof MuttiLabelElement
+ );
+ this.connectAriaWithLabel(label);
+
const items = children.filter(this.isMuttiItem);
this.subTracks = this.orderItemsIntoSubTracks(items);
this.applySubTrackInfoToElements(this.subTracks);
this.fillPositionMap(this.itemPositionMap, this.subTracks);
}
+ private static trackSequence = 0;
+ private connectAriaWithLabel(label?: MuttiLabelElement) {
+ if (!label) return;
+
+ label.id = label.id
+ ? label.id
+ : "mutti-label-" + MuttiTrackElement.trackSequence++;
+ this.setAttribute("aria-labelledby", label.id);
+ }
+
/** Called by the with delegated {@link FocusChangeEvent}s. */
public focusOnRelevantSubTrack(e: FocusChangeEvent): void {
const item = e.target;
@@ -202,6 +218,7 @@ export class MuttiTrackElement extends LitElement {
return html`
+
`;
diff --git a/src/controllers/camera-controller.ts b/src/controllers/camera-controller.ts
index a25f011..7485afb 100644
--- a/src/controllers/camera-controller.ts
+++ b/src/controllers/camera-controller.ts
@@ -16,7 +16,7 @@ export enum ZoomDetailLevel {
export const cameraProp = {
offset: "--mutti-camera-offset",
zoom: "--mutti-camera-zoom",
- dayWidth: "--mutti-dayWidth",
+ dayWidth: "--mutti-day-width",
};
export class CameraController implements ReactiveController {
diff --git a/src/main.ts b/src/main.ts
index 453b1fe..09061fe 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,6 +1,7 @@
export { MuttiItemElement } from "./components/item.js";
export { MuttiLabelElement } from "./components/label.js";
export { MuttiTimelineElement } from "./components/timeline.js";
+export { MuttiStaticItemElement } from "./components/static-item.js";
export { MuttiTrackElement } from "./components/track.js";
export {
diff --git a/src/test-utils/builder.ts b/src/test-utils/builder.ts
index 75ecffa..56da141 100644
--- a/src/test-utils/builder.ts
+++ b/src/test-utils/builder.ts
@@ -38,6 +38,7 @@ function oneOf(...args: T[]): T {
////////////////////////////////////////////////////////////////////////////////
export interface Item {
+ static: boolean;
children: string;
utilization?: number;
startDate: string;
@@ -66,6 +67,7 @@ export const buildItem = createBuilder- ((override) => {
const itemLength = faker.datatype.number({ min: 7, max: 500 });
const startDate = buildDate();
return {
+ static: faker.datatype.boolean(),
children: faker.commerce.productName(),
startDate: startDate.toString(),
endDate: MuttiDate.from(startDate, itemLength).toString(),