diff --git a/src/App.css b/src/App.css index fb77e43d..ba3f3ec8 100644 --- a/src/App.css +++ b/src/App.css @@ -2619,7 +2619,34 @@ body.has-player .chat-fab { } } -/* Below-header search row — small viewports only */ +/* Below-header search row — small viewports only. The reveal wrapper eases + its single grid track between 0fr and 1fr so the row (theme borders and + all) slides open and shut; the clip keeps the collapsed track at zero. */ +.header-search-reveal { + display: grid; + grid-template-rows: 0fr; + opacity: 0; + transition: + grid-template-rows var(--dur-reveal) var(--ease-out), + opacity var(--dur-reveal) var(--ease-out); +} + +.header-search-reveal--open { + grid-template-rows: 1fr; + opacity: 1; +} + +.header-search-reveal-clip { + min-height: 0; + overflow: hidden; +} + +@media (prefers-reduced-motion: reduce) { + .header-search-reveal { + transition: none; + } +} + .header-search-below { display: flex; align-items: center; @@ -2637,7 +2664,7 @@ body.has-player .chat-fab { } @media (min-width: 860px) { - .header-search-below { + .header-search-reveal { display: none; } } diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index 4e80c8ff..d7a3eb2b 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -117,14 +117,23 @@ export function AppHeader( - {searchExpanded && ( -
- + {/* Stays mounted so the row can slide open and shut; `inert` keeps the + collapsed input out of the tab order. */} +
+
+
+ +
- )} +
{wsStatus === "disconnected" && wsErrorMessage && (
diff --git a/src/components/ChatModal.tsx b/src/components/ChatModal.tsx index d1a1d192..0f589a43 100644 --- a/src/components/ChatModal.tsx +++ b/src/components/ChatModal.tsx @@ -15,6 +15,7 @@ import { Markdown } from "./Markdown.tsx"; import { Avatar } from "./Avatar.tsx"; import { ConfirmModal } from "./ConfirmModal.tsx"; import { Tooltip } from "./Tooltip.tsx"; +import { useHeightTransition } from "../hooks/useHeightTransition.ts"; import { useWS } from "../hooks/useWS.ts"; import { useAuth } from "../hooks/useAuth.ts"; import { can } from "../utils/permissions.ts"; @@ -68,6 +69,7 @@ function ChatMessageItem( }: ChatMessageItemProps, ) { const [editing, setEditing] = useState(false); + const bodyRef = useHeightTransition(editing); const [editDraft, setEditDraft] = useState(message.body); const [confirmDelete, setConfirmDelete] = useState(false); const editorRef = useRef(null); @@ -102,7 +104,7 @@ function ChatMessageItem( hasAvatar={!!message.authorAvatarMime} size={32} /> -
+
{message.replyToId && ( // Subtle one-line reference to the replied-to message; clicking jumps // to it. Falls back to a muted note when the target was deleted. @@ -192,7 +194,7 @@ function ChatMessageItem( {editing ? ( -
+
({ defaultValues: { body: defaultValue }, @@ -140,7 +143,10 @@ function CommentForm({ ) : ( -
+ {inner}
)} @@ -194,6 +200,9 @@ function CommentNode({ const [replyOpen, setReplyOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); + const innerRef = useHeightTransition( + `${editOpen}:${replyOpen}`, + ); const replyEditorRef = useRef(null); const editEditorRef = useRef(null); @@ -284,7 +293,11 @@ function CommentNode({ return (
  • -
    +
    Save} pendingLabel={Saving…} errorTitle={t`Failed to save edit`} + className="animate-in" onCancel={() => setEditOpen(false)} /> ) @@ -395,6 +409,7 @@ function CommentNode({ submitLabel={Post reply} pendingLabel={Posting…} errorTitle={t`Failed to post reply`} + className="animate-in" placeholder={t`Write a reply…`} onCancel={() => setReplyOpen(false)} /> diff --git a/src/components/ImagePicker.tsx b/src/components/ImagePicker.tsx index 90b6f335..e54d90e9 100644 --- a/src/components/ImagePicker.tsx +++ b/src/components/ImagePicker.tsx @@ -8,6 +8,7 @@ interface ImagePickerProps { onChange: (file: File) => void; uploading?: boolean; accept?: string; + className?: string; } export function ImagePicker({ @@ -18,6 +19,7 @@ export function ImagePicker({ onChange, uploading = false, accept = "image/jpeg,image/png,image/gif,image/webp", + className, }: ImagePickerProps) { const inputRef = useRef(null); const sizeStyle = { width: size, height: size, borderRadius }; @@ -30,7 +32,7 @@ export function ImagePicker({ return (
    !uploading && inputRef.current?.click()} title={src ? "Change image" : "Add image"} diff --git a/src/components/PlaylistCreateForm.tsx b/src/components/PlaylistCreateForm.tsx index 13e6e81e..581b98fd 100644 --- a/src/components/PlaylistCreateForm.tsx +++ b/src/components/PlaylistCreateForm.tsx @@ -21,6 +21,7 @@ interface PlaylistCreateFormProps { dumpId?: string; onCreated: (playlist: Playlist) => void; onCancel: () => void; + className?: string; } interface Values { @@ -30,7 +31,7 @@ interface Values { } export function PlaylistCreateForm( - { dumpId, onCreated, onCancel }: PlaylistCreateFormProps, + { dumpId, onCreated, onCancel, className }: PlaylistCreateFormProps, ) { const { authFetch } = useAuth(); const form = useApiForm({ @@ -64,7 +65,10 @@ export function PlaylistCreateForm( return ( -
    + name="title" placeholder={t`Title`} diff --git a/src/components/PlaylistMembershipPanel.tsx b/src/components/PlaylistMembershipPanel.tsx index 86b46fb2..6a15c206 100644 --- a/src/components/PlaylistMembershipPanel.tsx +++ b/src/components/PlaylistMembershipPanel.tsx @@ -63,6 +63,7 @@ export function PlaylistMembershipPanel({ {showNewForm ? ( { onPlaylistCreated({ playlist, hasDump: true }); diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index a2e11f17..43a0a58d 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -22,13 +22,25 @@ export function SearchBar( () => new URLSearchParams(location.search).get("q") ?? "", ); + // Collapsing always discards the draft, however it was collapsed (the ✕ in + // the header toggles `expanded` from outside). + const [prevExpanded, setPrevExpanded] = useState(expanded); + if (prevExpanded !== expanded) { + setPrevExpanded(expanded); + if (!expanded) setValue(""); + } + function setExpanded(v: boolean) { if (!isControlled) setExpandedState(v); onExpandedChange?.(v); } useEffect(() => { - if ((collapsible || isControlled) && expanded) inputRef.current?.focus(); + if ((collapsible || isControlled) && expanded) { + // No scroll-into-view: the below-header row is still clipped while it + // slides open, and scrolling the clip would offset its content. + inputRef.current?.focus({ preventScroll: true }); + } }, [expanded, collapsible, isControlled]); function handleIconClick() { diff --git a/src/hooks/useHeightTransition.ts b/src/hooks/useHeightTransition.ts new file mode 100644 index 00000000..584177fa --- /dev/null +++ b/src/hooks/useHeightTransition.ts @@ -0,0 +1,68 @@ +import { useCallback, useLayoutEffect, useRef } from "react"; + +const FALLBACK_MS = 250; + +/** + * Eases an element's height whenever `watch` changes, so an interaction that + * swaps part of its content (a view for an edit form, a reply box opening…) + * moves the surrounding layout smoothly instead of jumping. + * + * Returns a callback ref for the element whose height should transition. + * Only the box is animated; give the content that appears the `animate-in` + * class so it fades in as the box grows. Duration follows `--dur-reveal`. + */ +export function useHeightTransition(watch: unknown) { + const elRef = useRef(null); + // Last rendered height, kept current through a ResizeObserver so that by + // the time the swap commits we still know where the box *was* — including + // mid-animation, when a second toggle should start from the current frame. + const lastHeight = useRef(null); + const lastWatch = useRef(watch); + + const ref = useCallback((el: T | null) => { + if (!el) return; + elRef.current = el; + lastHeight.current = el.getBoundingClientRect().height; + const ro = new ResizeObserver(() => { + lastHeight.current = el.getBoundingClientRect().height; + }); + ro.observe(el); + return () => { + ro.disconnect(); + elRef.current = null; + lastHeight.current = null; + }; + }, []); + + useLayoutEffect(() => { + if (Object.is(lastWatch.current, watch)) return; + lastWatch.current = watch; + const el = elRef.current; + const from = lastHeight.current; + if (!el || from === null) return; + + for (const a of el.getAnimations()) { + if (a.id === "height-transition") a.cancel(); + } + const to = el.getBoundingClientRect().height; + lastHeight.current = to; + if ( + Math.abs(from - to) < 1 || + matchMedia("(prefers-reduced-motion: reduce)").matches + ) return; + + const style = getComputedStyle(el); + const duration = parseFloat(style.getPropertyValue("--dur-reveal")) * + 1000 || FALLBACK_MS; + const easing = style.getPropertyValue("--ease-out").trim() || "ease-out"; + el.animate( + [ + { height: `${from}px`, boxSizing: "border-box", overflow: "clip" }, + { height: `${to}px`, boxSizing: "border-box", overflow: "clip" }, + ], + { duration, easing, id: "height-transition" }, + ); + }, [watch]); + + return ref; +} diff --git a/src/index.css b/src/index.css index 8596ba96..6168df7b 100644 --- a/src/index.css +++ b/src/index.css @@ -65,3 +65,23 @@ button:hover { ::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); } + +/* Content revealed by an inline toggle (edit form, reply box…) eases in as + its container's height grows via useHeightTransition. Runs once, on mount. + No fill mode, so no lingering transform traps fixed-position popovers. */ +.animate-in { + animation: animate-in var(--dur-reveal) var(--ease-out); +} + +@keyframes animate-in { + from { + opacity: 0; + transform: translateY(-4px); + } +} + +@media (prefers-reduced-motion: reduce) { + .animate-in { + animation: none; + } +} diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx index 419bb75f..f4bc8f5f 100644 --- a/src/pages/Dump.tsx +++ b/src/pages/Dump.tsx @@ -30,6 +30,7 @@ import { useAuth } from "../hooks/useAuth.ts"; import { useCategories } from "../hooks/useCategories.ts"; import { useDocumentTitle } from "../hooks/useDocumentTitle.ts"; import { relativeTime } from "../utils/relativeTime.ts"; +import { useHeightTransition } from "../hooks/useHeightTransition.ts"; import { useWS } from "../hooks/useWS.ts"; import { Avatar } from "../components/Avatar.tsx"; import RichContentCard from "../components/RichContentCard.tsx"; @@ -74,6 +75,7 @@ export function Dump() { const [collections, setCollections] = useState([]); const [titleEditing, setTitleEditing] = useState(false); + const headerRightRef = useHeightTransition(titleEditing); const [titleDraft, setTitleDraft] = useState(""); const [titleSaving, setTitleSaving] = useState(false); const [titleError, setTitleError] = useState(null); @@ -364,10 +366,10 @@ export function Dump() { )}
    -
    +
    {titleEditing ? ( -
    +
    (null); const [editOpen, setEditOpen] = useState(false); + const headerRef = useHeightTransition(editOpen); const [confirmDelete, setConfirmDelete] = useState(false); // Mirrors activeDumpIds for use in effects without adding it as a dep. @@ -587,7 +589,7 @@ export function PlaylistDetail() { return ( -
    +
    {editOpen ? ( @@ -842,6 +844,7 @@ function PlaylistEditForm( : null)} alt="Cover" size={72} + className="animate-in" onChange={(file) => { field.onChange(file); setImagePreview(URL.createObjectURL(file)); @@ -850,7 +853,7 @@ function PlaylistEditForm( )} /> -
    +
    (showReset); const form = useApiForm({ defaultValues: { username: "", password: "" }, @@ -54,7 +56,7 @@ export function UserLogin() { return ( -
    +

    Log in

    @@ -115,7 +117,7 @@ function ResetRequestPanel() { if (sent) { return ( -
    +

    If that address is registered you'll receive a reset link shortly. @@ -126,7 +128,7 @@ function ResetRequestPanel() { } return ( -

    +
    diff --git a/src/pages/UserPublicProfile.tsx b/src/pages/UserPublicProfile.tsx index e094c21c..ebdafdbe 100644 --- a/src/pages/UserPublicProfile.tsx +++ b/src/pages/UserPublicProfile.tsx @@ -45,6 +45,7 @@ import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts"; import { useWS } from "../hooks/useWS.ts"; import { useDumpListSync } from "../hooks/useDumpListSync.ts"; import { useFading } from "../hooks/useFading.ts"; +import { useHeightTransition } from "../hooks/useHeightTransition.ts"; import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts"; import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts"; import type { Playlist, RawPlaylist } from "../model.ts"; @@ -327,6 +328,8 @@ export function UserPublicProfile() { const [descEditing, setDescEditing] = useState(false); const [emailEditing, setEmailEditing] = useState(false); + const infoRef = useHeightTransition(emailEditing); + const descRef = useHeightTransition(descEditing); const prevMyVotesRef = useRef | null>(null); const [changePasswordOpen, setChangePasswordOpen] = useState(false); @@ -786,7 +789,7 @@ export function UserPublicProfile() { )}
    -
    +

    {profileUser.username}

    @@ -856,7 +859,7 @@ export function UserPublicProfile() {
    {(profileUser.description || isOwnProfile) && ( -
    +
    {descEditing ? ( - + - +