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
8 changes: 6 additions & 2 deletions packages/shared/src/components/buttons/CardAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,22 @@ 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<CardActionDensity, ButtonSize> = {
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.
const densityToIconSize: Record<CardActionDensity, IconSize> = {
// `tight` is the feed-card tier, sized so six actions with counters fit the
// 272px min card width without shrinking.
export const densityToIconSize: Record<CardActionDensity, IconSize> = {
comfortable: IconSize.Small,
compact: IconSize.XSmall,
tight: IconSize.Size16,
};

type IconElement = React.ReactElement<IconProps>;
Expand Down
4 changes: 3 additions & 1 deletion packages/shared/src/components/buttons/CardActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ export type CardActionBarLayout =

const layoutToClass: Record<CardActionBarLayout, string> = {
default: 'gap-1',
feedCard: 'flex-1 min-w-0 gap-1 justify-between',
// No `gap`: `justify-between` already spreads the actions, and since buttons
// never shrink 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',
};
Expand Down
88 changes: 88 additions & 0 deletions packages/shared/src/components/cards/common/ActionButtons.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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(),
}));

// jsdom reports every media query as unmatched, so the viewport is forced:
// the award gate must behave the same on both sides of the laptop breakpoint.
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: () => <div data-testid="award-action" />,
}));

const mockImpressions = (enabled: boolean) =>
jest.mocked(usePostImpressions).mockReturnValue({
enabled,
showImpressions: enabled,
impressions: enabled ? 1000 : 0,
});

const renderComponent = (variant: ActionButtonsVariant) =>
render(
<TestBootProvider client={new QueryClient()}>
<ActionButtons post={post} variant={variant} />
</TestBootProvider>,
);

const variants: ActionButtonsVariant[] = ['grid', 'list', 'signal'];

