feat(t5): Bewegungs-Sprache mit Tokens, Stufen und Budget (T5a A11)
- 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:
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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user