From f959b6adb5ef1959bd1a78e4fd3fbd89684998dd Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Jul 2026 15:19:21 +0300 Subject: [PATCH 1/7] feat(share): split copy-link button backed by the DS dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `split` variant to ShareActions: two real buttons that read as one control. The left half copies the link and cross-fades its glyph to a green check; the right half is a chevron that opens the standard DropdownMenu with the social tiles. Geometry matches a standard Button at every size — height, radius, type scale and outer padding — deviating only at the shared edge, where both inner paddings tighten one step and the chevron drops its icon-only square. The divider is a single rule: the chevron half keeps its own DS border while the main half drops `border-r`, since two borders meeting is what reads as a double line. Borderless variants have no border to inherit and draw their own. Co-Authored-By: Claude Opus 4.8 --- .../src/components/share/CopyStateIcon.tsx | 47 +++++ .../src/components/share/ShareActions.tsx | 70 ++++++-- .../src/components/share/SplitShareButton.tsx | 169 ++++++++++++++++++ 3 files changed, 272 insertions(+), 14 deletions(-) create mode 100644 packages/shared/src/components/share/CopyStateIcon.tsx create mode 100644 packages/shared/src/components/share/SplitShareButton.tsx diff --git a/packages/shared/src/components/share/CopyStateIcon.tsx b/packages/shared/src/components/share/CopyStateIcon.tsx new file mode 100644 index 0000000000..6c5defa913 --- /dev/null +++ b/packages/shared/src/components/share/CopyStateIcon.tsx @@ -0,0 +1,47 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { CopyIcon, VIcon } from '../icons'; +import type { IconProps } from '../Icon'; + +/** + * easeOutExpo — the curve the design-system dropdown animates on. It + * decelerates into the target with no overshoot, which is what keeps a swap + * from reading as a wobble. + */ +export const EASE_OUT_EXPO = 'ease-[cubic-bezier(0.16,1,0.3,1)]'; + +/** + * A copy is a rare, deliberate moment, so the confirmation earns real motion. + * Both glyphs share one grid cell so the label never shifts mid-swap, and the + * transition collapses to an instant swap under `prefers-reduced-motion`. + */ +export const CopyStateIcon = ({ + copied, + className, + ...props +}: IconProps & { copied: boolean }): ReactElement => { + const layer = classNames( + className, + 'col-start-1 row-start-1 transition-[opacity,transform,filter] duration-200 motion-reduce:transition-none', + EASE_OUT_EXPO, + ); + + return ( + + + + + ); +}; diff --git a/packages/shared/src/components/share/ShareActions.tsx b/packages/shared/src/components/share/ShareActions.tsx index 50ee53e30e..5103702f77 100644 --- a/packages/shared/src/components/share/ShareActions.tsx +++ b/packages/shared/src/components/share/ShareActions.tsx @@ -5,7 +5,6 @@ import { Popover, PopoverTrigger } from '@radix-ui/react-popover'; import { PopoverContent } from '../popover/Popover'; import { SocialShareList } from '../widgets/SocialShareList'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; -import { CopyIcon } from '../icons'; import { Tooltip } from '../tooltip/Tooltip'; import { Typography, TypographyType } from '../typography/Typography'; import { useViewSize, ViewSize } from '../../hooks/useViewSize'; @@ -13,8 +12,10 @@ import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink'; import { shouldUseNativeShare } from '../../lib/func'; import { ShareProvider } from '../../lib/share'; import type { ReferralCampaignKey } from '../../lib/referral'; +import { CopyStateIcon } from './CopyStateIcon'; +import { SplitShareButton } from './SplitShareButton'; -export type ShareActionsVariant = 'icon' | 'inline'; +export type ShareActionsVariant = 'icon' | 'inline' | 'split'; export interface ShareActionsProps { link: string; @@ -28,6 +29,13 @@ export interface ShareActionsProps { buttonSize?: ButtonSize; /** Tooltip + accessible label for the icon-only trigger. */ label?: string; + /** + * Render `triggerText` beside the icon instead of an icon-only trigger. Gated + * so existing icon-only consumers keep their exact DOM. + */ + triggerText?: string; + /** `split` variant only: label for the chevron half that opens the list. */ + dropdownLabel?: string; emailTitle?: string; emailSummary?: string; className?: string; @@ -46,6 +54,8 @@ export function ShareActions({ buttonVariant = ButtonVariant.Tertiary, buttonSize = ButtonSize.Small, label = 'Copy link', + triggerText, + dropdownLabel = 'More share options', emailTitle, emailSummary, className, @@ -56,6 +66,15 @@ export function ShareActions({ const [copying, shareOrCopy] = useShareOrCopyLink({ link, text, cid }); const closeTimeout = useRef>(); + const onCopy = () => { + onShare?.(ShareProvider.CopyLink); + shareOrCopy(); + }; + + // `copying` stays true for a second after a copy, which is the whole window + // for the confirmation swap. + const copyIcon = ; + const list = ( { - onShare?.(ShareProvider.CopyLink); - shareOrCopy(); - }} + onCopy={onCopy} onNativeShare={() => { onShare?.(ShareProvider.Native); shareOrCopy(); @@ -92,7 +108,7 @@ export function ShareActions({ type="button" variant={buttonVariant} size={buttonSize} - icon={} + icon={copyIcon} aria-label={label} className={className} onClick={() => { @@ -103,11 +119,38 @@ export function ShareActions({ ); shareOrCopy(); }} - /> + > + {triggerText} + ); } + const shareList = ( + <> + + Share + + {list} + + ); + + if (variant === 'split') { + return ( + + ); + } + const cancelClose = () => { if (closeTimeout.current) { clearTimeout(closeTimeout.current); @@ -136,12 +179,14 @@ export function ShareActions({ type="button" variant={buttonVariant} size={buttonSize} - icon={} + icon={copyIcon} aria-label={label} pressed={open} className={className} {...hoverProps} - /> + > + {triggerText} + - - Share - - {list} + {shareList} ); diff --git a/packages/shared/src/components/share/SplitShareButton.tsx b/packages/shared/src/components/share/SplitShareButton.tsx new file mode 100644 index 0000000000..9c3f8d6e3e --- /dev/null +++ b/packages/shared/src/components/share/SplitShareButton.tsx @@ -0,0 +1,169 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { ArrowIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '../dropdown/DropdownMenu'; +import { Tooltip } from '../tooltip/Tooltip'; +import { CopyStateIcon, EASE_OUT_EXPO } from './CopyStateIcon'; + +export interface SplitShareButtonProps { + /** Tooltip and accessible name for the copy half. */ + label: string; + /** Accessible name for the chevron half. */ + dropdownLabel: string; + /** Visible text on the copy half; falls back to `label`. */ + triggerText?: string; + /** Contents of the dropdown the chevron opens. */ + menu: ReactNode; + /** Drives the copy-glyph confirmation swap. */ + copied: boolean; + onCopy: () => void; + variant?: ButtonVariant; + size?: ButtonSize; + className?: string; +} + +/** + * The halves meet at a single hairline, so the standard side paddings would + * leave a canyon around it. Both sides tighten by one step from the DS value — + * the outer edges keep the standard padding, so the control still reads as one + * button. + */ +const MAIN_INNER_PADDING: Record = { + [ButtonSize.XLarge]: '!pr-5', + [ButtonSize.Large]: '!pr-4', + [ButtonSize.Medium]: '!pr-3', + [ButtonSize.Small]: '!pr-2', + [ButtonSize.XSmall]: '!pr-1.5', +}; + +/** Drops the icon-only square so the chevron hugs the seam symmetrically. */ +const CHEVRON_PADDING: Record = { + [ButtonSize.XLarge]: '!w-auto !px-3', + [ButtonSize.Large]: '!w-auto !px-2.5', + [ButtonSize.Medium]: '!w-auto !px-2', + [ButtonSize.Small]: '!w-auto !px-1.5', + [ButtonSize.XSmall]: '!w-auto !px-1', +}; + +const DIVIDER_BASE = + "relative border-l-0 before:absolute before:left-0 before:w-px before:content-['']"; + +/** + * Variants that paint `--button-default-border-color: transparent` have no + * border for the divider to match, so they draw their own 1px rule. Every other + * variant returns nothing and keeps its real border as the divider. + * + * Alpha is mixed into the colour rather than applied as a separate utility: + * `before:opacity-*` does not survive this project's Tailwind build on + * pseudo-elements and silently renders at full strength. `bg-current` is + * likewise unusable — the theme replaces Tailwind's `colors` wholesale and has + * no `current` key, so it compiles to nothing at all. + */ +const dividerFor = (variant: ButtonVariant): string | false => { + // Sits on a solid fill, where only the label colour is guaranteed to read. + if (variant === ButtonVariant.Primary) { + return classNames( + DIVIDER_BASE, + 'before:inset-y-0 before:bg-[color-mix(in_srgb,var(--button-color,var(--button-default-color)),transparent_80%)]', + ); + } + + // A bare ghost button: a full-height rule would float with nothing to anchor + // it, so it gets a shorter one in the colour `tailwind/buttons.ts` gives the + // Subtle variant's border. + if (variant === ButtonVariant.Tertiary) { + return classNames( + DIVIDER_BASE, + 'before:inset-y-1.5 before:bg-[color-mix(in_srgb,var(--theme-border-subtlest-primary),transparent_70%)]', + ); + } + + return false; +}; + +/** + * Two real buttons that read as one control: the left half runs the primary + * action, the right half drops the standard menu. Geometry matches a standard + * button at every size — only the shared edge deviates. + */ +export const SplitShareButton = ({ + label, + dropdownLabel, + triggerText, + menu, + copied, + onCopy, + variant = ButtonVariant.Tertiary, + size = ButtonSize.Small, + className, +}: SplitShareButtonProps): ReactElement => { + const [open, setOpen] = useState(false); + + return ( +
+ + + + + +
+ ); +}; From 8802a9a9b04ce1c96d0a02e7ee47b16cf512eaf2 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Jul 2026 15:19:30 +0300 Subject: [PATCH 2/7] feat(share): flat end-of-conversation band by default Drops the card's fill and border for a single hairline rule separating the strip from the comments above it, and moves the trigger to the labelled split button. `variant="card"` keeps the heavier self-contained surface for surfaces that want it. Co-Authored-By: Claude Opus 4.8 --- .../post/EndOfConversationShare.spec.tsx | 59 ++++++++++++++++--- .../post/EndOfConversationShare.tsx | 31 ++++++++-- .../src/components/post/PostComments.tsx | 5 +- 3 files changed, 82 insertions(+), 13 deletions(-) diff --git a/packages/shared/src/components/post/EndOfConversationShare.spec.tsx b/packages/shared/src/components/post/EndOfConversationShare.spec.tsx index c4220c49de..2db04745cd 100644 --- a/packages/shared/src/components/post/EndOfConversationShare.spec.tsx +++ b/packages/shared/src/components/post/EndOfConversationShare.spec.tsx @@ -6,6 +6,7 @@ import { render, screen, waitFor, + within, } from '@testing-library/react'; import { QueryClient } from '@tanstack/react-query'; import { GrowthBook } from '@growthbook/growthbook-react'; @@ -85,7 +86,7 @@ describe('EndOfConversationShare threshold', () => { expect(band()).not.toBeInTheDocument(); expect( - screen.queryByLabelText('Share this discussion'), + screen.queryByRole('button', { name: 'Copy link' }), ).not.toBeInTheDocument(); }); @@ -130,16 +131,21 @@ describe('EndOfConversationShare flags', () => { }); describe('EndOfConversationShare sharing', () => { - it('copies the link and toasts on desktop', async () => { + it('copies the link from the main half of the split button on desktop', async () => { renderComponent(12); + const copyButton = screen.getByRole('button', { name: 'Copy link' }); + // Both glyphs are always mounted so they can cross-fade; only the check's + // opacity says which one is showing. + const check = () => copyButton.querySelector('.text-status-success'); + expect(check()).toHaveClass('opacity-0'); + await act(async () => { - fireEvent.click(screen.getByLabelText('Share this discussion')); - }); - await act(async () => { - fireEvent.click(await screen.findByText('Copy link')); + fireEvent.click(copyButton); }); + // The copy glyph cross-fades to a green check for the confirmation window. + await waitFor(() => expect(check()).not.toHaveClass('opacity-0')); await waitFor(() => expect(writeText).toHaveBeenCalledWith(permalink)); expect(displayToast).toHaveBeenCalledWith( '✅ Copied link to clipboard', @@ -156,6 +162,41 @@ describe('EndOfConversationShare sharing', () => { ); }); + it('opens the full share list from the chevron half, without copying', async () => { + renderComponent(12); + + // Radix dropdowns open on pointerdown (which jsdom cannot synthesise) or on + // Enter, so the keyboard path is what a test can drive. + await act(async () => { + fireEvent.keyDown(screen.getByLabelText('More share options'), { + key: 'Enter', + }); + }); + + const popover = await screen.findByRole('menu'); + expect(within(popover).getByTestId('social-share-X')).toBeInTheDocument(); + expect( + within(popover).getByTestId('social-share-WhatsApp'), + ).toBeInTheDocument(); + expect(writeText).not.toHaveBeenCalled(); + + // The copy action inside the list still works and logs the same origin. + await act(async () => { + fireEvent.click(within(popover).getByTestId('social-share-Copy link')); + }); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(permalink)); + expect(logEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: LogEvent.SharePost, + extra: JSON.stringify({ + provider: ShareProvider.CopyLink, + origin: Origin.EndOfConversation, + }), + }), + ); + }); + it('opens the native share sheet on a single tap on mobile', async () => { useViewSizeMock.mockReturnValue(false); const share = jest.fn().mockResolvedValue(undefined); @@ -164,8 +205,12 @@ describe('EndOfConversationShare sharing', () => { renderComponent(12); + // Mobile keeps a single button — no chevron half, no popover. + expect( + screen.queryByLabelText('More share options'), + ).not.toBeInTheDocument(); await act(async () => { - fireEvent.click(screen.getByLabelText('Share this discussion')); + fireEvent.click(screen.getByRole('button', { name: 'Copy link' })); }); await waitFor(() => expect(share).toHaveBeenCalled()); diff --git a/packages/shared/src/components/post/EndOfConversationShare.tsx b/packages/shared/src/components/post/EndOfConversationShare.tsx index 4b148f7a1d..0bb5ef1106 100644 --- a/packages/shared/src/components/post/EndOfConversationShare.tsx +++ b/packages/shared/src/components/post/EndOfConversationShare.tsx @@ -29,8 +29,16 @@ export const activeDiscussionCommentThreshold = 3; export const hasActiveDiscussion = (post: Post): boolean => (post.numComments ?? 0) > activeDiscussionCommentThreshold; +export type EndOfConversationShareVariant = 'card' | 'flat'; + export interface EndOfConversationShareProps { post: Post; + /** + * `flat` (default) drops the fill and leans on a single hairline rule to + * separate the strip from the comments above it; `card` is the heavier + * self-contained surface. + */ + variant?: EndOfConversationShareVariant; className?: string; } @@ -41,6 +49,7 @@ export interface EndOfConversationShareProps { */ export const EndOfConversationShareBand = ({ post, + variant = 'flat', className, }: EndOfConversationShareProps): ReactElement | null => { const { logEvent } = useLogContext(); @@ -61,7 +70,11 @@ export const EndOfConversationShareBand = ({ // Labelled by its own visible copy, so no aria-label here — a second // "Share this discussion" label would shadow the share button's. className={classNames( - 'flex flex-col items-center gap-3 rounded-16 border border-border-subtlest-tertiary bg-surface-float p-4 text-center tablet:flex-row tablet:justify-between tablet:text-left', + 'flex flex-col items-center gap-3 text-center tablet:flex-row tablet:justify-between tablet:text-left', + variant === 'card' && + 'rounded-16 border border-border-subtlest-tertiary bg-surface-float p-4', + variant === 'flat' && + 'border-t border-border-subtlest-tertiary py-4 pt-6', className, )} > @@ -80,9 +93,12 @@ export const EndOfConversationShareBand = ({ link={post.commentsPermalink} text={post.title ?? post.sharedPost?.title ?? ''} cid={ReferralCampaignKey.SharePost} + variant="split" buttonVariant={ButtonVariant.Primary} - buttonSize={ButtonSize.Medium} - label="Share this discussion" + buttonSize={ButtonSize.Small} + label="Copy link" + triggerText="Copy link" + dropdownLabel="More share options" className="shrink-0" onShare={onShare} /> @@ -97,6 +113,7 @@ export const EndOfConversationShareBand = ({ */ export const EndOfConversationShare = ({ post, + variant, className, }: EndOfConversationShareProps): ReactElement | null => { const isActive = hasActiveDiscussion(post); @@ -110,5 +127,11 @@ export const EndOfConversationShare = ({ return null; } - return ; + return ( + + ); }; diff --git a/packages/shared/src/components/post/PostComments.tsx b/packages/shared/src/components/post/PostComments.tsx index 42137bde59..1a04deab0a 100644 --- a/packages/shared/src/components/post/PostComments.tsx +++ b/packages/shared/src/components/post/PostComments.tsx @@ -176,8 +176,9 @@ export function PostComments({ {!hideEndOfConversationShare && ( )} From 598a1b0c92f39bd3043bd1ef0704a1d5776ea322 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Jul 2026 15:19:30 +0300 Subject: [PATCH 3/7] chore(storybook): share band and split button galleries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every band state (either side of the threshold, flat vs card, in context below a comment list, a real 390px mobile frame) and the split control across all variants and sizes, with computed geometry printed beside the standard buttons so the guideline match is checkable rather than asserted. The auth/log/query decorator the share stories share is extracted to `share.mocks.tsx` — it had been copied verbatim into three story files. Co-Authored-By: Claude Opus 4.8 --- .../EndOfConversationShare.stories.tsx | 339 ++++++++++++++---- .../components/ShareActions.stories.tsx | 71 +--- .../components/SplitShareButton.stories.tsx | 326 +++++++++++++++++ .../stories/components/share.mocks.tsx | 74 ++++ 4 files changed, 669 insertions(+), 141 deletions(-) create mode 100644 packages/storybook/stories/components/SplitShareButton.stories.tsx create mode 100644 packages/storybook/stories/components/share.mocks.tsx diff --git a/packages/storybook/stories/components/EndOfConversationShare.stories.tsx b/packages/storybook/stories/components/EndOfConversationShare.stories.tsx index 525fa35437..e29fda562c 100644 --- a/packages/storybook/stories/components/EndOfConversationShare.stories.tsx +++ b/packages/storybook/stories/components/EndOfConversationShare.stories.tsx @@ -1,15 +1,21 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import React from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { activeDiscussionCommentThreshold, EndOfConversationShareBand, } from '@dailydotdev/shared/src/components/post/EndOfConversationShare'; import type { Post } from '@dailydotdev/shared/src/graphql/posts'; -import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; -import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; -import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; -import { fn } from 'storybook/test'; +import { ShareActions } from '@dailydotdev/shared/src/components/share/ShareActions'; +import { + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/common'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { shareDecorator } from './share.mocks'; const basePost = { id: 'p1', @@ -37,64 +43,7 @@ kill-switch and the \`share_end_of_conversation\` experiment flag.`, }, }, decorators: [ - (Story) => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false, staleTime: Infinity } }, - }); - // Mock the short-URL resolution so copy/social actions don't hit network. - queryClient.setQueryData(['shortUrl'], 'https://dly.to/abc123'); - - const LogContext = getLogContextStatic(); - - const mockUser = { - id: '1', - name: 'Test User', - username: 'testuser', - email: 'test@example.com', - image: - 'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg', - providers: ['google'], - createdAt: '2024-01-01T00:00:00.000Z', - permalink: 'https://daily.dev/testuser', - } as unknown as LoggedUser; - - return ( - - - false, - }} - > -
- -
-
-
-
- ); - }, + shareDecorator('flex min-h-40 w-full max-w-2xl flex-col justify-center'), ], }; @@ -102,11 +51,17 @@ export default meta; type Story = StoryObj; -// Active discussion: the band renders below the comment list. +// Active discussion in the default flat treatment: no fill, no rounding, just +// a hairline rule above the strip. export const ActiveDiscussion: Story = { args: { post: withPost(12) }, }; +// The heavier alternative: a self-contained float surface with a full border. +export const Card: Story = { + args: { post: withPost(12), variant: 'card' }, +}; + // Exactly at the threshold — still hidden, the band needs MORE than 3 comments. export const AtThreshold: Story = { args: { post: withPost(activeDiscussionCommentThreshold) }, @@ -122,16 +77,252 @@ export const NoComments: Story = { args: { post: withPost(0) }, }; -// Narrow the Storybook viewport to see the mobile layout: the band stacks and -// a single tap on the share button opens the native share sheet (falling back -// to copy when the device has no share target). +const Section = ({ + title, + note, + children, +}: { + title: string; + note: string; + children: React.ReactNode; +}) => ( +
+
+ + {title} + + + {note} + +
+ {children} +
+); + +// A hidden state renders nothing at all, so the outline marks where the band +// would have been — otherwise the gallery just shows a gap. +const Hidden = ({ post }: { post: Post }) => ( +
+ + Nothing renders ({post.numComments ?? 0} comments) + + +
+); + +const commentAuthors = ['Ido Shamun', 'Nimrod Kramer', 'Tsahi Matsliah']; + +// Stand-in for the comment list so the band can be judged against what sits +// above it, without dragging the real Comment component's context in. +const CommentSkeletons = () => ( + <> + {commentAuthors.map((author) => ( +
+
+
+ + {author} + +
+
+
+
+ ))} + +); + +/** + * Rendered at a real phone viewport, not a narrow box on a desktop viewport — + * the `tablet:` breakpoint reads the viewport, so only a true 390px frame shows + * the stacked layout and the chevron-less single tap. `AllStates` embeds this + * story in a 390px iframe for exactly that reason. + */ export const Mobile: Story = { - args: { post: withPost(12) }, - decorators: [ - (Story) => ( -
- + render: () => ( +
+
+ +
- ), - ], + +
+ ), +}; + +// Storybook renders each story in its own iframe, so embedding one here gives +// the band a genuine 390px viewport instead of a squeezed desktop layout. The +// theme global is mirrored so the frame follows the toolbar toggle. +const MobileFrame = () => { + const [isDark, setIsDark] = React.useState(true); + + React.useEffect(() => { + const read = () => + setIsDark(document.documentElement.classList.contains('dark')); + read(); + + const observer = new MutationObserver(read); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'], + }); + + return () => observer.disconnect(); + }, []); + + return ( +