Skip to content

Commit 2481846

Browse files
feat: Allow button native element attributes to be set (#3552)
1 parent 7fbd0fb commit 2481846

File tree

16 files changed

+476
-36
lines changed

16 files changed

+476
-36
lines changed

src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4216,6 +4216,50 @@ It prevents users from clicking the button, but it can still be focused.",
42164216
"optional": true,
42174217
"type": "string",
42184218
},
4219+
{
4220+
"description": "Attributes to add to the native \`a\` element (when \`href\` is provided).
4221+
Some attributes will be automatically combined with Cloudscape-provided attribute values:
4222+
- \`className\` will be appended.
4223+
- Event handlers will be chained, unless the default is prevented.
4224+
4225+
We do not support using this attribute to apply custom styling.",
4226+
"inlineType": {
4227+
"name": "Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "children"> & Record<\`data-\${string}\`, string>",
4228+
"type": "union",
4229+
"values": [
4230+
"Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "children">",
4231+
"Record<\`data-\${string}\`, string>",
4232+
],
4233+
},
4234+
"name": "nativeAnchorAttributes",
4235+
"optional": true,
4236+
"systemTags": [
4237+
"core",
4238+
],
4239+
"type": "Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "children"> & Record<\`data-\${string}\`, string>",
4240+
},
4241+
{
4242+
"description": "Attributes to add to the native \`button\` element.
4243+
Some attributes will be automatically combined with Cloudscape-provided attribute values:
4244+
- \`className\` will be appended.
4245+
- Event handlers will be chained, unless the default is prevented.
4246+
4247+
We do not support using this attribute to apply custom styling.",
4248+
"inlineType": {
4249+
"name": "Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> & Record<\`data-\${string}\`, string>",
4250+
"type": "union",
4251+
"values": [
4252+
"Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children">",
4253+
"Record<\`data-\${string}\`, string>",
4254+
],
4255+
},
4256+
"name": "nativeButtonAttributes",
4257+
"optional": true,
4258+
"systemTags": [
4259+
"core",
4260+
],
4261+
"type": "Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> & Record<\`data-\${string}\`, string>",
4262+
},
42194263
{
42204264
"description": "Adds a \`rel\` attribute to the link. By default, the component sets the \`rel\` attribute to "noopener noreferrer" when \`target\` is \`"_blank"\`.
42214265
If the \`rel\` property is provided, it overrides the default behavior.",
@@ -20165,6 +20209,50 @@ It prevents users from clicking the button, but it can still be focused.",
2016520209
"optional": true,
2016620210
"type": "string",
2016720211
},
20212+
{
20213+
"description": "Attributes to add to the native \`a\` element (when \`href\` is provided).
20214+
Some attributes will be automatically combined with Cloudscape-provided attribute values:
20215+
- \`className\` will be appended.
20216+
- Event handlers will be chained, unless the default is prevented.
20217+
20218+
We do not support using this attribute to apply custom styling.",
20219+
"inlineType": {
20220+
"name": "Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "children"> & Record<\`data-\${string}\`, string>",
20221+
"type": "union",
20222+
"values": [
20223+
"Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "children">",
20224+
"Record<\`data-\${string}\`, string>",
20225+
],
20226+
},
20227+
"name": "nativeAnchorAttributes",
20228+
"optional": true,
20229+
"systemTags": [
20230+
"core",
20231+
],
20232+
"type": "Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "children"> & Record<\`data-\${string}\`, string>",
20233+
},
20234+
{
20235+
"description": "Attributes to add to the native \`button\` element.
20236+
Some attributes will be automatically combined with Cloudscape-provided attribute values:
20237+
- \`className\` will be appended.
20238+
- Event handlers will be chained, unless the default is prevented.
20239+
20240+
We do not support using this attribute to apply custom styling.",
20241+
"inlineType": {
20242+
"name": "Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> & Record<\`data-\${string}\`, string>",
20243+
"type": "union",
20244+
"values": [
20245+
"Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children">",
20246+
"Record<\`data-\${string}\`, string>",
20247+
],
20248+
},
20249+
"name": "nativeButtonAttributes",
20250+
"optional": true,
20251+
"systemTags": [
20252+
"core",
20253+
],
20254+
"type": "Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> & Record<\`data-\${string}\`, string>",
20255+
},
2016820256
{
2016920257
"defaultValue": "false",
2017020258
"description": "Sets the toggle button to pressed state.",

src/app-layout/visual-refresh/mobile-toolbar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export default function MobileToolbar() {
6969
className={testutilStyles['navigation-toggle']}
7070
ref={navigationRefs.toggle}
7171
disabled={hasDrawerViewportOverlay}
72-
__nativeAttributes={{ 'aria-haspopup': navigationOpen ? undefined : true }}
72+
nativeButtonAttributes={{ 'aria-haspopup': navigationOpen ? undefined : true }}
7373
/>
7474
</nav>
7575
)}
@@ -97,7 +97,7 @@ export default function MobileToolbar() {
9797
onClick={() => handleToolsClick(true)}
9898
variant="icon"
9999
ref={toolsRefs.toggle}
100-
__nativeAttributes={{ 'aria-haspopup': true }}
100+
nativeButtonAttributes={{ 'aria-haspopup': true }}
101101
/>
102102
</aside>
103103
)

src/app-layout/visual-refresh/trigger-button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ function TriggerButton(
6767
badge={badge}
6868
onClick={onClick}
6969
variant="icon"
70-
__nativeAttributes={{
70+
nativeButtonAttributes={{
7171
'aria-haspopup': true,
7272
...(testId && {
7373
'data-testid': testId,

src/attribute-editor/internal.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,8 @@ const InternalAttributeEditor = React.forwardRef(
152152
// Using aria-disabled="true" and tabindex="-1" instead of "disabled"
153153
// because focus can be dynamically moved to this button by calling
154154
// `focusAddButton()` on the ref.
155-
__nativeAttributes={disableAddButton ? { tabIndex: -1 } : {}}
155+
nativeButtonAttributes={disableAddButton ? { tabIndex: -1 } : {}}
156+
__skipNativeAttributesWarnings={true}
156157
__focusable={true}
157158
onClick={onAddButtonClick}
158159
formAction="none"

src/button-dropdown/internal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ const InternalButtonDropdown = React.forwardRef(
168168
ariaLabel,
169169
ariaExpanded: canBeOpened && isOpen,
170170
formAction: 'none',
171-
__nativeAttributes: {
171+
nativeButtonAttributes: {
172172
'aria-haspopup': true,
173173
},
174174
};

src/button/__tests__/button.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -814,8 +814,8 @@ describe('table grid navigation support', () => {
814814
test('does not override explicit tab index with 0', () => {
815815
const { setCurrentTarget } = renderWithSingleTabStopNavigation(
816816
<div>
817-
<InternalButton id="button1" __nativeAttributes={{ tabIndex: -2 }} />
818-
<InternalButton id="button2" __nativeAttributes={{ tabIndex: -2 }} />
817+
<InternalButton id="button1" nativeButtonAttributes={{ tabIndex: -2 }} />
818+
<InternalButton id="button2" nativeButtonAttributes={{ tabIndex: -2 }} />
819819
</div>
820820
);
821821
setCurrentTarget(getButton('#button1'));

src/button/__tests__/internal.test.tsx

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,8 @@ import { mockedFunnelInteractionId, mockFunnelMetrics } from '../../internal/ana
1212

1313
import styles from '../../../lib/components/button/styles.css.js';
1414

15-
test('specific properties take precedence over nativeAttributes', () => {
16-
const { container } = render(
17-
<InternalButton ariaLabel="property" __nativeAttributes={{ 'aria-label': 'native attribute' }} />
18-
);
19-
expect(container.querySelector('button')).toHaveAttribute('aria-label', 'property');
20-
});
21-
22-
test('supports providing custom attributes', () => {
23-
const { container } = render(<InternalButton __nativeAttributes={{ 'aria-hidden': 'true' }} />);
24-
expect(container.querySelector('button')).toHaveAttribute('aria-hidden', 'true');
25-
});
26-
2715
test('supports __iconClass property', () => {
28-
const { container } = render(
29-
<InternalButton __iconClass="example-class" iconName="settings" __nativeAttributes={{ 'aria-expanded': 'true' }} />
30-
);
16+
const { container } = render(<InternalButton __iconClass="example-class" iconName="settings" />);
3117
expect(container.querySelector(`button .${styles.icon}`)).toHaveClass('example-class');
3218
});
3319

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import React from 'react';
4+
import { render } from '@testing-library/react';
5+
6+
import Button from '../../../lib/components/button';
7+
import createWrapper from '../../../lib/components/test-utils/dom';
8+
9+
import styles from '../../../lib/components/button/styles.css.js';
10+
11+
describe('Button native attributes', () => {
12+
test('passes nativeAttributes to the button element', () => {
13+
const { container } = render(
14+
<Button
15+
nativeButtonAttributes={{
16+
'data-testid': 'test-button',
17+
'aria-controls': 'controlled-element',
18+
}}
19+
>
20+
Button text
21+
</Button>
22+
);
23+
const wrapper = createWrapper(container).findButton()!;
24+
25+
expect(wrapper.getElement()).toHaveAttribute('data-testid', 'test-button');
26+
expect(wrapper.getElement()).toHaveAttribute('aria-controls', 'controlled-element');
27+
});
28+
29+
test('passes nativeAttributes to the anchor element when href is provided', () => {
30+
const { container } = render(
31+
<Button
32+
href="https://example.com"
33+
nativeAnchorAttributes={{
34+
'data-testid': 'test-link',
35+
'aria-controls': 'controlled-element',
36+
}}
37+
>
38+
Link text
39+
</Button>
40+
);
41+
const wrapper = createWrapper(container).findButton()!;
42+
43+
expect(wrapper.getElement()).toHaveAttribute('data-testid', 'test-link');
44+
expect(wrapper.getElement()).toHaveAttribute('aria-controls', 'controlled-element');
45+
expect(wrapper.getElement().tagName).toBe('A');
46+
});
47+
48+
test('overrides built-in attributes with nativeAttributes', () => {
49+
const { container } = render(
50+
<Button
51+
ariaLabel="Button label"
52+
nativeButtonAttributes={{
53+
'aria-label': 'Override label',
54+
}}
55+
>
56+
Button text
57+
</Button>
58+
);
59+
const wrapper = createWrapper(container).findButton()!;
60+
61+
expect(wrapper.getElement()).toHaveAccessibleName('Override label');
62+
});
63+
64+
test('combines built-in className with any provided in nativeAttributes', () => {
65+
const { container } = render(
66+
<Button
67+
ariaLabel="Button label"
68+
nativeButtonAttributes={{
69+
className: 'my-additional-class',
70+
}}
71+
>
72+
Button text
73+
</Button>
74+
);
75+
const wrapper = createWrapper(container).findButton()!;
76+
77+
expect(wrapper.getElement()).toHaveClass('my-additional-class');
78+
expect(wrapper.getElement()).toHaveClass(styles.button);
79+
});
80+
81+
test('events get chained', () => {
82+
const mainClick = jest.fn();
83+
const nativeClick = jest.fn();
84+
const { container } = render(
85+
<Button
86+
ariaLabel="Button label"
87+
onClick={mainClick}
88+
nativeButtonAttributes={{
89+
onClick: nativeClick,
90+
}}
91+
>
92+
Button text
93+
</Button>
94+
);
95+
const wrapper = createWrapper(container).findButton()!;
96+
wrapper.click();
97+
expect(mainClick).toHaveBeenCalled();
98+
expect(nativeClick).toHaveBeenCalled();
99+
});
100+
});

src/button/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ const Button = React.forwardRef(
4242
form,
4343
i18nStrings,
4444
style,
45+
nativeButtonAttributes,
46+
nativeAnchorAttributes,
4547
...props
4648
}: ButtonProps,
4749
ref: React.Ref<ButtonProps.Ref>
@@ -83,6 +85,8 @@ const Button = React.forwardRef(
8385
form={form}
8486
i18nStrings={i18nStrings}
8587
style={style}
88+
nativeButtonAttributes={nativeButtonAttributes}
89+
nativeAnchorAttributes={nativeAnchorAttributes}
8690
__injectAnalyticsComponentMetadata={true}
8791
>
8892
{children}

src/button/interfaces.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import React from 'react';
55
import { IconProps } from '../icon/interfaces';
66
import { BaseComponentProps } from '../internal/base-component';
77
import { BaseNavigationDetail, CancelableEventHandler, ClickDetail as _ClickDetail } from '../internal/events';
8+
import { NativeAttributes } from '../internal/utils/with-native-attributes';
89

910
export interface BaseButtonProps {
1011
/**
@@ -100,6 +101,30 @@ export interface BaseButtonProps {
100101
* @i18n
101102
*/
102103
i18nStrings?: ButtonProps.I18nStrings;
104+
105+
/**
106+
* Attributes to add to the native `button` element.
107+
* Some attributes will be automatically combined with Cloudscape-provided attribute values:
108+
* - `className` will be appended.
109+
* - Event handlers will be chained, unless the default is prevented.
110+
*
111+
* We do not support using this attribute to apply custom styling.
112+
*
113+
* @awsuiSystem core
114+
*/
115+
nativeButtonAttributes?: NativeAttributes<React.ButtonHTMLAttributes<HTMLButtonElement>>;
116+
117+
/**
118+
* Attributes to add to the native `a` element (when `href` is provided).
119+
* Some attributes will be automatically combined with Cloudscape-provided attribute values:
120+
* - `className` will be appended.
121+
* - Event handlers will be chained, unless the default is prevented.
122+
*
123+
* We do not support using this attribute to apply custom styling.
124+
*
125+
* @awsuiSystem core
126+
*/
127+
nativeAnchorAttributes?: NativeAttributes<React.AnchorHTMLAttributes<HTMLAnchorElement>>;
103128
}
104129

105130
export interface ButtonProps extends BaseComponentProps, BaseButtonProps {

0 commit comments

Comments
 (0)