Skip to content

Commit cbec75e

Browse files
tsahimatsliahcursoragentrebelchris
authored
feat(buttons): reskin v1 with v2 design + soft labels + v2 polish (#6061)
Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Chris Bongers <chrisbongers@gmail.com>
1 parent bdc89ca commit cbec75e

10 files changed

Lines changed: 1455 additions & 367 deletions

File tree

packages/shared/src/components/buttons/Button.spec.tsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,4 +170,75 @@ describe('Button', () => {
170170
});
171171
expect(await screen.findByRole('link')).toBeInTheDocument();
172172
});
173+
174+
describe('inactive prop', () => {
175+
it('sets aria-disabled but stays interactive', async () => {
176+
const onClick = jest.fn();
177+
renderComponent({ children: 'Inactive', inactive: true, onClick });
178+
const button = await screen.findByRole('button');
179+
expect(button).toHaveAttribute('aria-disabled', 'true');
180+
expect(button).not.toBeDisabled();
181+
button.click();
182+
expect(onClick).toHaveBeenCalledTimes(1);
183+
});
184+
185+
it('defers to native disabled when both are set', async () => {
186+
renderComponent({
187+
children: 'Disabled',
188+
inactive: true,
189+
disabled: true,
190+
});
191+
const button = await screen.findByRole('button');
192+
expect(button).not.toHaveAttribute('aria-disabled');
193+
expect(button).toBeDisabled();
194+
});
195+
});
196+
197+
describe('bold prop', () => {
198+
it('upgrades primary label from semibold to bold', async () => {
199+
const { rerender } = render(
200+
<Button variant={ButtonVariant.Primary}>Default</Button>,
201+
);
202+
expect(screen.getByRole('button')).toHaveClass('font-semibold');
203+
expect(screen.getByRole('button')).not.toHaveClass('font-bold');
204+
205+
rerender(
206+
<Button variant={ButtonVariant.Primary} bold>
207+
Bolder
208+
</Button>,
209+
);
210+
expect(screen.getByRole('button')).toHaveClass('font-bold');
211+
expect(screen.getByRole('button')).not.toHaveClass('font-semibold');
212+
});
213+
214+
it('is a no-op on non-primary variants', async () => {
215+
renderComponent({
216+
variant: ButtonVariant.Tertiary,
217+
bold: true,
218+
children: 'Tertiary bold',
219+
});
220+
const button = await screen.findByRole('button');
221+
expect(button).toHaveClass('font-medium');
222+
expect(button).not.toHaveClass('font-bold');
223+
});
224+
});
225+
226+
describe('useDefaultCursor prop', () => {
227+
it('renders default cursor instead of pointer', async () => {
228+
renderComponent({
229+
children: 'Default cursor',
230+
useDefaultCursor: true,
231+
});
232+
const button = await screen.findByRole('button');
233+
expect(button).toHaveClass('cursor-default');
234+
expect(button).not.toHaveClass('cursor-pointer');
235+
});
236+
237+
it('defaults to pointer when omitted', async () => {
238+
renderComponent({ children: 'Pointer cursor' });
239+
const button = await screen.findByRole('button');
240+
expect(button).toHaveClass('cursor-pointer');
241+
expect(button).not.toHaveClass('cursor-default');
242+
});
243+
});
173244
});

packages/shared/src/components/buttons/Button.tsx

Lines changed: 118 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import type { HTMLAttributes, ReactElement, ReactNode, Ref } from 'react';
1+
import type {
2+
HTMLAttributes,
3+
MouseEvent as ReactMouseEvent,
4+
ReactElement,
5+
ReactNode,
6+
Ref,
7+
} from 'react';
28
import React, { forwardRef, useState } from 'react';
39
import classNames from 'classnames';
410
import type { IconProps } from '../Icon';
@@ -13,6 +19,9 @@ import {
1319
useGetIconWithSize,
1420
IconOnlySizeToClassName,
1521
SizeToClassName,
22+
HorizontalPadding,
23+
IconSidePadding,
24+
SizeToGap,
1625
VariantColorToClassName,
1726
VariantToClassName,
1827
} from './common';
@@ -24,14 +33,34 @@ export { ButtonColor, ButtonSize, ButtonVariant, ButtonIconPosition };
2433