describe.each([
[false, false],
[false, true],
[true, false],
[true, true],
])('ActionButtons (v2: %s, laptop: %s)', (isV2, isLaptop) => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(useEngagementBarV2).mockReturnValue(isV2);
jest.mocked(useViewSize).mockReturnValue(isLaptop);
});

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();
},
);
});
53 changes: 27 additions & 26 deletions packages/shared/src/components/cards/common/ActionButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,22 @@ import {
LinkIcon,
DownvoteIcon,
} from '../../icons';
import { ButtonColor, ButtonSize, ButtonVariant } from '../../buttons/Button';
import { useFeedPreviewMode, useViewSize, ViewSize } from '../../../hooks';
import { ButtonColor, ButtonVariant } from '../../buttons/Button';
import { useFeedPreviewMode } from '../../../hooks';
import { UpvoteButtonIcon } from './UpvoteButtonIcon';
import { BookmarkButton } from '../../buttons';
import { IconSize } from '../../Icon';
import { Tooltip } from '../../tooltip/Tooltip';
import PostAwardAction from '../../post/PostAwardAction';
import ConditionalWrapper from '../../ConditionalWrapper';
import { PostTagsPanel } from '../../post/block/PostTagsPanel';
import { LinkWithTooltip } from '../../tooltips/LinkWithTooltip';
import { useCardActions } from '../../../hooks/cards/useCardActions';
import {
actionCounterClassName as counterClassName,
actionCounterLabelClassName as counterLabelClassName,
FEED_ACTION_BUTTON_SIZE,
FEED_ACTION_ICON_SIZE,
} from './actionCounter';
import { useBrandSponsorship } from '../../../hooks/useBrandSponsorship';
import { usePostImpressionsModal } from '../../../hooks/post/usePostImpressionsModal';
import { usePostImpressions } from '../../../hooks/post/usePostImpressions';
Expand All @@ -45,22 +50,24 @@ export interface ActionButtonsProps {

const variantConfig = {
grid: {
buttonSize: ButtonSize.Small,
iconSize: IconSize.XSmall,
containerClassName: 'px-1 pb-1',
buttonSize: FEED_ACTION_BUTTON_SIZE,
iconSize: FEED_ACTION_ICON_SIZE,
// Asymmetric: an icon sits on the left edge and the impressions number on
// the right, which needs more room to look optically centred.
containerClassName: 'py-1.5 pl-1 pr-2.5',
showTagsPanel: false,
useCommentLink: false,
},
list: {
buttonSize: ButtonSize.Small,
iconSize: IconSize.XSmall,
buttonSize: FEED_ACTION_BUTTON_SIZE,
iconSize: FEED_ACTION_ICON_SIZE,
containerClassName: '',
showTagsPanel: true,
useCommentLink: true,
},
signal: {
buttonSize: ButtonSize.Small,
iconSize: IconSize.XSmall,
buttonSize: FEED_ACTION_BUTTON_SIZE,
iconSize: FEED_ACTION_ICON_SIZE,
containerClassName: '',
showTagsPanel: false,
useCommentLink: true,
Expand All @@ -81,14 +88,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 {
Expand Down Expand Up @@ -145,7 +145,7 @@ const ActionButtonsV1 = ({
href={post.commentsPermalink}
>
<QuaternaryButton
labelClassName="!pl-0"
labelClassName={counterLabelClassName}
id={`post-${post.id}-comment-btn`}
className="btn-tertiary-blueCheese pointer-events-auto"
color={ButtonColor.BlueCheese}
Expand All @@ -171,7 +171,7 @@ const ActionButtonsV1 = ({
) : (
<Tooltip content="Comments" side="bottom">
<QuaternaryButton
labelClassName="!pl-[1px]"
labelClassName={counterLabelClassName}
id={`post-${post.id}-comment-btn`}
icon={<CommentIcon secondary={post.commented} size={iconSize} />}
pressed={post.commented}
Expand Down Expand Up @@ -206,7 +206,7 @@ const ActionButtonsV1 = ({
side={variant === 'grid' ? 'bottom' : undefined}
>
<QuaternaryButton
labelClassName={variant === 'grid' ? '!pl-[1px]' : '!pl-0'}
labelClassName={counterLabelClassName}
className="btn-tertiary-avocado pointer-events-auto"
id={`post-${post.id}-upvote-btn`}
color={ButtonColor.Avocado}
Expand Down Expand Up @@ -250,11 +250,12 @@ const ActionButtonsV1 = ({
/>
</Tooltip>
)}
{/* 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) && (
<PostAwardAction post={post} iconSize={iconSize} />
{showAwardAction && !impressionsEnabled && (
Comment thread
tsahimatsliah marked this conversation as resolved.
<PostAwardAction
post={post}
iconSize={iconSize}
buttonSize={buttonSize}
/>
)}
<BookmarkButton
tooltipSide={variant === 'grid' ? 'bottom' : undefined}
Expand Down Expand Up @@ -293,7 +294,7 @@ const ActionButtonsV1 = ({
side={variant === 'grid' ? 'bottom' : undefined}
>
<QuaternaryButton
labelClassName={variant === 'grid' ? '!pl-[1px]' : '!pl-0'}
labelClassName={counterLabelClassName}
id={`post-${post.id}-impressions-btn`}
size={buttonSize}
icon={<AnalyticsIcon size={iconSize} />}
Expand Down
13 changes: 6 additions & 7 deletions packages/shared/src/components/cards/common/ActionButtons.v2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -39,11 +39,13 @@ export interface ActionButtonsProps {
showAwardAction?: boolean;
}

const FEED_CARD_DENSITY = 'compact';
const FEED_CARD_DENSITY = 'tight';

const variantConfig = {
grid: {
containerClassName: 'px-1 pb-1',
// Matches the v1 bar: `py-1.5` holds the row at 36px around the h-6
// buttons, and the wider right edge gives the trailing number room.
containerClassName: 'py-1.5 pl-1 pr-2.5',
showTagsPanel: false,
useCommentLink: false,
},
Expand Down Expand Up @@ -73,9 +75,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 {
Expand Down Expand Up @@ -207,7 +206,7 @@ const ActionButtons = ({
/>
</Tooltip>
)}
{showAwardAction && (!impressionsEnabled || isLaptop) && (
{showAwardAction && !impressionsEnabled && (
<PostAwardAction post={post} density={FEED_CARD_DENSITY} />
)}
<BookmarkButton
Expand Down
10 changes: 10 additions & 0 deletions packages/shared/src/components/cards/common/actionCounter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ButtonSize } from '../../buttons/common';
import { IconSize } from '../../Icon';

// Six actions with counters have to fit the 272px min card width on their
// intrinsic widths alone, because buttons never shrink (global flex-shrink: 0).
export const FEED_ACTION_BUTTON_SIZE = ButtonSize.XSmall;
export const FEED_ACTION_ICON_SIZE = IconSize.Size16;

export const actionCounterClassName = 'tabular-nums typo-footnote';
export const actionCounterLabelClassName = '!pl-0.5 pr-0.5';
Loading
Loading