Feed browser: Keep the delete error clear of text-only quotes #8
@@ -340,9 +340,10 @@ function PostSlide({
|
|||||||
onExpandedChange?.(index, textOpen);
|
onExpandedChange?.(index, textOpen);
|
||||||
}, [index, textOpen, onExpandedChange]);
|
}, [index, textOpen, onExpandedChange]);
|
||||||
useEffect(() => () => onExpandedChange?.(index, false), [index, onExpandedChange]);
|
useEffect(() => () => onExpandedChange?.(index, false), [index, onExpandedChange]);
|
||||||
|
const deleteError = !!p.deleteErrors?.has(post.key);
|
||||||
const cls =
|
const cls =
|
||||||
`slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}${playing ? " slide-playing" : ""}` +
|
`slide ${media ? "slide-on-media" : "slide-on-text"}${current ? " is-cur" : ""}${playing ? " slide-playing" : ""}` +
|
||||||
`${own ? " slide-own" : ""}${textOpen ? " slide-text-open" : ""}`;
|
`${own ? " slide-own" : ""}${deleteError ? " slide-has-error" : ""}${textOpen ? " slide-text-open" : ""}`;
|
||||||
const lastTap = useRef<number | null>(null);
|
const lastTap = useRef<number | null>(null);
|
||||||
const [burst, setBurst] = useState(0);
|
const [burst, setBurst] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -384,7 +385,7 @@ function PostSlide({
|
|||||||
<div className="slide-scrim" aria-hidden="true" />
|
<div className="slide-scrim" aria-hidden="true" />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<QuoteText text={post.text} open={textOpen} onOpenChange={setTextOpen} />
|
<QuoteText text={post.text} reserved={deleteError} open={textOpen} onOpenChange={setTextOpen} />
|
||||||
)}
|
)}
|
||||||
{burst ? (
|
{burst ? (
|
||||||
<svg className="slide-burst" data-testid="like-burst" viewBox="0 0 24 24" aria-hidden="true">
|
<svg className="slide-burst" data-testid="like-burst" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
@@ -395,7 +396,7 @@ function PostSlide({
|
|||||||
<PostMenu post={post} onDelete={p.onDelete} deleteBusy={p.deleteBusy?.has(post.key)} className="slide-menu" buttonClassName="slide-menu-btn" />
|
<PostMenu post={post} onDelete={p.onDelete} deleteBusy={p.deleteBusy?.has(post.key)} className="slide-menu" buttonClassName="slide-menu-btn" />
|
||||||
) : null}
|
) : null}
|
||||||
<div className="slide-meta">
|
<div className="slide-meta">
|
||||||
{p.deleteErrors?.has(post.key) ? <p className="slide-error" role="alert">Could not delete the post.</p> : null}
|
{deleteError ? <p className="slide-error" role="alert">Could not delete the post.</p> : null}
|
||||||
<div className="slide-who">
|
<div className="slide-who">
|
||||||
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
|
<HexAvatar name={post.authorName} pictureUrl={post.authorPictureUrl} size="lg" />
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function useRemeasure(box: React.RefObject<HTMLElement | null>, measure: () => v
|
|||||||
* between the top bar and the author block. When it still does not fit, it
|
* between the top bar and the author block. When it still does not fit, it
|
||||||
* clamps and ends with "Read more", which opens the full text over the slide.
|
* clamps and ends with "Read more", which opens the full text over the slide.
|
||||||
*/
|
*/
|
||||||
export function QuoteText({ text, open, onOpenChange }: { text: string; open: boolean; onOpenChange: (open: boolean) => void }) {
|
export function QuoteText({ text, reserved = false, open, onOpenChange }: { text: string; /** The delete-error line is shown: the box ends above it, so the quote re-fits. */ reserved?: boolean; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||||
const box = useRef<HTMLDivElement>(null);
|
const box = useRef<HTMLDivElement>(null);
|
||||||
const inner = useRef<HTMLDivElement>(null);
|
const inner = useRef<HTMLDivElement>(null);
|
||||||
const quote = useRef<HTMLParagraphElement>(null);
|
const quote = useRef<HTMLParagraphElement>(null);
|
||||||
@@ -93,7 +93,7 @@ export function QuoteText({ text, open, onOpenChange }: { text: string; open: bo
|
|||||||
apply(result);
|
apply(result);
|
||||||
setFit((cur) => (cur.size === result.size && cur.lines === result.lines ? cur : result));
|
setFit((cur) => (cur.size === result.size && cur.lines === result.lines ? cur : result));
|
||||||
}, []);
|
}, []);
|
||||||
useRemeasure(box, measure, [text]);
|
useRemeasure(box, measure, [text, reserved]);
|
||||||
|
|
||||||
// Focus follows the reader: into it on open, back to "Read more" on close.
|
// Focus follows the reader: into it on open, back to "Read more" on close.
|
||||||
const wasOpen = useRef(false);
|
const wasOpen = useRef(false);
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export function FeedPreview() {
|
|||||||
// Fixture switches for the new-posts pill and the loading-more skeleton.
|
// Fixture switches for the new-posts pill and the loading-more skeleton.
|
||||||
const [newCount, setNewCount] = useState(0);
|
const [newCount, setNewCount] = useState(0);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
// Forces the failed-delete line on the own long text-only post (preview only).
|
||||||
|
const [deleteError, setDeleteError] = useState(false);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const list = useRef<HTMLDivElement | null>(null);
|
const list = useRef<HTMLDivElement | null>(null);
|
||||||
// The list element swaps between the panel and the phone reel; paging re-attaches to it.
|
// The list element swaps between the panel and the phone reel; paging re-attaches to it.
|
||||||
@@ -103,6 +105,9 @@ export function FeedPreview() {
|
|||||||
<label style={{ margin: 0, fontSize: 12 }}>
|
<label style={{ margin: 0, fontSize: 12 }}>
|
||||||
<input type="checkbox" data-testid="fx-new" checked={newCount > 0} onChange={(e) => setNewCount(e.target.checked ? 3 : 0)} style={{ padding: 0 }} /> New posts (3)
|
<input type="checkbox" data-testid="fx-new" checked={newCount > 0} onChange={(e) => setNewCount(e.target.checked ? 3 : 0)} style={{ padding: 0 }} /> New posts (3)
|
||||||
</label>
|
</label>
|
||||||
|
<label style={{ margin: 0, fontSize: 12 }}>
|
||||||
|
<input type="checkbox" data-testid="fx-delete-error" checked={deleteError} onChange={(e) => setDeleteError(e.target.checked)} style={{ padding: 0 }} /> Delete error
|
||||||
|
</label>
|
||||||
<label style={{ margin: 0, fontSize: 12 }}>
|
<label style={{ margin: 0, fontSize: 12 }}>
|
||||||
<input type="checkbox" data-testid="fx-more" checked={loadingMore} onChange={(e) => setLoadingMore(e.target.checked)} style={{ padding: 0 }} /> Loading more
|
<input type="checkbox" data-testid="fx-more" checked={loadingMore} onChange={(e) => setLoadingMore(e.target.checked)} style={{ padding: 0 }} /> Loading more
|
||||||
</label>
|
</label>
|
||||||
@@ -159,6 +164,7 @@ export function FeedPreview() {
|
|||||||
}}
|
}}
|
||||||
onToggleLike={toggleLike}
|
onToggleLike={toggleLike}
|
||||||
feedUserId={FIXTURE_PERSON.feedUserId}
|
feedUserId={FIXTURE_PERSON.feedUserId}
|
||||||
|
deleteErrors={deleteError ? new Set(["p-own-long"]) : undefined}
|
||||||
onDelete={(post) => setPosts((cur) => cur.filter((p) => p.key !== post.key))}
|
onDelete={(post) => setPosts((cur) => cur.filter((p) => p.key !== post.key))}
|
||||||
composer={{
|
composer={{
|
||||||
value: draft,
|
value: draft,
|
||||||
|
|||||||
@@ -107,6 +107,19 @@ export const FIXTURE_FEED_ITEMS: unknown[] = [
|
|||||||
mediaItems: [],
|
mediaItems: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Own, very long text-only post: "Delete error" in the preview shows the failed-delete line on it.
|
||||||
|
type: "post",
|
||||||
|
publishedAt: "2026-10-19T10:50:00Z",
|
||||||
|
likesCount: 3,
|
||||||
|
commentsCount: 0,
|
||||||
|
post: {
|
||||||
|
id: "p-own-long",
|
||||||
|
content: "I spent ten years building products before I learned the most useful habit of all: write down what you expect to happen before you ship, then check it a month later.\n\nMost teams skip the first half. They launch, watch a dashboard for a week and call whatever they see a win. Without the written guess there is nothing to be wrong about, so nobody learns.\n\nHere is the routine we use now. Every change gets one sentence: who it is for, what they will do differently, and the number that will move if we are right. The sentence goes in the ticket, not in a slide.\n\nThirty days later the person who wrote it reads it out loud in the weekly review and says what actually happened. No blame, no speeches. Just the guess, the result and one line on why they differ.\n\nAfter a quarter you get something no analytics tool gives you: a record of how good your team is at predicting its own customers. Ours was humbling. We were right about a third of the time.\n\nThat number changed how we plan. We now run smaller bets, we talk to five customers before any big build, and we kill features faster when the guess was wrong.\n\nIf you try one thing this month, try the sentence. It takes two minutes and it will teach you more than the next roadmap offsite.",
|
||||||
|
author: author(FIXTURE_FEED_USER_ID, "Jordan Rivera"),
|
||||||
|
mediaItems: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// Photo post with a caption longer than three lines: "more" / "less".
|
// Photo post with a caption longer than three lines: "more" / "less".
|
||||||
type: "post",
|
type: "post",
|
||||||
|
|||||||
@@ -179,7 +179,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-shadow: 0 1px 12px var(--media-shadow);
|
text-shadow: 0 1px 12px var(--media-shadow);
|
||||||
}
|
}
|
||||||
.slide-error { margin: 0; color: var(--danger); font-size: 13px; font-weight: 600; }
|
.slide-error { margin: 0; color: var(--danger); font-size: 13px; font-weight: 600; line-height: 18px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.slide-on-media .slide-error { color: var(--on-media); }
|
.slide-on-media .slide-error { color: var(--on-media); }
|
||||||
|
|
||||||
/* Text-only post: the text is the hero, a serif quote with a crimson opening mark.
|
/* Text-only post: the text is the hero, a serif quote with a crimson opening mark.
|
||||||
@@ -197,6 +197,8 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
/* A failed delete adds the error line (18px) and its 12px gap above the author block: the quote box ends above it. */
|
||||||
|
.slide-has-error .slide-quote-w { bottom: 211px; }
|
||||||
.slide-own .slide-quote-w { top: calc(122px + env(safe-area-inset-top, 0px)); }
|
.slide-own .slide-quote-w { top: calc(122px + env(safe-area-inset-top, 0px)); }
|
||||||
/* Auto margins center the quote and, unlike justify-content, never push overflow above the box. */
|
/* Auto margins center the quote and, unlike justify-content, never push overflow above the box. */
|
||||||
.slide-quote-in { margin: auto 0; }
|
.slide-quote-in { margin: auto 0; }
|
||||||
|
|||||||
@@ -257,3 +257,24 @@ describe("long post text stylesheet", () => {
|
|||||||
expect(css).toMatch(/prefers-reduced-motion: reduce\) \{[^}]*\.slide-reader,\s*\.slide-text\.is-open,/);
|
expect(css).toMatch(/prefers-reduced-motion: reduce\) \{[^}]*\.slide-reader,\s*\.slide-text\.is-open,/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("delete error on a text-only slide", () => {
|
||||||
|
const css = readFileSync("src/styles/mobile.css", "utf8");
|
||||||
|
const slideOf = (html: string) => html.split("<section").filter((s) => s.includes('data-testid="feed-slide"'));
|
||||||
|
|
||||||
|
it("marks only the slide with the error so the quote box can end above it", () => {
|
||||||
|
const html = render({ deleteErrors: new Set([POSTS[0].key]) });
|
||||||
|
const [own, image] = slideOf(html);
|
||||||
|
expect(own).toContain("slide-has-error");
|
||||||
|
expect(own).toContain("Could not delete the post.");
|
||||||
|
expect(image).not.toContain("slide-has-error");
|
||||||
|
expect(slideOf(render({}))[0]).not.toContain("slide-has-error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lifts the quote box by the error line and its gap", () => {
|
||||||
|
const base = Number(/\.slide-quote-w \{[^}]*?bottom: (\d+)px/.exec(css)![1]);
|
||||||
|
const withError = Number(/\.slide-has-error \.slide-quote-w \{ bottom: (\d+)px; \}/.exec(css)![1]);
|
||||||
|
const line = Number(/\.slide-error \{[^}]*?line-height: (\d+)px/.exec(css)![1]);
|
||||||
|
expect(withError).toBe(base + line + 12);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user