From 4395e9dff230cd9247cb3a1302fe2de9bd7ff8ec Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Thu, 4 Sep 2025 16:33:31 +0200 Subject: [PATCH] feat: Add native attributes support for link, toggle, toggle button --- .../__snapshots__/documenter.test.ts.snap | 66 ++++++++++++------- src/link/__tests__/index.test.tsx | 18 +++++ src/link/interfaces.ts | 16 +++++ src/link/internal.tsx | 16 +++-- .../__tests__/toggle-button.test.tsx | 19 ++++++ src/toggle-button/index.tsx | 2 + src/toggle-button/interfaces.ts | 2 +- src/toggle-button/internal.tsx | 2 + src/toggle/__tests__/toggle.test.tsx | 14 ++++ src/toggle/interfaces.ts | 16 +++++ src/toggle/internal.tsx | 7 +- 11 files changed, 150 insertions(+), 28 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 00180c5050..f1990e79fc 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -15355,6 +15355,28 @@ use the \`id\` attribute, consider setting it on a parent element instead.", "optional": true, "type": "string", }, + { + "description": "Attributes to add to the native element. +Some attributes will be automatically combined with internal attribute values: +- \`className\` will be appended. +- Event handlers will be chained, unless the default is prevented. + +We do not support using this attribute to apply custom styling.", + "inlineType": { + "name": "Omit, "children"> & Record<\`data-\${string}\`, string>", + "type": "union", + "values": [ + "Omit, "children">", + "Record<\`data-\${string}\`, string>", + ], + }, + "name": "nativeAttributes", + "optional": true, + "systemTags": [ + "core", + ], + "type": "Omit, "children"> & Record<\`data-\${string}\`, string>", + }, { "description": "Adds a \`rel\` attribute to the link. If the \`rel\` property is provided, it overrides the default behaviour. By default, the component sets the \`rel\` attribute to "noopener noreferrer" when \`external\` is \`true\` or \`target\` is \`"_blank"\`.", @@ -25423,6 +25445,28 @@ use the \`id\` attribute, consider setting it on a parent element instead.", "optional": true, "type": "string", }, + { + "description": "Attributes to add to the native \`input\` element. +Some attributes will be automatically combined with internal attribute values: +- \`className\` will be appended. +- Event handlers will be chained, unless the default is prevented. + +We do not support using this attribute to apply custom styling.", + "inlineType": { + "name": "Omit, "children"> & Record<\`data-\${string}\`, string>", + "type": "union", + "values": [ + "Omit, "children">", + "Record<\`data-\${string}\`, string>", + ], + }, + "name": "nativeInputAttributes", + "optional": true, + "systemTags": [ + "core", + ], + "type": "Omit, "children"> & Record<\`data-\${string}\`, string>", + }, { "description": "Specifies if the control is read-only, which prevents the user from modifying the value. Should be used only inside forms. @@ -25893,28 +25937,6 @@ It prevents users from clicking the button, but it can still be focused.", "optional": true, "type": "string", }, - { - "description": "Attributes to add to the native \`a\` element (when \`href\` is provided). -Some attributes will be automatically combined with internal attribute values: -- \`className\` will be appended. -- Event handlers will be chained, unless the default is prevented. - -We do not support using this attribute to apply custom styling.", - "inlineType": { - "name": "Omit, "children"> & Record<\`data-\${string}\`, string>", - "type": "union", - "values": [ - "Omit, "children">", - "Record<\`data-\${string}\`, string>", - ], - }, - "name": "nativeAnchorAttributes", - "optional": true, - "systemTags": [ - "core", - ], - "type": "Omit, "children"> & Record<\`data-\${string}\`, string>", - }, { "description": "Attributes to add to the native \`button\` element. Some attributes will be automatically combined with internal attribute values: diff --git a/src/link/__tests__/index.test.tsx b/src/link/__tests__/index.test.tsx index 4b92151b9b..f033577bf9 100644 --- a/src/link/__tests__/index.test.tsx +++ b/src/link/__tests__/index.test.tsx @@ -424,3 +424,21 @@ describe('Style API', () => { expect(getComputedStyle(link).getPropertyValue(customCssProps.styleFocusRingBorderWidth)).toBe('4px'); }); }); + +describe('native attributes', () => { + it('adds native attributes', () => { + const { container } = render(); + expect(container.querySelectorAll('[data-testid="my-test-id"]')).toHaveLength(1); + expect(container.querySelectorAll('a[data-testid="my-test-id"]')).toHaveLength(1); + }); + it('adds native attributes (button link)', () => { + const { container } = render(); + expect(container.querySelectorAll('[data-testid="my-test-id"]')).toHaveLength(1); + expect(container.querySelectorAll('a[data-testid="my-test-id"]')).toHaveLength(1); + }); + it('concatenates class names', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass(styles.link); + expect(container.firstChild).toHaveClass('additional-class'); + }); +}); diff --git a/src/link/interfaces.ts b/src/link/interfaces.ts index 70808b8cf7..256f837337 100644 --- a/src/link/interfaces.ts +++ b/src/link/interfaces.ts @@ -9,6 +9,10 @@ import { ClickDetail as _ClickDetail, NonCancelableEventHandler, } from '../internal/events'; +/** + * @awsuiSystem core + */ +import { NativeAttributes } from '../internal/utils/with-native-attributes'; export interface LinkProps extends BaseComponentProps { /** @@ -111,6 +115,18 @@ export interface LinkProps extends BaseComponentProps { * @awsuiSystem core */ style?: LinkProps.Style; + + /** + * Attributes to add to the native element. + * Some attributes will be automatically combined with internal attribute values: + * - `className` will be appended. + * - Event handlers will be chained, unless the default is prevented. + * + * We do not support using this attribute to apply custom styling. + * + * @awsuiSystem core + */ + nativeAttributes?: NativeAttributes>; } export namespace LinkProps { diff --git a/src/link/internal.tsx b/src/link/internal.tsx index d36764b3f4..ccf9f38804 100644 --- a/src/link/internal.tsx +++ b/src/link/internal.tsx @@ -25,6 +25,7 @@ import { InternalBaseComponentProps } from '../internal/hooks/use-base-component import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { KeyCode } from '../internal/keycode'; import { checkSafeUrl } from '../internal/utils/check-safe-url'; +import WithNativeAttributes from '../internal/utils/with-native-attributes'; import { LinkProps } from './interfaces'; import { getLinkStyles } from './style'; @@ -50,6 +51,7 @@ const InternalLink = React.forwardRef( onFollow, onClick, children, + nativeAttributes, __internalRootRef, style, ...props @@ -210,21 +212,27 @@ const InternalLink = React.forwardRef( if (isButton) { return ( - {content} - + ); } return ( - {content} - + ); } ); diff --git a/src/toggle-button/__tests__/toggle-button.test.tsx b/src/toggle-button/__tests__/toggle-button.test.tsx index 42e91c1b1a..41ae06994e 100644 --- a/src/toggle-button/__tests__/toggle-button.test.tsx +++ b/src/toggle-button/__tests__/toggle-button.test.tsx @@ -9,6 +9,8 @@ import createWrapper from '../../../lib/components/test-utils/dom'; import ToggleButton, { ToggleButtonProps } from '../../../lib/components/toggle-button'; import { getToggleIcon } from '../../../lib/components/toggle-button/util'; +import styles from '../../../lib/components/button/styles.css.js'; + jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ ...jest.requireActual('@cloudscape-design/component-toolkit/internal'), warnOnce: jest.fn(), @@ -133,4 +135,21 @@ describe('ToggleButton Component', () => { expect(getToggleIcon(true, 'star')).toBe('star'); }); }); + + describe('native attributes', () => { + it('adds native attributes', () => { + const { container } = render( + + ); + expect(container.querySelectorAll('[data-testid="my-test-id"]')).toHaveLength(1); + expect(container.querySelectorAll('button[data-testid="my-test-id"]')).toHaveLength(1); + }); + it('concatenates class names', () => { + const { container } = render( + + ); + expect(container.firstChild).toHaveClass(styles.button); + expect(container.firstChild).toHaveClass('additional-class'); + }); + }); }); diff --git a/src/toggle-button/index.tsx b/src/toggle-button/index.tsx index 645e4453d5..bdfd2bdd3d 100644 --- a/src/toggle-button/index.tsx +++ b/src/toggle-button/index.tsx @@ -31,6 +31,7 @@ const ToggleButton = React.forwardRef( ariaDescribedby, ariaControls, pressed = false, + nativeButtonAttributes, onChange, ...props }: ToggleButtonProps, @@ -65,6 +66,7 @@ const ToggleButton = React.forwardRef( pressedIconUrl={pressedIconUrl} pressedIconSvg={pressedIconSvg} pressed={pressed} + nativeButtonAttributes={nativeButtonAttributes} onChange={onChange} > {children} diff --git a/src/toggle-button/interfaces.ts b/src/toggle-button/interfaces.ts index 417bf4dd92..485a9ca753 100644 --- a/src/toggle-button/interfaces.ts +++ b/src/toggle-button/interfaces.ts @@ -7,7 +7,7 @@ import { IconProps } from '../icon/interfaces'; import { BaseComponentProps } from '../internal/base-component'; import { NonCancelableEventHandler } from '../internal/events'; -export interface ToggleButtonProps extends BaseComponentProps, BaseButtonProps { +export interface ToggleButtonProps extends BaseComponentProps, Omit { /** Determines the general styling of the toggle button as follows: * * `normal` for secondary buttons. * * `icon` to display an icon only (no text). diff --git a/src/toggle-button/internal.tsx b/src/toggle-button/internal.tsx index 4272aae6e8..00fb7fc9a4 100644 --- a/src/toggle-button/internal.tsx +++ b/src/toggle-button/internal.tsx @@ -24,6 +24,7 @@ export const InternalToggleButton = React.forwardRef( iconUrl: defaultIconUrl, pressedIconUrl, variant, + nativeButtonAttributes, onChange, className, ...rest @@ -60,6 +61,7 @@ export const InternalToggleButton = React.forwardRef( }} {...rest} ref={ref} + nativeButtonAttributes={nativeButtonAttributes} /> ); } diff --git a/src/toggle/__tests__/toggle.test.tsx b/src/toggle/__tests__/toggle.test.tsx index f6fd88acf3..62357d85ff 100644 --- a/src/toggle/__tests__/toggle.test.tsx +++ b/src/toggle/__tests__/toggle.test.tsx @@ -232,3 +232,17 @@ test('all style api properties', () => { expect(getComputedStyle(toggleHandle).getPropertyValue('background-color')).toBe('blue'); expect(getComputedStyle(toggleLabel).getPropertyValue('color')).toBe('orange'); }); + +describe('native attributes', () => { + it('adds native attributes', () => { + const { container } = render(); + expect(container.querySelectorAll('[data-testid="my-test-id"]')).toHaveLength(1); + expect(container.querySelectorAll('input[data-testid="my-test-id"]')).toHaveLength(1); + }); + it('concatenates class names', () => { + const { container } = render(); + const input = container.querySelector('input'); + expect(input).toHaveClass(abstractSwitchStyles['native-input']); + expect(input).toHaveClass('additional-class'); + }); +}); diff --git a/src/toggle/interfaces.ts b/src/toggle/interfaces.ts index 3d748dd758..d686bd5dd1 100644 --- a/src/toggle/interfaces.ts +++ b/src/toggle/interfaces.ts @@ -4,6 +4,10 @@ import React from 'react'; import { BaseCheckboxProps } from '../checkbox/base-checkbox'; import { NonCancelableEventHandler } from '../internal/events'; +/** + * @awsuiSystem core + */ +import { NativeAttributes } from '../internal/utils/with-native-attributes'; export interface ToggleProps extends BaseCheckboxProps { /** @@ -22,6 +26,18 @@ export interface ToggleProps extends BaseCheckboxProps { * @awsuiSystem core */ style?: ToggleProps.Style; + + /** + * Attributes to add to the native `input` element. + * Some attributes will be automatically combined with internal attribute values: + * - `className` will be appended. + * - Event handlers will be chained, unless the default is prevented. + * + * We do not support using this attribute to apply custom styling. + * + * @awsuiSystem core + */ + nativeInputAttributes?: NativeAttributes>; } export namespace ToggleProps { diff --git a/src/toggle/internal.tsx b/src/toggle/internal.tsx index e89e1b878d..480be02bdf 100644 --- a/src/toggle/internal.tsx +++ b/src/toggle/internal.tsx @@ -14,6 +14,7 @@ import { useFormFieldContext } from '../internal/context/form-field-context'; import { fireNonCancelableEvent } from '../internal/events'; import useForwardFocus from '../internal/hooks/forward-focus'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import WithNativeAttributes from '../internal/utils/with-native-attributes'; import { GeneratedAnalyticsMetadataToggleComponent } from './analytics-metadata/interfaces'; import { ToggleProps } from './interfaces'; import { getAbstractSwitchStyles, getStyledControlStyle } from './style'; @@ -39,6 +40,7 @@ const InternalToggle = React.forwardRef( onFocus, onBlur, onChange, + nativeInputAttributes, __internalRootRef, style, __injectAnalyticsComponentMetadata, @@ -89,8 +91,11 @@ const InternalToggle = React.forwardRef( ariaDescribedby={ariaDescribedby} ariaControls={ariaControls} nativeControl={nativeControlProps => ( -