diff --git a/frontend/__tests__/components/common/Conditional.spec.tsx b/frontend/__tests__/components/common/Conditional.spec.tsx new file mode 100644 index 000000000000..6e7f73a9a1f1 --- /dev/null +++ b/frontend/__tests__/components/common/Conditional.spec.tsx @@ -0,0 +1,214 @@ +import { cleanup, render, screen } from "@solidjs/testing-library"; +import { createSignal } from "solid-js"; +import { afterEach, describe, expect, it } from "vitest"; + +import { Conditional } from "../../../src/ts/components/common/Conditional"; + +describe("Conditional", () => { + afterEach(() => { + cleanup(); + }); + + describe("static rendering", () => { + it("renders then when if is true", () => { + render(() => then content} />); + + expect(screen.getByText("then content")).toBeInTheDocument(); + }); + + it("renders then when if is a truthy object", () => { + render(() => ( + then content} /> + )); + + expect(screen.getByText("then content")).toBeInTheDocument(); + }); + + it("renders then when if is a truthy string", () => { + render(() => then content} />); + + expect(screen.getByText("then content")).toBeInTheDocument(); + }); + + it("renders else fallback when if is false", () => { + render(() => ( + then content} + else={
else content
} + /> + )); + + expect(screen.queryByText("then content")).not.toBeInTheDocument(); + expect(screen.getByText("else content")).toBeInTheDocument(); + }); + + it("renders else fallback when if is null", () => { + render(() => ( + then content} + else={
else content
} + /> + )); + + expect(screen.queryByText("then content")).not.toBeInTheDocument(); + expect(screen.getByText("else content")).toBeInTheDocument(); + }); + + it("renders else fallback when if is undefined", () => { + render(() => ( + then content} + else={
else content
} + /> + )); + + expect(screen.queryByText("then content")).not.toBeInTheDocument(); + expect(screen.getByText("else content")).toBeInTheDocument(); + }); + + it("renders else fallback when if is 0", () => { + render(() => ( + then content} + else={
else content
} + /> + )); + + expect(screen.queryByText("then content")).not.toBeInTheDocument(); + expect(screen.getByText("else content")).toBeInTheDocument(); + }); + + it("renders nothing when if is falsy and else is not provided", () => { + const { container } = render(() => ( + then content} /> + )); + + expect(screen.queryByText("then content")).not.toBeInTheDocument(); + expect(container.firstChild).toBeNull(); + }); + }); + + describe("then as function", () => { + it("passes the truthy value to then function", () => { + const obj: { label: string } | null = { label: "hello" }; + render(() => ( +
{value().label}
} /> + )); + + expect(screen.getByText("hello")).toBeInTheDocument(); + }); + + it("does not call then function when if is falsy", () => { + const obj: { label: string } | null = null; + render(() => ( + then content} + else={
else content
} + /> + )); + + expect(screen.queryByText("then content")).not.toBeInTheDocument(); + expect(screen.getByText("else content")).toBeInTheDocument(); + }); + }); + + describe("reactivity", () => { + it("switches from else to then when if becomes truthy", async () => { + const [condition, setCondition] = createSignal(false); + + render(() => ( + then content} + else={
else content
} + /> + )); + + expect(screen.queryByText("then content")).not.toBeInTheDocument(); + expect(screen.getByText("else content")).toBeInTheDocument(); + + setCondition(true); + + expect(screen.getByText("then content")).toBeInTheDocument(); + expect(screen.queryByText("else content")).not.toBeInTheDocument(); + }); + + it("switches from then to else when if becomes falsy", async () => { + const [condition, setCondition] = createSignal(true); + + render(() => ( + then content} + else={
else content
} + /> + )); + + expect(screen.getByText("then content")).toBeInTheDocument(); + + setCondition(false); + + expect(screen.queryByText("then content")).not.toBeInTheDocument(); + expect(screen.getByText("else content")).toBeInTheDocument(); + }); + + it("then JSXElement updates reactively when inner signal changes", async () => { + const [label, setLabel] = createSignal("initial"); + + render(() => {label()}} />); + + expect(screen.getByText("initial")).toBeInTheDocument(); + + setLabel("updated"); + + expect(screen.getByText("updated")).toBeInTheDocument(); + }); + + it("then JSXElement updates reactively when if changes from a signal", async () => { + const [data, setData] = createSignal(undefined); + + render(() => ( + {data()}} + else={
no data
} + /> + )); + + expect(screen.getByText("no data")).toBeInTheDocument(); + expect(screen.queryByTestId("content")).not.toBeInTheDocument(); + + setData("resolved"); + + expect(screen.getByTestId("content")).toHaveTextContent("resolved"); + expect(screen.queryByText("no data")).not.toBeInTheDocument(); + }); + + it("then function value accessor tracks reactive if", () => { + const [data, setData] = createSignal<{ name: string } | null>(null); + + render(() => ( +
{value().name}
} + else={
no data
} + /> + )); + + expect(screen.getByText("no data")).toBeInTheDocument(); + + setData({ name: "Alice" }); + + expect(screen.getByTestId("content")).toHaveTextContent("Alice"); + + setData({ name: "Bob" }); + + expect(screen.getByTestId("content")).toHaveTextContent("Bob"); + }); + }); +}); diff --git a/frontend/__tests__/components/common/anime/AnimeConditional.spec.tsx b/frontend/__tests__/components/common/anime/AnimeConditional.spec.tsx new file mode 100644 index 000000000000..7746be712dcd --- /dev/null +++ b/frontend/__tests__/components/common/anime/AnimeConditional.spec.tsx @@ -0,0 +1,230 @@ +import { cleanup, render, screen } from "@solidjs/testing-library"; +import { createSignal } from "solid-js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockAnimate } = vi.hoisted(() => ({ + mockAnimate: vi.fn().mockImplementation(() => ({ + pause: vi.fn(), + then: vi.fn((cb: () => void) => { + cb(); + return Promise.resolve(); + }), + })), +})); + +vi.mock("animejs", () => ({ + animate: mockAnimate, +})); + +vi.mock("../../../../src/ts/utils/misc", () => ({ + applyReducedMotion: vi.fn((duration: number) => duration), +})); + +import { AnimeConditional } from "../../../../src/ts/components/common/anime/AnimeConditional"; + +describe("AnimeConditional", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders `then` content when `if` is truthy", () => { + render(() => ( + then} + else={
else
} + /> + )); + + expect(screen.getByTestId("then-content")).toBeInTheDocument(); + expect(screen.queryByTestId("else-content")).not.toBeInTheDocument(); + }); + + it("renders `else` content when `if` is falsy", () => { + render(() => ( + then} + else={
else
} + /> + )); + + expect(screen.queryByTestId("then-content")).not.toBeInTheDocument(); + expect(screen.getByTestId("else-content")).toBeInTheDocument(); + }); + + it("renders `else` content when `if` is null", () => { + render(() => ( + then} + else={
else
} + /> + )); + + expect(screen.queryByTestId("then-content")).not.toBeInTheDocument(); + expect(screen.getByTestId("else-content")).toBeInTheDocument(); + }); + + it("switches reactively from `then` to `else`", () => { + const [condition, setCondition] = createSignal(true); + + render(() => ( + then} + else={
else
} + /> + )); + + expect(screen.getByTestId("then-content")).toBeInTheDocument(); + + setCondition(false); + + expect(screen.queryByTestId("then-content")).not.toBeInTheDocument(); + expect(screen.getByTestId("else-content")).toBeInTheDocument(); + }); + + it("switches reactively from `else` to `then`", () => { + const [condition, setCondition] = createSignal(false); + + render(() => ( + then} + else={
else
} + /> + )); + + expect(screen.getByTestId("else-content")).toBeInTheDocument(); + + setCondition(true); + + expect(screen.getByTestId("then-content")).toBeInTheDocument(); + expect(screen.queryByTestId("else-content")).not.toBeInTheDocument(); + }); + + it("supports `then` as a function and passes the truthy value", () => { + const obj = { label: "hello" }; + render(() => ( +
{value().label}
} + /> + )); + + expect(screen.getByTestId("fn-content")).toHaveTextContent("hello"); + }); + + it("does not throw without `else` prop", () => { + expect(() => { + render(() => ( + then} + /> + )); + }).not.toThrow(); + + expect(screen.getByTestId("then-content")).toBeInTheDocument(); + }); + + it("does not throw on mount/unmount", () => { + const [show, setShow] = createSignal(true); + + expect(() => { + render(() => ( + then} + else={
else
} + /> + )); + }).not.toThrow(); + + expect(() => setShow(false)).not.toThrow(); + expect(() => setShow(true)).not.toThrow(); + }); + + describe("default animations (opacity fade)", () => { + it("applies default opacity animate on `then` branch", () => { + render(() => then} />); + + expect(mockAnimate).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ opacity: 1, duration: 125 }), + ); + }); + + it("applies default opacity initial state on `then` branch", () => { + render(() => then} />); + + // Initial call: opacity:0 with duration:0 + expect(mockAnimate).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ opacity: 0, duration: 0 }), + ); + }); + }); + + describe("custom animeProps", () => { + it("uses custom animate params when animeProps provided", () => { + render(() => ( + then} + animeProps={{ + initial: { opacity: 0, translateY: -10 }, + animate: { opacity: 1, translateY: 0, duration: 400 }, + exit: { opacity: 0, translateY: -10, duration: 200 }, + }} + /> + )); + + expect(mockAnimate).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ opacity: 1, translateY: 0, duration: 400 }), + ); + }); + + it("uses custom initial state when animeProps provided", () => { + render(() => ( + then} + animeProps={{ + initial: { opacity: 0, translateY: -10 }, + animate: { opacity: 1, translateY: 0, duration: 400 }, + }} + /> + )); + + // Initial state applied with duration:0 + expect(mockAnimate).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ opacity: 0, translateY: -10, duration: 0 }), + ); + }); + }); + + it("exitBeforeEnter prop does not throw on condition change", () => { + const [cond, setCond] = createSignal(true); + + expect(() => { + render(() => ( + then} + else={
else
} + /> + )); + }).not.toThrow(); + + expect(() => setCond(false)).not.toThrow(); + }); +}); diff --git a/frontend/__tests__/components/common/anime/AnimeShow.spec.tsx b/frontend/__tests__/components/common/anime/AnimeShow.spec.tsx new file mode 100644 index 000000000000..ef33c93522b8 --- /dev/null +++ b/frontend/__tests__/components/common/anime/AnimeShow.spec.tsx @@ -0,0 +1,157 @@ +import { cleanup, render, screen } from "@solidjs/testing-library"; +import { createSignal } from "solid-js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockAnimate } = vi.hoisted(() => ({ + mockAnimate: vi.fn().mockImplementation(() => { + const callbacks: Array<() => void> = []; + const animation = { + pause: vi.fn(), + then: vi.fn((cb: () => void) => { + callbacks.push(cb); + // Invoke immediately so exit animations complete synchronously in tests + cb(); + return Promise.resolve(); + }), + }; + return animation; + }), +})); + +vi.mock("animejs", () => ({ + animate: mockAnimate, +})); + +vi.mock("../../../../src/ts/utils/misc", () => ({ + applyReducedMotion: vi.fn((duration: number) => duration), +})); + +import { AnimeShow } from "../../../../src/ts/components/common/anime/AnimeShow"; + +describe("AnimeShow", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders children when `when` is true", () => { + render(() => ( + +
hello
+
+ )); + expect(screen.getByTestId("content")).toBeInTheDocument(); + }); + + it("does not render children when `when` is false", () => { + render(() => ( + +
hello
+
+ )); + expect(screen.queryByTestId("content")).not.toBeInTheDocument(); + }); + + it("shows and hides reactively", () => { + const [visible, setVisible] = createSignal(true); + + render(() => ( + +
hello
+
+ )); + + expect(screen.getByTestId("content")).toBeInTheDocument(); + setVisible(false); + expect(screen.queryByTestId("content")).not.toBeInTheDocument(); + setVisible(true); + expect(screen.getByTestId("content")).toBeInTheDocument(); + }); + + it("applies class to the wrapper element when visible", () => { + const { container } = render(() => ( + + content + + )); + expect(container.querySelector(".my-class")).toBeTruthy(); + }); + + it("does not throw on mount/unmount", () => { + const [show, setShow] = createSignal(true); + + expect(() => { + render(() => ( + +
content
+
+ )); + }).not.toThrow(); + + expect(() => setShow(false)).not.toThrow(); + }); + + describe("slide mode", () => { + it("renders children when `when` is true in slide mode", () => { + render(() => ( + +
slide
+
+ )); + expect(screen.getByTestId("slide-content")).toBeInTheDocument(); + }); + + it("does not render children when `when` is false in slide mode", () => { + render(() => ( + +
slide
+
+ )); + expect(screen.queryByTestId("slide-content")).not.toBeInTheDocument(); + }); + + it("animates height in slide mode", () => { + render(() => ( + +
content
+
+ )); + + const heightCalls = mockAnimate.mock.calls.filter( + ([, params]) => params.height !== undefined, + ); + expect(heightCalls.length).toBeGreaterThan(0); + }); + }); + + describe("duration prop", () => { + it("uses the provided duration", () => { + render(() => ( + +
content
+
+ )); + + const durationCalls = mockAnimate.mock.calls.filter( + ([, params]) => params.duration === 400, + ); + expect(durationCalls.length).toBeGreaterThan(0); + }); + + it("defaults to 125ms when no duration is provided", () => { + render(() => ( + +
content
+
+ )); + + const defaultDurationCalls = mockAnimate.mock.calls.filter( + ([, params]) => params.duration === 125, + ); + expect(defaultDurationCalls.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/frontend/__tests__/components/common/anime/AnimeSwitch.spec.tsx b/frontend/__tests__/components/common/anime/AnimeSwitch.spec.tsx new file mode 100644 index 000000000000..0c0eb51c4b6e --- /dev/null +++ b/frontend/__tests__/components/common/anime/AnimeSwitch.spec.tsx @@ -0,0 +1,205 @@ +import { cleanup, render, screen } from "@solidjs/testing-library"; +import { createSignal } from "solid-js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockAnimate } = vi.hoisted(() => ({ + mockAnimate: vi.fn().mockImplementation(() => ({ + pause: vi.fn(), + then: vi.fn((cb: () => void) => { + cb(); + return Promise.resolve(); + }), + })), +})); + +vi.mock("animejs", () => ({ + animate: mockAnimate, +})); + +vi.mock("../../../../src/ts/utils/misc", () => ({ + applyReducedMotion: vi.fn((duration: number) => duration), +})); + +import { AnimeMatch } from "../../../../src/ts/components/common/anime/AnimeMatch"; +import { AnimeSwitch } from "../../../../src/ts/components/common/anime/AnimeSwitch"; + +describe("AnimeSwitch", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders the matched child", () => { + render(() => ( + + +
A
+
+ +
B
+
+
+ )); + + expect(screen.getByTestId("match-a")).toBeInTheDocument(); + expect(screen.queryByTestId("match-b")).not.toBeInTheDocument(); + }); + + it("switches to the next matched child reactively", () => { + const [tab, setTab] = createSignal<"a" | "b">("a"); + + render(() => ( + + +
View A
+
+ +
View B
+
+
+ )); + + expect(screen.getByTestId("view-a")).toBeInTheDocument(); + expect(screen.queryByTestId("view-b")).not.toBeInTheDocument(); + + setTab("b"); + + expect(screen.queryByTestId("view-a")).not.toBeInTheDocument(); + expect(screen.getByTestId("view-b")).toBeInTheDocument(); + }); + + it("renders nothing when no match", () => { + const { container } = render(() => ( + + +
never
+
+
+ )); + + expect(screen.queryByTestId("no-match")).not.toBeInTheDocument(); + // Only AnimePresence wrapper remains + expect(container.querySelectorAll("[data-testid]").length).toBe(0); + }); + + it("does not throw when switching between children", () => { + const [view, setView] = createSignal<"a" | "b">("a"); + + expect(() => { + render(() => ( + + +
View A
+
+ +
View B
+
+
+ )); + }).not.toThrow(); + + expect(() => setView("b")).not.toThrow(); + }); + + it("passes animeProps down to all AnimeMatch children", () => { + render(() => ( + + +
content
+
+
+ )); + + // Expect animate call with the shared animeProps + expect(mockAnimate).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ opacity: 1, duration: 300 }), + ); + }); +}); + +describe("AnimeMatch", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("renders children when `when` is true", () => { + render(() => ( + + +
match
+
+
+ )); + expect(screen.getByTestId("match-content")).toBeInTheDocument(); + }); + + it("does not render children when `when` is false", () => { + render(() => ( + + +
hidden
+
+
+ )); + expect(screen.queryByTestId("hidden")).not.toBeInTheDocument(); + }); + + it("per-match animate overrides the shared animeProps", () => { + render(() => ( + + +
override
+
+
+ )); + + // The per-match duration (500) should be used, not the shared one (200) + expect(mockAnimate).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ opacity: 1, duration: 500 }), + ); + const callsWithSharedDuration = mockAnimate.mock.calls.filter( + ([, params]) => params.duration === 200, + ); + expect(callsWithSharedDuration.length).toBe(0); + }); + + it("falls back to context animeProps when no per-match props provided", () => { + render(() => ( + + +
content
+
+
+ )); + + // Should use context duration 250 + expect(mockAnimate).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ opacity: 1, duration: 250 }), + ); + }); +}); diff --git a/frontend/__tests__/hooks/createEvent.spec.ts b/frontend/__tests__/hooks/createEvent.spec.ts new file mode 100644 index 000000000000..af5a28518a2f --- /dev/null +++ b/frontend/__tests__/hooks/createEvent.spec.ts @@ -0,0 +1,46 @@ +import { createRoot } from "solid-js"; +import { describe, expect, it } from "vitest"; +import { createEvent } from "../../src/ts/hooks/createEvent"; + +describe("createEvent", () => { + it("initial value is 0", () => { + createRoot((dispose) => { + const [event] = createEvent(); + expect(event()).toBe(0); + dispose(); + }); + }); + + it("dispatch increments the value by 1", () => { + createRoot((dispose) => { + const [event, dispatch] = createEvent(); + dispatch(); + expect(event()).toBe(1); + dispose(); + }); + }); + + it("each dispatch increments independently", () => { + createRoot((dispose) => { + const [event, dispatch] = createEvent(); + dispatch(); + dispatch(); + dispatch(); + expect(event()).toBe(3); + dispose(); + }); + }); + + it("two independent events do not share state", () => { + createRoot((dispose) => { + const [eventA, dispatchA] = createEvent(); + const [eventB, dispatchB] = createEvent(); + dispatchA(); + dispatchA(); + dispatchB(); + expect(eventA()).toBe(2); + expect(eventB()).toBe(1); + dispose(); + }); + }); +}); diff --git a/frontend/__tests__/hooks/createSignalWithSetters.spec.ts b/frontend/__tests__/hooks/createSignalWithSetters.spec.ts new file mode 100644 index 000000000000..b23749aa3824 --- /dev/null +++ b/frontend/__tests__/hooks/createSignalWithSetters.spec.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { createRoot } from "solid-js"; +import { createSignalWithSetters } from "../../src/ts/hooks/createSignalWithSetters"; + +describe("createSignalWithSetters", () => { + it("returns default value from getter", () => { + createRoot((dispose) => { + const [count] = createSignalWithSetters(42)({}); + expect(count()).toBe(42); + dispose(); + }); + }); + + it("exposes raw set on the setters object", () => { + createRoot((dispose) => { + const [count, { set }] = createSignalWithSetters(0)({}); + set(7); + expect(count()).toBe(7); + dispose(); + }); + }); + + it("raw set accepts an updater function", () => { + createRoot((dispose) => { + const [count, { set }] = createSignalWithSetters(3)({}); + set((prev) => prev * 2); + expect(count()).toBe(6); + dispose(); + }); + }); + + it("calls a no-arg named setter", () => { + createRoot((dispose) => { + const [count, { increment }] = createSignalWithSetters(0)({ + increment: (set) => set((n) => n + 1), + }); + increment(); + expect(count()).toBe(1); + increment(); + expect(count()).toBe(2); + dispose(); + }); + }); + + it("calls a named setter with custom args", () => { + createRoot((dispose) => { + const [count, { addBy }] = createSignalWithSetters(0)({ + addBy: (set, amount: number) => set((n) => n + amount), + }); + addBy(5); + expect(count()).toBe(5); + addBy(3); + expect(count()).toBe(8); + dispose(); + }); + }); + + it("supports multiple named setters independently", () => { + createRoot((dispose) => { + const [count, { increment, decrement, reset }] = createSignalWithSetters( + 10, + )({ + increment: (set) => set((n) => n + 1), + decrement: (set) => set((n) => n - 1), + reset: (set) => set(0), + }); + increment(); + expect(count()).toBe(11); + decrement(); + decrement(); + expect(count()).toBe(9); + reset(); + expect(count()).toBe(0); + dispose(); + }); + }); + + it("works with non-primitive default values", () => { + createRoot((dispose) => { + const [state, { setName }] = createSignalWithSetters({ name: "Alice" })({ + setName: (set, name: string) => set((prev) => ({ ...prev, name })), + }); + setName("Bob"); + expect(state().name).toBe("Bob"); + dispose(); + }); + }); + + it("raw set and named setters share the same underlying signal", () => { + createRoot((dispose) => { + const [count, { increment, set }] = createSignalWithSetters(0)({ + increment: (set) => set((n) => n + 1), + }); + increment(); + set(100); + expect(count()).toBe(100); + increment(); + expect(count()).toBe(101); + dispose(); + }); + }); +}); diff --git a/frontend/src/ts/components/common/Conditional.tsx b/frontend/src/ts/components/common/Conditional.tsx index 698cc06aeb11..ad315aa59acd 100644 --- a/frontend/src/ts/components/common/Conditional.tsx +++ b/frontend/src/ts/components/common/Conditional.tsx @@ -1,13 +1,15 @@ -import { JSXElement, Show } from "solid-js"; +import { Accessor, JSXElement, Show } from "solid-js"; -export function Conditional(props: { - if: boolean; - then: JSXElement; +export function Conditional(props: { + if: T; + then: JSXElement | ((value: Accessor>) => JSXElement); else?: JSXElement; }): JSXElement { return ( - {props.then} + {typeof props.then === "function" + ? props.then + : () => props.then as JSXElement} ); } diff --git a/frontend/src/ts/components/common/anime/Anime.tsx b/frontend/src/ts/components/common/anime/Anime.tsx index b38469f479cd..26a4cbda3c3c 100644 --- a/frontend/src/ts/components/common/anime/Anime.tsx +++ b/frontend/src/ts/components/common/anime/Anime.tsx @@ -96,6 +96,11 @@ export type AnimeProps = ParentProps<{ * CSS styles for the wrapper element. */ style?: string | Record; + + /** + * Callback ref to access the underlying DOM element. + */ + ref?: (el: HTMLElement) => void; }>; /** @@ -166,6 +171,7 @@ export function Anime(props: AnimeProps): JSXElement { "as", "class", "style", + "ref", ]); let element: HTMLElement | undefined = undefined; @@ -345,6 +351,7 @@ export function Anime(props: AnimeProps): JSXElement { const setElementRef = (el: unknown): void => { element = el as HTMLElement; + local.ref?.(el as HTMLElement); }; return ( diff --git a/frontend/src/ts/components/common/anime/AnimeConditional.tsx b/frontend/src/ts/components/common/anime/AnimeConditional.tsx new file mode 100644 index 000000000000..f3040115a453 --- /dev/null +++ b/frontend/src/ts/components/common/anime/AnimeConditional.tsx @@ -0,0 +1,82 @@ +import { Accessor, JSXElement, ParentProps } from "solid-js"; + +import { Conditional } from "../Conditional"; +import { Anime, AnimeProps } from "./Anime"; +import { AnimePresence } from "./AnimePresence"; + +/** + * A convenience wrapper that renders animated `if/then/else` conditionals. + * + * Combines `` + `` + `` into a single + * component. Both branches are wrapped in `` so they fade in/out + * automatically. Use `animeProps` to customise the animation; the fallback + * is a quick 125 ms opacity fade. + * + * @example + * ```tsx + * } + * else={} + * exitBeforeEnter + * /> + * ``` + * + * @example + * Custom animation: + * ```tsx + * } + * animeProps={{ + * initial: { opacity: 0, translateY: -8 }, + * animate: { opacity: 1, translateY: 0, duration: 200 }, + * exit: { opacity: 0, translateY: -8, duration: 150 }, + * }} + * /> + * ``` + */ +export function AnimeConditional( + props: ParentProps<{ + exitBeforeEnter?: boolean; + if: T; + then: JSXElement | ((value: Accessor>) => JSXElement); + else?: JSXElement; + animeProps?: AnimeProps; + }>, +): JSXElement { + /** Fallback animation used when no `animeProps` are provided. */ + const defaultAnimeProps = { + initial: { opacity: 0 }, + animate: { opacity: 1, duration: 125 }, + exit: { opacity: 0, duration: 125 }, + }; + + return ( + + ( + + {typeof props.then === "function" ? props.then(value) : props.then} + + )} + else={ + + {props.else} + + } + /> + + ); +} diff --git a/frontend/src/ts/components/common/anime/AnimeGroupTest.tsx b/frontend/src/ts/components/common/anime/AnimeGroupTest.tsx deleted file mode 100644 index 72cdb713cf6e..000000000000 --- a/frontend/src/ts/components/common/anime/AnimeGroupTest.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { createSignal, For, JSXElement } from "solid-js"; - -import { Button } from "../Button"; -import { AnimeGroup } from "./AnimeGroup"; - -let nextId = 1; - -export function AnimeGroupTest(): JSXElement { - const [items, setItems] = createSignal<{ id: number; label: string }[]>([ - { id: nextId++, label: "Item 1" }, - { id: nextId++, label: "Item 2" }, - { id: nextId++, label: "Item 3" }, - ]); - - const addItem = (): void => { - const id = nextId++; - setItems((prev) => [...prev, { id, label: `Item ${id}` }]); - }; - - const removeItem = (): void => { - setItems((prev) => prev.slice(0, -1)); - }; - - return ( -
-
-
- - - {(item) => ( -
- {item.label} -
- )} -
-
-
- ); -} diff --git a/frontend/src/ts/components/common/anime/AnimeMatch.tsx b/frontend/src/ts/components/common/anime/AnimeMatch.tsx new file mode 100644 index 000000000000..0fe5cf1a51a3 --- /dev/null +++ b/frontend/src/ts/components/common/anime/AnimeMatch.tsx @@ -0,0 +1,55 @@ +import { AnimationParams } from "animejs"; +import { JSXElement, Match, ParentProps, useContext } from "solid-js"; + +import { Anime } from "./Anime"; +import { AnimeSwitchContext } from "./AnimeSwitch"; + +/** + * A `` wrapper that integrates with `` to apply exit/enter + * animations to conditional content inside a `` block. + * + * Animation props (`initial`, `animate`, `exit`) fall back to whatever was + * provided on the parent ``, but can be overridden per-case. + * + * Must be a direct child of ``. + * + * @example + * ```tsx + * + * + * + * + * + * + * + * + * ``` + */ +export function AnimeMatch( + props: ParentProps<{ + when: boolean; + initial?: Partial; + animate?: AnimationParams; + exit?: AnimationParams; + duration?: number; + }>, +): JSXElement { + const ctx = useContext(AnimeSwitchContext); + + // Fall back to context values from AnimeSwitch when not explicitly provided + const initial = () => props.initial ?? ctx?.()?.initial; + const animate = () => props.animate ?? ctx?.()?.animate; + const exit = () => props.exit ?? ctx?.()?.exit; + + return ( + + + {props.children} + + + ); +} diff --git a/frontend/src/ts/components/common/anime/AnimatedShow.tsx b/frontend/src/ts/components/common/anime/AnimeShow.tsx similarity index 86% rename from frontend/src/ts/components/common/anime/AnimatedShow.tsx rename to frontend/src/ts/components/common/anime/AnimeShow.tsx index 38d440b7682f..cd93227d0408 100644 --- a/frontend/src/ts/components/common/anime/AnimatedShow.tsx +++ b/frontend/src/ts/components/common/anime/AnimeShow.tsx @@ -15,26 +15,27 @@ import { AnimePresence } from "./AnimePresence"; * * @example * ```tsx - * + * *
Fades in and out automatically
- *
+ * * ``` * * @example * ```tsx - * + * *
Slides open/closed
- *
+ * * ``` */ -export function AnimatedShow( +export function AnimeShow( props: ParentProps<{ when: boolean; slide?: true; duration?: number; + class?: string; }>, ): JSXElement { - const duration = () => props.duration ?? 250; + const duration = () => props.duration ?? 125; return ( } animate={{ opacity: 1, duration: duration() } as AnimationParams} exit={{ opacity: 0, duration: duration() } as AnimationParams} + class={props.class} > {props.children}
@@ -62,6 +64,7 @@ export function AnimatedShow( } exit={{ height: 0, duration: duration() } as AnimationParams} style={{ overflow: "hidden" }} + class={props.class} > {props.children}
diff --git a/frontend/src/ts/components/common/anime/AnimeSwitch.tsx b/frontend/src/ts/components/common/anime/AnimeSwitch.tsx new file mode 100644 index 000000000000..3697092e0ca5 --- /dev/null +++ b/frontend/src/ts/components/common/anime/AnimeSwitch.tsx @@ -0,0 +1,57 @@ +import { + Accessor, + Context, + createContext, + createMemo, + JSXElement, + ParentProps, + Switch, +} from "solid-js"; + +import { AnimeProps } from "./Anime"; +import { AnimePresence } from "./AnimePresence"; + +/** + * Context that passes shared `AnimeProps` defaults from `` down to + * `` children. Children can override individual props, but fall back + * to the context values when not specified. + */ +export const AnimeSwitchContext: Context< + Accessor | undefined +> = createContext | undefined>(undefined); + +/** + * A convenience wrapper that combines `` + `` with shared + * animation defaults for all child `` elements. + * + * Pass `animeProps` to define default `initial`/`animate`/`exit` values for every + * match case. Each `` can still override those values individually. + * + * @example + * ```tsx + * + * + * + * + * ``` + */ +export function AnimeSwitch( + props: ParentProps<{ exitBeforeEnter?: boolean; animeProps?: AnimeProps }>, +): JSXElement { + const animeProps = createMemo(() => props.animeProps); + + return ( + + + {props.children} + + + ); +} diff --git a/frontend/src/ts/components/common/anime/index.ts b/frontend/src/ts/components/common/anime/index.ts index f412b928c242..b6564735bbef 100644 --- a/frontend/src/ts/components/common/anime/index.ts +++ b/frontend/src/ts/components/common/anime/index.ts @@ -7,6 +7,8 @@ export type { AnimeGroupProps } from "./AnimeGroup"; export { AnimePresence } from "./AnimePresence"; export type { AnimePresenceProps } from "./AnimePresence"; -export { AnimatedShow } from "./AnimatedShow"; +export { AnimeShow } from "./AnimeShow"; +export { AnimeSwitch } from "./AnimeSwitch"; +export { AnimeConditional } from "./AnimeConditional"; export type { AnimationParams, JSAnimation } from "animejs"; diff --git a/frontend/src/ts/hooks/createEvent.ts b/frontend/src/ts/hooks/createEvent.ts new file mode 100644 index 000000000000..85c9686aa631 --- /dev/null +++ b/frontend/src/ts/hooks/createEvent.ts @@ -0,0 +1,33 @@ +import { Accessor, createSignal } from "solid-js"; + +/** + * Creates a reactive event primitive. + * + * Returns a tuple of: + * - An accessor whose value increments each time the event is dispatched. + * Reactive consumers (effects, memos, etc.) re-run whenever the event fires. + * - A dispatch function that triggers the event. + * + * @returns `[accessor, dispatch]` + * + * @example + * ```ts + * const [onSave, dispatchSave] = createEvent(); + * + * createEffect(() => { + * onSave(); // re-runs every time dispatchSave() is called + * console.log("saved!"); + * }); + * + * dispatchSave(); // triggers the effect + * ``` + */ +export function createEvent(): [Accessor, () => void] { + const [get, set] = createSignal(0); + return [ + get, + () => { + set((v) => v + 1); + }, + ]; +} diff --git a/frontend/src/ts/hooks/createSignalWithSetters.ts b/frontend/src/ts/hooks/createSignalWithSetters.ts new file mode 100644 index 000000000000..b50978266034 --- /dev/null +++ b/frontend/src/ts/hooks/createSignalWithSetters.ts @@ -0,0 +1,67 @@ +import { createSignal } from "solid-js"; + +/** The raw SolidJS setter, accepting a value or an updater function. */ +type OriginalSetter = (value: T | ((prev: T) => T)) => void; + +/** + * A named setter definition. Receives the raw setter as its first argument, + * followed by any custom arguments. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type SetterFn = (originalSetter: OriginalSetter, ...args: any[]) => void; + +/** Extracts the custom argument types from a {@link SetterFn}, dropping the leading `originalSetter`. */ +type SetterArgs> = S extends ( + orig: OriginalSetter, + ...args: infer A +) => void + ? A + : never; + +/** A map of named {@link SetterFn} definitions keyed by setter name. */ +type SettersMap = Record>; + +/** + * The object returned alongside the getter. Each key from `S` becomes a + * bound setter with its custom signature, and `set` exposes the raw + * {@link OriginalSetter} for direct use. + */ +type MappedSetters> = { + [K in keyof S]: (...args: SetterArgs) => void; +} & { + set: OriginalSetter; +}; + +/** + * Creates a SolidJS signal together with a set of named, pre-bound setters. + * + * Usage is curried so that TypeScript can infer `T` from `defaultValue` while + * still inferring `S` from the `setters` object passed in the second call. + * + * @example + * ```ts + * const [count, { increment, decrement, set }] = createSignalWithSetters(0)({ + * increment: (set) => set((n) => n + 1), + * decrement: (set) => set((n) => n - 1), + * }); + * ``` + * + * @param defaultValue - Initial value for the underlying SolidJS signal. + * @returns A curried function that accepts a {@link SettersMap} and returns + * a `[getter, setters]` tuple, where `setters` contains each named setter + * plus a raw `set` passthrough. + */ +export function createSignalWithSetters(defaultValue: T) { + return function >( + setters: S, + ): [() => T, MappedSetters] { + const [get, _set] = createSignal(defaultValue); + const mapped = Object.fromEntries( + Object.entries(setters).map(([key, setter]) => [ + key, + (...args: never[]) => setter(_set, ...args), + ]), + ) as unknown as MappedSetters; + return [get, { ...mapped, set: _set }]; + }; +}