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
5 changes: 5 additions & 0 deletions .changeset/react-19-types-compat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiny-design/react': patch
---

Fix TypeScript compatibility with React 19. Component prop interfaces used the pattern `React.PropsWithRef<JSX.IntrinsicElements['x']>`, which relied on a globally-augmented `JSX` namespace that `@types/react@19` no longer provides. The resolved prop types collapsed to `any`, silently dropping every intrinsic HTML attribute (`onClick`, `type`, `disabled`, `aria-*`, `children`, etc.) for consumers on React 19. Replaced the pattern with `React.ComponentProps<'x'>` / `React.ComponentPropsWithoutRef<'x'>` across ~60 component type files. Works on React 18 and 19.
25 changes: 25 additions & 0 deletions packages/react/REFS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Ref Conventions

This package treats `ref` as an explicit component capability, not an accidental side effect of inheriting DOM props.

## Rules

- Use `React.ComponentPropsWithoutRef<'tag'>` for DOM-derived prop interfaces by default.
- Only expose `ref` when the component intentionally supports DOM access through `React.forwardRef`.
- Do not rely on `React.ComponentProps<'tag'>` to leak `ref` into props interfaces.
- If a component is primarily compositional or layout-only, do not expose `ref` unless there is a concrete DOM integration need.

## Components That Should Expose `ref`

- Interactive controls and focus targets such as `Button`, `Input`, `NativeSelect`, `Link`, `Switch`, `Checkbox`, `Radio`.
- Components frequently used for measurement, scrolling, positioning, or animation integration such as `Tree`, `Slider`, `Grid`, `Space`, `Flex`, `Layout`, `Anchor`, `Waterfall`, `Transfer`, `Steps`, and typography primitives.

## Components That Usually Should Not Expose `ref`

- Grouping or compositional wrappers such as `Button.Group`, `Checkbox.Group`, `Radio.Group`, `Input.Group`, `Input.Addon`, `Form.Item`, `Descriptions`, `Descriptions.Item`.

## Practical Guidance

- If consumers need the outer wrapper and an inner native control, expose both intentionally, for example `Checkbox` plus `checkboxRef`, or `Radio` plus `radioRef`.
- If a component renders different DOM elements by state, make the forwarded ref type reflect that reality instead of pretending it always points to one tag.
- When adding a new forwarded ref, add a focused test that proves the ref resolves to the expected DOM node.
2 changes: 1 addition & 1 deletion packages/react/src/alert/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export type AlertType = 'success' | 'info' | 'warning' | 'error';

export interface AlertProps
extends BaseProps,
Omit<React.PropsWithoutRef<JSX.IntrinsicElements['div']>, 'title'> {
Omit<React.ComponentPropsWithoutRef<'div'>, 'title'> {
/** alert title */
title?: string | ReactNode;

Expand Down
12 changes: 12 additions & 0 deletions packages/react/src/anchor/__tests__/anchor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,16 @@ describe('<Anchor />', () => {
expect(getByText('Link 1')).toBeInTheDocument();
expect(getByText('Link 2')).toBeInTheDocument();
});

it('should forward ref to root list element', () => {
const ref = React.createRef<HTMLUListElement>();

render(
<Anchor ref={ref}>
<Anchor.Link href="#section1" title="Section 1" />
</Anchor>
);

expect(ref.current).toBeInstanceOf(HTMLUListElement);
});
});
28 changes: 25 additions & 3 deletions packages/react/src/anchor/anchor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,20 @@ import { AnchorLinkProps, AnchorProps } from './types';
import { AnchorContext } from './anchor-context';
import Sticky from '../sticky';

