Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,27 @@ it('should display the collection source stack in the header', async () => {
await screen.findByAltText('Avatar of src2');
});

it('should link each source in the stack to its source page', async () => {
renderComponent({
post: {
...post,
collectionSources: [
{
id: 'src1',
handle: 'src1',
name: 'Source One',
image: 'https://daily.dev/src1.png',
permalink: 'https://daily.dev/sources/src1',
},
],
numCollectionSources: 1,
} as Post,
});
const link = await screen.findByLabelText('Go to Source One');
expect(link).toHaveAttribute('href', 'https://daily.dev/sources/src1');
expect(link).toContainElement(screen.getByAltText('Avatar of src1'));
});

it('should show options button on hover when in laptop size', async () => {
renderComponent();
const header = await screen.findByLabelText('Options');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import type { ReactElement, ReactNode } from 'react';
import React from 'react';
import classNames from 'classnames';
import { Pill } from '../../Pill';
import type { SourceAvatarProps } from '../../profile/source';
import { SourceAvatar } from '../../profile/source';
import type { LinkableSource } from '../../profile/source/SourceAvatarLink';
import { SourceAvatarLink } from '../../profile/source/SourceAvatarLink';
import { ProfilePictureGroup } from '../../ProfilePictureGroup';
import { ProfileImageSize } from '../../ProfilePicture';

Expand All @@ -12,7 +12,7 @@ interface CollectionPillSourcesProps {
main?: string;
avatar?: string;
};
sources: SourceAvatarProps['source'][];
sources: LinkableSource[];
alwaysShowSources?: boolean;
totalSources: number;
size?: ProfileImageSize;
Expand Down Expand Up @@ -49,8 +49,8 @@ export const CollectionPillSources = ({
limit={limit}
>
{sources.map((source) => (
<SourceAvatar
className={classNames(
<SourceAvatarLink
avatarClassName={classNames(
'-my-0.5 !mr-0 box-content border-2 border-background-default',
className?.avatar,
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { ReactElement } from 'react';
import React from 'react';
import classNames from 'classnames';
import dynamic from 'next/dynamic';
import { SourceAvatar } from '../../profile/source/SourceAvatar';
import { SourceAvatarLink } from '../../profile/source/SourceAvatarLink';
import { ProfileImageSize, sizeClasses } from '../../ProfilePicture';
import HoverCard from '../../cards/common/HoverCard';
import type { SourceTooltip } from '../../../graphql/sources';
Expand Down Expand Up @@ -139,16 +139,13 @@ export const CollectionSourceStack = ({
{shown.map((source, index) =>
withHoverCard(
source,
<div
<SourceAvatarLink
style={{ ...circleStyle(index), zIndex: shown.length - index }}
className={classNames('relative rounded-full', marginClass)}
>
<SourceAvatar
className="!mr-0 box-content ring-2 ring-background-default"
source={source}
size={size}
/>
</div>,
avatarClassName="!mr-0 box-content ring-2 ring-background-default"
source={source}
size={size}
/>,
),
)}
{remaining > 0 && (
Expand Down
67 changes: 67 additions & 0 deletions packages/shared/src/components/profile/source/SourceAvatarLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { HTMLAttributes, ReactElement, Ref } from 'react';
import React, { forwardRef } from 'react';
import classNames from 'classnames';
import Link from '../../utilities/Link';
import { SourceAvatar } from './SourceAvatar';
import type { Source } from '../../../graphql/sources';
import { ProfileImageSize } from '../../ProfilePicture';

export type LinkableSource = Pick<Source, 'image' | 'handle'> &
Partial<Pick<Source, 'name' | 'permalink'>>;

export interface SourceAvatarLinkProps extends HTMLAttributes<HTMLElement> {
source: LinkableSource;
size?: ProfileImageSize;
avatarClassName?: string;
}

/**
* A source avatar that navigates to the source (or squad) page, matching the
* behavior of the single source avatar on article cards. Falls back to a bare
* avatar when the query that produced the source did not select `permalink`.
*/
function SourceAvatarLinkComponent(
{
source,
size = ProfileImageSize.Medium,
className,
avatarClassName,
...props
}: SourceAvatarLinkProps,
ref?: Ref<HTMLAnchorElement>,
): ReactElement {
const avatar = (
<SourceAvatar className={avatarClassName} source={source} size={size} />
);

// No permalink means nothing to link to, but the ref still has to reach a DOM
// node: Radix's hover card trigger composes one onto this component and never
// opens without it. A span (not an href-less anchor) also keeps the click
// falling through to the card link, since `.card a` gets pointer-events: all.
if (!source?.permalink) {
return (
<span
{...props}
ref={ref as Ref<HTMLSpanElement>}
className={classNames('flex', className)}
>
{avatar}
</span>
);
}

return (
<Link href={source.permalink} passHref prefetch={false}>
<a
{...props}
ref={ref}
aria-label={`Go to ${source.name ?? source.handle}`}
className={classNames('pointer-events-auto flex', className)}
>
{avatar}
</a>
</Link>
);
}

export const SourceAvatarLink = forwardRef(SourceAvatarLinkComponent);
9 changes: 9 additions & 0 deletions packages/shared/src/features/profile/common.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
/**
* The profile page wraps every section in `p-6`, so a horizontally scrolling
* strip clips 24px short of the page edge — the content looks abruptly cut off
* instead of continuing off-screen. Cancel that padding on the scroll container
* and re-apply it inside, so the strip clips at the card edge while its items
* stay aligned with the section heading.
*/
export const profileStripBleed = '-mx-6 px-6';

export const profileSecondaryFieldStyles = {
outerLabel: '!px-0 !typo-callout',
baseField: '!h-12',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,14 @@ export const COMMENT_CLASS_NAME = {
} as const;

export const MIN_ITEMS_FOR_SHOW_MORE = 3;
// The bleed classes are the `profileStripBleed` pair scoped to the feed's own
// scroll container, so the strip clips at the card edge rather than 24px short
// of it. `scroll-px-6` keeps snapped cards aligned with the section heading.
// They match on `.grid.snap-x` (only FeedContainer's horizontal scroller has
// both) rather than plain `.grid`: a bare `grid` class also appears inside list
// cards, and margins there would visibly break them.
export const HORIZONTAL_FEED_CLASSES =
'[&_.grid]:!auto-cols-[17rem] tablet:[&_.grid]:!auto-cols-[20rem] [&_.grid]:gap-4';
'[&_.grid]:!auto-cols-[17rem] tablet:[&_.grid]:!auto-cols-[20rem] [&_.grid]:gap-4 [&_.grid.snap-x]:-mx-6 [&_.grid.snap-x]:px-6 [&_.grid.snap-x]:scroll-px-6';
export const TAB_ITEMS = activityTabs.map((tab) => ({ label: tab.title }));

export const ACTIVITY_QUERY_KEYS = {
Expand Down
13 changes: 12 additions & 1 deletion packages/shared/src/features/profile/components/Activity.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ReactElement } from 'react';
import React, { useContext, useState, useMemo, useCallback } from 'react';
import classNames from 'classnames';
import type { PublicProfile } from '../../../lib/user';
import AuthContext from '../../../contexts/AuthContext';
import { ActivityTabIndex, activityTabs } from './Activity.helpers';
Expand Down Expand Up @@ -71,7 +72,17 @@ export const Activity = ({ user }: ActivityProps): ReactElement | null => {
}

return (
<div className="mb-4 flex flex-col gap-3 overflow-hidden pt-6">
<div
className={classNames(
'mb-4 flex flex-col gap-3 pt-6',
// The posts/upvoted feeds scroll themselves and bleed past this box to
// the card edge (see HORIZONTAL_FEED_CLASSES), so clipping here would
// cut them short of the page edge again. Replies are plain content with
// no scroller of their own, so they keep a guard against wide markdown
// widening the page. `clip` over `hidden` so popovers still escape on y.
selectedTabIndex === ActivityTabIndex.Replies && 'overflow-x-clip',
)}
>
{renderContent()}
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useConditionalFeature } from '../../../../hooks/useConditionalFeature';
import { achievementTrackingWidgetFeature } from '../../../../lib/featureManagement';
import { useProfileAchievements } from '../../../../hooks/profile/useProfileAchievements';
import { shouldShowAchievementTracker } from '../../../../lib/achievements';
import { profileStripBleed } from '../../common';

const BadgesAndAwards = dynamic(() =>
import('./BadgesAndAwards').then((mod) => mod.BadgesAndAwards),
Expand Down Expand Up @@ -110,7 +111,12 @@ export function ProfileWidgets({
return (
<div
className={classNames(
// Below laptop this is the profile page's scrolling "Highlights" strip,
// so it bleeds past the page's p-6 to clip at the card edge. At laptop
// it becomes the sidebar column and sits inside the padding again.
'my-4 flex gap-2 laptop:my-0 laptop:flex-col',
profileStripBleed,
'laptop:mx-0 laptop:px-0',
className,
)}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { AchievementCard } from './AchievementCard';
import { RaritySparkles } from './RaritySparkles';
import { useLazyModal } from '../../../../hooks/useLazyModal';
import { LazyModal } from '../../../../components/modals/common/types';
import { profileStripBleed } from '../../common';

interface ProfileAchievementShowcaseProps {
user: PublicProfile;
Expand Down Expand Up @@ -66,7 +67,7 @@ export function ProfileAchievementShowcase({
}

return (
<div className="flex flex-col gap-4 py-4">
<div className="flex min-w-0 flex-col gap-4 py-4">
<div className="flex items-center justify-between">
<Typography
type={TypographyType.Body}
Expand All @@ -88,7 +89,14 @@ export function ProfileAchievementShowcase({
</div>

{hasShowcase ? (
<div className="flex gap-3">
// -my-2/py-2 keeps the rarity glow and sparkles, which deliberately
// escape each tile, from being clipped by the scroll container
<div
className={classNames(
'no-scrollbar -my-2 flex gap-3 overflow-x-auto py-2',
profileStripBleed,
)}
>
{showcaseAchievements.map((userAchievement) => {
const { achievement } = userAchievement;
const rarityTier = getAchievementRarityTier(achievement.rarity);
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/src/graphql/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,8 @@ export const POST_BY_ID_QUERY = gql`
collectionSources {
handle
image
name
permalink
}
}
relatedCollectionPosts: relatedPosts(
Expand Down Expand Up @@ -502,6 +504,8 @@ export const POST_BY_ID_STATIC_FIELDS_QUERY = gql`
collectionSources {
handle
image
name
permalink
}
sharedPost {
...SharedPostInfo
Expand Down
Loading