2534
export const ButtonGroup = classed(
2635
'div',
27-
'flex gap-1 rounded-12 border border-border-subtlest-tertiary p-1',
36+
'flex gap-1 rounded-14 border border-border-subtlest-tertiary p-1',
2837
);
2938

3039
interface CommonButtonProps {
3140
size?: ButtonSize;
3241
loading?: boolean;
3342
pressed?: boolean;
3443
disabled?: boolean;
44+
/**
45+
* Primer-style "looks active, behaves disabled". Renders default
46+
* visuals + `aria-disabled="true"` + `cursor: not-allowed` but
47+
* remains keyboard-focusable and `onClick`-firing so callers can
48+
* surface a tooltip / toast explaining why the action isn't
49+
* available.
50+
*/
51+
inactive?: boolean;
52+
/**
53+
* Bumps Primary from `font-semibold` (600) to `font-bold` (700) for
54+
* marketing-heavy CTAs. Has no effect on other variants.
55+
*/
56+
bold?: boolean;
57+
/**
58+
* HIG-pure preview: render with default cursor instead of pointer
59+
* (matches Apple / Microsoft / W3C guidance that pointer is for
60+
* links). Off by default; daily.dev's convention is pointer on
61+
* buttons.
62+
*/
63+
useDefaultCursor?: boolean;
3564
children?: ReactNode;
3665
tag?: React.ElementType & AllowedTags;
3766
}
@@ -64,6 +93,48 @@ export type ButtonProps<T extends AllowedTags> = BaseButtonProps &
6493
ref?: Ref<ButtonElementType<T>>;
6594
};
6695

