Skip to content

Commit 60ab4a0

Browse files
committed
refactor(components): uniformize handling of link and button
1 parent 57644cc commit 60ab4a0

File tree

18 files changed

+342
-190
lines changed

18 files changed

+342
-190
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- `@lumx/react`:
1616
- Import types and constants from `@lumx/core` and re-export
1717
- Import `className` utilities from `@lumx/core`
18+
- Uniformize link and button handling in `Link`, `Button`, `SideNavigationItem`, `Thumbnail`, `NavigationItem`
19+
and `ListItem`.
20+
- Render disabled link as link instead of disabled buttons.
1821

1922
## [3.19.0][] - 2025-11-07
2023

packages/lumx-react/src/components/button/Button.test.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,10 @@ describe(`<${Button.displayName}>`, () => {
7878
it('should render disabled link', async () => {
7979
const onClick = jest.fn();
8080
const { button } = setup({ children: 'Label', disabled: true, href: 'https://example.com', onClick });
81-
// Disabled link do not exist so we fallback to a button
82-
expect(screen.queryByRole('link')).not.toBeInTheDocument();
83-
expect(button).toHaveAttribute('disabled');
81+
expect(screen.queryByRole('link')).toBeInTheDocument();
82+
expect(button).toHaveAttribute('aria-disabled', 'true');
83+
// Simulate standard disabled state (not focusable)
84+
expect(button).toHaveAttribute('tabindex', '-1');
8485
await userEvent.click(button);
8586
expect(onClick).not.toHaveBeenCalled();
8687
});
@@ -102,8 +103,7 @@ describe(`<${Button.displayName}>`, () => {
102103
onClick,
103104
});
104105
expect(button).toHaveAccessibleName('Label');
105-
// Disabled link do not exist so we fallback to a button
106-
expect(screen.queryByRole('link')).not.toBeInTheDocument();
106+
expect(screen.queryByRole('link')).toBeInTheDocument();
107107
expect(button).toHaveAttribute('aria-disabled', 'true');
108108
await userEvent.click(button);
109109
expect(onClick).not.toHaveBeenCalled();

packages/lumx-react/src/components/button/ButtonRoot.tsx

Lines changed: 6 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
import React, { AriaAttributes, ButtonHTMLAttributes, DetailedHTMLProps, RefObject } from 'react';
22

3-
import isEmpty from 'lodash/isEmpty';
4-
53
import classNames from 'classnames';
64

75
import { ColorPalette, Emphasis, Size, Theme } from '@lumx/react';
86
import { CSS_PREFIX } from '@lumx/react/constants';
97
import { GenericProps, HasTheme } from '@lumx/react/utils/type';
108
import { handleBasicClasses } from '@lumx/core/js/utils/className';
11-
import { renderLink } from '@lumx/react/utils/react/renderLink';
129
import { forwardRef } from '@lumx/react/utils/react/forwardRef';
13-
import { useDisableStateProps } from '@lumx/react/utils/disabled/useDisableStateProps';
1410
import { HasAriaDisabled } from '@lumx/react/utils/type/HasAriaDisabled';
11+
import { RawClickable } from '@lumx/react/utils/react/RawClickable';
12+
import { useDisableStateProps } from '@lumx/react/utils/disabled';
1513

1614
type HTMLButtonProps = DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>;
1715

@@ -107,18 +105,14 @@ export const ButtonRoot = forwardRef<ButtonRootProps, HTMLButtonElement | HTMLAn
107105
color,
108106
emphasis,
109107
hasBackground,
110-
href,
111108
isSelected,
112109
isActive,
113110
isFocused,
114111
isHovered,
115112
linkAs,
116-
name,
117113
size,
118-
target,
119114
theme,
120115
variant,
121-
type = 'button',
122116
fullWidth,
123117
...forwardedProps
124118
} = otherProps;
@@ -139,7 +133,7 @@ export const ButtonRoot = forwardRef<ButtonRootProps, HTMLButtonElement | HTMLAn
139133
color: adaptedColor,
140134
emphasis,
141135
isSelected,
142-
isDisabled: isAnyDisabled,
136+
isDisabled: props.isDisabled || props['aria-disabled'],
143137
isActive,
144138
isFocused,
145139
isHovered,
@@ -151,42 +145,18 @@ export const ButtonRoot = forwardRef<ButtonRootProps, HTMLButtonElement | HTMLAn
151145
}),
152146
);
153147

