feat: add scroll-away headers
49 unmodified lines50515253545455565758181 unmodified linesExpand all49 unmodified linestype ProfileTabId = 'collections' | 'media' | 'posts';
export function ProfileScreen() { return ( <Layout.Screen noInsetTop> <Layout.Screen className={Layout.ScrollAway.scope} noInsetTop> <ProfileScreenInner /> </Layout.Screen> );}181 unmodified linesExpand all2 unmodified lines3456789101124 unmodified lines3637383939404142434493 unmodified lines13813914014114214314414514614714814914815015115215315415515615715815916045 unmodified lines2 unmodified lines
import { useQueryClient } from '@tanstack/react-query';
import { cleanError } from '#/lib/errors';import { useElementHeight } from '#/lib/hooks/use-element-height';
import { useModerationOpts } from '#/state/moderation/moderation-opts';import { useListQuery } from '#/state/queries/list';import { RQKEY as FEED_RQKEY } from '#/state/queries/post-feed';24 unmodified linesimport { FeedSection } from './FeedSection';
export function ProfileListScreen() { return ( <Layout.Screen> <Layout.Screen className={Layout.ScrollAway.scope}> <ProfileListScreenInner /> </Layout.Screen> );}93 unmodified lines const isOwner = currentAccount?.did === list.creator.did; const addUserDialogHandle = Dialog.useDialogHandle(); const onPressAddUser = () => addUserDialogHandle.open(null); const [{ tab }, replaceParams] = useParams('ProfileList'); const [headerRef, headerHeight] = useElementHeight<HTMLDivElement>();
useTitle(isHidden ? m['screens.profileList.hide.hiddenToast']() : list.name);
const onChangeMembers = () => { void truncateAndInvalidate(queryClient, FEED_RQKEY({ type: 'list', uri: list.uri })); };
const header = <Header list={list} preferences={preferences} />; const header = <Header list={list} preferences={preferences} ref={headerRef} />;
return ( <> <Tabs header={header} headerOffset={headerHeight} onValueChange={(next) => replaceParams({ tab: next })} sections={[ { children: (45 unmodified lines39 unmodified lines404142434445444546474849110 unmodified linesExpand all39 unmodified lines </div> );}
export function Content({ children }: { children?: ReactNode }) { return <div className={styles.content}>{children}</div>;export function Content({ children, className }: { children?: ReactNode; className?: string }) { return <div className={clsx(styles.content, className)}>{children}</div>;}
export function Slot({ children }: { children?: ReactNode }) { return <div className={styles.slot}>{children}</div>;110 unmodified linesExpand all12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364import { keyframes, style } from '@vanilla-extract/css';
import { HEADER_HEIGHT } from '#/components/web/Layout/const';import { driven, TIMELINE } from '#/components/web/Layout/scroll-away';
import { colors } from '#/styles/colors';
export const scope = style({ timelineScope: TIMELINE,});
export const region = style({ viewTimelineName: TIMELINE, viewTimelineAxis: 'block', viewTimelineInset: `calc(${HEADER_HEIGHT}px + env(safe-area-inset-top, 0px)) auto`,});
const solid = { borderBottomColor: colors.borderContrastLow, backgroundColor: colors.bg,} as const;
const revealed = { visibility: 'visible', translate: '0 0', opacity: 1,} as const;
const solidifying = keyframes({ from: { borderBottomColor: 'transparent', backgroundColor: 'transparent', }, to: solid,});
const revealing = keyframes({ from: { visibility: 'hidden', translate: '0 6px', opacity: 0, }, to: revealed,});
export const backdrop = style([ { position: 'absolute', inset: 0, zIndex: -1, borderBottom: '1px solid transparent', backgroundColor: 'transparent', }, driven(solidifying, solid),]);
export const reveal = style([ { visibility: 'hidden', translate: '0 6px', opacity: 0, }, driven(revealing, revealed),]);123456789101112131415161718192021222324252627'use no memo'; // composition props usually invalidate the generated wrapper caches
import type { ComponentPropsWithoutRef } from 'react';
import { clsx } from 'clsx';
import * as styles from '#/components/web/Layout/ScrollAway.css';
export { reveal, scope } from '#/components/web/Layout/ScrollAway.css';
/** * marks content that drives the scroll-away animation. * * @param children the scroll-away content */export const Region = ({ className, children, ...rest }: ComponentPropsWithoutRef<'div'>) => { return ( <div className={clsx(styles.region, className)} {...rest}> {children} </div> );};
/** renders the sticky header's animated background. */export const Backdrop = ({ className, ...rest }: ComponentPropsWithoutRef<'div'>) => { return <div className={clsx(styles.backdrop, className)} {...rest} />;};7 unmodified lines891011121314151625 unmodified lines7 unmodified lines
import * as styles from '#/components/web/Layout/Layout.css';
export * as Header from '#/components/web/Layout/Header';export * as ScrollAway from '#/components/web/Layout/ScrollAway';
export type ScreenProps = ComponentPropsWithoutRef<'div'> & { noInsetTop?: boolean;};25 unmodified lines12345678910111213141516171819202122232425262728293031import type { StyleRule } from '@vanilla-extract/css';
export const TIMELINE = '--scroll-away';
export const SUPPORTS = `(timeline-scope: ${TIMELINE})`;
export const OVERLAP = '--scroll-away-overlap';
const LEAD_IN = 0.25;
const RANGE = `exit calc(${LEAD_IN * 100}% + ${1 - LEAD_IN} * var(${OVERLAP}, 0px)) exit 100%`;
/** * creates a scroll-driven animation rule with a static fallback. * * @param animation keyframes * @param settled fallback state * @returns composable style rule */export const driven = (animation: string, settled: StyleRule): StyleRule => ({ '@supports': { [SUPPORTS]: { animationName: animation, animationTimingFunction: 'linear', animationFillMode: 'both', animationTimeline: TIMELINE, animationRange: RANGE, }, [`not ${SUPPORTS}`]: settled, },});13 unmodified lines14151617181920212248 unmodified lines71727374757475767778798081828384858687888976777879808182838485868788899091929394959640 unmodified lines13 unmodified linesimport { LabelsOnMe } from '#/components/moderation/LabelsOnMe';import { ProfileHeaderAlerts } from '#/components/moderation/ProfileHeaderAlerts';import { UserAvatar } from '#/components/UserAvatar';import { UserBanner } from '#/components/UserBanner';import * as Layout from '#/components/web/Layout';
import { m } from '#/paraglide/messages';
import { useProfileHeader } from './Context';48 unmodified lines );
return ( <div className={css.frame}> <Layout.ScrollAway.Region> {isPlaceholderProfile ? ( <UserBanner /> ) : profile.banner && canViewMedia ? ( <Dialog.Trigger type="button" handle={lightboxHandle} payload={{ images: [{ src: profile.banner }], index: 0 }} className={css.bannerButton} aria-label={m['screens.profile.banner.a11y.view']()} onPointerDown={preloadLightbox} > {bannerImage} </Dialog.Trigger> ) : ( bannerImage )} {isPlaceholderProfile ? ( <UserBanner /> ) : profile.banner && canViewMedia ? ( <Dialog.Trigger type="button" handle={lightboxHandle} payload={{ images: [{ src: profile.banner }], index: 0 }} className={css.bannerButton} aria-label={m['screens.profile.banner.a11y.view']()} onPointerDown={preloadLightbox} > {bannerImage} </Dialog.Trigger> ) : ( bannerImage )} </Layout.ScrollAway.Region>
{/* placed before the header body so its tab order matches its visual spot (top-left, over the banner edge) */} <div className={css.avatarAnchor}> {live.isActive ? (40 unmodified lines1123345678910111212131415161718192021222324252627282930313233343536373839404142131415169 unmodified lines26272829603062313233343565363738394041717273747576777879808182834243858687888990919293949596974445464748491045051import { keyframes, style, type StyleRule } from '@vanilla-extract/css';import { keyframes, style } from '@vanilla-extract/css';
import { BANNER_ASPECT_RATIO } from '#/components/UserBanner.css';import { SCRIM_BACKGROUND } from '#/components/web/Button.css';import { HEADER_HEIGHT } from '#/components/web/Layout/const';import { driven, OVERLAP, SUPPORTS } from '#/components/web/Layout/scroll-away';import { reveal } from '#/components/web/Layout/ScrollAway.css';
import { colors } from '#/styles/colors';import { space, zIndex } from '#/styles/tokens.css';
const height = `calc(${HEADER_HEIGHT}px + env(safe-area-inset-top, 0px))`;
const PADDING_INLINE = space.lg;
const columnWidth = `calc(100cqw + ${PADDING_INLINE * 2}px)`;
const scrollRange = `calc(${columnWidth} / ${BANNER_ASPECT_RATIO} - ${height})`;
const fadeStart = `calc(${scrollRange} * 0.25)`;
const scrollDriven = (animation: string, scrolled?: StyleRule): StyleRule => ({ '@supports': { '(animation-timeline: scroll())': { animationName: animation, animationTimingFunction: 'linear', animationFillMode: 'both', animationTimeline: 'scroll(root block)', animationRange: `${fadeStart} ${scrollRange}`, }, ...(scrolled && { 'not (animation-timeline: scroll())': scrolled }), },});
const solidify = keyframes({ from: { borderBottomColor: 'transparent', backgroundColor: 'transparent' }, to: { borderBottomColor: colors.borderContrastLow, backgroundColor: colors.bg },});
const reveal = keyframes({ from: { translate: '0 6px', opacity: 0 }, to: { translate: '0 0', opacity: 1 },});
const buttonOnSolid = { backgroundColor: 'transparent', color: colors.contrast_600 };
const unscrim = keyframes({ from: { backgroundColor: SCRIM_BACKGROUND, color: colors.white },9 unmodified lines flexShrink: 0, gap: space.sm, alignItems: 'center', zIndex: zIndex.float, containerType: 'inline-size', paddingTop: 'env(safe-area-inset-top, 0px)', paddingInline: PADDING_INLINE, paddingInline: space.lg, height, // the overlaid header consumes this much of the banner's scroll range. vars: { [OVERLAP]: height }, '@supports': { '(animation-timeline: scroll())': { [SUPPORTS]: { marginBottom: `calc(-1 * ${height})`, }, },});
export const backdrop = style([ { position: 'absolute', inset: 0, zIndex: -1, borderBottom: '1px solid transparent', backgroundColor: 'transparent', }, scrollDriven(solidify, { borderBottomColor: colors.borderContrastLow, backgroundColor: colors.bg, }),]);export const content = style([reveal, { minWidth: 0 }]);
export const content = style([ { display: 'flex', flex: '1 1 0%', flexDirection: 'column', justifyContent: 'center', translate: '0 6px', minWidth: 0, opacity: 0, }, scrollDriven(reveal, { translate: '0 0', opacity: 1 }),]);
export const backButton = style([ { selectors: { '&:hover:not(:disabled)': { boxShadow: 'inset 0 0 0 999px rgb(from currentColor r g b / 0.15)' }, }, }, scrollDriven(unscrim, buttonOnSolid), driven(unscrim, buttonOnSolid),]);29 unmodified lines303132333434353637381 unmodified line40414243444445464647484929 unmodified lines return ( <div className={css.outer} ref={ref}> <Layout.Header.BackButton className={css.backButton} variant="scrim" /> {!isPlaceholderProfile && ( <div className={css.content}> <Layout.Header.Content className={css.content}> <Text numberOfLines={1} size="lg" weight="bold"> {profile.handle} </Text> <Text color="textContrastMedium" numberOfLines={1} size="sm">1 unmodified line count: postsCount, formatted: formatCount(postsCount), })} </Text> </div> </Layout.Header.Content> )} <div className={css.backdrop} /> <Layout.ScrollAway.Backdrop /> </div> );}1234563 unmodified lines101112131415161718192021222023242526272829303132333435363738394010 unmodified lines51525354555657585944 unmodified lines10410510610710210810910411011111211311411511611711811912012146 unmodified lines168169170171172173160161162163164174175176177178179import type { Ref } from 'react';
import type { AppBskyGraphDefs } from '@atcute/bluesky';
import { useListBlockMutation, useListMuteMutation } from '#/state/queries/list';import {3 unmodified lines} from '#/state/queries/preferences';import { useSession } from '#/state/session';
import { Spinner } from '#/components/Spinner';import { Text } from '#/components/Text';import * as Toast from '#/components/Toast';import { Button, ButtonIcon, ButtonText } from '#/components/web/Button';import * as Layout from '#/components/web/Layout';
import PinIcon from '#/icons/central/Thumbtack_round_outlined_radius1_stroke2.svg';import { m } from '#/paraglide/messages';import { useRouter } from '#/router';
import { ListHeader } from './ListHeader';import { ListByline, ListHeader } from './ListHeader';import { MoreOptionsMenu } from './MoreOptionsMenu';import { SubscribeMenu } from './SubscribeMenu';
export function Header({ bottomBorder, list, preferences, ref,}: { bottomBorder?: boolean; list: AppBskyGraphDefs.ListView; preferences: UsePreferencesQueryResponse; ref?: Ref<HTMLDivElement>;}) { const router = useRouter(); const { currentAccount } = useSession(); const canGoBack = router.canGoBack;10 unmodified lines const isPending = isAddSavedFeedPending || isUpdatingSavedFeeds;
const savedFeedConfig = preferences?.savedFeeds?.find((f) => f.value === list.uri); const isPinned = !!savedFeedConfig?.pinned; const isOwner = list.creator.did === currentAccount?.did;
const onTogglePinned = async () => { try { if (savedFeedConfig) {44 unmodified lines };
return ( <> <Layout.Header.Outer noBottomBorder sticky={false}> <Layout.Header.Outer noBottomBorder ref={ref}> {canGoBack ? <Layout.Header.BackButton /> : <Layout.Header.MenuButton />} <Layout.Header.Content /> <Layout.Header.Content className={Layout.ScrollAway.reveal}> <Text numberOfLines={1} size="lg" weight="semiBold"> {list.name || ''} </Text> <Text color="textContrastMedium" numberOfLines={1} size="sm"> <ListByline isOwner={isOwner} list={list} /> </Text> </Layout.Header.Content> <Layout.Header.Slot> {isCurateList ? ( <Button color={isPinned ? 'secondary' : 'primary_subtle'}46 unmodified lines ) ) : null} <MoreOptionsMenu list={list} /> </Layout.Header.Slot> <Layout.ScrollAway.Backdrop /> </Layout.Header.Outer> <ListHeader list={list} isOwner={list.creator.did === currentAccount?.did} bottomBorder={bottomBorder} /> <Layout.ScrollAway.Region> <ListHeader list={list} isOwner={isOwner} bottomBorder={bottomBorder} /> </Layout.ScrollAway.Region> </> );}1234569 unmodified lines161718192021222324252627282930313233343536373839404142434445464748495051525354555612 unmodified lines697071723873747576777844798081828319 unmodified lines10310410510672737475767778798081821071081091101113 unmodified linesimport type { ReactNode } from 'react';
import type { AppBskyGraphDefs } from '@atcute/bluesky';
import { profileTarget } from '#/lib/routes/targets';
9 unmodified linesimport { m } from '#/paraglide/messages';
import * as css from './ListHeader.css';
/** * renders a localized list byline. * * @param creator custom creator content * @param isOwner whether the viewer owns the list * @param list displayed list */export function ListByline({ creator, isOwner, list,}: { creator?: ReactNode; isOwner: boolean; list: AppBskyGraphDefs.ListView;}) { const renderCreator = () => creator ?? list.creator.handle;
if (list.purpose === 'app.bsky.graph.defs#modlist') { return isOwner ? ( m['view.profile.list.moderationByYou']() ) : ( <Trans message={m['view.profile.list.moderationBy']} markup={{ t0: renderCreator }} /> ); }
return isOwner ? ( m['view.profile.list.byYou']() ) : ( <Trans message={m['view.profile.list.by']} markup={{ t0: renderCreator }} /> );}
export function ListHeader({ bottomBorder, isOwner, list,12 unmodified lines }); } };
const creatorLink = () => ( const creatorLink = ( <InlineLinkText to={profileTarget(list.creator.did)} label={m['screens.profile.avatar.a11y.viewProfile']({ handle: list.creator.handle })} color="textContrastMedium" > {list.creator.handle || ''} {list.creator.handle} </InlineLinkText> );
const descriptionRT = list.description19 unmodified lines <Text size="xl" weight="semiBold" numberOfLines={2}> {list.name || ''} </Text> <Text color="textContrastMedium" numberOfLines={1}> {list.purpose === 'app.bsky.graph.defs#modlist' ? ( isOwner ? ( m['view.profile.list.moderationByYou']() ) : ( <Trans message={m['view.profile.list.moderationBy']} markup={{ t0: creatorLink }} /> ) ) : isOwner ? ( m['view.profile.list.byYou']() ) : ( <Trans message={m['view.profile.list.by']} markup={{ t0: creatorLink }} /> )} <ListByline creator={creatorLink} isOwner={isOwner} list={list} /> </Text> </div> </div> {descriptionRT ? <RichText value={descriptionRT} /> : null}3 unmodified lines