feat(t5): Bewegungs-Sprache mit Tokens, Stufen und Budget (T5a A11)
Backend Image / build-and-push (push) Successful in 11m29s
Frontend Image / build-and-push (push) Successful in 23m35s

- src/lib/motion/tokens.ts und --motion-* in globals.css: Dauern,
  Kurven und Feder an einer Stelle; Beute-Bühne liest dieselben Werte.
- AppMotion: LazyMotion strict, Bewegungs-Code (domAnimation) als ein
  asynchroner Chunk, 28 kB gzip; check:motion-budget im Docker-Build
  neben dem 3D-Budget (Spec 17.6).
- PresenceList: abgehakte Zeilen blenden aus, die übrigen rücken nur bei
  „Voll“ nach; „Dezent“ blendet nur, „Aus“ sofort. Die Zeile geht nach
  der festen Verzögerung, auch wenn der Server schweigt (Spec 17.5);
  Fokus wandert zum nächsten Haken, auch ohne Bewegungs-Chunk.
- Overlays (Sheet, Dialog, Menü, Select) über Base-UI-Start/Ende-Stile
  mit Token-Zeiten; Verschiebung und Skalierung nur bei motion-full.
- Kein transition-all, kein Hover-Heben, keine Breiten-Animation von
  Daten; Skeleton erst nach 150 ms; Wächter-Regeln für alles davon.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 21:15:42 +02:00
1 parent 7025a6b589
commit cda3e02a31
66 files changed
+2574 -773

No files matched your search

@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
const load = vi.fn(() => Promise.resolve({}));
vi.mock("@/components/motion/presence-list", () => ({ loadMotionRuntime: () => load() }));
const { AppMotion } = await import("./app-motion");
describe("AppMotion (spec 17.2, 17.6)", () => {
afterEach(() => load.mockClear());
it("renders its children right away, without waiting for motion", () => {
render(
<AppMotion>
<p>Inhalt</p>
</AppMotion>,
);
expect(screen.getByText("Inhalt")).toBeInTheDocument();
});
it("preloads the one motion chunk once the shell has mounted", async () => {
render(
<AppMotion>
<p>Inhalt</p>
</AppMotion>,
);
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
});
it("keeps the app running when the chunk cannot be loaded (offline, first visit)", async () => {
load.mockReturnValueOnce(Promise.reject(new Error("offline")));
render(
<AppMotion>
<p>Inhalt</p>
</AppMotion>,
);
await waitFor(() => expect(load).toHaveBeenCalled());
expect(screen.getByText("Inhalt")).toBeInTheDocument();
});
});
@@ -0,0 +1,18 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { loadMotionRuntime } from "@/components/motion/presence-list";
/**
* The motion frame of the (app) layout (spec 17.2, 17.6). It carries no `motion` code itself: once
* the shell has mounted it preloads the one async motion chunk (motion-runtime.tsx: LazyMotion,
* MotionConfig per level, the animated list), so lists are animated by the time they need it and
* login, Herkunft and every first load stay free of motion.
*/
export function AppMotion({ children }: { children: ReactNode }) {
useEffect(() => {
// Without the chunk (offline, first visit) lists simply stay plain.
loadMotionRuntime().catch(() => undefined);
}, []);
return children;
}
@@ -50,7 +50,7 @@ export function RestrictionOverride({ onReason }: RestrictionOverrideProps) {
Ausnahme mit Begründung
<ChevronDown
aria-hidden
className={cn("size-4 motion-safe:transition-transform", open && "rotate-180")}
className={cn("size-4 motion-full:transition-transform motion-full:duration-(--motion-settle)", open && "rotate-180")}
/>
</button>
{open ? (
@@ -83,9 +83,9 @@ function targetIndex(key: string, current: number, count: number): number | null
const OPTION_CLASS: Record<ChoiceAppearance, string> = {
buttons:
"inline-flex min-h-11 min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 py-1 text-base motion-safe:transition-colors",
"inline-flex min-h-11 min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 py-1 text-base transition-colors",
cards:
"flex min-h-14 w-full items-start gap-3 rounded-lg px-3 py-2 text-left text-base motion-safe:transition-colors",
"flex min-h-14 w-full items-start gap-3 rounded-lg px-3 py-2 text-left text-base transition-colors",
};
const MARK_ICON = { tick: Check, dash: Minus } as const;
@@ -68,7 +68,7 @@ export function FormSection({
<ChevronDown
aria-hidden
className={cn(
"size-4 motion-safe:transition-transform",
"size-4 motion-full:transition-transform motion-full:duration-(--motion-settle)",
open && "rotate-180",
)}
/>
@@ -27,7 +27,7 @@ export interface NumberStepperProps {
}
const STEP_BUTTON_CLASS =
"inline-flex min-h-11 min-w-11 items-center justify-center rounded-xl border border-input bg-card text-xl font-semibold motion-safe:transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50";
"inline-flex min-h-11 min-w-11 items-center justify-center rounded-xl border border-input bg-card text-xl font-semibold transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50";
const MAX_DIGITS = 4;
const MAX_VALUE = 10 ** MAX_DIGITS - 1;
@@ -1,7 +1,8 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ZoomControls } from "./editor-stage-overlay";
import { DragChip, ZoomControls } from "./editor-stage-overlay";
import type { HiveDragView } from "./use-hive-drag";
describe("ZoomControls", () => {
it("zooms in and out in steps", async () => {
@@ -26,3 +27,28 @@ describe("ZoomControls", () => {
expect(screen.getByRole("button", { name: "Verkleinern" })).toHaveAttribute("aria-disabled", "false");
});
});
describe("DragChip", () => {
const drag: HiveDragView = {
source: { partTypeId: "box" },
label: "Zarge 3",
slots: [],
hover: { kind: "stack", index: 0 } as unknown as HiveDragView["hover"],
overRemove: false,
};
it("in sun sits on the same solid plate as the hover label: 16 px, white text, no blur (A12 rule)", () => {
render(<DragChip drag={drag} pointer={{ current: null }} />);
const where = screen.getByText("Loslassen setzt ein");
const chip = where.parentElement as HTMLElement;
const classes = chip.className.split(/\s+/);
for (const sunClass of ["sun:bg-holo-label-bg", "sun:border-holo-edge", "sun:text-base", "sun:backdrop-blur-none", "sun:shadow-none"]) {
expect(classes).toContain(sunClass);
}
// The hint line is full white and 16 px in sun, not the dimmed 13 px of the normal stage.
const whereClasses = where.className.split(/\s+/);
expect(whereClasses).toContain("sun:text-holo-label-fg");
expect(whereClasses).toContain("sun:text-base");
expect(whereClasses).not.toContain("text-xs");
});
});
@@ -3,7 +3,7 @@
import { useEffect, useRef } from "react";
import type { RefObject } from "react";
import { Trash2, ZoomIn, ZoomOut } from "lucide-react";
import { GLASS_BUTTON, GLASS_SURFACE } from "../hive-glass";
import { GLASS_BUTTON, GLASS_SURFACE, HOLO_LABEL_PILL } from "../hive-glass";
import { ZOOM_MAX, ZOOM_MIN, stepZoom } from "../hive-scene/camera-fit";
import type { DragPointer, HiveDragView } from "./use-hive-drag";
@@ -91,10 +91,10 @@ export function DragChip({ drag, pointer }: { drag: HiveDragView; pointer: RefOb
<div
ref={chip}
aria-hidden
className={`pointer-events-none fixed start-0 top-0 z-50 rounded-xl px-3 py-1.5 text-sm font-medium ${GLASS_SURFACE}`}
className={`pointer-events-none fixed start-0 top-0 z-50 ${HOLO_LABEL_PILL}`}
>
{drag.label}
{where ? <span className="block text-xs text-holo-label-fg/80">{where}</span> : null}
{where ? <span className="block text-sm text-holo-label-fg/80 sun:text-base sun:text-holo-label-fg">{where}</span> : null}
</div>
);
}
@@ -135,7 +135,7 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange, initial
<div
role="status"
aria-label="Beute wird geladen"
className="holo-stage min-h-40 flex-1 bg-holo-bg motion-safe:animate-pulse"
className="holo-stage min-h-40 flex-1 bg-holo-bg motion-full:animate-pulse"
/>
);
}
@@ -11,3 +11,10 @@ export const GLASS_SURFACE = `border border-white/15 shadow-[0_8px_24px_-12px_rg
export const GLASS_BUTTON = `inline-flex h-11 shrink-0 items-center justify-center gap-1.5 rounded-xl px-3 text-sm sm:gap-2 sm:px-4 font-medium transition-colors hover:bg-holo-label-bg/90 disabled:cursor-not-allowed disabled:opacity-60 aria-pressed:border-2 aria-pressed:border-holo-edge-strong aria-pressed:bg-holo-edge/25 [&_svg]:size-5 border border-holo-edge/35 shadow-[0_0_18px_-8px_var(--holo-edge)] sun:shadow-none ${GLASS_FILL}`;
export const GLASS_PILL = `inline-flex h-11 shrink-0 items-center gap-2 rounded-full px-4 text-sm font-medium transition-colors hover:bg-holo-label-bg/90 ${GLASS_SURFACE}`;
/**
* A label on the stage: hover labels in the scene and the drag chip in the editor. In sun 16 px on
* a solid plate with white text, without blur and glow (Beute spec 9.3, T5 17.6).
*/
export const HOLO_LABEL_PILL =
"whitespace-nowrap rounded-xl border border-holo-edge/45 bg-holo-label-bg/75 px-3 py-1.5 text-sm font-medium text-holo-label-fg shadow-[0_0_22px_-6px_var(--holo-edge)] backdrop-blur-md sun:border-holo-edge sun:bg-holo-label-bg sun:text-base sun:shadow-none sun:backdrop-blur-none";
@@ -3,13 +3,14 @@
import { Html } from "@react-three/drei";
import type { Vec3 } from "@/lib/hive/layout";
import type { SceneLabel } from "./scene-model";
import { HOLO_LABEL_PILL } from "../hive-glass";
// DOM labels in the scene (spec 9.3): sharp, translatable text in a glass
// pill with a thin leader line. At most three at once (sceneLabels caps it).
// In sun 16 px on a solid plate, without blur and glow (spec 9.3, T5 17.6).
export const HOLO_LABEL_PILL =
"whitespace-nowrap rounded-xl border border-holo-edge/45 bg-holo-label-bg/75 px-3 py-1.5 text-sm font-medium text-holo-label-fg shadow-[0_0_22px_-6px_var(--holo-edge)] backdrop-blur-md sun:border-holo-edge sun:bg-holo-label-bg sun:text-base sun:shadow-none sun:backdrop-blur-none";
// The pill lives in hive-glass.ts (DOM only), so the editor's drag chip can share it without three.
export { HOLO_LABEL_PILL } from "../hive-glass";
export interface HoloLabelProps {
position: Vec3;
@@ -418,6 +418,59 @@ describe("HiveSceneContent in sun: state cues without colour", () => {
expect(idle[0].uniforms.uLinePixels.value).toBe(2);
for (const material of markers) expect(material.uniforms.uTubeCorner.value).toBeCloseTo(0.015 * Math.SQRT2, 9);
});
it("in sun the active slot follows the hover during a drag (white 3 px moves, the rest stays grey 2 px)", async () => {
document.documentElement.setAttribute("data-contrast", "sun");
const state = hiveState();
const layout = hiveLayout(state, catalog);
const slots = dropSlots(state, catalog, { partTypeId: "box" });
expect(slots.length).toBeGreaterThan(1);
const editorFor = (hover: DropSlot | null): SceneEditor => ({
drag: { source: { partTypeId: "box" }, slots, hover },
pointer: { current: null },
subscribePointer: () => () => {},
onHoverSlot: vi.fn(),
onPartPress: vi.fn(),
zoom: 1,
onZoom: vi.fn(),
});
const store = createHiveViewStore();
const scene = (editor: SceneEditor) => (
<HiveViewProvider store={store}>
<SceneKitProvider motion="off">
<HiveSceneContent
layout={layout}
state={state}
catalog={catalog}
partName={(id) => partDisplayName(state, catalog, id)}
onSelectPart={vi.fn()}
onSelectFrame={vi.fn()}
idle={false}
editor={editor}
/>
</SceneKitProvider>
</HiveViewProvider>
);
const renderer = await ReactThreeTestRenderer.create(scene(editorFor(null)));
current = { renderer, store, onSelectPart: vi.fn(), onSelectFrame: vi.fn() };
const activeIndexes = () =>
renderer.scene
.findAll((node) => node.props.name === "drop-slot")
.map((node) => (node.instance as unknown as Mesh).material as ShaderMaterial)
.flatMap((material, index) =>
material.uniforms.uSunGrey.value === 0 ? [{ index, pixels: material.uniforms.uLinePixels.value }] : [],
);
await renderer.advanceFrames(1, 1 / 60);
expect(activeIndexes()).toEqual([]);
await renderer.update(scene(editorFor(slots[1])));
await renderer.advanceFrames(1, 1 / 60);
expect(activeIndexes()).toEqual([{ index: 1, pixels: 3 }]);
await renderer.update(scene(editorFor(slots[0])));
await renderer.advanceFrames(1, 1 / 60);
expect(activeIndexes()).toEqual([{ index: 0, pixels: 3 }]);
});
});
describe("HiveSceneContent drop anchors", () => {
@@ -1,4 +1,5 @@
// Adaptive quality and the flight spring, as pure functions.
import { SPRING_FLIGHT } from "@/lib/motion/tokens";
/** High: reflective floor, MSAA, particles, dispersion, depth of field when fast. Low: none of these. */
export type StageTier = "high" | "low";
@@ -52,8 +53,8 @@ export function createQualityMonitor(
}
/** Spring for flights at motion `full`: a touch of overshoot (damping ratio ≈ 0.72), settled in ~0.5 s. */
export const SPRING_STIFFNESS = 210;
export const SPRING_DAMPING = 21;
export const SPRING_STIFFNESS = SPRING_FLIGHT.stiffness;
export const SPRING_DAMPING = SPRING_FLIGHT.damping;
const SPRING_SUBSTEP = 1 / 120;
const SPRING_EPSILON = 1e-3;
@@ -289,7 +289,7 @@ export function stepToward(current: number, target: number, deltaSeconds: number
}
/** Linear fade used at motion level `subtle` (150 ms). */
export const SUBTLE_FADE_SECONDS = 0.15;
export { SUBTLE_FADE_SECONDS } from "@/lib/motion/tokens";
export function fadeToward(current: number, target: number, deltaSeconds: number, seconds: number): number {
if (seconds <= 0) return target;
@@ -10,12 +10,14 @@ import type { SceneEditor } from "./hive-scene/scene-editor";
import { canUseWebGL, shouldUse3D } from "@/lib/hive/webgl";
import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference";
import type { MotionLevel } from "@/lib/motion/motion-preference";
import { MOTION } from "@/lib/motion/tokens";
import { HiveSvg } from "./hive-svg";
// three, fiber and friends live only in this chunk, fetched when the card is on screen.
const HiveCanvas = dynamic(() => import("./hive-scene/hive-canvas"), { ssr: false });
const FADE_MS: Record<MotionLevel, number> = { full: 400, subtle: 150, off: 0 };
// Full: the stage arrives with its own glow (Beute §9.8); subtle uses the app-wide fade token.
const FADE_MS: Record<MotionLevel, number> = { full: 400, subtle: MOTION.fadeMs, off: 0 };
const FORCED_COLORS_QUERY = "(forced-colors: active)";
/** First context loss remounts the scene once, the second keeps the SVG for good. */
const MAX_CONTEXT_LOSSES = 1;
@@ -88,7 +88,7 @@ export function LabelCompliancePanel({
<div className="flex flex-col gap-3">
{summary ? (
<p aria-live="polite" className="flex items-center gap-2 text-sm font-medium">
{pending ? <Loader aria-hidden className="size-4 animate-spin motion-reduce:animate-none" /> : null}
{pending ? <Loader aria-hidden className="size-4 motion-full:animate-spin" /> : null}
{summary}
</p>
) : null}
@@ -0,0 +1,116 @@
"use client";
import { createContext, useContext, useLayoutEffect, useRef, type ComponentProps, type RefObject } from "react";
import { AnimatePresence, LazyMotion, MotionConfig, domAnimation, useIsPresent } from "motion/react";
import * as m from "motion/react-m";
import type { MotionLevel } from "@/lib/motion/motion-preference";
import { EASE_OUT_EXPO, MOTION, SPRING_EASING_MS, springEasing, transitionFor } from "@/lib/motion/tokens";
/**
* Everything of `motion` the DOM uses, in one module that is only ever loaded with a dynamic
* import (spec 17.6): LazyMotion with its features, MotionConfig per level and the animated list.
* One async chunk for the whole app (AppMotion preloads it), never in a first load, never
* duplicated per page.
*
* Features: domAnimation, not domMax. The `layout` projection of domMax alone is about 14 kB gzip
* more and breaks the 30 kB budget; rows that move up after a removal slide by a measured
* transform instead (FLIP on the flight spring as CSS linear()).
*/
type MotionConfigProps = Omit<Parameters<typeof MotionConfig>[0], "children">;
/**
* Per level (spec 17.2): at "Voll" motion must not ask the system again (the effective level has
* it folded in already); at "Dezent" motion keeps opacity and colour and drops every transform;
* at "Aus" everything is instant.
*/
export const MOTION_CONFIG: Record<MotionLevel, MotionConfigProps> = {
full: { reducedMotion: "never" },
subtle: { reducedMotion: "always" },
off: { reducedMotion: "always", skipAnimations: true, transition: { duration: 0 } },
};
const SPRING_EASING = springEasing();
const SETTLE_EASING = `cubic-bezier(${EASE_OUT_EXPO.join(", ")})`;
/** The motion level, read once per list (not once per row). */
const ListLevel = createContext<MotionLevel>("full");
/**
* Moves an element from `distancePx` lower back to its place on the flight spring (WAAPI, transform
* only). Browsers without CSS `linear()` easing take the settle token instead.
*/
function slideUp(element: HTMLElement, distancePx: number): void {
if (typeof element.animate !== "function") return;
const keyframes = [{ transform: `translateY(${distancePx}px)` }, { transform: "none" }];
try {
element.animate(keyframes, { duration: SPRING_EASING_MS, easing: SPRING_EASING });
} catch {
element.animate(keyframes, { duration: MOTION.settleMs, easing: SETTLE_EASING });
}
}
/**
* Rows that moved up after a render slide from where they were (FLIP). One measurement per row and
* render (offsetTop, which a running transform does not change), then only `transform` moves.
* Leaving rows are out of the flow (popLayout) and hidden, and are not measured.
*/
function useSlideUpAfterRemoval(listRef: RefObject<HTMLUListElement | null>, enabled: boolean): void {
const tops = useRef(new Map<Element, number>());
useLayoutEffect(() => {
const list = listRef.current;
if (!list) return;
const next = new Map<Element, number>();
for (const child of Array.from(list.children)) {
if (!(child instanceof HTMLElement) || child.getAttribute("aria-hidden") === "true") continue;
const top = child.offsetTop;
const before = tops.current.get(child);
if (enabled && before !== undefined && before > top) slideUp(child, before - top);
next.set(child, top);
}
tops.current = next;
});
}
export type MotionListProps = Omit<ComponentProps<"ul">, "ref"> & { level: MotionLevel };
/** The animated PresenceList (see presence-list.tsx for the behaviour per level). */
export function MotionList({ level, children, ...props }: MotionListProps) {
const listRef = useRef<HTMLUListElement>(null);
useSlideUpAfterRemoval(listRef, level === "full");
return (
<LazyMotion features={domAnimation} strict>
<MotionConfig {...MOTION_CONFIG[level]}>
<ListLevel value={level}>
<ul ref={listRef} {...props}>
{/* "Voll": popLayout takes the leaving row out of the flow, positioned against this list.
"Dezent": the row fades in the flow, so it never lies on top of the row below. */}
<AnimatePresence initial={false} mode={level === "full" ? "popLayout" : "sync"}>
{children}
</AnimatePresence>
</ul>
</ListLevel>
</MotionConfig>
</LazyMotion>
);
}
export type MotionItemProps = ComponentProps<"li">;
/** The animated PresenceItem: while it leaves, it is inert and hidden from assistive tech. */
export function MotionItem({ children, ref, ...props }: MotionItemProps) {
const level = useContext(ListLevel);
const isPresent = useIsPresent();
return (
<m.li
{...(props as ComponentProps<typeof m.li>)}
ref={ref}
initial={false}
exit={{ opacity: 0, transition: transitionFor(level, "exit") }}
inert={!isPresent}
aria-hidden={isPresent ? undefined : true}
>
{children}
</m.li>
);
}
@@ -0,0 +1,156 @@
import { useContext } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MotionConfigContext } from "motion/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import { AppMotion } from "@/components/app-motion";
import { setMotionLevel, type MotionLevel } from "@/lib/motion/motion-preference";
import { MOTION, SPRING_EASING_MS, springEasing } from "@/lib/motion/tokens";
import { PresenceItem, PresenceList, loadMotionRuntime } from "./presence-list";
const ROW_PX = 64;
const originalMatchMedia = window.matchMedia;
const originalAnimate = HTMLElement.prototype.animate;
const offsetTop = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetTop");
function List({ items }: { items: string[] }) {
return (
<AppMotion>
<PresenceList>
{items.map((item) => (
<PresenceItem key={item}>{item}</PresenceItem>
))}
</PresenceList>
</AppMotion>
);
}
async function renderAt(level: MotionLevel, items: string[]) {
act(() => setMotionLevel(level));
// AppMotion preloads the chunk with the shell; a list mounted before it would stay plain.
await loadMotionRuntime();
const view = render(<List items={items} />);
await act(async () => {
await loadMotionRuntime();
await new Promise((resolve) => setTimeout(resolve, 50));
});
return view;
}
const row = (label: string) => screen.getByText(label).closest("li") as HTMLElement;
describe("PresenceList (spec 17.4 „Listenzeile weg“)", () => {
// jsdom has no layout: rows get a size, and offsetTop follows the rows still in the flow.
const rowSize = document.createElement("style");
rowSize.textContent = `li { width: 320px; height: ${ROW_PX - 8}px; }`;
let animate: ReturnType<typeof vi.fn>;
beforeEach(() => {
document.head.appendChild(rowSize);
window.localStorage.clear();
window.matchMedia = (() => ({
matches: false,
addEventListener: () => undefined,
removeEventListener: () => undefined,
})) as unknown as typeof window.matchMedia;
Object.defineProperty(HTMLElement.prototype, "offsetTop", {
configurable: true,
get(this: HTMLElement) {
const inFlow = Array.from(this.parentElement?.children ?? []).filter(
(element) => !element.hasAttribute("data-motion-pop-id"),
);
return Math.max(0, inFlow.indexOf(this)) * ROW_PX;
},
});
animate = vi.fn();
HTMLElement.prototype.animate = animate as unknown as typeof HTMLElement.prototype.animate;
});
afterEach(() => {
rowSize.remove();
window.matchMedia = originalMatchMedia;
HTMLElement.prototype.animate = originalAnimate;
if (offsetTop) Object.defineProperty(HTMLElement.prototype, "offsetTop", offsetTop);
delete document.documentElement.dataset.motion;
});
it("a leaving row is inert and hidden from assistive tech", async () => {
const view = await renderAt("full", ["a", "b", "c"]);
const leaving = row("b");
expect(leaving).not.toHaveAttribute("aria-hidden");
view.rerender(<List items={["a", "c"]} />);
expect(leaving).toHaveAttribute("aria-hidden", "true");
expect(leaving).toHaveAttribute("inert");
await waitFor(() => expect(leaving).not.toBeInTheDocument(), { timeout: 1000 });
});
it("at Voll the rows below slide up from where they were, on the flight spring", async () => {
const view = await renderAt("full", ["a", "b", "c"]);
const below = row("c");
view.rerender(<List items={["a", "c"]} />);
const calls = animate.mock.calls.filter(([, options]) => options?.duration === SPRING_EASING_MS);
expect(calls.map(([keyframes]) => keyframes)).toEqual([
[{ transform: `translateY(${ROW_PX}px)` }, { transform: "none" }],
]);
expect(animate.mock.contexts.filter((_, index) => calls.includes(animate.mock.calls[index]))).toEqual([below]);
expect(calls[0][1]).toMatchObject({ easing: springEasing() });
});
it("at Dezent the leaving row only fades in place and nothing slides", async () => {
const view = await renderAt("subtle", ["a", "b", "c"]);
const leaving = row("b");
view.rerender(<List items={["a", "c"]} />);
// In the flow (no popLayout): it does not lie on top of the row below while it fades.
expect(leaving).not.toHaveAttribute("data-motion-pop-id");
expect(animate.mock.calls.filter(([, options]) => options?.duration === SPRING_EASING_MS)).toEqual([]);
await waitFor(() => expect(leaving).not.toBeInTheDocument(), { timeout: MOTION.fadeMs * 6 });
});
it("at Aus the leaving row is gone at once and nothing slides", async () => {
const view = await renderAt("off", ["a", "b", "c"]);
const leaving = row("b");
view.rerender(<List items={["a", "c"]} />);
await waitFor(() => expect(leaving).not.toBeInTheDocument(), { timeout: 50 });
expect(animate.mock.calls.filter(([, options]) => options?.duration === SPRING_EASING_MS)).toEqual([]);
});
it("sets reducedMotion per level and follows a level change in the same tab (spec 17.2)", async () => {
function ConfigProbe() {
const config = useContext(MotionConfigContext);
return (
<li data-testid="config">
{JSON.stringify({ reducedMotion: config.reducedMotion, skip: config.skipAnimations ?? false })}
</li>
);
}
const config = () => JSON.parse(screen.getByTestId("config").textContent ?? "{}");
act(() => setMotionLevel("full"));
await loadMotionRuntime();
render(
<PresenceList>
<ConfigProbe key="probe" />
</PresenceList>,
);
await act(async () => {
await loadMotionRuntime();
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Full: the effective level already folds in the system setting, so motion must not ask again.
expect(config()).toEqual({ reducedMotion: "never", skip: false });
act(() => setMotionLevel("subtle"));
expect(config()).toEqual({ reducedMotion: "always", skip: false });
act(() => setMotionLevel("off"));
expect(config()).toEqual({ reducedMotion: "always", skip: true });
});
it("renders its rows at once", () => {
render(<List items={["a", "b"]} />);
expect(screen.getAllByRole("listitem")).toHaveLength(2);
});
});
@@ -0,0 +1,84 @@
"use client";
import { createContext, useContext, useEffect, useState, type ComponentProps } from "react";
import { cn } from "@/lib/utils";
import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference";
type MotionRuntime = typeof import("./motion-runtime");
let runtime: MotionRuntime | null = null;
let runtimeLoad: Promise<MotionRuntime> | null = null;
/**
* The one dynamic import of `motion` in the DOM (spec 17.6): AppMotion's features and the animated
* list come from the same async chunk, loaded once after the (app) shell has mounted.
*/
export function loadMotionRuntime(): Promise<MotionRuntime> {
runtimeLoad ??= import("./motion-runtime").then((module) => {
runtime = module;
return module;
});
return runtimeLoad;
}
/**
* The runtime if it was there when the list mounted. A list never switches from plain to animated
* while it lives: that would swap its <li> elements and remount every row (focus, open dialogs,
* input). AppMotion preloads the chunk with the shell, so lists that mount later are animated; one
* that mounted earlier stays plain until it mounts again.
*/
function useMotionRuntime(): MotionRuntime | null {
const [loaded] = useState<MotionRuntime | null>(runtime);
useEffect(() => {
// Without the chunk (offline, first visit) lists simply stay plain.
if (!loaded) loadMotionRuntime().catch(() => undefined);
}, [loaded]);
return loaded;
}
const RuntimeContext = createContext<MotionRuntime | null>(null);
/**
* A list whose rows can leave (spec 17.4 „Listenzeile weg“):
* - "Voll": the row fades out of the flow (--motion-fade, ease-in) while the rows below slide up on
* the flight spring, over `transform` only.
* - "Dezent": the row fades in place (it stays in the flow, so nothing overlaps), then the rest
* stands at its place at once.
* - "Aus": the row is gone at once.
* Rows of the first render and rows arriving later appear at once (data stands still, spec 17.5).
* A list that mounts before the motion chunk is there stays plain: rows come and go at once.
*
* Children are PresenceItem rows (directly or inside a row component that passes `ref` on to it),
* each with a stable `key`.
*/
export function PresenceList({ className, children, ...props }: Omit<ComponentProps<"ul">, "ref">) {
const level = useEffectiveMotionLevel();
const loaded = useMotionRuntime();
const classes = cn("relative", className);
if (!loaded) {
return (
<ul className={classes} {...props}>
{children}
</ul>
);
}
return (
<RuntimeContext value={loaded}>
<loaded.MotionList level={level} className={classes} {...props}>
{children}
</loaded.MotionList>
</RuntimeContext>
);
}
type PresenceItemProps = ComponentProps<"li"> & { [data: `data-${string}`]: string | undefined };
/**
* One row of a PresenceList. While it fades out it is inert and hidden from assistive tech; in a
* plain list it is simply gone at once.
*/
export function PresenceItem(props: PresenceItemProps) {
const loaded = useContext(RuntimeContext);
if (!loaded) return <li {...props} />;
return <loaded.MotionItem {...props} />;
}
@@ -61,11 +61,11 @@ async function discardRejectedEntry(nextSummary: { own: number; failed: number;
await screen.findByRole("button", { name: "1 Eintrag abgelehnt – ansehen" }),
);
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
// The list loads with the first opening (lazy body): wait for the entry before the next state.
const discardButton = await within(sheet).findByRole("button", { name: /^Verwerfen: Durchsicht vom / });
vi.mocked(summarizeQueue).mockResolvedValue(nextSummary);
vi.mocked(listQueue).mockResolvedValue(nextSummary.own > 0 ? [WAITING] : []);
await userEvent.click(
await within(sheet).findByRole("button", { name: /^Verwerfen: Durchsicht vom / }),
);
await userEvent.click(discardButton);
const confirm = await screen.findByRole("alertdialog", { name: /^Durchsicht vom .+ verwerfen\?$/ });
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
await waitFor(() => expect(discardQueueItem).toHaveBeenCalledWith(REJECTED));
@@ -251,7 +251,7 @@ describe("OfflineBand", () => {
);
expect(await screen.findByRole("dialog", { name: "Wartende Einträge" })).toBeInTheDocument();
expect(listQueue).toHaveBeenCalledWith("user-1");
await waitFor(() => expect(listQueue).toHaveBeenCalledWith("user-1"));
});
it("closes the sheet once nothing is left and keeps focus in the band while it has content", async () => {
@@ -0,0 +1,517 @@
"use client";
import { useRef, type MouseEvent } from "react";
import Link from "next/link";
import { AlertTriangle, Camera, Clock, Mic, RotateCw } from "lucide-react";
import { toast } from "sonner";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { PresenceItem, PresenceList } from "@/components/motion/presence-list";
import { PhotoThumbnail } from "@/components/photos/photo-capture-field";
import { Button, buttonVariants } from "@/components/ui/button";
import {
discardQueueItem,
listQueue,
retryBulkWithout,
retryInspectionWithoutHiveChange,
retryInspectionWithoutVoice,
type QueueError,
type QueueItem,
} from "@/lib/offline/db";
import {
entriesSynced,
foreignEntriesWaiting,
photosAttached,
queueRejectionText,
VOICE_TOO_MANY_OPEN_HINT,
voiceRejectionText,
} from "@/lib/offline/queue-texts";
import { syncPendingEntries } from "@/lib/offline/sync";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import { discardAllPendingVoice, discardPendingVoice } from "@/lib/voice/pending-voice";
import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store";
interface PendingQueueBodyProps {
userId: string;
/** Entries of another account on this device - shown only as a count. */
foreignCount: number;
onOpenChange: (open: boolean) => void;
}
interface Named {
label: string;
queuedAt: string;
}
const PHOTO_LABEL = "Foto";
const VOICE_STORE = "pending-voice";
/** 75 seconds as "1:15". */
function durationLabel(totalSec: number): string {
const seconds = Math.round(totalSec);
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
}
/** True for a plain left click; ctrl, meta, shift, alt and middle click open a link elsewhere. */
function isPlainLeftClick(event: MouseEvent): boolean {
return (
event.button === 0 &&
!event.defaultPrevented &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey
);
}
/** The recording page, for the colony of a rejected note. */
function recordAgainHref(item: QueueItem): string {
return `/voice/new?${new URLSearchParams({ colonyId: item.voice?.colonyId ?? "" }).toString()}`;
}
function queuedAtLabel(queuedAt: string): string {
return new Date(queuedAt).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
}
/**
* „Durchsicht vom 13.10.26, 10:15:05" - a name per entry for its buttons and
* dialog. Seconds tell apart entries of the same minute; a running number
* „(2)" those queued in the same second.
*/
function entryNames(items: readonly Named[]): string[] {
const seen = new Map<string, number>();
return items.map((item) => {
const time = new Date(item.queuedAt).toLocaleString("de-DE", {
dateStyle: "short",
timeStyle: "medium",
});
const name = `${item.label} vom ${time}`;
const occurrence = (seen.get(name) ?? 0) + 1;
seen.set(name, occurrence);
return occurrence === 1 ? name : `${name} (${occurrence})`;
});
}
/** A queued inspection takes its photos along when it is discarded. */
function discardDescription(item: QueueItem): string {
if (!item.photoCount) {
return "Der Eintrag wird von diesem Gerät gelöscht und nicht mehr gesendet.";
}
const photos = item.photoCount === 1 ? "sein Foto" : `seine ${item.photoCount} Fotos`;
return `Der Eintrag und ${photos} werden von diesem Gerät gelöscht und nicht mehr gesendet.`;
}
/** Rejected with the server's reason, or waiting - in words, never by colour alone. */
function QueueStatus({
lastError,
changesHive = false,
isVoice = false,
}: {
lastError?: QueueError;
changesHive?: boolean;
isVoice?: boolean;
}) {
return lastError ? (
<p className="flex items-start gap-2 text-sm text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>
Abgelehnt:{" "}
{isVoice ? voiceRejectionText(lastError) : queueRejectionText(lastError, changesHive)}
</span>
</p>
) : (
<p className="flex items-start gap-2 text-sm text-muted-foreground">
<Clock className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>Wartet auf Senden</span>
</p>
);
}
/** „Ohne V-3 erneut senden“ - or, when a code is unknown, without naming them. */
function ResendWithoutButton({
resend,
disabled,
onResend,
}: {
resend: NonNullable<QueueItem["resendWithout"]>;
disabled: boolean;
onResend: () => void;
}) {
const isNamed = resend.codes.length === resend.colonyIds.length;
const without = isNamed ? resend.codes.join(", ") : "die abgelehnten Völker";
return (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
disabled={disabled}
onClick={onResend}
>
<RotateCw aria-hidden />
Ohne {without} erneut senden
</Button>
);
}
/**
* What this device still has to send for the signed-in user: each entry with
* its state, a rejected one with the server's reason, and a way to discard it.
* Photos whose inspection is already on the server are listed on their own;
* a rejected one can be sent again. Mounted only while the sheet is open, and
* loaded with it (PendingQueueSheet), so the shell carries none of it.
*/
export function PendingQueueBody({ userId, foreignCount, onOpenChange }: PendingQueueBodyProps) {
const queryClient = useQueryClient();
const { data: items = [], isSuccess } = useQuery({
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "items"],
queryFn: () => listQueue(userId),
// Reads this device's storage, never the network.
networkMode: "always",
});
const { data: photos = [] } = useQuery({
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "photos"],
queryFn: () => listPendingPhotos(userId),
networkMode: "always",
});
const discard = useMutation({
mutationFn: (item: QueueItem) => discardQueueItem(item),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Eintrag verworfen");
},
onError: () => toast.error("Eintrag konnte nicht verworfen werden."),
});
const voiceHeadingRef = useRef<HTMLHeadingElement>(null);
const discardVoice = useMutation({
mutationFn: (noteId: string) => discardPendingVoice(noteId, userId),
networkMode: "always",
onSuccess: () => {
// The discard button is gone: keep the focus inside the list.
voiceHeadingRef.current?.focus();
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Sprachnotiz verworfen");
},
onError: () => toast.error("Sprachnotiz konnte nicht verworfen werden."),
});
const discardAllVoice = useMutation({
mutationFn: () => discardAllPendingVoice(userId),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Sprachnotizen verworfen");
},
onError: () => toast.error("Sprachnotizen konnten nicht verworfen werden."),
});
const discardPhoto = useMutation({
mutationFn: (photoId: string) => discardReadyPhoto(photoId),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Foto verworfen");
},
onError: () => toast.error("Foto konnte nicht verworfen werden."),
});
// „Erneut senden": clear the rejection, then a normal sync run sends it.
const retryPhoto = useMutation({
mutationFn: async (photoId: string) => {
await resetPhotoError(photoId);
// A sync already running may have passed this photo: it runs once more.
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
// New files on the server: document lists may be stale now.
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Foto konnte nicht erneut gesendet werden."),
});
// „Ohne … erneut senden“: the refused colonies leave the bulk entry (same
// key, rejection cleared), then a normal run sends the rest.
const resendBulk = useMutation({
mutationFn: async ({ id, colonyIds }: { id: number; colonyIds: string[] }) => {
await retryBulkWithout(id, colonyIds);
// A sync already running may have passed this entry: it runs once more.
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
});
// „Ohne Beute-Änderung senden“: the hive change leaves the inspection (same
// key, rejection cleared), then a normal run sends it.
const resendWithoutHive = useMutation({
mutationFn: async (id: number) => {
await retryInspectionWithoutHiveChange(id);
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
});
// „Ohne Sprachnotiz senden“: only after the server said the note is another colony's.
const resendWithoutVoice = useMutation({
mutationFn: async (id: number) => {
await retryInspectionWithoutVoice(id);
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
});
const records = items.filter((item) => item.store !== VOICE_STORE);
const voiceNotes = items.filter((item) => item.store === VOICE_STORE);
const names = entryNames(records);
const voiceNames = entryNames(voiceNotes);
const photoNames = entryNames(
photos.map((photo) => ({ label: PHOTO_LABEL, queuedAt: photo.createdAt })),
);
return (
<div className="flex flex-col gap-3 px-4 pb-4">
{isSuccess && items.length === 0 && photos.length === 0 ? (
<p className="text-sm text-muted-foreground">Von dir wartet nichts mehr.</p>
) : null}
<PresenceList className="flex flex-col gap-2">
{records.map((item, index) => (
<PresenceItem
key={`${item.store}-${item.id}`}
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium">{item.label}</p>
<p className="text-sm text-muted-foreground">
Erfasst am {queuedAtLabel(item.queuedAt)}
</p>
{item.photoCount ? (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Camera className="size-4 shrink-0" aria-hidden />
{photosAttached(item.photoCount)}
</p>
) : null}
</div>
<ConfirmDialog
triggerLabel="Verwerfen"
triggerAriaLabel={`Verwerfen: ${names[index]}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${names[index]} verwerfen?`}
description={discardDescription(item)}
confirmLabel="Verwerfen"
onConfirm={() => discard.mutate(item)}
isPending={discard.isPending}
/>
</div>
<QueueStatus lastError={item.lastError} changesHive={item.hasHiveChange} />
{item.canResendWithoutHiveChange && typeof item.id === "number" ? (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
disabled={resendWithoutHive.isPending}
onClick={() => resendWithoutHive.mutate(item.id as number)}
>
<RotateCw aria-hidden />
Ohne Beute-Änderung senden
</Button>
) : null}
{item.canResendWithoutVoice && typeof item.id === "number" ? (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
disabled={resendWithoutVoice.isPending}
onClick={() => resendWithoutVoice.mutate(item.id as number)}
>
<RotateCw aria-hidden />
Ohne Sprachnotiz senden
</Button>
) : null}
{item.resendWithout && typeof item.id === "number" ? (
<ResendWithoutButton
resend={item.resendWithout}
disabled={resendBulk.isPending}
onResend={() =>
resendBulk.mutate({
id: item.id as number,
colonyIds: item.resendWithout?.colonyIds ?? [],
})
}
/>
) : null}
</PresenceItem>
))}
{photos.map((photo, index) => (
<PresenceItem
key={`photo-${photo.id}`}
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
>
<div className="flex items-start gap-3">
<PhotoThumbnail
blob={photo.blob}
alt=""
className="size-14 shrink-0 rounded-lg border border-border/60 object-cover"
/>
<div className="min-w-0">
<p className="font-medium">Foto zur Durchsicht</p>
<p className="text-sm text-muted-foreground">
Aufgenommen am {queuedAtLabel(photo.createdAt)}
</p>
</div>
</div>
<QueueStatus lastError={photo.lastError} />
<div className="flex flex-wrap gap-2">
{photo.lastError ? (
<Button
type="button"
variant="outline"
size="touch"
aria-label={`Erneut senden: ${photoNames[index]}`}
disabled={retryPhoto.isPending}
onClick={() => retryPhoto.mutate(photo.id)}
>
<RotateCw aria-hidden />
Erneut senden
</Button>
) : null}
<ConfirmDialog
triggerLabel="Verwerfen"
triggerAriaLabel={`Verwerfen: ${photoNames[index]}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${photoNames[index]} verwerfen?`}
description="Das Foto wird von diesem Gerät gelöscht und nicht mehr gesendet."
confirmLabel="Verwerfen"
onConfirm={() => discardPhoto.mutate(photo.id)}
isPending={discardPhoto.isPending}
/>
</div>
</PresenceItem>
))}
</PresenceList>
{voiceNotes.length > 0 ? (
<section aria-labelledby="queue-voice-heading" className="flex flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3
id="queue-voice-heading"
ref={voiceHeadingRef}
tabIndex={-1}
className="font-medium"
>
Sprachnotizen
</h3>
{voiceNotes.length > 1 ? (
<ConfirmDialog
triggerLabel="Alle Sprachnotizen verwerfen"
triggerVariant="ghost"
triggerSize="touch"
title="Alle Sprachnotizen verwerfen?"
description="Die Sprachnotizen werden von diesem Gerät gelöscht und nicht mehr gesendet."
confirmLabel="Alle verwerfen"
onConfirm={() => discardAllVoice.mutate()}
isPending={discardAllVoice.isPending}
/>
) : null}
</div>
{voiceNotes.some((item) => item.voice?.blockedBy === "TOO_MANY_OPEN") ? (
<p role="status" className="text-sm text-muted-foreground">
{VOICE_TOO_MANY_OPEN_HINT}
</p>
) : null}
<PresenceList className="flex flex-col gap-2">
{voiceNotes.map((item, index) => (
<PresenceItem
key={`voice-${item.id}`}
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
>
<div className="flex items-start gap-3">
<Mic className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0">
<p className="font-medium">{item.label}</p>
<p className="text-sm text-muted-foreground">
Aufgenommen am {queuedAtLabel(item.queuedAt)}
{item.voice ? ` · Dauer ${durationLabel(item.voice.durationSec)}` : ""}
</p>
</div>
</div>
<QueueStatus lastError={item.lastError} isVoice />
<div className="flex flex-wrap gap-2">
{item.lastError ? (
<Link
href={recordAgainHref(item)}
className={buttonVariants({ variant: "outline", size: "touch" })}
aria-label={`Erneut aufnehmen: ${voiceNames[index]}`}
onClick={(event) => {
// Only a plain click stays in this tab; the refused audio is of no
// use any more and the new recording replaces it.
if (!isPlainLeftClick(event)) return;
discardVoice.mutate(String(item.id));
onOpenChange(false);
}}
>
<RotateCw aria-hidden />
Erneut aufnehmen
</Link>
) : null}
<ConfirmDialog
triggerLabel="Verwerfen"
triggerAriaLabel={`Verwerfen: ${voiceNames[index]}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${voiceNames[index]} verwerfen?`}
description="Die Sprachnotiz wird von diesem Gerät gelöscht und nicht mehr gesendet."
confirmLabel="Verwerfen"
onConfirm={() => discardVoice.mutate(String(item.id))}
isPending={discardVoice.isPending}
/>
</div>
</PresenceItem>
))}
</PresenceList>
</section>
) : null}
{foreignCount > 0 ? (
<p className="text-sm text-muted-foreground">{foreignEntriesWaiting(foreignCount)}</p>
) : null}
</div>
);
}
@@ -1,35 +1,17 @@
"use client";
import { useRef, type ComponentProps, type MouseEvent, type ReactNode } from "react";
import Link from "next/link";
import { AlertTriangle, Camera, Clock, Mic, RotateCw } from "lucide-react";
import { toast } from "sonner";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ComponentProps, ReactNode } from "react";
import dynamic from "next/dynamic";
import { NavSheet } from "@/components/nav/nav-sheet";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { PhotoThumbnail } from "@/components/photos/photo-capture-field";
import { Button, buttonVariants } from "@/components/ui/button";
import {
discardQueueItem,
listQueue,
retryBulkWithout,
retryInspectionWithoutHiveChange,
retryInspectionWithoutVoice,
type QueueError,
type QueueItem,
} from "@/lib/offline/db";
import {
entriesSynced,
foreignEntriesWaiting,
photosAttached,
queueRejectionText,
VOICE_TOO_MANY_OPEN_HINT,
voiceRejectionText,
} from "@/lib/offline/queue-texts";
import { syncPendingEntries } from "@/lib/offline/sync";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import { discardAllPendingVoice, discardPendingVoice } from "@/lib/voice/pending-voice";
import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store";
// The list (with its motion) loads with the first opening, not with the app shell (spec 17.6).
const PendingQueueBody = dynamic(() => import("./pending-queue-body").then((module) => module.PendingQueueBody), {
loading: () => (
<p role="status" className="min-h-5 px-4 pb-4 text-sm text-muted-foreground">
Wird geladen…
</p>
),
});
interface PendingQueueSheetProps {
userId: string;
@@ -43,128 +25,7 @@ interface PendingQueueSheetProps {
finalFocus?: ComponentProps<typeof NavSheet>["finalFocus"];
}
interface Named {
label: string;
queuedAt: string;
}
const PHOTO_LABEL = "Foto";
const VOICE_STORE = "pending-voice";
/** 75 seconds as "1:15". */
function durationLabel(totalSec: number): string {
const seconds = Math.round(totalSec);
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
}
/** True for a plain left click; ctrl, meta, shift, alt and middle click open a link elsewhere. */
function isPlainLeftClick(event: MouseEvent): boolean {
return (
event.button === 0 &&
!event.defaultPrevented &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey
);
}
/** The recording page, for the colony of a rejected note. */
function recordAgainHref(item: QueueItem): string {
return `/voice/new?${new URLSearchParams({ colonyId: item.voice?.colonyId ?? "" }).toString()}`;
}
function queuedAtLabel(queuedAt: string): string {
return new Date(queuedAt).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
}
/**
* „Durchsicht vom 13.10.26, 10:15:05" - a name per entry for its buttons and
* dialog. Seconds tell apart entries of the same minute; a running number
* „(2)" those queued in the same second.
*/
function entryNames(items: readonly Named[]): string[] {
const seen = new Map<string, number>();
return items.map((item) => {
const time = new Date(item.queuedAt).toLocaleString("de-DE", {
dateStyle: "short",
timeStyle: "medium",
});
const name = `${item.label} vom ${time}`;
const occurrence = (seen.get(name) ?? 0) + 1;
seen.set(name, occurrence);
return occurrence === 1 ? name : `${name} (${occurrence})`;
});
}
/** A queued inspection takes its photos along when it is discarded. */
function discardDescription(item: QueueItem): string {
if (!item.photoCount) {
return "Der Eintrag wird von diesem Gerät gelöscht und nicht mehr gesendet.";
}
const photos = item.photoCount === 1 ? "sein Foto" : `seine ${item.photoCount} Fotos`;
return `Der Eintrag und ${photos} werden von diesem Gerät gelöscht und nicht mehr gesendet.`;
}
/** Rejected with the server's reason, or waiting - in words, never by colour alone. */
function QueueStatus({
lastError,
changesHive = false,
isVoice = false,
}: {
lastError?: QueueError;
changesHive?: boolean;
isVoice?: boolean;
}) {
return lastError ? (
<p className="flex items-start gap-2 text-sm text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>
Abgelehnt:{" "}
{isVoice ? voiceRejectionText(lastError) : queueRejectionText(lastError, changesHive)}
</span>
</p>
) : (
<p className="flex items-start gap-2 text-sm text-muted-foreground">
<Clock className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>Wartet auf Senden</span>
</p>
);
}
/** „Ohne V-3 erneut senden“ - or, when a code is unknown, without naming them. */
function ResendWithoutButton({
resend,
disabled,
onResend,
}: {
resend: NonNullable<QueueItem["resendWithout"]>;
disabled: boolean;
onResend: () => void;
}) {
const isNamed = resend.codes.length === resend.colonyIds.length;
const without = isNamed ? resend.codes.join(", ") : "die abgelehnten Völker";
return (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
disabled={disabled}
onClick={onResend}
>
<RotateCw aria-hidden />
Ohne {without} erneut senden
</Button>
);
}
/**
* What this device still has to send for the signed-in user: each entry with
* its state, a rejected one with the server's reason, and a way to discard it.
* Photos whose inspection is already on the server are listed on their own;
* a rejected one can be sent again.
*/
/** The sheet „Wartende Einträge“: what this device still has to send (PendingQueueBody). */
export function PendingQueueSheet({
userId,
foreignCount,
@@ -173,149 +34,6 @@ export function PendingQueueSheet({
trigger,
finalFocus,
}: PendingQueueSheetProps) {
const queryClient = useQueryClient();
const { data: items = [], isSuccess } = useQuery({
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "items"],
queryFn: () => listQueue(userId),
enabled: open,
// Reads this device's storage, never the network.
networkMode: "always",
});
const { data: photos = [] } = useQuery({
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "photos"],
queryFn: () => listPendingPhotos(userId),
enabled: open,
networkMode: "always",
});
const discard = useMutation({
mutationFn: (item: QueueItem) => discardQueueItem(item),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Eintrag verworfen");
},
onError: () => toast.error("Eintrag konnte nicht verworfen werden."),
});
const voiceHeadingRef = useRef<HTMLHeadingElement>(null);
const discardVoice = useMutation({
mutationFn: (noteId: string) => discardPendingVoice(noteId, userId),
networkMode: "always",
onSuccess: () => {
// The discard button is gone: keep the focus inside the list.
voiceHeadingRef.current?.focus();
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Sprachnotiz verworfen");
},
onError: () => toast.error("Sprachnotiz konnte nicht verworfen werden."),
});
const discardAllVoice = useMutation({
mutationFn: () => discardAllPendingVoice(userId),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Sprachnotizen verworfen");
},
onError: () => toast.error("Sprachnotizen konnten nicht verworfen werden."),
});
const discardPhoto = useMutation({
mutationFn: (photoId: string) => discardReadyPhoto(photoId),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Foto verworfen");
},
onError: () => toast.error("Foto konnte nicht verworfen werden."),
});
// „Erneut senden": clear the rejection, then a normal sync run sends it.
const retryPhoto = useMutation({
mutationFn: async (photoId: string) => {
await resetPhotoError(photoId);
// A sync already running may have passed this photo: it runs once more.
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
// New files on the server: document lists may be stale now.
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Foto konnte nicht erneut gesendet werden."),
});
// „Ohne … erneut senden“: the refused colonies leave the bulk entry (same
// key, rejection cleared), then a normal run sends the rest.
const resendBulk = useMutation({
mutationFn: async ({ id, colonyIds }: { id: number; colonyIds: string[] }) => {
await retryBulkWithout(id, colonyIds);
// A sync already running may have passed this entry: it runs once more.
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
});
// „Ohne Beute-Änderung senden“: the hive change leaves the inspection (same
// key, rejection cleared), then a normal run sends it.
const resendWithoutHive = useMutation({
mutationFn: async (id: number) => {
await retryInspectionWithoutHiveChange(id);
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
});
// „Ohne Sprachnotiz senden“: only after the server said the note is another colony's.
const resendWithoutVoice = useMutation({
mutationFn: async (id: number) => {
await retryInspectionWithoutVoice(id);
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
});
const records = items.filter((item) => item.store !== VOICE_STORE);
const voiceNotes = items.filter((item) => item.store === VOICE_STORE);
const names = entryNames(records);
const voiceNames = entryNames(voiceNotes);
const photoNames = entryNames(
photos.map((photo) => ({ label: PHOTO_LABEL, queuedAt: photo.createdAt })),
);
return (
<NavSheet
open={open}
@@ -324,215 +42,8 @@ export function PendingQueueSheet({
title="Wartende Einträge"
finalFocus={finalFocus}
>
<div className="flex flex-col gap-3 px-4 pb-4">
{isSuccess && items.length === 0 && photos.length === 0 ? (
<p className="text-sm text-muted-foreground">Von dir wartet nichts mehr.</p>
) : null}
<ul className="flex flex-col gap-2">
{records.map((item, index) => (
<li
key={`${item.store}-${item.id}`}
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium">{item.label}</p>
<p className="text-sm text-muted-foreground">
Erfasst am {queuedAtLabel(item.queuedAt)}
</p>
{item.photoCount ? (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Camera className="size-4 shrink-0" aria-hidden />
{photosAttached(item.photoCount)}
</p>
) : null}
</div>
<ConfirmDialog
triggerLabel="Verwerfen"
triggerAriaLabel={`Verwerfen: ${names[index]}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${names[index]} verwerfen?`}
description={discardDescription(item)}
confirmLabel="Verwerfen"
onConfirm={() => discard.mutate(item)}
isPending={discard.isPending}
/>
</div>
<QueueStatus lastError={item.lastError} changesHive={item.hasHiveChange} />
{item.canResendWithoutHiveChange && typeof item.id === "number" ? (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
disabled={resendWithoutHive.isPending}
onClick={() => resendWithoutHive.mutate(item.id as number)}
>
<RotateCw aria-hidden />
Ohne Beute-Änderung senden
</Button>
) : null}
{item.canResendWithoutVoice && typeof item.id === "number" ? (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
disabled={resendWithoutVoice.isPending}
onClick={() => resendWithoutVoice.mutate(item.id as number)}
>
<RotateCw aria-hidden />
Ohne Sprachnotiz senden
</Button>
) : null}
{item.resendWithout && typeof item.id === "number" ? (
<ResendWithoutButton
resend={item.resendWithout}
disabled={resendBulk.isPending}
onResend={() =>
resendBulk.mutate({
id: item.id as number,
colonyIds: item.resendWithout?.colonyIds ?? [],
})
}
/>
) : null}
</li>
))}
{photos.map((photo, index) => (
<li
key={`photo-${photo.id}`}
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
>
<div className="flex items-start gap-3">
<PhotoThumbnail
blob={photo.blob}
alt=""
className="size-14 shrink-0 rounded-lg border border-border/60 object-cover"
/>
<div className="min-w-0">
<p className="font-medium">Foto zur Durchsicht</p>
<p className="text-sm text-muted-foreground">
Aufgenommen am {queuedAtLabel(photo.createdAt)}
</p>
</div>
</div>
<QueueStatus lastError={photo.lastError} />
<div className="flex flex-wrap gap-2">
{photo.lastError ? (
<Button
type="button"
variant="outline"
size="touch"
aria-label={`Erneut senden: ${photoNames[index]}`}
disabled={retryPhoto.isPending}
onClick={() => retryPhoto.mutate(photo.id)}
>
<RotateCw aria-hidden />
Erneut senden
</Button>
) : null}
<ConfirmDialog
triggerLabel="Verwerfen"
triggerAriaLabel={`Verwerfen: ${photoNames[index]}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${photoNames[index]} verwerfen?`}
description="Das Foto wird von diesem Gerät gelöscht und nicht mehr gesendet."
confirmLabel="Verwerfen"
onConfirm={() => discardPhoto.mutate(photo.id)}
isPending={discardPhoto.isPending}
/>
</div>
</li>
))}
</ul>
{voiceNotes.length > 0 ? (
<section aria-labelledby="queue-voice-heading" className="flex flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3
id="queue-voice-heading"
ref={voiceHeadingRef}
tabIndex={-1}
className="font-medium"
>
Sprachnotizen
</h3>
{voiceNotes.length > 1 ? (
<ConfirmDialog
triggerLabel="Alle Sprachnotizen verwerfen"
triggerVariant="ghost"
triggerSize="touch"
title="Alle Sprachnotizen verwerfen?"
description="Die Sprachnotizen werden von diesem Gerät gelöscht und nicht mehr gesendet."
confirmLabel="Alle verwerfen"
onConfirm={() => discardAllVoice.mutate()}
isPending={discardAllVoice.isPending}
/>
) : null}
</div>
{voiceNotes.some((item) => item.voice?.blockedBy === "TOO_MANY_OPEN") ? (
<p role="status" className="text-sm text-muted-foreground">
{VOICE_TOO_MANY_OPEN_HINT}
</p>
) : null}
<ul className="flex flex-col gap-2">
{voiceNotes.map((item, index) => (
<li
key={`voice-${item.id}`}
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
>
<div className="flex items-start gap-3">
<Mic className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0">
<p className="font-medium">{item.label}</p>
<p className="text-sm text-muted-foreground">
Aufgenommen am {queuedAtLabel(item.queuedAt)}
{item.voice ? ` · Dauer ${durationLabel(item.voice.durationSec)}` : ""}
</p>
</div>
</div>
<QueueStatus lastError={item.lastError} isVoice />
<div className="flex flex-wrap gap-2">
{item.lastError ? (
<Link
href={recordAgainHref(item)}
className={buttonVariants({ variant: "outline", size: "touch" })}
aria-label={`Erneut aufnehmen: ${voiceNames[index]}`}
onClick={(event) => {
// Only a plain click stays in this tab; the refused audio is of no
// use any more and the new recording replaces it.
if (!isPlainLeftClick(event)) return;
discardVoice.mutate(String(item.id));
onOpenChange(false);
}}
>
<RotateCw aria-hidden />
Erneut aufnehmen
</Link>
) : null}
<ConfirmDialog
triggerLabel="Verwerfen"
triggerAriaLabel={`Verwerfen: ${voiceNames[index]}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${voiceNames[index]} verwerfen?`}
description="Die Sprachnotiz wird von diesem Gerät gelöscht und nicht mehr gesendet."
confirmLabel="Verwerfen"
onConfirm={() => discardVoice.mutate(String(item.id))}
isPending={discardVoice.isPending}
/>
</div>
</li>
))}
</ul>
</section>
) : null}
{foreignCount > 0 ? (
<p className="text-sm text-muted-foreground">{foreignEntriesWaiting(foreignCount)}</p>
) : null}
</div>
{/* Rendered only while the sheet is open (or closing): the body loads with the first opening. */}
<PendingQueueBody userId={userId} foreignCount={foreignCount} onOpenChange={onOpenChange} />
</NavSheet>
);
}
@@ -6,6 +6,15 @@ import { QueryState } from "./query-state";
const base = { isLoading: false, isError: false, isFetching: false, refetch: vi.fn() };
describe("QueryState", () => {
it("keeps the line height for the loading status while its text waits (no jump after 150 ms)", () => {
render(
<QueryState query={{ ...base, data: undefined, isLoading: true }} what="Völker">
{() => <p>Daten</p>}
</QueryState>,
);
expect(screen.getByRole("status")).toHaveClass("min-h-5");
});
it("never shows the empty state for a failed load, and offers a retry", async () => {
const refetch = vi.fn();
render(
@@ -3,6 +3,8 @@
import { ReactNode } from "react";
import { AlertCircle, RotateCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SKELETON_DELAY_MS } from "@/lib/motion/tokens";
import { useAfterDelay } from "@/lib/motion/use-after-delay";
interface ErrorStateProps {
/** What could not be loaded, e.g. "Völker". */
@@ -35,10 +37,12 @@ export function ErrorState({ what, onRetry, isRetrying }: ErrorStateProps) {
);
}
/** The status region is there at once; its line follows after SKELETON_DELAY_MS (no blinking). */
export function LoadingState({ what }: { what: string }) {
const isShown = useAfterDelay(SKELETON_DELAY_MS);
return (
<p role="status" className="text-sm text-muted-foreground">
{what} werden geladen…
<p role="status" className="min-h-5 text-sm text-muted-foreground">
{isShown ? `${what} werden geladen…` : null}
</p>
);
}
@@ -30,7 +30,7 @@ export function StatBarList({ items, emptyText = "Keine Daten vorhanden.", forma
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary motion-full:transition-[width] motion-full:duration-700 motion-full:ease-out"
className="h-full rounded-full bg-primary"
style={{ width: `${item.value > 0 ? Math.max((item.value / max) * 100, 4) : 0}%` }}
/>
</div>
@@ -157,7 +157,7 @@ describe("SuggestionGroup", () => {
setup(THREE_PLAN);
const chevron = screen.getByRole("button", { name: /Einzeln anzeigen/ }).querySelector("svg");
expect(chevron).toHaveClass("motion-safe:transition-transform");
expect(chevron).toHaveClass("motion-full:transition-transform");
expect(chevron).not.toHaveClass("transition-transform");
});
});
@@ -101,7 +101,7 @@ function CombinedRow({
>
<ChevronDown
aria-hidden
className={cn("motion-safe:transition-transform", isOpen && "rotate-180")}
className={cn("motion-full:transition-transform motion-full:duration-(--motion-settle)", isOpen && "rotate-180")}
/>
{isOpen ? "Einzeln ausblenden" : "Einzeln anzeigen"}
</Button>
@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { completeTask } from "@/lib/tasks/api";
import { setMotionLevel } from "@/lib/motion/motion-preference";
import type { Task } from "@/lib/tasks/types";
import { TodayPanel } from "./today-panel";
// The motion chunk never arrives in this file: the list stays plain (the usual first dashboard).
vi.mock("@/components/motion/motion-runtime", () => new Promise(() => undefined));
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ user: { name: "Nick Test" }, hasPermission: () => true }),
useCurrentUserId: () => "user-1",
}));
vi.mock("@/lib/tasks/api", () => ({ completeTask: vi.fn(), updateTask: vi.fn() }));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn().mockResolvedValue([]) }));
vi.mock("sonner", () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }) }));
const NOW = new Date();
const originalMatchMedia = window.matchMedia;
function task(id: string, title: string): Task {
const yesterday = new Date(NOW.getTime() - 24 * 60 * 60 * 1000).toISOString();
return {
id,
title,
description: null,
priority: "NORMAL",
dueDate: yesterday,
status: "OPEN",
assignedUserId: null,
createdByUserId: null,
relatedType: "GENERAL",
relatedId: null,
sourceInspectionId: null,
sourceTemplateId: null,
sourceAutomationTrigger: null,
createdAt: yesterday,
completedAt: null,
};
}
const TASKS = [task("t1", "Erste Aufgabe"), task("t2", "Zweite Aufgabe"), task("t3", "Dritte Aufgabe")];
const tickBox = (title: string) => screen.getByRole("checkbox", { name: `„${title}“ als erledigt markieren` });
function renderPanel(tasks: Task[]) {
render(
<QueryClientProvider client={new QueryClient()}>
<TodayPanel tasks={tasks} isLoading={false} isError={false} onRetry={() => undefined} now={NOW} />
</QueryClientProvider>,
);
}
describe("TodayPanel without the motion chunk (plain list)", () => {
beforeEach(() => {
window.localStorage.clear();
window.matchMedia = (() => ({
matches: false,
addEventListener: () => undefined,
removeEventListener: () => undefined,
})) as unknown as typeof window.matchMedia;
vi.mocked(completeTask).mockReturnValue(new Promise(() => undefined));
});
afterEach(() => {
window.matchMedia = originalMatchMedia;
delete document.documentElement.dataset.motion;
});
it("ticking the focused row moves the focus to the next row's tick", async () => {
act(() => setMotionLevel("full"));
renderPanel([...TASKS, task("t4", "Vierte Aufgabe")]);
tickBox("Zweite Aufgabe").focus();
await userEvent.keyboard(" ");
await waitFor(() => expect(screen.queryByText("Zweite Aufgabe")).not.toBeInTheDocument(), { timeout: 1500 });
expect(tickBox("Dritte Aufgabe")).toHaveFocus();
});
it("ticking the focused last row moves the focus to the list heading", async () => {
act(() => setMotionLevel("off"));
renderPanel(TASKS);
tickBox("Dritte Aufgabe").focus();
await userEvent.keyboard(" ");
await waitFor(() => expect(screen.getByRole("heading", { name: "Außerdem heute" })).toHaveFocus());
});
it("does not take the focus when it was elsewhere", async () => {
act(() => setMotionLevel("off"));
renderPanel(TASKS);
await userEvent.click(tickBox("Zweite Aufgabe"));
const link = screen.getByRole("link", { name: "Alle Aufgaben" });
link.focus();
await waitFor(() => expect(screen.queryByText("Zweite Aufgabe")).not.toBeInTheDocument());
expect(link).toHaveFocus();
});
});
@@ -0,0 +1,188 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AppMotion } from "@/components/app-motion";
import { loadMotionRuntime } from "@/components/motion/presence-list";
import { completeTask } from "@/lib/tasks/api";
import { setMotionLevel, type MotionLevel } from "@/lib/motion/motion-preference";
import type { Task } from "@/lib/tasks/types";
import { TodayPanel } from "./today-panel";
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ user: { name: "Nick Test" }, hasPermission: () => true }),
useCurrentUserId: () => "user-1",
}));
vi.mock("@/lib/tasks/api", () => ({ completeTask: vi.fn(), updateTask: vi.fn() }));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn().mockResolvedValue([]) }));
vi.mock("sonner", () => ({
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
}));
const NOW = new Date();
const originalMatchMedia = window.matchMedia;
function task(id: string, title: string): Task {
const yesterday = new Date(NOW.getTime() - 24 * 60 * 60 * 1000).toISOString();
return {
id,
title,
description: null,
priority: "NORMAL",
dueDate: yesterday,
status: "OPEN",
assignedUserId: null,
createdByUserId: null,
relatedType: "GENERAL",
relatedId: null,
sourceInspectionId: null,
sourceTemplateId: null,
sourceAutomationTrigger: null,
createdAt: yesterday,
completedAt: null,
};
}
// The first task becomes „Als Nächstes“; the others are rows of „Außerdem heute“.
const TASKS = [task("t1", "Erste Aufgabe"), task("t2", "Zweite Aufgabe"), task("t3", "Dritte Aufgabe")];
function renderPanel(tasks: Task[]) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const panel = (list: Task[]) => (
<QueryClientProvider client={client}>
<AppMotion>
<TodayPanel tasks={list} isLoading={false} isError={false} onRetry={() => undefined} now={NOW} />
</AppMotion>
</QueryClientProvider>
);
const view = render(panel(tasks));
return { ...view, rerenderWith: (list: Task[]) => view.rerender(panel(list)) };
}
/** Renders the panel at a motion level with the animation features loaded (LazyMotion is async). */
async function renderAt(level: MotionLevel, tasks: Task[] = TASKS) {
act(() => setMotionLevel(level));
// AppMotion preloads the chunk with the shell; a list mounted before it would stay plain.
await loadMotionRuntime();
const view = renderPanel(tasks);
await act(async () => {
await loadMotionRuntime();
await new Promise((resolve) => setTimeout(resolve, 50));
});
return view;
}
const rowOf = (title: string) => screen.getByText(title).closest("li") as HTMLElement;
const tickBox = (title: string) =>
screen.getByRole("checkbox", { name: `„${title}“ als erledigt markieren` });
describe("TodayPanel – a ticked row leaves (spec 17.4)", () => {
// jsdom has no layout: give the rows a size, so the leaving row can be taken out of the flow.
const rowSize = document.createElement("style");
rowSize.textContent = "li { width: 320px; height: 56px; }";
beforeEach(() => {
document.head.appendChild(rowSize);
window.localStorage.clear();
window.matchMedia = (() => ({
matches: false,
addEventListener: () => undefined,
removeEventListener: () => undefined,
})) as unknown as typeof window.matchMedia;
vi.mocked(completeTask).mockReset();
});
afterEach(() => {
rowSize.remove();
window.matchMedia = originalMatchMedia;
delete document.documentElement.dataset.motion;
});
it("a ticked task leaves after the moment even when the network is slow (nothing waits for a click)", async () => {
// The server never answers: the row must not wait for it.
vi.mocked(completeTask).mockReturnValue(new Promise(() => undefined));
await renderAt("full");
const leaving = rowOf("Zweite Aufgabe");
const clicked = performance.now();
await userEvent.click(tickBox("Zweite Aufgabe"));
// The honey-check moment first: the row is still there and ticked.
expect(leaving).toBeInTheDocument();
expect(tickBox("Zweite Aufgabe")).toBeChecked();
// Then, after EXIT_DELAY_MS (720 ms at Voll) and not later, it fades out of the flow
// (popLayout) while the rows below move up - the server still has not answered.
await waitFor(() => expect(leaving).toHaveAttribute("data-motion-pop-id"), { timeout: 1500 });
const leftAfter = performance.now() - clicked;
expect(leftAfter).toBeGreaterThanOrEqual(700);
expect(leftAfter).toBeLessThan(1400);
await waitFor(() => expect(leaving).not.toBeInTheDocument(), { timeout: 1500 });
expect(screen.getByText("Dritte Aufgabe")).toBeInTheDocument();
});
it("at off the ticked task is gone at once, before the server answers", async () => {
vi.mocked(completeTask).mockReturnValue(new Promise(() => undefined));
await renderAt("off");
const leaving = rowOf("Zweite Aufgabe");
await userEvent.click(tickBox("Zweite Aufgabe"));
await waitFor(() => expect(leaving).not.toBeInTheDocument(), { timeout: 50 });
expect(screen.getByText("Dritte Aufgabe")).toBeInTheDocument();
});
it("a failed save brings the ticked task back", async () => {
vi.mocked(completeTask).mockRejectedValue(new Error("offline"));
await renderAt("off");
await userEvent.click(tickBox("Zweite Aufgabe"));
await waitFor(() => expect(tickBox("Zweite Aufgabe")).not.toBeChecked());
expect(tickBox("Zweite Aufgabe")).toBeEnabled();
});
it("when the focused row leaves, the focus moves to the next row's checkbox", async () => {
vi.mocked(completeTask).mockReturnValue(new Promise(() => undefined));
await renderAt("off", [...TASKS, task("t4", "Vierte Aufgabe")]);
tickBox("Zweite Aufgabe").focus();
await userEvent.keyboard(" ");
await waitFor(() => expect(tickBox("Dritte Aufgabe")).toHaveFocus());
});
it("when the focused last row leaves, the focus moves to the list heading", async () => {
vi.mocked(completeTask).mockReturnValue(new Promise(() => undefined));
await renderAt("off");
tickBox("Dritte Aufgabe").focus();
await userEvent.keyboard(" ");
await waitFor(() => expect(screen.getByRole("heading", { name: "Außerdem heute" })).toHaveFocus());
});
it("a ticked task never collapses grid-template-rows", () => {
renderPanel(TASKS);
const row = screen.getByText("Zweite Aufgabe").closest("li") as HTMLElement;
expect(row.className).not.toMatch(/today-row/);
expect(row).not.toHaveAttribute("data-leaving");
const globals = readFileSync(join(__dirname, "..", "..", "app", "globals.css"), "utf8");
expect(globals).not.toMatch(/\.today-row|grid-template-rows/);
});
it("the loading skeleton appears after 150 ms and pulses only at full motion", async () => {
render(
<QueryClientProvider client={new QueryClient()}>
<TodayPanel tasks={undefined} isLoading isError={false} onRetry={() => undefined} now={NOW} />
</QueryClientProvider>,
);
const skeleton = screen.getByLabelText("Aufgaben werden geladen");
// A quick load never blinks: nothing is drawn at first.
expect(skeleton.children).toHaveLength(0);
await waitFor(() => expect(skeleton.children).toHaveLength(3));
for (const block of Array.from(skeleton.children)) {
expect(block.className).toMatch(/(?:^|\s)motion-full:animate-pulse(?:\s|$)/);
expect(block.className).not.toMatch(/(?:^|\s)animate-pulse/);
}
});
});
@@ -1,11 +1,13 @@
"use client";
import { useRef, type Ref } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, Check, ClipboardPlus, Lightbulb, ListPlus, Play, Sun } from "lucide-react";
import { Task, TaskRelatedType } from "@/lib/tasks/types";
import { dueState, pickNextStep, relatedHref, tasksForToday } from "@/lib/tasks/today";
import { CheckboxField } from "@/components/form/checkbox-field";
import { PresenceItem, PresenceList } from "@/components/motion/presence-list";
import { Button } from "@/components/ui/button";
import { DueChip, TaskSourceBadge, TaskStatusPill } from "@/components/task-status";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
@@ -19,6 +21,8 @@ import { useAuth } from "@/lib/auth/auth-context";
import { fetchColonies } from "@/lib/colonies/api";
import { todayLocalIsoDate } from "@/lib/dates";
import { INTENT_ICONS } from "@/lib/suggestions/labels";
import { SKELETON_DELAY_MS } from "@/lib/motion/tokens";
import { useAfterDelay } from "@/lib/motion/use-after-delay";
import { InspectionDueCard } from "./inspection-due-card";
import { useTaskActions } from "./use-task-actions";
@@ -65,21 +69,29 @@ export function TodayPanel({
now,
autopilot = null,
}: TodayPanelProps) {
const actions = useTaskActions();
// The row that holds the focus (also after its tick turned disabled and dropped it to body).
const focusedRowId = useRef<string | null>(null);
const actions = useTaskActions({
// Decided when the row is marked gone, with or without the motion chunk.
onGone: (id) => {
if (focusedRowId.current !== id) return;
focusedRowId.current = null;
focusAfterLeaving(id);
},
});
const onRowFocus = (id: string, focused: boolean) => {
if (focused) focusedRowId.current = id;
else if (focusedRowId.current === id) focusedRowId.current = null;
};
const stepActions = useStepActions("next-step-heading");
const { hasPermission } = useAuth();
const colonyCodes = useColonyCodes(loadedTasks);
// The autopilot is enough to work with when the task list is missing.
const tasks = loadedTasks ?? (autopilot ? [] : undefined);
// A ticked task whose moment is over is gone at once, whatever the server is still doing.
const tasks = (loadedTasks ?? (autopilot ? [] : undefined))?.filter((task) => !actions.goneIds.has(task.id));
if (isLoading) {
return (
<div aria-busy="true" aria-label="Aufgaben werden geladen" className="flex flex-col gap-3">
<div className="h-40 animate-pulse rounded-2xl bg-muted/70" />
<div className="h-14 animate-pulse rounded-xl bg-muted/50" />
<div className="h-14 animate-pulse rounded-xl bg-muted/40" />
</div>
);
return <TodaySkeleton />;
}
if (!tasks || (isError && !autopilot)) {
@@ -153,7 +165,7 @@ export function TodayPanel({
<section aria-labelledby="today-heading" className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-3">
<h2 id="today-heading" className="font-heading text-lg font-semibold tracking-tight">
<h2 id="today-heading" tabIndex={-1} className="font-heading text-lg font-semibold tracking-tight">
Außerdem heute
</h2>
<Link
@@ -178,10 +190,11 @@ export function TodayPanel({
Sonst ist für heute nichts fällig. Ein Schritt reicht.
</p>
) : (
<ul className="flex flex-col gap-2">
<PresenceList className="flex flex-col gap-2">
{visible.map((task) => (
<TodayRow
key={task.id}
onFocusChange={(focused) => onRowFocus(task.id, focused)}
task={task}
now={now}
leaving={actions.leavingIds.has(task.id)}
@@ -189,7 +202,7 @@ export function TodayPanel({
onComplete={() => actions.complete(task)}
/>
))}
</ul>
</PresenceList>
)}
{hidden > 0 ? (
<Link
@@ -383,51 +396,100 @@ interface TodayRowProps {
/** Code of the colony the task is about, when known. */
colonyCode?: string;
onComplete: () => void;
/** Focus entered the row (true) or moved elsewhere on purpose (false). */
onFocusChange: (focused: boolean) => void;
/** Handed on by the PresenceList: the leaving row is taken out of the flow by its element. */
ref?: Ref<HTMLLIElement>;
}
function TodayRow({ task, now, leaving, colonyCode, onComplete }: TodayRowProps) {
/**
* One row of „Außerdem heute“. Ticked, it shows the honey-check moment and stays until the list is
* refreshed (EXIT_DELAY_MS in use-task-actions.ts); then it fades out and the rows below move up.
*/
function TodayRow({ task, now, leaving, colonyCode, onComplete, onFocusChange, ref }: TodayRowProps) {
const href = relatedHref(task);
const { isOffline, hintId, describedBy } = useOnlineOnly();
return (
<li className="today-row" data-leaving={leaving}>
<div>
<div className="flex items-center gap-2 rounded-xl border border-border/60 bg-card py-1.5 pr-2 pl-1 shadow-[var(--shadow-soft)]">
<CheckboxField
appearance="task"
label={`„${task.title}“ als erledigt markieren`}
checked={leaving}
disabled={leaving || isOffline}
describedBy={describedBy}
onCheckedChange={(next) => {
if (next) onComplete();
}}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1 py-1">
<span className="line-clamp-2 font-medium">{task.title}</span>
<span className="flex flex-wrap items-center gap-1.5">
{colonyCode ? (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium">
{colonyCode}
</span>
) : null}
{task.status === "IN_PROGRESS" ? <TaskStatusPill status={task.status} /> : null}
<DueChip state={dueState(task, now)} dueDate={task.dueDate} />
<TaskSourceBadge task={task} />
</span>
<OnlineOnlyHint id={hintId} />
</div>
{href ? (
<Link
href={href}
aria-label={OPEN_RELATED_LABEL[task.relatedType]}
className="grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ArrowRight className="size-5" aria-hidden />
</Link>
) : null}
<PresenceItem
ref={ref}
data-task-row={task.id}
onFocus={() => onFocusChange(true)}
onBlur={(event) => {
// A disabled tick drops the focus to body (no relatedTarget): that still counts as here.
if (event.relatedTarget && !event.currentTarget.contains(event.relatedTarget)) onFocusChange(false);
}}
>
<div className="flex items-center gap-2 rounded-xl border border-border/60 bg-card py-1.5 pr-2 pl-1 shadow-[var(--shadow-soft)]">
<CheckboxField
appearance="task"
label={`„${task.title}“ als erledigt markieren`}
checked={leaving}
disabled={leaving || isOffline}
describedBy={describedBy}
onCheckedChange={(next) => {
if (next) onComplete();
}}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1 py-1">
<span className="line-clamp-2 font-medium">{task.title}</span>
<span className="flex flex-wrap items-center gap-1.5">
{colonyCode ? (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium">
{colonyCode}
</span>
) : null}
{task.status === "IN_PROGRESS" ? <TaskStatusPill status={task.status} /> : null}
<DueChip state={dueState(task, now)} dueDate={task.dueDate} />
<TaskSourceBadge task={task} />
</span>
<OnlineOnlyHint id={hintId} />
</div>
{href ? (
<Link
href={href}
aria-label={OPEN_RELATED_LABEL[task.relatedType]}
className="grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ArrowRight className="size-5" aria-hidden />
</Link>
) : null}
</div>
</li>
</PresenceItem>
);
}
/**
* The focused row leaves: the focus goes to the tick of the next row still in the list, or to the
* list heading when it was the last one, so keyboard and screen reader users stay in place. Runs
* when the row is marked gone (still in the DOM), with or without the motion chunk.
*/
function focusAfterLeaving(id: string): void {
const row = Array.from(document.querySelectorAll<HTMLElement>("[data-task-row]")).find(
(element) => element.dataset.taskRow === id,
);
if (!row) return;
const active = document.activeElement;
if (active && active !== document.body && !row.contains(active)) return;
let next = row.nextElementSibling;
while (next && next.getAttribute("aria-hidden") === "true") next = next.nextElementSibling;
const tick = next?.querySelector<HTMLElement>('[role="checkbox"]:not([disabled])');
(tick ?? document.getElementById("today-heading"))?.focus();
}
/** Appears after SKELETON_DELAY_MS so a quick load never blinks; pulses only at full motion. */
function TodaySkeleton() {
const isShown = useAfterDelay(SKELETON_DELAY_MS);
return (
<div aria-busy="true" aria-label="Aufgaben werden geladen" className="flex min-h-74 flex-col gap-3">
{isShown ? (
<>
<div className="h-40 rounded-2xl bg-muted/70 motion-full:animate-pulse" />
<div className="h-14 rounded-xl bg-muted/50 motion-full:animate-pulse" />
<div className="h-14 rounded-xl bg-muted/40 motion-full:animate-pulse" />
</>
) : null}
</div>
);
}
@@ -1,13 +1,15 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { completeTask, updateTask } from "@/lib/tasks/api";
import { Task } from "@/lib/tasks/types";
import { effectiveMotionLevel } from "@/lib/motion/motion-preference";
// How long the honey cell fill + row fold take per motion level (globals.css).
// How long a ticked row stays per motion level before it leaves the list: the honey-check
// moment (--motion-moment, 420 ms) plus a beat to see the tick at full motion. The row leaves on
// this timer, never on the server's answer (spec 17.5: no click waits).
const EXIT_DELAY_MS = { full: 720, subtle: 200, off: 0 } as const;
const UNDO_WINDOW_MS = 10_000;
@@ -15,15 +17,70 @@ function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function toggled(ids: ReadonlySet<string>, id: string, on: boolean): ReadonlySet<string> {
if (ids.has(id) === on) return ids;
const next = new Set(ids);
if (on) next.add(id);
else next.delete(id);
return next;
}
/**
* Complete / start / undo for tasks on the start page. Completing shows the
* tick immediately, lets the row fold away and only then refreshes the list,
* so the finished task visibly leaves instead of blinking out. Every
* completion can be taken back from the toast.
* tick immediately; after the moment (EXIT_DELAY_MS) the row leaves the list
* (`goneIds`), however long the server takes. The list is refreshed after both.
* A failed save or an undo brings the row back. Every completion can be taken
* back from the toast.
*/
export function useTaskActions() {
interface TaskActionsOptions {
/** Called right before a ticked task leaves the list (the row is still in the DOM). */
onGone?: (id: string) => void;
}
export function useTaskActions({ onGone }: TaskActionsOptions = {}) {
const queryClient = useQueryClient();
const onGoneRef = useRef(onGone);
useEffect(() => {
onGoneRef.current = onGone;
});
const [leavingIds, setLeavingIds] = useState<ReadonlySet<string>>(new Set());
const [goneIds, setGoneIds] = useState<ReadonlySet<string>>(new Set());
const goneTimers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
useEffect(() => {
const timers = goneTimers.current;
return () => timers.forEach((timer) => clearTimeout(timer));
}, []);
const setGone = (id: string, gone: boolean) => setGoneIds((current) => toggled(current, id, gone));
const markGone = (id: string) => {
onGoneRef.current?.(id);
setGone(id, true);
};
/** The row leaves after the moment of the current motion level ("Aus": at once). */
const leaveAfterMoment = (id: string) => {
const delay = EXIT_DELAY_MS[effectiveMotionLevel()];
if (delay === 0) {
markGone(id);
return;
}
goneTimers.current.set(
id,
setTimeout(() => {
goneTimers.current.delete(id);
markGone(id);
}, delay),
);
};
const comeBack = (id: string) => {
clearTimeout(goneTimers.current.get(id));
goneTimers.current.delete(id);
setGone(id, false);
setLeaving(id, false);
};
const refresh = () =>
Promise.all([
@@ -31,19 +88,15 @@ export function useTaskActions() {
queryClient.invalidateQueries({ queryKey: ["dashboard"] }),
]);
const setLeaving = (id: string, leaving: boolean) =>
setLeavingIds((current) => {
const next = new Set(current);
if (leaving) next.add(id);
else next.delete(id);
return next;
});
function setLeaving(id: string, leaving: boolean) {
setLeavingIds((current) => toggled(current, id, leaving));
}
const undo = useMutation({
mutationFn: (task: Task) => updateTask(task.id, { status: task.status }),
onSuccess: async (_result, task) => {
await refresh();
setLeaving(task.id, false);
comeBack(task.id);
toast.success("Wieder offen");
},
onError: () =>
@@ -51,6 +104,7 @@ export function useTaskActions() {
});
const complete = useMutation({
// The refresh waits for the moment too, so fresh data never cuts the tick short.
mutationFn: async (task: Task) => {
const [done] = await Promise.all([
completeTask(task.id),
@@ -58,7 +112,10 @@ export function useTaskActions() {
]);
return done;
},
onMutate: (task) => setLeaving(task.id, true),
onMutate: (task) => {
setLeaving(task.id, true);
leaveAfterMoment(task.id);
},
onSuccess: async (_done, task) => {
await refresh();
toast.success(`Erledigt: ${task.title}`, {
@@ -68,7 +125,7 @@ export function useTaskActions() {
});
},
onError: (_error, task) => {
setLeaving(task.id, false);
comeBack(task.id);
toast.error("Konnte nicht als erledigt gespeichert werden. Bitte noch einmal tippen.");
},
});
@@ -84,6 +141,8 @@ export function useTaskActions() {
return {
leavingIds,
/** Ticked tasks whose moment is over: they are no longer shown, whatever the server says. */
goneIds,
complete: (task: Task) => complete.mutate(task),
start: (task: Task) => start.mutate(task),
isStarting: start.isPending,
+1 -1
View File
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5.5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-semibold whitespace-nowrap transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive [&>svg]:pointer-events-none [&>svg]:size-3!",
"group/badge inline-flex h-5.5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-semibold whitespace-nowrap transition-colors has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
@@ -9,7 +9,7 @@ const HIT_AREA =
"relative pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-11 pointer-coarse:after:-translate-1/2"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all select-none motion-full:active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,opacity,scale] duration-100 select-none motion-full:active:not-aria-[haspopup]:scale-[0.97] disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
+2 -2
View File
@@ -16,9 +16,9 @@ function Card({
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-clip rounded-2xl border border-border/50 bg-card py-(--card-spacing) text-sm text-card-foreground shadow-[var(--shadow-soft)] transition-all duration-200 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-2xl *:[img:last-child]:rounded-b-2xl",
"group/card flex flex-col gap-(--card-spacing) overflow-clip rounded-2xl border border-border/50 bg-card py-(--card-spacing) text-sm text-card-foreground shadow-[var(--shadow-soft)] [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-2xl *:[img:last-child]:rounded-b-2xl",
interactive &&
"motion-safe:hover:-translate-y-0.5 hover:shadow-[var(--shadow-elevated)] hover:border-border",
"transition-colors hover:border-border",
className
)}
{...props}
@@ -31,7 +31,7 @@ function DialogOverlay({
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
"fixed inset-0 isolate z-50 bg-black/10 transition-opacity duration-(--motion-enter) ease-(--ease-out-expo) data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-(--motion-fade) data-ending-style:ease-(--ease-in) supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
@@ -53,7 +53,7 @@ function DialogContent({
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-2xl bg-popover p-5 text-sm text-popover-foreground shadow-[var(--shadow-elevated)] ring-1 ring-foreground/10 max-h-[calc(100dvh-2rem)] overflow-y-auto overscroll-contain duration-100 sm:max-w-sm data-open:animate-in data-open:fade-in-0 motion-full:data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 motion-full:data-closed:zoom-out-95",
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-2xl bg-popover p-5 text-sm text-popover-foreground shadow-[var(--shadow-elevated)] ring-1 ring-foreground/10 max-h-[calc(100dvh-2rem)] overflow-y-auto overscroll-contain sm:max-w-sm transition-[opacity,scale] duration-(--motion-enter) ease-(--ease-out-expo) data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-(--motion-fade) data-ending-style:ease-(--ease-in) motion-full:data-starting-style:scale-[0.97] motion-full:data-ending-style:scale-[0.97]",
className
)}
{...props}
@@ -41,7 +41,7 @@ function DropdownMenuContent({
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 transition-[opacity,scale] duration-(--motion-enter) ease-(--ease-out-expo) data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-(--motion-fade) data-ending-style:ease-(--ease-in) motion-full:data-starting-style:scale-[0.97] motion-full:data-ending-style:scale-[0.97] data-closed:overflow-hidden", className )}
{...props}
/>
</MenuPrimitive.Positioner>
@@ -135,7 +135,7 @@ function DropdownMenuSubContent({
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 transition-[opacity,scale] duration-(--motion-enter) ease-(--ease-out-expo) data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-(--motion-fade) data-ending-style:ease-(--ease-in) motion-full:data-starting-style:scale-[0.97] motion-full:data-ending-style:scale-[0.97]", className )}
align={align}
alignOffset={alignOffset}
side={side}
+1 -1
View File
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"h-11 w-full min-w-0 pointer-fine:h-9 rounded-xl border border-input bg-card px-3 py-1 text-base shadow-xs transition-all file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground hover:border-foreground/20 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive",
"h-11 w-full min-w-0 pointer-fine:h-9 rounded-xl border border-input bg-card px-3 py-1 text-base shadow-xs transition-colors file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground hover:border-foreground/20 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive",
className
)}
{...props}
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
function StatusSelect() {
return (
<Select defaultValue="all">
<SelectTrigger aria-label="Status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle Status</SelectItem>
<SelectItem value="active">Aktiv</SelectItem>
</SelectContent>
</Select>
);
}
/** Listboxes a screen reader could still reach: not inside a hidden or inert ancestor. */
function reachableListboxes(): Element[] {
return Array.from(document.querySelectorAll('[role="listbox"]')).filter(
(element) => !element.closest("[hidden], [inert], [aria-hidden='true']"),
);
}
describe("SelectContent (spec 17.4)", () => {
it("fades in and out with the overlay tokens, also when aligned with the trigger", async () => {
render(<StatusSelect />);
await userEvent.click(screen.getByRole("combobox", { name: "Status" }));
const popup = await screen.findByRole("listbox");
const classes = (popup.closest('[data-slot="select-content"]') ?? popup).className;
expect(classes).toMatch(/duration-\(--motion-enter\)/);
expect(classes).toMatch(/data-ending-style:duration-\(--motion-fade\)/);
expect(classes).not.toMatch(/transition-none/);
// Aligned with the trigger it lies over it: fade only, no scale.
expect(classes).toMatch(/data-\[align-trigger=true\]:transition-opacity/);
});
it("leaves no listbox a screen reader could reach after closing", async () => {
render(<StatusSelect />);
await userEvent.click(screen.getByRole("combobox", { name: "Status" }));
await userEvent.click(await screen.findByRole("option", { name: "Aktiv" }));
await waitFor(() => expect(reachableListboxes()).toEqual([]));
});
});
@@ -41,7 +41,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-xl border border-input bg-card py-2 pr-2.5 pl-3 text-sm whitespace-nowrap shadow-xs transition-all select-none hover:border-foreground/20 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive data-placeholder:text-muted-foreground data-[size=default]:h-11 pointer-fine:data-[size=default]:h-9 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"flex w-fit items-center justify-between gap-1.5 rounded-xl border border-input bg-card py-2 pr-2.5 pl-3 text-sm whitespace-nowrap shadow-xs transition-colors select-none hover:border-foreground/20 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive data-placeholder:text-muted-foreground data-[size=default]:h-11 pointer-fine:data-[size=default]:h-9 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@@ -83,7 +83,7 @@ function SelectContent({
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-xl bg-popover p-1 text-popover-foreground shadow-[var(--shadow-elevated)] ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-xl bg-popover p-1 text-popover-foreground shadow-[var(--shadow-elevated)] ring-1 ring-foreground/10 transition-[opacity,scale] duration-(--motion-enter) ease-(--ease-out-expo) data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-(--motion-fade) data-ending-style:ease-(--ease-in) motion-full:data-starting-style:scale-[0.97] motion-full:data-ending-style:scale-[0.97] data-[align-trigger=true]:transition-opacity", className )}
{...props}
>
<SelectScrollUpButton />
+2 -2
View File
@@ -28,7 +28,7 @@ function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-(--motion-enter) ease-(--ease-out-expo) data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-(--motion-fade) data-ending-style:ease-(--ease-in) supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
@@ -53,7 +53,7 @@ function SheetContent({
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t motion-full:data-[side=bottom]:data-ending-style:translate-y-[2.5rem] motion-full:data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r motion-full:data-[side=left]:data-ending-style:translate-x-[-2.5rem] motion-full:data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l motion-full:data-[side=right]:data-ending-style:translate-x-[2.5rem] motion-full:data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b motion-full:data-[side=top]:data-ending-style:translate-y-[-2.5rem] motion-full:data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition-[opacity,translate] duration-(--motion-enter) ease-(--ease-out-expo) data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-(--motion-fade) data-ending-style:ease-(--ease-in) data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t motion-full:data-[side=bottom]:data-ending-style:translate-y-[2.5rem] motion-full:data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r motion-full:data-[side=left]:data-ending-style:translate-x-[-2.5rem] motion-full:data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l motion-full:data-[side=right]:data-ending-style:translate-x-[2.5rem] motion-full:data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b motion-full:data-[side=top]:data-ending-style:translate-y-[-2.5rem] motion-full:data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
@@ -35,7 +35,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
<Loader2Icon className="size-4 motion-full:animate-spin" />
),
}}
style={
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-xl border border-input bg-card px-3 py-2 text-base shadow-xs transition-all placeholder:text-muted-foreground hover:border-foreground/20 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive",
"flex field-sizing-content min-h-16 w-full rounded-xl border border-input bg-card px-3 py-2 text-base shadow-xs transition-colors placeholder:text-muted-foreground hover:border-foreground/20 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive",
className
)}
{...props}
@@ -100,7 +100,9 @@ describe("ui sizes, type and focus (spec 4.5, 4.6)", () => {
it("the press nudge of a button only moves with the motion level full", () => {
render(<Button>Los</Button>);
const classes = screen.getByRole("button", { name: "Los" }).className;
expect(has(classes, "motion-full:active:not-aria-[haspopup]:translate-y-px")).toBe(true);
expect(classes).not.toMatch(/(^|\s)active:not-aria-\[haspopup\]:translate-y-px/);
// Spec 17.4: the button gives way (scale 0.97, 100 ms) at full motion only.
expect(has(classes, "motion-full:active:not-aria-[haspopup]:scale-[0.97]")).toBe(true);
expect(classes).not.toMatch(/(^|\s)active:not-aria-\[haspopup\]:(?:scale|translate)/);
expect(classes).not.toMatch(/translate-y-px|transition-all/);
});
});
@@ -53,7 +53,7 @@ function StatusLine({ status }: { status: VoiceStatusText }) {
return (
<p className="flex items-center gap-2 text-sm font-medium">
<Icon
className={`size-4 shrink-0${status.icon === "work" ? " motion-safe:animate-spin" : ""}`}
className={`size-4 shrink-0${status.icon === "work" ? " motion-full:animate-spin" : ""}`}
aria-hidden
/>
{status.label}
@@ -98,7 +98,7 @@ describe("VoiceRecorder", () => {
await startAndRecord(user);
const dot = screen.getByText("Aufnahme läuft", { selector: "p" }).querySelector("span");
expect(dot?.className).toContain("motion-safe:animate-pulse");
expect(dot?.className).toContain("motion-full:animate-pulse");
expect(dot?.className.split(" ")).not.toContain("animate-pulse");
});
@@ -378,7 +378,7 @@ export function VoiceRecorder({
<p className="flex items-center gap-2 text-lg font-semibold text-destructive">
<span
aria-hidden
className="size-3 rounded-full bg-destructive motion-safe:animate-pulse"
className="size-3 rounded-full bg-destructive motion-full:animate-pulse"
/>
Aufnahme läuft
</p>