154-
/**
155-
* If the linkAs prop is used, we use the linkAs component instead of a <button>.
156-
* If there is an href attribute, we display an <a> instead of a <button>.
157-
*
158-
* However, in any case, if the component is disabled, we returned a <button> since disabled is not compatible with <a>.
159-
*/
160-
if ((linkAs || !isEmpty(props.href)) && !isAnyDisabled) {
161-
return renderLink(
162-
{
163-
linkAs,
164-
...forwardedProps,
165-
'aria-label': ariaLabel,
166-
href,
167-
target,
168-
className: buttonClassName,
169-
ref: ref as RefObject<HTMLAnchorElement>,
170-
},
171-
children,
172-
);
173-
}
174148
return (
175-
<button
149+
<RawClickable
150+
as={linkAs || (forwardedProps.href ? 'a' : 'button')}
176151
{...forwardedProps}
177152
{...disabledStateProps}
178153
aria-disabled={isAnyDisabled}
179154
aria-label={ariaLabel}
180155
ref={ref as RefObject<HTMLButtonElement>}
181156
className={buttonClassName}
182-
name={name}
183-
type={
184-
// eslint-disable-next-line react/button-has-type
185-
type
186-
}
187157
>
188158
{children}
189-
</button>
159+
</RawClickable>
190160
);
191161
});
192162
ButtonRoot.displayName = COMPONENT_NAME;

