From 9950e19b8c1d1c012369539b174551b9424dee8f Mon Sep 17 00:00:00 2001 From: Mike Molisani Date: Thu, 24 Sep 2026 23:18:41 +0000 Subject: [PATCH 1/7] feat: Add ref forwarding via nativeAttributes Components accepting nativeAttributes now accept an optional ref alongside the attributes, merged with the component's own forwarded ref, so consumers can reach the underlying native element for text selection, imperative scrolling, measurement and observation, containment checks, and integration with libraries that take an element ref. The merge happens in the shared nativeAttributes wrapper, so every component already accepting nativeAttributes gains the ref at once. The component's own exposed ref is unaffected and continues to represent imperative actions on the composite component. The ref is typed React.Ref, so object refs, callback refs and null all work. BREAKING CHANGE: NativeAttributes takes the element as a required first type parameter, e.g. NativeAttributes>. It affects only consumers who name the type explicitly; object literals passed to component props are unchanged, and no compiled behaviour changes. --- pages/native-refs/default.page.tsx | 225 ++++++++++++++++++ .../__snapshots__/documenter.test.ts.snap | 132 +++++----- src/action-card/interfaces.ts | 4 +- src/badge/interfaces.ts | 2 +- src/box/interfaces.ts | 2 +- src/button-dropdown/interfaces.ts | 6 +- src/button/interfaces.ts | 4 +- src/checkbox/interfaces.ts | 2 +- src/divider/interfaces.ts | 2 +- src/icon/interfaces.ts | 2 +- src/input/interfaces.ts | 2 +- .../components/autosuggest-input/index.tsx | 2 +- .../components/masked-input/index.tsx | 4 +- .../__tests__/with-native-attributes.test.tsx | 48 ++++ src/internal/utils/with-native-attributes.tsx | 29 ++- src/item-card/interfaces.ts | 2 +- src/item-card/internal.tsx | 4 +- src/link/interfaces.ts | 2 +- src/prompt-input/interfaces.ts | 2 +- src/radio-button/interfaces.ts | 2 +- src/space-between/interfaces.ts | 2 +- src/spinner/interfaces.ts | 2 +- src/status-indicator/interfaces.ts | 2 +- src/textarea/interfaces.ts | 2 +- src/toggle/interfaces.ts | 2 +- src/types/native-attributes.ts | 5 +- 26 files changed, 397 insertions(+), 96 deletions(-) create mode 100644 pages/native-refs/default.page.tsx diff --git a/pages/native-refs/default.page.tsx b/pages/native-refs/default.page.tsx new file mode 100644 index 0000000000..5d8d20b987 --- /dev/null +++ b/pages/native-refs/default.page.tsx @@ -0,0 +1,225 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useLayoutEffect, useRef, useState } from 'react'; + +import { Box } from '~components'; +import Button from '~components/button'; +import Icon from '~components/icon'; +import Input from '~components/input'; +import KeyValuePairs from '~components/key-value-pairs'; +import SpaceBetween from '~components/space-between'; +import Textarea from '~components/textarea'; + +export default function NativeRefsPage() { + return ( + +

Native Element Refs

+ + + + + + +
+ ); +} + +/** Use case: Text selection APIs (selectionStart, setSelectionRange) */ +function TextSelectionDemo() { + const [value, setValue] = useState(`Select some of this text`); + const [selection, setSelection] = useState(``); + const ref = useRef(null); + const nativeAttributesRef = useRef(null); + + const getSelection = () => { + const el = nativeAttributesRef.current; + if (el) { + const selected = value.slice(el.selectionStart ?? 0, el.selectionEnd ?? 0); + setSelection(selected || `(nothing selected)`); + } + }; + + const selectFirstWord = () => { + const el = nativeAttributesRef.current; + if (el) { + const firstSpace = value.indexOf(` `); + el.setSelectionRange(0, firstSpace > 0 ? firstSpace : value.length); + el.focus(); + } + }; + + const focusViaRef = () => { + ref.current?.focus(); + }; + + const selectViaRef = () => { + ref.current?.select(); + }; + + return ( +
+

Text selection (with mixed internal and external refs)

+

+ The component ref exposes focus() and select(). The{' '} + nativeAttributes.ref gives direct access to the <input> element for APIs like{' '} + selectionStart and setSelectionRange(). +

+ + setValue(e.detail.value)} + nativeInputAttributes={{ ref: nativeAttributesRef }} + /> +

ref

+ + + + +

nativeAttributes.ref

+ + + + + {selection && ( +
+ Selected: {selection} +
+ )} +
+
+ ); +} + +/** Use case: scrollIntoView */ +function ScrollIntoViewDemo() { + const buttonRef = useRef(null); + + const scrollToButton = () => { + buttonRef.current?.scrollIntoView({ behavior: `smooth`, block: `center` }); + }; + + return ( +
+

Imperative Scrolling

+

+ Call scrollIntoView() on a Button element via nativeAttributes.ref. +

+ +
+
+ +
+
+
+ ); +} + +/** Use case: getBoundingClientRect for measurement */ +function MeasurementDemo() { + const iconRef = useRef(null); + const [rect, setRect] = useState(null); + + const measure = () => { + const el = iconRef.current; + if (el) { + setRect(el.getBoundingClientRect()); + } + }; + + return ( +
+

Element Measurement

+

+ Call getBoundingClientRect() on an Icon via nativeAttributes.ref for tooltip/popover + positioning. +

+ +
+ ← measure this icon +
+ + {rect && ( + + )} +
+
+ ); +} + +/** Use case: paste at cursor in Textarea */ +function PasteAtCursorDemo() { + const [value, setValue] = useState(`Place your cursor anywhere in this text and click insert.`); + const textareaRef = useRef(null); + const [cursorPosition, setCursorPosition] = useState(null); + + useLayoutEffect(() => { + if (cursorPosition !== null) { + const el = textareaRef.current; + if (el) { + el.setSelectionRange(cursorPosition, cursorPosition); + el.focus(); + } + setCursorPosition(null); + } + }, [cursorPosition]); + + const insertAtCursor = () => { + const el = textareaRef.current; + if (el) { + const start = el.selectionStart ?? 0; + const end = el.selectionEnd ?? 0; + const inserted = `[INSERTED]`; + const next = value.slice(0, start) + inserted + value.slice(end); + setValue(next); + setCursorPosition(start + inserted.length); + } + }; + + return ( +
+

Paste at Cursor

+

+ Insert text at the cursor position in a Textarea via nativeAttributes.ref and retain + cursor position. +

+ +