const Anchor = (props: AnchorProps): JSX.Element => {
function assignRef<T>(ref: React.Ref<T> | undefined, value: T | null): void {
if (!ref) {
return;
}

if (typeof ref === 'function') {
ref(value);
return;
}

(ref as React.MutableRefObject<T | null>).current = value;
}

const Anchor = React.forwardRef<HTMLUListElement, AnchorProps>((props, ref): JSX.Element => {
const {
affix = false,
offsetTop = 0,
Expand All @@ -20,6 +33,7 @@ const Anchor = (props: AnchorProps): JSX.Element => {
style,
children,
prefixCls: customisedCls,
...otherProps
} = props;
const configContext = useContext(ConfigContext);
const prefixCls = getPrefixCls('anchor', configContext.prefixCls, customisedCls);
Expand All @@ -37,6 +51,14 @@ const Anchor = (props: AnchorProps): JSX.Element => {
linksRef.current.delete(href);
}, []);

const setAnchorNode = useCallback(
(node: HTMLUListElement | null) => {
anchorRef.current = node;
assignRef(ref, node);
},
[ref]
);

const updateInk = useCallback(() => {
const anchorEl = anchorRef.current;
if (!anchorEl) return;
Expand Down Expand Up @@ -200,7 +222,7 @@ const Anchor = (props: AnchorProps): JSX.Element => {
);

const anchorContent = (
<ul className={cls} style={style} ref={anchorRef}>
<ul {...otherProps} className={cls} style={style} ref={setAnchorNode}>
<div className={`${prefixCls}__ink`}>
<div className={`${prefixCls}__ink-ball`} ref={inkBallRef} />
</div>
Expand Down Expand Up @@ -228,7 +250,7 @@ const Anchor = (props: AnchorProps): JSX.Element => {
)}
</AnchorContext.Provider>
);
};
});

Anchor.displayName = 'Anchor';

Expand Down
6 changes: 4 additions & 2 deletions packages/react/src/anchor/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import React from 'react';
import { BaseProps } from '../_utils/props';

export interface AnchorProps extends BaseProps {
export interface AnchorProps
extends BaseProps,
Omit<React.ComponentPropsWithoutRef<'ul'>, 'children' | 'onChange' | 'onClick'> {
affix?: boolean;
type?: 'dot' | 'line';
offsetBottom?: number;
Expand All @@ -12,7 +14,7 @@ export interface AnchorProps extends BaseProps {
children?: React.ReactNode;
}

export interface AnchorLinkProps extends BaseProps, React.PropsWithRef<JSX.IntrinsicElements['a']> {
export interface AnchorLinkProps extends BaseProps, React.ComponentPropsWithoutRef<'a'> {
href: string;
title: string;
children?: React.ReactElement<AnchorLinkProps>[];
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/aspect-ratio/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { BaseProps } from '../_utils/props';

export interface AspectRatioProps
extends BaseProps,
React.PropsWithoutRef<JSX.IntrinsicElements['div']> {
React.ComponentPropsWithoutRef<'div'> {
/** the width of the content */
width?: number | string;

Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/avatar/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export type AvatarPresence = 'online' | 'busy' | 'away' | 'offline';

export interface AvatarProps
extends BaseProps,
React.PropsWithoutRef<JSX.IntrinsicElements['span']> {
React.ComponentPropsWithoutRef<'span'> {
/** use an icon */
icon?: React.ReactNode;

Expand All @@ -28,7 +28,7 @@ export interface AvatarProps

export interface AvatarGroupProps
extends BaseProps,
React.PropsWithoutRef<JSX.IntrinsicElements['span']> {
React.ComponentPropsWithoutRef<'span'> {
/** the distance between two avatars */
gap: number | string;
}
2 changes: 1 addition & 1 deletion packages/react/src/badge/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { BaseProps } from '../_utils/props';

export interface BadgeProps
extends BaseProps,
React.PropsWithoutRef<JSX.IntrinsicElements['span']> {
React.ComponentPropsWithoutRef<'span'> {
/** the number to show in badge */
count?: React.ReactNode;

Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/breadcrumb/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import { BaseProps } from '../_utils/props';

export interface BreadcrumbProps
extends BaseProps,
React.PropsWithoutRef<JSX.IntrinsicElements['nav']> {
React.ComponentPropsWithoutRef<'nav'> {
separator?: React.ReactNode;
children: ReactElement<BreadcrumbItemProps> | ReactElement<BreadcrumbItemProps>[];
}

export interface BreadcrumbItemProps
extends BaseProps,
React.PropsWithoutRef<JSX.IntrinsicElements['li']> {
React.ComponentPropsWithoutRef<'li'> {
separator?: React.ReactNode;
children?: React.ReactNode;
}
126 changes: 62 additions & 64 deletions packages/react/src/button/button-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,76 +8,74 @@ import { BUTTON_MARK } from './button';

const hasOwnProp = <T extends object>(props: T, key: keyof T): boolean => props[key] !== undefined;

const ButtonGroup = React.forwardRef<HTMLDivElement, ButtonGroupProps>(
(props: ButtonGroupProps, ref) => {
const {
size = 'md',
variant = 'solid',
color = 'default',
disabled = false,
round = false,
shape,
inheritMode = 'fill',
prefixCls: customisedCls,
className,
children,
...otherProps
} = props;
const configContext = useContext(ConfigContext);
const prefixCls = getPrefixCls('btn-group', configContext.prefixCls, customisedCls);
const btnSize = props.size || configContext.componentSize || size;
const resolvedShape = shape || (round ? 'round' : 'default');
const cls = classNames(
prefixCls,
{
[`${prefixCls}_round`]: resolvedShape === 'round',
[`${prefixCls}_variant-${variant}`]: variant,
[`${prefixCls}_color-${color}`]: color,
[`${prefixCls}_${btnSize}`]: btnSize,
},
className
);
return (
<div {...otherProps} className={cls} ref={ref}>
{React.Children.map(children, (child) => {
if (!React.isValidElement<ButtonProps>(child)) {
return child;
}
const ButtonGroup = (props: ButtonGroupProps): React.ReactElement => {
const {
size = 'md',
variant = 'solid',
color = 'default',
disabled = false,
round = false,
shape,
inheritMode = 'fill',
prefixCls: customisedCls,
className,
children,
...otherProps
} = props;
const configContext = useContext(ConfigContext);
const prefixCls = getPrefixCls('btn-group', configContext.prefixCls, customisedCls);
const btnSize = props.size || configContext.componentSize || size;
const resolvedShape = shape || (round ? 'round' : 'default');
const cls = classNames(
prefixCls,
{
[`${prefixCls}_round`]: resolvedShape === 'round',
[`${prefixCls}_variant-${variant}`]: variant,
[`${prefixCls}_color-${color}`]: color,
[`${prefixCls}_${btnSize}`]: btnSize,
},
className
);
return (
<div {...otherProps} className={cls}>
{React.Children.map(children, (child) => {
if (!React.isValidElement<ButtonProps>(child)) {
return child;
}

const childType = child.type as Record<PropertyKey, unknown>;
if (!childType[BUTTON_MARK]) {
return child;
}
const childType = child.type as unknown as Record<PropertyKey, unknown>;
if (!childType[BUTTON_MARK]) {
return child;
}

if (inheritMode === 'none') {
return child;
}
if (inheritMode === 'none') {
return child;
}

const childProps: Partial<ButtonProps> = {};
const applyProp = <K extends keyof ButtonProps>(key: K, value: ButtonProps[K]): void => {
if (inheritMode === 'override' || !hasOwnProp(child.props, key)) {
childProps[key] = value;
}
};
const childProps: Partial<ButtonProps> = {};
const applyProp = <K extends keyof ButtonProps>(key: K, value: ButtonProps[K]): void => {
if (inheritMode === 'override' || !hasOwnProp(child.props, key)) {
childProps[key] = value;
}
};

applyProp('variant', variant as ButtonVariant);
applyProp('color', color as ButtonColor);
applyProp('size', btnSize as SizeType);
applyProp('disabled', disabled);
applyProp('variant', variant as ButtonVariant);
applyProp('color', color as ButtonColor);
applyProp('size', btnSize as SizeType);
applyProp('disabled', disabled);

if (
inheritMode === 'override' ||
(!hasOwnProp(child.props, 'shape') && !hasOwnProp(child.props, 'round'))
) {
childProps.shape = resolvedShape as ButtonShape;
}
if (
inheritMode === 'override' ||
(!hasOwnProp(child.props, 'shape') && !hasOwnProp(child.props, 'round'))
) {
childProps.shape = resolvedShape as ButtonShape;
}

return React.cloneElement(child, childProps);
})}
</div>
);
}
);
return React.cloneElement(child, childProps);
})}
</div>
);
};

ButtonGroup.displayName = 'ButtonGroup';

Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getPrefixCls } from '../_utils/general';
import { ButtonProps } from './types';

export const BUTTON_MARK = Symbol('tiny-design.button');
const isProduction = (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env?.NODE_ENV === 'production';

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props: ButtonProps, ref) => {
const {
Expand Down Expand Up @@ -37,7 +38,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props: ButtonPr
const accessibleName =
otherProps['aria-label'] || otherProps['aria-labelledby'] || otherProps.title;

if (process.env.NODE_ENV !== 'production' && isIconOnly && !accessibleName) {
if (!isProduction && isIconOnly && !accessibleName) {
// Icon-only buttons need an accessible name.
console.warn(
'Button with icon only should provide `aria-label`, `aria-labelledby`, or `title`.'
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/button/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export type ButtonIconPosition = 'start' | 'end';
export type ButtonGroupInheritMode = 'fill' | 'override' | 'none';

export interface ButtonProps
extends BaseProps, React.PropsWithRef<JSX.IntrinsicElements['button']> {
extends BaseProps, React.ComponentPropsWithoutRef<'button'> {
variant?: ButtonVariant;
color?: ButtonColor;
loading?: boolean;
Expand All @@ -27,7 +27,7 @@ export interface ButtonProps
}

export interface ButtonGroupProps
extends BaseProps, React.PropsWithRef<JSX.IntrinsicElements['div']> {
extends BaseProps, React.ComponentPropsWithoutRef<'div'> {
variant?: ButtonVariant;
color?: ButtonColor;
size?: SizeType;
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/calendar/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export type SelectionMode = 'single' | 'range' | 'multiple';

export interface CalendarProps
extends BaseProps,
Omit<React.PropsWithoutRef<JSX.IntrinsicElements['div']>, 'onChange' | 'onSelect' | 'defaultValue'> {
Omit<React.ComponentPropsWithoutRef<'div'>, 'onChange' | 'onSelect' | 'defaultValue'> {
/** Selected date (controlled, single mode) */
defaultValue?: Date;
/** Controlled selected date */
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/card/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import { BaseProps } from '../_utils/props';

export type CardVariant = 'outlined' | 'elevated' | 'filled';

export interface CardContentProps extends React.PropsWithoutRef<JSX.IntrinsicElements['div']> {
export interface CardContentProps extends React.ComponentPropsWithoutRef<'div'> {
prefixCls?: string;
children: ReactNode;
}

export interface CardProps
extends BaseProps,
Omit<React.PropsWithoutRef<JSX.IntrinsicElements['div']>, 'title'> {
Omit<React.ComponentPropsWithoutRef<'div'>, 'title'> {
title?: ReactNode;
extra?: ReactNode;
/** Card surface style */
Expand Down
Loading
Loading