packages/lumx-react/src/components/link/Link.test.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,17 +85,20 @@ describe(`<${Link.displayName}>`, () => {
8585
it('should render disabled link', async () => {
8686
const onClick = jest.fn();
8787
const { link } = setup({ children: 'Label', isDisabled: true, href: 'https://example.com', onClick });
88-
// Disabled link do not exist so we fallback to a button
89-
expect(screen.queryByRole('link')).not.toBeInTheDocument();
90-
expect(link).toHaveAttribute('disabled');
88+
expect(screen.queryByRole('link')).toBeInTheDocument();
89+
expect(link).toHaveAttribute('aria-disabled');
90+
// Simulate standard disabled state (not focusable)
91+
expect(link).toHaveAttribute('tabindex', '-1');
9192
await userEvent.click(link);
9293
expect(onClick).not.toHaveBeenCalled();
9394
});
9495

9596
it('should render aria-disabled button', async () => {
9697
const onClick = jest.fn();
9798
const { link } = setup({ children: 'Label', 'aria-disabled': true, onClick });
98-
expect(link).toHaveAttribute('aria-disabled');
99+
expect(screen.queryByRole('button')).toBeInTheDocument();
100+
expect(link).toHaveAttribute('aria-disabled', 'true');
101+
expect(link).not.toHaveAttribute('tabindex');
99102
await userEvent.click(link);
100103
expect(onClick).not.toHaveBeenCalled();
101104
});
@@ -109,8 +112,7 @@ describe(`<${Link.displayName}>`, () => {
109112
onClick,
110113
});
111114
expect(link).toHaveAccessibleName('Label');
112-
// Disabled link do not exist so we fallback to a button
113-
expect(screen.queryByRole('link')).not.toBeInTheDocument();
115+
expect(screen.queryByRole('link')).toBeInTheDocument();
114116
expect(link).toHaveAttribute('aria-disabled', 'true');
115117
await userEvent.click(link);
116118
expect(onClick).not.toHaveBeenCalled();

packages/lumx-react/src/components/link/Link.tsx

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import {
1212
} from '@lumx/core/js/utils/className';
1313
import { forwardRef } from '@lumx/react/utils/react/forwardRef';
1414
import { wrapChildrenIconWithSpaces } from '@lumx/react/utils/react/wrapChildrenIconWithSpaces';
15-
import { useDisableStateProps } from '@lumx/react/utils/disabled/useDisableStateProps';
1615
import { HasAriaDisabled } from '@lumx/react/utils/type/HasAriaDisabled';
16+
import { RawClickable } from '@lumx/react/utils/react/RawClickable';
17+
import { useDisableStateProps } from '@lumx/react/utils/disabled';
1718

1819
type HTMLAnchorProps = React.DetailedHTMLProps<React.AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>;
1920

@@ -67,38 +68,26 @@ const CLASSNAME = getRootClassName(COMPONENT_NAME);
6768
* @return React element.
6869
*/
6970
export const Link = forwardRef<LinkProps, HTMLAnchorElement | HTMLButtonElement>((props, ref) => {
70-
const { isAnyDisabled, disabledStateProps, otherProps } = useDisableStateProps(props);
71+
const { disabledStateProps, otherProps } = useDisableStateProps(props);
7172
const {
7273
children,
7374
className,
7475
color: propColor,
7576
colorVariant: propColorVariant,
76-
href,
7777
leftIcon,
78-
linkAs,
7978
rightIcon,
80-
target,
8179
typography,
80+
linkAs,
8281
...forwardedProps
8382
} = otherProps;
8483
const [color, colorVariant] = resolveColorWithVariants(propColor, propColorVariant);
8584

86-
const isLink = linkAs || href;
87-
const Component = isLink && !isAnyDisabled ? linkAs || 'a' : 'button';
88-
const baseProps: React.ComponentProps<typeof Component> = {};
89-
if (Component === 'button') {
90-
baseProps.type = 'button';
91-
Object.assign(baseProps, disabledStateProps);
92-
} else if (isLink) {
93-
baseProps.href = href;
94-
baseProps.target = target;
95-
}
96-
9785
return (
98-
<Component
99-
ref={ref}
86+
<RawClickable
87+
ref={ref as any}
88+
as={linkAs || (forwardedProps.href ? 'a' : 'button')}
10089
{...forwardedProps}
101-
{...baseProps}
90+
{...disabledStateProps}
10291
className={classNames(
10392
className,
10493
handleBasicClasses({ prefix: CLASSNAME, color, colorVariant, hasTypography: !!typography }),
@@ -112,7 +101,7 @@ export const Link = forwardRef<LinkProps, HTMLAnchorElement | HTMLButtonElement>
112101
{rightIcon && <Icon icon={rightIcon} className={`${CLASSNAME}__right-icon`} />}
113102
</>,
114103
)}
115-
</Component>
104+
</RawClickable>
116105
);
117106
});
118107
Link.displayName = COMPONENT_NAME;

packages/lumx-react/src/components/list/ListItem.test.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ describe(`<${ListItem.displayName}>`, () => {
4343
it('should render disabled list item button', async () => {
4444
const onItemSelected = jest.fn();
4545
const { link } = setup({ children: 'Label', isDisabled: true, onItemSelected });
46-
expect(link).toHaveAttribute('aria-disabled', 'true');
46+
expect(link).toBeDisabled();
4747
// The `renderLink` util removes the onClick handler but `user-event` will also not fire events on disabled elements.
4848
if (link) await userEvent.click(link);
4949
expect(onItemSelected).not.toHaveBeenCalled();
@@ -57,7 +57,6 @@ describe(`<${ListItem.displayName}>`, () => {
5757
linkProps: { href: 'https://example.com' },
5858
onItemSelected,
5959
});
60-
expect(link).not.toHaveAttribute('href');
6160
expect(link).toHaveAttribute('aria-disabled', 'true');
6261
if (link) await userEvent.click(link);
6362
expect(onItemSelected).not.toHaveBeenCalled();
@@ -79,7 +78,6 @@ describe(`<${ListItem.displayName}>`, () => {
7978
linkProps: { href: 'https://example.com' },
8079
onItemSelected,
8180
});
82-
expect(link).not.toHaveAttribute('href');
8381
expect(link).toHaveAttribute('aria-disabled', 'true');
8482
if (link) await userEvent.click(link);
8583
expect(onItemSelected).not.toHaveBeenCalled();

packages/lumx-react/src/components/list/ListItem.tsx

Lines changed: 19 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
1-
import React, { ReactNode, Ref, SyntheticEvent, useMemo } from 'react';
1+
import React, { ReactNode, Ref, SyntheticEvent } from 'react';
22

33
import classNames from 'classnames';
44
import isEmpty from 'lodash/isEmpty';
55

66
import { ListProps, Size } from '@lumx/react';
77
import { GenericProps } from '@lumx/react/utils/type';
8-
import { onEnterPressed, onButtonPressed } from '@lumx/core/js/utils';
98
import { getRootClassName, handleBasicClasses } from '@lumx/core/js/utils/className';
10-
import { renderLink } from '@lumx/react/utils/react/renderLink';
119
import { forwardRef } from '@lumx/react/utils/react/forwardRef';
1210
import { useDisableStateProps } from '@lumx/react/utils/disabled/useDisableStateProps';
1311
import { HasAriaDisabled } from '@lumx/react/utils/type/HasAriaDisabled';
12+
import { RawClickable } from '@lumx/react/utils/react/RawClickable';
1413

1514
export type ListItemSize = Extract<Size, 'tiny' | 'regular' | 'big' | 'huge'>;
1615

@@ -94,13 +93,6 @@ export const ListItem = forwardRef<ListItemProps, HTMLLIElement>((props, ref) =>
9493
...forwardedProps
9594
} = otherProps;
9695

97-
const role = linkAs || linkProps.href ? 'link' : 'button';
98-
const onKeyDown = useMemo(() => {
99-
if (onItemSelected && role === 'link') return onEnterPressed(onItemSelected as any);
100-
if (onItemSelected && role === 'button') return onButtonPressed(onItemSelected as any);
101-
return undefined;
102-
}, [role, onItemSelected]);
103-
10496
const content = (
10597
<>
10698
{before && <div className={`${CLASSNAME}__before`}>{before}</div>}
@@ -123,28 +115,23 @@ export const ListItem = forwardRef<ListItemProps, HTMLLIElement>((props, ref) =>
123115
>
124116
{isClickable({ linkProps, onItemSelected }) ? (
125117
/* Clickable list item */
126-
renderLink(
127-
{
128-
linkAs,
129-
tabIndex: !disabledStateProps.disabled ? 0 : undefined,
130-
role,
131-
'aria-disabled': isAnyDisabled,
132-
...linkProps,
133-
href: isAnyDisabled ? undefined : linkProps.href,
134-
className: classNames(
135-
handleBasicClasses({
136-
prefix: `${CLASSNAME}__link`,
137-
isHighlighted,
138-
isSelected,
139-
isDisabled: isAnyDisabled,
140-
}),
141-
),
142-
onClick: isAnyDisabled ? undefined : onItemSelected,
143-
onKeyDown: isAnyDisabled ? undefined : onKeyDown,
144-
ref: linkRef,
145-
},
146-
content,
147-
)
118+
<RawClickable
119+
as={linkAs || (linkProps.href ? 'a' : 'button')}
120+
{...linkProps}
121+
{...disabledStateProps}
122+
className={classNames(
123+
handleBasicClasses({
124+
prefix: `${CLASSNAME}__link`,
125+
isHighlighted,
126+
isSelected,
127+
isDisabled: isAnyDisabled,
128+
}),
129+
)}
130+
onClick={onItemSelected}
131+
ref={linkRef}
132+
>
133+
{content}
134+
</RawClickable>
148135
) : (
149136
/* Non clickable list item */
150137
<div className={`${CLASSNAME}__wrapper`}>{content}</div>

packages/lumx-react/src/components/navigation/NavigationItem.tsx

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import React, { ElementType, ReactNode } from 'react';
22
import { Icon, Placement, Size, Tooltip, Text } from '@lumx/react';
33
import { getRootClassName, handleBasicClasses } from '@lumx/core/js/utils/className';
4-
import { ComponentRef, HasClassName, HasPolymorphicAs, HasTheme } from '@lumx/react/utils/type';
4+
import { ComponentRef, HasClassName, HasPolymorphicAs, HasRequiredLinkHref, HasTheme } from '@lumx/react/utils/type';
55
import classNames from 'classnames';
66
import { forwardRefPolymorphic } from '@lumx/react/utils/react/forwardRefPolymorphic';
77
import { useTheme } from '@lumx/react/utils/theme/ThemeContext';
88
import { useOverflowTooltipLabel } from '@lumx/react/hooks/useOverflowTooltipLabel';
9+
import { RawClickable } from '@lumx/react/utils/react/RawClickable';
910

1011
type BaseNavigationItemProps = {
1112
/** Icon (SVG path). */
@@ -16,17 +17,14 @@ type BaseNavigationItemProps = {
1617
isCurrentPage?: boolean;
1718
};
1819

19-
/** Make `href` required when `as` is `a` */
20-
type RequiredLinkHref<E> = E extends 'a' ? { href: string } : Record<string, unknown>;
21-
2220
/**
2321
* Navigation item props
2422
*/
2523
export type NavigationItemProps<E extends ElementType = 'a'> = HasPolymorphicAs<E> &
2624
HasTheme &
2725
HasClassName &
2826
BaseNavigationItemProps &
29-
RequiredLinkHref<E>;
27+
HasRequiredLinkHref<E>;
3028

3129
/**
3230
* Component display name.
@@ -44,8 +42,6 @@ export const NavigationItem = Object.assign(
4442
const theme = useTheme();
4543
const { tooltipLabel, labelRef } = useOverflowTooltipLabel(label);
4644

47-
const buttonProps = Element === 'button' ? { type: 'button' } : {};
48-
4945
return (
5046
<li
5147
className={classNames(
@@ -57,14 +53,14 @@ export const NavigationItem = Object.assign(
5753
)}
5854
>
5955
<Tooltip label={tooltipLabel} placement={Placement.TOP}>
60-
<Element
56+
<RawClickable
57+
as={Element}
6158
className={handleBasicClasses({
6259
prefix: `${CLASSNAME}__link`,
6360
isSelected: isCurrentPage,
6461
})}
6562
ref={ref as React.Ref<any>}
6663
aria-current={isCurrentPage ? 'page' : undefined}
67-
{...buttonProps}
6864
{...forwardedProps}
6965
>
7066
{icon ? (
@@ -74,7 +70,7 @@ export const NavigationItem = Object.assign(
7470
<Text as="span" truncate className={`${CLASSNAME}__label`} ref={labelRef}>
7571
{label}
7672
</Text>
77-
</Element>
73+
</RawClickable>
7874
</Tooltip>
7975
</li>
8076
);

0 commit comments

Comments
 (0)