96+
/**
97+
* Variant-driven font weight — same contract as V2 (ChatGPT pattern).
98+
* Hierarchy is carried by fill / border, not weight uniformity, so we
99+
* drop V1's universal `font-bold`.
100+
*/
101+
const variantFontWeight = (
102+
variant: ButtonVariant | undefined,
103+
bold: boolean | undefined,
104+
): string => {
105+
if (variant === ButtonVariant.Option || variant === ButtonVariant.Quiz) {
106+
return 'font-medium';
107+
}
108+
if (variant === ButtonVariant.Primary) {
109+
return bold ? 'font-bold' : 'font-semibold';
110+
}
111+
return 'font-medium';
112+
};
113+
114+
const sizeClassMap = (size: ButtonSize, iconOnly: boolean): string =>
115+
iconOnly ? IconOnlySizeToClassName[size] : SizeToClassName[size];
116+
117+
const horizontalPaddingClass = (
118+
size: ButtonSize,
119+
iconOnly: boolean,
120+
hasIcon: boolean,
121+
hasChildren: boolean,
122+
iconPosition: ButtonIconPosition,
123+
): string | null => {
124+
if (iconOnly) {
125+
return null;
126+
}
127+
if (hasIcon && hasChildren) {
128+
if (iconPosition === ButtonIconPosition.Left) {
129+
return IconSidePadding[size].left;
130+
}
131+
if (iconPosition === ButtonIconPosition.Right) {
132+
return IconSidePadding[size].right;
133+
}
134+
}
135+
return HorizontalPadding[size];
136+
};
137+
67138
function ButtonComponent<TagName extends AllowedTags>(
68139
{
69140
variant,
@@ -75,6 +146,10 @@ function ButtonComponent<TagName extends AllowedTags>(
75146
iconSecondaryOnHover = false,
76147
loading,
77148
pressed,
149+
inactive,
150+
bold,
151+
useDefaultCursor,
152+
disabled,
78153
children,
79154
onClick,
80155
tag: Tag = 'button',
@@ -89,12 +164,8 @@ function ButtonComponent<TagName extends AllowedTags>(
89164
childNodes.every(
90165
(child) => typeof child === 'string' || typeof child === 'number',
91166
);
92-
const iconOnly = icon && !hasChildren;
93-
const getIconWithSize = useGetIconWithSize(
94-
size,
95-
iconOnly ?? false,
96-
iconPosition,
97-
);
167+
const iconOnly = !!(icon && !hasChildren);
168+
const getIconWithSize = useGetIconWithSize(size, iconOnly, iconPosition);
98169
const isAnchor = Tag === 'a';
99170
const anchorClickProps =
100171
isAnchor && onClick
@@ -106,30 +177,48 @@ function ButtonComponent<TagName extends AllowedTags>(
106177
variant === ButtonVariant.Option || variant === ButtonVariant.Quiz;
107178
const [isHovering, setIsHovering] = useState(false);
108179

180+
// `inactive` keeps the element interactive (no `disabled` attr) but
181+
// marks it via `aria-disabled` so screen readers announce the state.
182+
const ariaDisabled = inactive && !disabled ? true : undefined;
183+
109184
return (
110185
<Tag
111186
{...props}
112187
{...(isAnchor ? anchorClickProps : { onClick })}
113188
aria-busy={loading}
114189
aria-pressed={pressed}
190+
aria-disabled={ariaDisabled}
191+
disabled={disabled}
115192
ref={ref}
116193
className={classNames(
117-
`btn focus-outline inline-flex cursor-pointer select-none flex-row
118-
items-center border no-underline shadow-none transition
119-
duration-200 ease-in-out typo-callout`,
120-
!isOptionOrQuiz && 'justify-center font-bold',
194+
'btn inline-flex select-none flex-row items-center border no-underline shadow-none',
195+
useDefaultCursor ? 'cursor-default' : 'cursor-pointer',
196+
!isOptionOrQuiz && 'justify-center',
197+
variantFontWeight(variant, bold),
198+
// Tighten letter spacing on the largest sizes — typo-title3 (20 px)
199+
// and typo-body (17 px) read better with -1 % tracking.
200+
(size === ButtonSize.XLarge || size === ButtonSize.Large) &&
201+
'tracking-[-0.01em]',
121202
{ iconOnly },
122-
iconOnly ? IconOnlySizeToClassName[size] : SizeToClassName[size],
123-
iconPosition === ButtonIconPosition.Top && `flex-col !px-2`,
203+
sizeClassMap(size, iconOnly),
204+
horizontalPaddingClass(
205+
size,
206+
iconOnly,
207+
!!icon,
208+
hasChildren,
209+
iconPosition,
210+
),
211+
!iconOnly && icon && hasChildren && SizeToGap[size],
212+
iconPosition === ButtonIconPosition.Top && 'flex-col !gap-0.5 !px-2',
124213
variant && !color && VariantToClassName[variant],
125214
variant && color && VariantColorToClassName[variant]?.[color],
126215
className,
127216
)}
128-
onMouseEnter={(e: React.MouseEvent<AllowedElements>) => {
217+
onMouseEnter={(e: ReactMouseEvent<AllowedElements>) => {
129218
props.onMouseEnter?.(e);
130219
setIsHovering(true);
131220
}}
132-
onMouseLeave={(e: React.MouseEvent<AllowedElements>) => {
221+
onMouseLeave={(e: ReactMouseEvent<AllowedElements>) => {
133222
props.onMouseLeave?.(e);
134223
setIsHovering(false);
135224
}}
@@ -140,7 +229,15 @@ function ButtonComponent<TagName extends AllowedTags>(
140229
) &&
141230
getIconWithSize(icon, iconSecondaryOnHover ? isHovering : false)}
142231
{shouldWrapLabel ? (
143-
<span className={classNames('btn-label', loading && 'invisible')}>
232+
// `truncate` + `min-w-0` so a full-width button (`w-full`) with a
233+
// long label ellipsises cleanly. `min-w-0` is mandatory on flex
234+
// children for `truncate` to actually shrink them.
235+
<span
236+
className={classNames(
237+
'btn-label min-w-0 truncate',
238+
loading && 'invisible',
239+
)}
240+
>
144241
{children}
145242
</span>
146243
) : (
@@ -160,10 +257,9 @@ function ButtonComponent<TagName extends AllowedTags>(
160257
}
161258

162259
/**
163-
* @deprecated Prefer `ButtonV2` from `./ButtonV2` for new work. v1 `Button`
164-
* stays for back-compat with existing call sites and will be removed once
165-
* the v2 button system migration is complete. See
166-
* `packages/shared/src/components/buttons/Buttons.mdx` for the migration
167-
* guide and per-variant differences.
260+
* `Button` is daily.dev's default button. The V1 component shell is kept
261+
* for back-compat with every existing call site; the visual layer
262+
* underneath now matches `ButtonV2` (same tokens, same polish, same
263+
* size/typo/gap/padding scale). See `Buttons.mdx` for the design DNA.
168264
*/
169265
export const Button = forwardRef(ButtonComponent);

0 commit comments

Comments
 (0)