Pair content-visibility with its render-ahead observer in one component
This commit is contained in:
parent
1485e290e9
commit
dd7c15153e
14 changed files with 96 additions and 24 deletions
|
|
@ -4,6 +4,7 @@
|
|||
import {getAddress} from "@welshman/util"
|
||||
import {Article} from "@welshman/domain"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Cv from "@lib/components/Cv.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import ArticleActions from "@app/components/ArticleActions.svelte"
|
||||
|
|
@ -27,7 +28,7 @@
|
|||
const image = $derived(article.image())
|
||||
</script>
|
||||
|
||||
<div data-component="ArticleItem" class="cv relative w-full card card-interactive">
|
||||
<Cv data-component="ArticleItem" class="relative w-full card card-interactive">
|
||||
<!-- An overlay rather than a wrapper: the card carries a profile button and the room and action
|
||||
links, and none of those may sit inside an anchor. -->
|
||||
<Link class="absolute inset-0 rounded-2xl" href={path} aria-label={title || "Untitled"} />
|
||||
|
|
@ -58,4 +59,4 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Cv>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {getAddress, tagValue, tagSpec} from "@welshman/util"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Cv from "@lib/components/Cv.svelte"
|
||||
import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
|
||||
import type {FeedContext} from "@app/feeds"
|
||||
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
|
||||
|
|
@ -22,7 +23,7 @@
|
|||
const h = $derived(tagValue(tagSpec("h"), event.tags))
|
||||
</script>
|
||||
|
||||
<Link class="cv relative flex flex-col gap-3 card card-interactive w-full" href={path}>
|
||||
<Cv tag={Link} class="relative flex flex-col gap-3 card card-interactive w-full" href={path}>
|
||||
<UnreadDot {path} class="absolute right-3 top-3" />
|
||||
<CalendarEventHeader {event} />
|
||||
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
||||
|
|
@ -34,4 +35,4 @@
|
|||
</span>
|
||||
<CalendarEventActions showActivity {url} {event} {context} />
|
||||
</div>
|
||||
</Link>
|
||||
</Cv>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import {getAddress} from "@welshman/util"
|
||||
import {Classified} from "@welshman/domain"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Cv from "@lib/components/Cv.svelte"
|
||||
import CurrencySymbol from "@lib/components/CurrencySymbol.svelte"
|
||||
import {reader} from "@app/core"
|
||||
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
|
||||
|
|
@ -31,7 +32,7 @@
|
|||
const price = $derived(classified.price())
|
||||
</script>
|
||||
|
||||
<Link class="cv relative flex flex-col gap-2 card card-interactive w-full" href={path}>
|
||||
<Cv tag={Link} class="relative flex flex-col gap-2 card card-interactive w-full" href={path}>
|
||||
<UnreadDot {path} class="absolute right-3 top-3" />
|
||||
{#if title}
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
|
|
@ -64,4 +65,4 @@
|
|||
</span>
|
||||
<ClassifiedActions showActivity {url} {event} {context} />
|
||||
</div>
|
||||
</Link>
|
||||
</Cv>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {ZapGoal} from "@welshman/domain"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Cv from "@lib/components/Cv.svelte"
|
||||
import {reader} from "@app/core"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
|
|
@ -28,7 +29,7 @@
|
|||
const h = goal.room()
|
||||
</script>
|
||||
|
||||
<Link class="cv relative flex flex-col gap-2 card card-interactive w-full" href={path}>
|
||||
<Cv tag={Link} class="relative flex flex-col gap-2 card card-interactive w-full" href={path}>
|
||||
<UnreadDot {path} class="absolute right-3 top-3" />
|
||||
<p class="text-2xl">{title}</p>
|
||||
<Content
|
||||
|
|
@ -47,4 +48,4 @@
|
|||
</span>
|
||||
<GoalActions showActivity {url} {event} {context} />
|
||||
</div>
|
||||
</Link>
|
||||
</Cv>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
let muted = $state($isEventMuted(event))
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2 {restProps.class}" style={restProps.style}>
|
||||
<div {...restProps} class={cx("flex flex-col gap-2", restProps.class)}>
|
||||
{#if muted}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-2 relative">
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||
import Cv from "@lib/components/Cv.svelte"
|
||||
import NoteContent from "@app/components/NoteContent.svelte"
|
||||
import {publishReaction, retractReaction} from "@app/reactions"
|
||||
import NoteCard from "@app/components/NoteCard.svelte"
|
||||
|
|
@ -33,7 +34,7 @@
|
|||
const onEmoji = (emoji: NativeEmoji) => createReaction({content: emoji.unicode, tags: []})
|
||||
</script>
|
||||
|
||||
<NoteCard {event} {url} class="cv card card-interactive">
|
||||
<Cv tag={NoteCard} {event} {url} class="card card-interactive">
|
||||
<NoteContent {event} expandMode="inline" />
|
||||
<div class="flex w-full justify-between gap-2">
|
||||
<ReactionSummary
|
||||
|
|
@ -52,4 +53,4 @@
|
|||
</ReactionSummary>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</NoteCard>
|
||||
</Cv>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import UserCircle from "@assets/icons/user-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Cv from "@lib/components/Cv.svelte"
|
||||
import Profile from "@app/components/Profile.svelte"
|
||||
import ProfileAbout from "@app/components/ProfileAbout.svelte"
|
||||
import ProfileBadges from "@app/components/ProfileBadges.svelte"
|
||||
|
|
@ -15,7 +16,7 @@
|
|||
const {pubkey, url}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="cv card card-interactive flex flex-col gap-4">
|
||||
<Cv class="card card-interactive flex flex-col gap-4">
|
||||
<div class="flex justify-between">
|
||||
<Profile {pubkey} {url} />
|
||||
<Link href={makeProfilePath(pubkey)} class="button button-primary hidden sm:flex">
|
||||
|
|
@ -29,4 +30,4 @@
|
|||
<Icon icon={UserCircle} />
|
||||
View Profile
|
||||
</Link>
|
||||
</div>
|
||||
</Cv>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {tagSpec, tagValue} from "@welshman/util"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Cv from "@lib/components/Cv.svelte"
|
||||
import NoteContent from "@app/components/NoteContent.svelte"
|
||||
import PollActions from "@app/components/PollActions.svelte"
|
||||
import type {FeedContext} from "@app/feeds"
|
||||
|
|
@ -22,7 +23,7 @@
|
|||
const h = tagValue(tagSpec("h"), event.tags)
|
||||
</script>
|
||||
|
||||
<Link class="cv relative flex flex-col gap-2 card card-interactive w-full" href={path}>
|
||||
<Cv tag={Link} class="relative flex flex-col gap-2 card card-interactive w-full" href={path}>
|
||||
<UnreadDot {path} class="absolute right-3 top-3" />
|
||||
<NoteContent {event} {url} />
|
||||
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
|
||||
|
|
@ -34,4 +35,4 @@
|
|||
</span>
|
||||
<PollActions showActivity {url} {event} {context} />
|
||||
</div>
|
||||
</Link>
|
||||
</Cv>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Cv from "@lib/components/Cv.svelte"
|
||||
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
|
||||
|
|
@ -25,7 +26,7 @@
|
|||
const onClick = () => navigate(h ? makeRoomPath(url, h) : makeSpaceChatPath(url))
|
||||
</script>
|
||||
|
||||
<Button class="cv card card-interactive" onclick={onClick}>
|
||||
<Cv tag={Button} class="card card-interactive" onclick={onClick}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
{#if h}
|
||||
|
|
@ -55,4 +56,4 @@
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Cv>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import {LOCAL_RELAY_URL} from "@welshman/net"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import Card from "@lib/components/Card.svelte"
|
||||
import Cv from "@lib/components/Cv.svelte"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Profile from "@app/components/Profile.svelte"
|
||||
import ProfileName from "@app/components/ProfileName.svelte"
|
||||
|
|
@ -36,7 +37,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<Card sm class="cv flex flex-col gap-4">
|
||||
<Cv tag={Card} sm class="flex flex-col gap-4">
|
||||
<div class="flex justify-between">
|
||||
<div>
|
||||
<ProfileName pubkey={event.pubkey} {url} />
|
||||
|
|
@ -80,4 +81,4 @@
|
|||
<Profile pubkey={ptag[1]} />
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
</Cv>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {COMMENT, tagSpec, tagValue} from "@welshman/util"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Cv from "@lib/components/Cv.svelte"
|
||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||
import ProfileName from "@app/components/ProfileName.svelte"
|
||||
import UnreadDot from "@app/components/UnreadDot.svelte"
|
||||
|
|
@ -30,9 +31,10 @@
|
|||
</script>
|
||||
|
||||
{#if stacked}
|
||||
<Link
|
||||
<Cv
|
||||
tag={Link}
|
||||
href={path}
|
||||
class="cv hover:bg-surface-less flex w-full flex-col gap-2 border-b border-solid border-line px-4 py-3 text-left text-sm transition-colors">
|
||||
class="hover:bg-surface-less flex w-full flex-col gap-2 border-b border-solid border-line px-4 py-3 text-left text-sm transition-colors">
|
||||
<p class="truncate font-medium">
|
||||
<UnreadDot {path} class="mr-1" />{title || "Untitled thread"}
|
||||
</p>
|
||||
|
|
@ -46,7 +48,7 @@
|
|||
<span>{replyCount} {replyCount === 1 ? "reply" : "replies"}</span>
|
||||
<span>{formatTimestamp(lastActive)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</Cv>
|
||||
{:else}
|
||||
<tr
|
||||
onclick={onClick}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {tagSpec, tagValue} from "@welshman/util"
|
||||
import Link from "@lib/components/Link.svelte"
|
||||
import Cv from "@lib/components/Cv.svelte"
|
||||
import Content from "@app/components/Content.svelte"
|
||||
import ProfileLink from "@app/components/ProfileLink.svelte"
|
||||
import ThreadActions from "@app/components/ThreadActions.svelte"
|
||||
|
|
@ -24,7 +25,7 @@
|
|||
const h = tagValue(tagSpec("h"), event.tags)
|
||||
</script>
|
||||
|
||||
<Link class="cv relative flex flex-col gap-2 card card-interactive w-full" href={path}>
|
||||
<Cv tag={Link} class="relative flex flex-col gap-2 card card-interactive w-full" href={path}>
|
||||
<UnreadDot {path} class="absolute right-3 top-3" />
|
||||
{#if title}
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
|
|
@ -49,4 +50,4 @@
|
|||
</span>
|
||||
<ThreadActions showActivity {url} {event} {context} />
|
||||
</div>
|
||||
</Link>
|
||||
</Cv>
|
||||
|
|
|
|||
58
src/lib/components/Cv.svelte
Normal file
58
src/lib/components/Cv.svelte
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<script lang="ts" module>
|
||||
import type {Maybe} from "@welshman/lib"
|
||||
|
||||
let instance: Maybe<IntersectionObserver>
|
||||
|
||||
/** Browsers only paint a `content-visibility: auto` element within half a viewport of the scroll
|
||||
* position, which a fast scroll outruns, leaving empty cards behind. There is no css control over
|
||||
* that distance, so the element is painted two viewports ahead instead. The observer has to watch
|
||||
* the element carrying `cv` — a descendant of one is inside the subtree being skipped, which is
|
||||
* why the class and the attachment live in the same component. */
|
||||
const renderAhead = (element: HTMLElement) => {
|
||||
const observer = (instance ||= new IntersectionObserver(
|
||||
entries => {
|
||||
for (const entry of entries) {
|
||||
const target = entry.target as HTMLElement
|
||||
|
||||
target.style.contentVisibility = entry.isIntersecting ? "visible" : ""
|
||||
}
|
||||
},
|
||||
{rootMargin: "200% 0px"},
|
||||
))
|
||||
|
||||
observer.observe(element)
|
||||
|
||||
return () => observer.unobserve(element)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type {Component, Snippet} from "svelte"
|
||||
import cx from "classnames"
|
||||
|
||||
// The root of anything rendered in a long list. `cv` lets the browser skip an offscreen one,
|
||||
// and `renderAhead` is what keeps a fast scroll from outrunning that; `tag` takes whatever the
|
||||
// item was already using as its root, so nothing gains a wrapper element and everything else
|
||||
// passes through to it.
|
||||
type Props = {
|
||||
children?: Snippet
|
||||
tag?: string | Component<any>
|
||||
class?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
const {children, tag = "div", ...restProps}: Props = $props()
|
||||
|
||||
const className = $derived(cx("cv", restProps.class))
|
||||
</script>
|
||||
|
||||
{#if typeof tag === "string"}
|
||||
<svelte:element this={tag} {...restProps} class={className} {@attach renderAhead}>
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
||||
{:else}
|
||||
{@const Host = tag}
|
||||
<Host {...restProps} class={className} {@attach renderAhead}>
|
||||
{@render children?.()}
|
||||
</Host>
|
||||
{/if}
|
||||
|
|
@ -99,6 +99,8 @@
|
|||
@utility superheading {
|
||||
@apply font-heading text-center text-4xl font-extrabold;
|
||||
}
|
||||
/* Applied by Cv.svelte, which pairs it with the observer that paints ahead of a fast scroll.
|
||||
Nothing else should reach for it. */
|
||||
@utility cv {
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue