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' ;
28import React , { forwardRef , useState } from 'react' ;
39import classNames from 'classnames' ;
410import 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
2534export 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
3039interface 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+
67138function 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 */
169265export const Button = forwardRef ( ButtonComponent ) ;
0 commit comments