diff --git a/packages/shared/src/components/buttons/CardAction.tsx b/packages/shared/src/components/buttons/CardAction.tsx index f5e64df8b7d..70309415c5f 100644 --- a/packages/shared/src/components/buttons/CardAction.tsx +++ b/packages/shared/src/components/buttons/CardAction.tsx @@ -14,18 +14,23 @@ import { ButtonSize, ButtonVariant, ButtonIconPosition } from './common'; import type { ColorName } from '../../styles/colors'; import InteractionCounter from '../InteractionCounter'; -export type CardActionDensity = 'comfortable' | 'compact'; +export type CardActionDensity = 'comfortable' | 'compact' | 'tight'; const densityToSize: Record = { comfortable: ButtonSize.Medium, compact: ButtonSize.Small, + tight: ButtonSize.XSmall, }; // Larger than buttonSizeToIconSizeV2: engagement-bar icons sit closer // to a 60% ratio (Material 3, Instagram, Reddit) so they read at a glance. +// `tight` is the feed-card tier: buttons never shrink (global flex-shrink: 0), +// so six actions plus counters have to fit the 272px min card width on their +// intrinsic widths alone. It matches the floating glass bar. const densityToIconSize: Record = { comfortable: IconSize.Small, compact: IconSize.XSmall, + tight: IconSize.Size16, }; type IconElement = React.ReactElement; diff --git a/packages/shared/src/components/buttons/CardActionBar.tsx b/packages/shared/src/components/buttons/CardActionBar.tsx index 6b0a9233a43..d03a55ba766 100644 --- a/packages/shared/src/components/buttons/CardActionBar.tsx +++ b/packages/shared/src/components/buttons/CardActionBar.tsx @@ -10,7 +10,10 @@ export type CardActionBarLayout = const layoutToClass: Record = { default: 'gap-1', - feedCard: 'flex-1 min-w-0 gap-1 justify-between', + // No `gap` on the feed row: `justify-between` already spreads the actions + // whenever there is slack, and because buttons never shrink (global + // flex-shrink: 0) a gap only adds width the 272px min card cannot give back. + feedCard: 'flex-1 min-w-0 justify-between', between: 'gap-1 justify-between w-full', compact: 'gap-0.5', }; diff --git a/packages/shared/src/components/cards/common/ActionButtons.spec.tsx b/packages/shared/src/components/cards/common/ActionButtons.spec.tsx new file mode 100644 index 00000000000..b14c847071d --- /dev/null +++ b/packages/shared/src/components/cards/common/ActionButtons.spec.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import ActionButtons from './ActionButtons'; +import type { ActionButtonsVariant } from './ActionButtons'; +import post from '../../../../__tests__/fixture/post'; +import { TestBootProvider } from '../../../../__tests__/helpers/boot'; +import { usePostImpressions } from '../../../hooks/post/usePostImpressions'; +import { useEngagementBarV2 } from '../../../hooks/useEngagementBarV2'; +import { useViewSize } from '../../../hooks/useViewSize'; + +jest.mock('../../../hooks/post/usePostImpressions', () => ({ + usePostImpressions: jest.fn(), +})); + +// Awards used to survive on laptop when impressions were enabled, and jsdom +// reports every media query as unmatched, so the viewport has to be forced. +jest.mock('../../../hooks/useViewSize', () => ({ + ...jest.requireActual('../../../hooks/useViewSize'), + useViewSize: jest.fn(), +})); + +jest.mock('../../../hooks/post/usePostImpressionsModal', () => ({ + usePostImpressionsModal: () => jest.fn(), +})); + +jest.mock('../../../hooks/useEngagementBarV2', () => ({ + useEngagementBarV2: jest.fn(), +})); + +jest.mock('../../post/PostAwardAction', () => ({ + __esModule: true, + default: () =>
, +})); + +const mockImpressions = (enabled: boolean) => + jest.mocked(usePostImpressions).mockReturnValue({ + enabled, + showImpressions: enabled, + impressions: enabled ? 1000 : 0, + }); + +const renderComponent = (variant: ActionButtonsVariant) => + render( + + + , + ); + +const variants: ActionButtonsVariant[] = ['grid', 'list', 'signal']; + +describe.each([false, true])('ActionButtons (v2: %s)', (isV2) => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useEngagementBarV2).mockReturnValue(isV2); + jest.mocked(useViewSize).mockReturnValue(true); + }); + + it.each(variants)( + 'hides the award action on a %s card when impressions are enabled', + (variant) => { + mockImpressions(true); + + renderComponent(variant); + + expect(screen.queryByTestId('award-action')).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Impressions' }), + ).toBeInTheDocument(); + }, + ); + + it.each(variants)( + 'keeps the award action on a %s card when impressions are disabled', + (variant) => { + mockImpressions(false); + + renderComponent(variant); + + expect(screen.getByTestId('award-action')).toBeInTheDocument(); + }, + ); +}); diff --git a/packages/shared/src/components/cards/common/ActionButtons.tsx b/packages/shared/src/components/cards/common/ActionButtons.tsx index effbd994982..d324d43f6d4 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.tsx @@ -11,7 +11,7 @@ import { DownvoteIcon, } from '../../icons'; import { ButtonColor, ButtonSize, ButtonVariant } from '../../buttons/Button'; -import { useFeedPreviewMode, useViewSize, ViewSize } from '../../../hooks'; +import { useFeedPreviewMode } from '../../../hooks'; import { UpvoteButtonIcon } from './UpvoteButtonIcon'; import { BookmarkButton } from '../../buttons'; import { IconSize } from '../../Icon'; @@ -43,30 +43,43 @@ export interface ActionButtonsProps { showAwardAction?: boolean; } +// Sizing follows the floating glass bar (FeedCardGlassActions): XSmall buttons +// with 16px icons, so six actions incl. the impressions counter fit a 272px +// card without the trailing number reaching the edge. const variantConfig = { grid: { - buttonSize: ButtonSize.Small, - iconSize: IconSize.XSmall, - containerClassName: 'px-1 pb-1', + buttonSize: ButtonSize.XSmall, + iconSize: IconSize.Size16, + // Asymmetric like the glass pill: the left edge holds an icon, the right + // edge holds the impressions number, which needs more room to sit right. + // `py-1.5` around the h-6 buttons gives the row the same 36px height the + // bar had at the previous (wider) button size, so shrinking the actions to + // fit the 272px card doesn't make the bar read as cramped vertically. + containerClassName: 'py-1.5 pl-1 pr-2.5', showTagsPanel: false, useCommentLink: false, }, list: { - buttonSize: ButtonSize.Small, - iconSize: IconSize.XSmall, + buttonSize: ButtonSize.XSmall, + iconSize: IconSize.Size16, containerClassName: '', showTagsPanel: true, useCommentLink: true, }, signal: { - buttonSize: ButtonSize.Small, - iconSize: IconSize.XSmall, + buttonSize: ButtonSize.XSmall, + iconSize: IconSize.Size16, containerClassName: '', showTagsPanel: false, useCommentLink: true, }, } as const; +// Monospaced digits so counters never jitter, and a hair of padding on each +// side of the number — both taken from the glass bar. +const counterClassName = 'tabular-nums typo-footnote'; +const counterLabelClassName = '!pl-0.5 pr-0.5'; + const ActionButtonsV1 = ({ post, onUpvoteClick, @@ -81,14 +94,7 @@ const ActionButtonsV1 = ({ }: ActionButtonsProps): ReactElement | null => { const config = variantConfig[variant]; const isFeedPreview = useFeedPreviewMode(); - const isLaptop = useViewSize(ViewSize.Laptop); const { buttonSize, iconSize } = config; - // On mobile/tablet keep full-size icons but shrink the count so the icon - // reads as the primary affordance and the number as a subtle stat. - const counterClassName = classNames( - 'tabular-nums', - isLaptop ? variant === 'grid' && 'typo-footnote' : 'typo-caption1', - ); const { getUpvoteAnimation } = useBrandSponsorship(); const { @@ -145,7 +151,7 @@ const ActionButtonsV1 = ({ href={post.commentsPermalink} > } pressed={post.commented} @@ -206,7 +212,7 @@ const ActionButtonsV1 = ({ side={variant === 'grid' ? 'bottom' : undefined} > )} - {/* When impressions are enabled, drop awards below laptop to make room - for the extra action; with the flag off, awards stay on every - viewport (unchanged from control). */} - {showAwardAction && (!impressionsEnabled || isLaptop) && ( + {/* Impressions take the award slot on feed cards; with the flag off, + awards stay (unchanged from control). */} + {showAwardAction && !impressionsEnabled && ( )} } diff --git a/packages/shared/src/components/cards/common/ActionButtons.v2.tsx b/packages/shared/src/components/cards/common/ActionButtons.v2.tsx index 3bdf0f9270c..d5ea8c33091 100644 --- a/packages/shared/src/components/cards/common/ActionButtons.v2.tsx +++ b/packages/shared/src/components/cards/common/ActionButtons.v2.tsx @@ -11,7 +11,7 @@ import { DownvoteIcon, } from '../../icons'; import { ButtonColor } from '../../buttons/ButtonV2'; -import { useFeedPreviewMode, useViewSize, ViewSize } from '../../../hooks'; +import { useFeedPreviewMode } from '../../../hooks'; import { UpvoteButtonIcon } from './UpvoteButtonIcon'; import { BookmarkButton } from '../../buttons/BookmarkButton.v2'; import { Tooltip } from '../../tooltip/Tooltip'; @@ -39,11 +39,14 @@ export interface ActionButtonsProps { showAwardAction?: boolean; } -const FEED_CARD_DENSITY = 'compact'; +const FEED_CARD_DENSITY = 'tight'; const variantConfig = { grid: { - containerClassName: 'px-1 pb-1', + // Same padding as the v1 bar and the glass pill: `py-1.5` keeps the row at + // 36px around the h-6 buttons, and the wider right edge gives the trailing + // impressions number room so it doesn't read as touching the card. + containerClassName: 'py-1.5 pl-1 pr-2.5', showTagsPanel: false, useCommentLink: false, }, @@ -73,9 +76,6 @@ const ActionButtons = ({ }: ActionButtonsProps): ReactElement | null => { const config = variantConfig[variant]; const isFeedPreview = useFeedPreviewMode(); - // When impressions are enabled, awards are hidden below laptop (tablet + - // mobile) to make room for the extra action. - const isLaptop = useViewSize(ViewSize.Laptop); const { getUpvoteAnimation } = useBrandSponsorship(); const { @@ -207,7 +207,7 @@ const ActionButtons = ({ /> )} - {showAwardAction && (!impressionsEnabled || isLaptop) && ( + {showAwardAction && !impressionsEnabled && ( )} ({ + useAuthContext: jest.fn(), +})); + +jest.mock('../../hooks/useCoresFeature', () => ({ + useCanAwardUser: jest.fn(), +})); + +jest.mock('../../hooks/useEngagementBarV2', () => ({ + useEngagementBarV2: jest.fn(), +})); + +jest.mock('../../hooks/useLazyModal', () => ({ + useLazyModal: () => ({ openModal: jest.fn() }), +})); + +const post = { + id: 'p1', + numAwards: 0, +} as Post; + +const mockAuth = (user: Partial | null) => + jest + .mocked(useAuthContext) + .mockReturnValue({ user, showLogin: jest.fn() } as never); + +const renderComponent = (postProps: Partial = {}) => + render( + + + , + ); + +describe.each([false, true])('PostAwardAction (v2: %s)', (isV2) => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useEngagementBarV2).mockReturnValue(isV2); + jest.mocked(useCanAwardUser).mockReturnValue(false); + }); + + it('stays hidden for a logged out user on a post without an author', () => { + mockAuth(null); + + renderComponent(); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('stays hidden for a logged in user on a post without an author', () => { + mockAuth({ id: 'u1' }); + + renderComponent(); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('renders on the logged in user own post', () => { + mockAuth({ id: 'u1' }); + + renderComponent({ author: { id: 'u1' } as Post['author'] }); + + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('renders when the author can be awarded', () => { + mockAuth({ id: 'u1' }); + jest.mocked(useCanAwardUser).mockReturnValue(true); + + renderComponent({ author: { id: 'u2' } as Post['author'] }); + + expect(screen.getByRole('button')).toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/post/PostAwardAction.tsx b/packages/shared/src/components/post/PostAwardAction.tsx index 34d876fb367..48d5a815d6b 100644 --- a/packages/shared/src/components/post/PostAwardAction.tsx +++ b/packages/shared/src/components/post/PostAwardAction.tsx @@ -15,18 +15,19 @@ import { AuthTriggers } from '../../lib/auth'; import { LazyModal } from '../modals/common/types'; import type { LoggedUser } from '../../lib/user'; import { useEngagementBarV2 } from '../../hooks/useEngagementBarV2'; +import type { CardActionDensity } from '../buttons/CardAction'; import PostAwardActionV2 from './PostAwardAction.v2'; export interface PostAwardActionProps { post: Post; iconSize?: IconSize; - density?: 'comfortable' | 'compact'; + density?: CardActionDensity; } const PostAwardActionV1 = ({ post, iconSize }: PostAwardActionProps) => { const { openModal } = useLazyModal(); const { user, showLogin } = useAuthContext(); - const isSameUser = user?.id === post?.author?.id; + const isSameUser = !!user?.id && user.id === post?.author?.id; const canAward = useCanAwardUser({ sendingUser: user, receivingUser: post?.author as LoggedUser, diff --git a/packages/shared/src/components/post/PostAwardAction.v2.tsx b/packages/shared/src/components/post/PostAwardAction.v2.tsx index 8c6ec1c4e66..e8368d873fb 100644 --- a/packages/shared/src/components/post/PostAwardAction.v2.tsx +++ b/packages/shared/src/components/post/PostAwardAction.v2.tsx @@ -27,7 +27,7 @@ const PostAwardAction = ({ }: PostAwardActionProps) => { const { openModal } = useLazyModal(); const { user, showLogin } = useAuthContext(); - const isSameUser = user?.id === post?.author?.id; + const isSameUser = !!user?.id && user.id === post?.author?.id; const canAward = useCanAwardUser({ sendingUser: user, receivingUser: post?.author as LoggedUser, diff --git a/packages/storybook/stories/components/cards/ActionBarAlignment.stories.tsx b/packages/storybook/stories/components/cards/ActionBarAlignment.stories.tsx new file mode 100644 index 00000000000..3c852d535db --- /dev/null +++ b/packages/storybook/stories/components/cards/ActionBarAlignment.stories.tsx @@ -0,0 +1,132 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { fn } from 'storybook/test'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { PostType, UserVote } from '@dailydotdev/shared/src/graphql/posts'; +import { ArticleGrid } from '@dailydotdev/shared/src/components/cards/article/ArticleGrid'; +import ExtensionProviders from '../../extension/_providers'; +import { FeatureOverrides } from '../../../mock/GrowthBookProvider'; + +const basePost = { + id: 'article-1', + title: + 'Cloud Run now scales to zero, even when your service uses less than a single CPU or less than 1792 MB of memory', + permalink: 'https://api.daily.dev/r/article-1', + commentsPermalink: 'https://daily.dev/posts/article-1', + createdAt: '2024-01-15T10:30:00.000Z', + readTime: 8, + tags: ['javascript'], + type: PostType.Article, + image: + 'https://media.daily.dev/image/upload/f_auto,q_auto/v1/posts/article-placeholder', + userState: { vote: UserVote.None, flags: { feedbackDismiss: false } }, + source: { + id: 'tds', + handle: 'tds', + name: 'Towards Data Science', + permalink: 'https://app.daily.dev/sources/tds', + image: 'https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/tds', + type: 'machine' as const, + active: true, + }, +} as unknown as Post; + +const makePost = ( + id: string, + numUpvotes: number, + numComments: number, + impressions: number, +): Post => + ({ + ...basePost, + id, + numUpvotes, + numComments, + analytics: { impressions }, + } as Post); + +const cases = [ + { label: '36 · 3 · 52.4K', post: makePost('a', 36, 3, 52400) }, + { label: '100 · 80 · 100K', post: makePost('b', 100, 80, 100000) }, + { label: '200 · 80 · 200K', post: makePost('c', 200, 80, 234500) }, + { label: '9999 · 999 · 1.2M', post: makePost('d', 9999, 999, 1200000) }, +]; + +const handlers = { + onPostClick: fn(), + onPostAuxClick: fn(), + onUpvoteClick: fn(), + onDownvoteClick: fn(), + onCommentClick: fn(), + onBookmarkClick: fn(), + onCopyLinkClick: fn(), + onShare: fn(), + onReadArticleClick: fn(), +}; + +const v1 = { + card_impressions: true, + engagement_bar_v2: false, + feed_card_glass_actions: false, +}; +const v2 = { ...v1, engagement_bar_v2: true }; +const glass = { ...v1, feed_card_glass_actions: true }; + +const Row = ({ + title, + values, + width, +}: { + title: string; + values: Record; + width: string; +}) => ( +
+

