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(),