Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/marketing/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export default defineConfig({
site: "https://t3.codes",
server: {
port: Number(process.env.PORT ?? 4173),
allowedHosts: true,
},
});
93 changes: 3 additions & 90 deletions apps/marketing/src/lib/homeMotion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,8 @@ class ElementStub extends EventTarget {
properties = new Map<string, string>();
style = { setProperty: (name: string, value: string) => this.properties.set(name, value) };
children: ElementStub[] = [];
scrollLeft = 0;
scrollWidth = 1_200;
clientWidth = 400;
matches = () => false;
contains = (target: EventTarget | null) =>
target === this || (target instanceof ElementStub && this.children.includes(target));
querySelectorAll = () => this.children;
getBoundingClientRect = vi.fn(() => ({ left: 0, top: 0, width: 400, height: 600 }));
scrollTo = vi.fn((options: ScrollToOptions) => {
this.scrollLeft = options.left ?? this.scrollLeft;
});
}

let observers: ObserverStub[] = [];
Expand Down Expand Up @@ -79,12 +70,11 @@ function fixture() {
const mark = new ElementStub();
const otherMark = new ElementStub();
field.children = [mark, otherMark];
const endorsements = new ElementStub();
const caret = new ElementStub();
dispose = startHomeMotion({ hero, field, endorsements, caret } as unknown as Parameters<
dispose = startHomeMotion({ hero, field, caret } as unknown as Parameters<
typeof startHomeMotion
>[0]);
return { hero, field, mark, otherMark, endorsements, caret, observer: observers[0]! };
return { hero, field, mark, otherMark, caret, observer: observers[0]! };
}

function movePointer(hero: ElementStub, x = 400, y = 600) {
Expand Down Expand Up @@ -133,86 +123,9 @@ describe("homepage motion", () => {
expect(mark.properties.get("--home-motion-state")).toBe("running");
});

it("pages every eight seconds, reverses at the end, and has no timer without overflow", () => {
const { endorsements, observer } = fixture();
expect(vi.getTimerCount()).toBe(0);
observer.report(endorsements, true);
vi.advanceTimersByTime(7_999);
expect(endorsements.scrollTo).not.toHaveBeenCalled();
vi.advanceTimersByTime(16_001);
expect(endorsements.scrollTo.mock.calls.map(([options]) => options.left)).toEqual([
400, 800, 400,
]);
expect(
endorsements.scrollTo.mock.calls.every(([options]) => options.behavior === "smooth"),
).toBe(true);

endorsements.clientWidth = endorsements.scrollWidth;
viewport.dispatchEvent(new Event("resize"));
expect(vi.getTimerCount()).toBe(0);
expect(endorsements.scrollTo).toHaveBeenLastCalledWith({ left: 400, behavior: "instant" });
endorsements.clientWidth = 400;
viewport.dispatchEvent(new Event("resize"));
expect(vi.getTimerCount()).toBe(1);
});

it("pauses paging for hover, focus, hidden content, and reduced motion", () => {
const { endorsements, observer } = fixture();
observer.report(endorsements, true);
const changeVisibility = (visible: boolean) => {
page.visibilityState = visible ? "visible" : "hidden";
page.dispatchEvent(new Event("visibilitychange"));
};
const changeMotion = (matches: boolean) => {
reduced.matches = matches;
reduced.dispatchEvent(new Event("change"));
};
const pauses = [
[
() => endorsements.dispatchEvent(new Event("pointerenter")),
() => endorsements.dispatchEvent(new Event("pointerleave")),
],
[
() => endorsements.dispatchEvent(new Event("focusin")),
() =>
endorsements.dispatchEvent(Object.assign(new Event("focusout"), { relatedTarget: null })),
],
[() => changeVisibility(false), () => changeVisibility(true)],
[() => observer.report(endorsements, false), () => observer.report(endorsements, true)],
[() => changeMotion(true), () => changeMotion(false)],
] as const;
for (const [pause, resume] of pauses) {
pause();
expect(vi.getTimerCount()).toBe(0);
vi.advanceTimersByTime(16_000);
resume();
expect(vi.getTimerCount()).toBe(1);
}
expect(endorsements.scrollTo).not.toHaveBeenCalled();
vi.advanceTimersByTime(8_000);
expect(endorsements.scrollTo).toHaveBeenCalledWith({ left: 400, behavior: "smooth" });
endorsements.dispatchEvent(new Event("pointerenter"));
expect(endorsements.scrollTo).toHaveBeenLastCalledWith({ left: 400, behavior: "instant" });
expect(vi.getTimerCount()).toBe(0);
});

it.each(["wheel", "pointerdown", "keydown"])("hands control to the user after %s", (event) => {
const { endorsements, observer } = fixture();
observer.report(endorsements, true);
endorsements.dispatchEvent(new Event(event));
observer.report(endorsements, false);
observer.report(endorsements, true);
endorsements.dispatchEvent(new Event("pointerleave"));
viewport.dispatchEvent(new Event("resize"));
vi.advanceTimersByTime(60_000);
expect(vi.getTimerCount()).toBe(0);
expect(endorsements.scrollTo).not.toHaveBeenCalled();
});

it("cancels pending work and ignores events after cleanup", () => {
const { hero, mark, endorsements, observer } = fixture();
const { hero, mark, observer } = fixture();
observer.report(mark, true);
observer.report(endorsements, true);
movePointer(hero);
dispose?.();
observer.report(mark, true);
Expand Down
87 changes: 2 additions & 85 deletions apps/marketing/src/lib/homeMotion.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
/** Runs homepage motion only while its content is visible. Manual scrolling stops paging. */
/** Runs homepage motion only while its content is visible. */
export function startHomeMotion({
hero,
field,
endorsements,
caret,
}: {
hero: HTMLElement;
field: HTMLElement;
endorsements: HTMLElement;
caret: HTMLElement;
}) {
if (typeof IntersectionObserver === "undefined") return () => {};
Expand All @@ -19,12 +17,6 @@ export function startHomeMotion({
const events = new AbortController();
const eventOptions = { signal: events.signal };
let disposed = false;
let hovered = endorsements.matches(":hover");
let focused = endorsements.contains(document.activeElement);
let userControlled = false;
let direction = 1;
let automaticScroll = false;
let pageTimer: ReturnType<typeof setTimeout> | undefined;
let pointerFrame: number | undefined;
let pointer: { x: number; y: number } | null = null;

Expand All @@ -34,13 +26,6 @@ export function startHomeMotion({
document.visibilityState === "visible" &&
!reducedMotion.matches;
const canParallax = () => finePointer.matches && marks.some(canMove);
const canPage = () =>
canMove(endorsements) &&
!hovered &&
!focused &&
!userControlled &&
endorsements.scrollWidth > endorsements.clientWidth;

function resetPointer() {
if (pointerFrame !== undefined) cancelAnimationFrame(pointerFrame);
pointerFrame = undefined;
Expand All @@ -49,34 +34,6 @@ export function startHomeMotion({
field.style.setProperty("--py", "0px");
}

function updatePaging() {
if (canPage()) {
pageTimer ??= setTimeout(advancePage, 8_000);
return;
}
if (pageTimer !== undefined) clearTimeout(pageTimer);
pageTimer = undefined;
if (automaticScroll) {
automaticScroll = false;
endorsements.scrollTo({ left: endorsements.scrollLeft, behavior: "instant" });
}
}

function advancePage() {
pageTimer = undefined;
if (!canPage()) return;
const end = endorsements.scrollWidth - endorsements.clientWidth;
const current = endorsements.scrollLeft;
if (current >= end - 1) direction = -1;
else if (current <= 1) direction = 1;
automaticScroll = true;
endorsements.scrollTo({
left: Math.max(0, Math.min(end, current + direction * endorsements.clientWidth)),
behavior: "smooth",
});
updatePaging();
}

function update() {
for (const mark of marks) {
mark.style.setProperty("--home-motion-state", canMove(mark) ? "running" : "paused");
Expand All @@ -85,7 +42,6 @@ export function startHomeMotion({
const parallax = canParallax();
field.style.setProperty("--parallax-duration", parallax ? "0.7s" : "0s");
if (!parallax) resetPointer();
updatePaging();
}

const observer = new IntersectionObserver((entries) => {
Expand All @@ -96,7 +52,7 @@ export function startHomeMotion({
}
update();
});
for (const element of [...marks, endorsements, caret]) observer.observe(element);
for (const element of [...marks, caret]) observer.observe(element);

hero.addEventListener(
"pointermove",
Expand All @@ -121,45 +77,6 @@ export function startHomeMotion({
eventOptions,
);
hero.addEventListener("pointerleave", resetPointer, eventOptions);
endorsements.addEventListener(
"pointerenter",
() => {
hovered = true;
updatePaging();
},
eventOptions,
);
endorsements.addEventListener(
"pointerleave",
() => {
hovered = false;
updatePaging();
},
eventOptions,
);
endorsements.addEventListener(
"focusin",
() => {
focused = true;
updatePaging();
},
eventOptions,
);
endorsements.addEventListener(
"focusout",
(event) => {
focused = event.relatedTarget instanceof Node && endorsements.contains(event.relatedTarget);
updatePaging();
},
eventOptions,
);
const takeControl = () => {
userControlled = true;
updatePaging();
};
endorsements.addEventListener("wheel", takeControl, { ...eventOptions, passive: true });
endorsements.addEventListener("pointerdown", takeControl, eventOptions);
endorsements.addEventListener("keydown", takeControl, eventOptions);
document.addEventListener("visibilitychange", update, eventOptions);
window.addEventListener("resize", update, eventOptions);
reducedMotion.addEventListener("change", update, eventOptions);
Expand Down
Loading
Loading