{title}

+
+ {cases.map(({ label, post }) => ( +
+

{label}

+ + + +
+ ))} +
+
+); + +const ActionBarAlignment = () => ( + +
+ + + + + + +
+
+); + +const meta: Meta = { + title: 'Components/Cards/ActionBarAlignment', + component: ActionBarAlignment, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Default: StoryObj = {}; diff --git a/packages/webapp/pages/dev/buttons.tsx b/packages/webapp/pages/dev/buttons.tsx index 0b9b222e59a..74e63ff36ce 100644 --- a/packages/webapp/pages/dev/buttons.tsx +++ b/packages/webapp/pages/dev/buttons.tsx @@ -621,14 +621,14 @@ const V1EngagementBar = ({ pressed = false }: { pressed?: boolean }) => ( ); /** - * v2 engagement bar — `density="compact"` + `feedCard` layout, the - * production rule for grid cards. Width footprint matches v1 - * exactly (5 × 32 px), so swapping in is layout-neutral. + * v2 engagement bar — `density="tight"` + `feedCard` layout, the + * production rule for grid cards. 24 px buttons with 16 px icons, + * matching the v1 bar and the floating glass pill. */ const V2EngagementBar = ({ pressed = false }: { pressed?: boolean }) => ( } iconPressed={} @@ -637,28 +637,28 @@ const V2EngagementBar = ({ pressed = false }: { pressed?: boolean }) => ( count={1234} /> } iconPressed={} label="Downvote" color={ButtonColor.Ketchup} /> } label="Comment" color={ButtonColor.BlueCheese} count={42} /> } iconPressed={} label="Bookmark" color={ButtonColor.Bun} /> } label="Copy link" color={ButtonColor.Cabbage} @@ -1141,7 +1141,7 @@ const ButtonsDevPage = (): ReactElement => { Subtle = outlined chip, Material 3 outlined pattern).{' '} Card-action width contract: on a multi-column feed grid (where a card can render at 140 – 280 px), use{' '} - density="compact" on every{' '} + density="tight" on every{' '} CardAction and wrap the row in{' '} <CardActionBar layout="feedCard"> — the bar then sits at flex-1 min-w-0 justify-between{' '} @@ -1389,7 +1389,7 @@ const ButtonsDevPage = (): ReactElement => {
{
} iconPressed={} label="Upvote" color={ButtonColor.Avocado} /> } label="Comment" color={ButtonColor.BlueCheese} /> } iconPressed={} label="Bookmark" color={ButtonColor.Bun} /> } label="Share" /> @@ -1506,7 +1506,7 @@ const ButtonsDevPage = (): ReactElement => {
} iconPressed={} label="Upvote" @@ -1514,21 +1514,21 @@ const ButtonsDevPage = (): ReactElement => { count={1234} /> } label="Comment" color={ButtonColor.BlueCheese} count={42} /> } iconPressed={} label="Bookmark" color={ButtonColor.Bun} /> } label="Share" /> @@ -1584,7 +1584,7 @@ const ButtonsDevPage = (): ReactElement => {
} iconPressed={} @@ -1593,14 +1593,14 @@ const ButtonsDevPage = (): ReactElement => { count={1235} /> } label="Comment" color={ButtonColor.BlueCheese} count={42} /> } iconPressed={} @@ -1608,7 +1608,7 @@ const ButtonsDevPage = (): ReactElement => { color={ButtonColor.Bun} /> } label="Share" /> @@ -1685,34 +1685,34 @@ const ButtonsDevPage = (): ReactElement => {
} iconPressed={} label="Upvote" color={ButtonColor.Avocado} /> } iconPressed={} label="Downvote" color={ButtonColor.Ketchup} /> } label="Comment" color={ButtonColor.BlueCheese} /> } iconPressed={} label="Bookmark" color={ButtonColor.Bun} /> } label="Share" /> @@ -1773,13 +1773,13 @@ const ButtonsDevPage = (): ReactElement => {
- RIGHT @ 272 px · feedCard layout + compact density — bar + RIGHT @ 272 px · feedCard layout + tight density — bar fills 272 px, distributes children, never pushes the card
} iconPressed={} label="Upvote" @@ -1787,28 +1787,28 @@ const ButtonsDevPage = (): ReactElement => { count={1234} /> } iconPressed={} label="Downvote" color={ButtonColor.Ketchup} /> } label="Comment" color={ButtonColor.BlueCheese} count={42} /> } iconPressed={} label="Bookmark" color={ButtonColor.Bun} /> } label="Share" />