From f2c4ef44ee970344fac28f731328bbe47c1f06ca Mon Sep 17 00:00:00 2001 From: Amr Mohamed Date: Mon, 8 Jun 2026 10:22:28 +0200 Subject: [PATCH] feat: Add renderCustomTrigger prop to Select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a public `renderCustomTrigger` prop on Select that lets consumers fully replace the default trigger element while Select retains ownership of the dropdown lifecycle, focus return, ARIA wiring, and form-field integration. The `SelectProps.CustomTriggerProps` props bag exposes: - `triggerRef` — attach to the focusable element so Select can return focus on close - `isOpen` — current dropdown state - `onClick` — toggles the dropdown open/closed - `ariaProps` — { id, 'aria-expanded', 'aria-labelledby', 'aria-describedby', 'aria-required' } to spread on the focusable element The consumer is responsible for rendering `aria-haspopup="listbox"` on the focusable element themselves (fixed value, not in the bag). When `renderCustomTrigger` is provided, the `placeholder` prop is not consumed by Select — the consumer renders their own empty state. Test utilities: - New `findCustomTrigger()` returns the wrapper around the consumer's custom trigger; null for default Select. - Existing `findTrigger()` is unchanged for default Select; returns null when `renderCustomTrigger` is provided. Backward compatible: default Select behavior is unchanged when the new prop is not provided. Refs: AWSUI-61998 --- pages/select/render-custom-trigger.page.tsx | 79 +++++++++++ .../__integ__/render-custom-trigger.test.ts | 134 ++++++++++++++++++ .../__tests__/render-custom-trigger.test.tsx | 103 ++++++++++++++ src/select/index.tsx | 2 + src/select/interfaces.ts | 28 ++++ src/select/internal.tsx | 3 + src/select/parts/styles.scss | 4 + src/select/parts/trigger.tsx | 27 +++- src/test-utils/dom/select/index.ts | 18 +++ 9 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 pages/select/render-custom-trigger.page.tsx create mode 100644 src/select/__integ__/render-custom-trigger.test.ts create mode 100644 src/select/__tests__/render-custom-trigger.test.tsx diff --git a/pages/select/render-custom-trigger.page.tsx b/pages/select/render-custom-trigger.page.tsx new file mode 100644 index 0000000000..27712cd138 --- /dev/null +++ b/pages/select/render-custom-trigger.page.tsx @@ -0,0 +1,79 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import Box from '~components/box'; +import Select, { SelectProps } from '~components/select'; + +import ScreenshotArea from '../utils/screenshot-area'; + +// SelectProps.CustomTriggerProps is added by the implementation agent in parallel. +// Until the interfaces file is updated, we declare a local alias to avoid implicit-any +// on the renderCustomTrigger callback parameters. +type CustomTriggerProps = SelectProps.CustomTriggerProps; + +const options: SelectProps.Options = [ + { + value: 'direct', + label: 'Create a case', + description: 'Submit a support case directly to AWS Support.', + }, + { + value: 'ai-assist', + label: 'AI assist, then create a case', + iconName: 'gen-ai', + description: 'Let AI help draft your case details before submitting.', + }, +]; + +export default function RenderCustomTriggerPage() { + const [selectedOption, setSelectedOption] = useState(options[0]); + + return ( + + Select — renderCustomTrigger + + + Demonstrates a flat, borderless link-style trigger (replicating the AWS Support Console pattern from + CR-273907463). Test: click trigger to open dropdown; selecting an option updates the trigger label and icon; + keyboard — Enter/Space opens, Escape closes, focus returns to trigger. + + + ); + return createWrapper(container).findSelect()!; +} + +describe('Select renderCustomTrigger', () => { + test('renders the consumer-provided trigger element when renderCustomTrigger is set', () => { + const wrapper = renderSelect({ + renderCustomTrigger: ({ triggerRef, ariaProps }) => ( + + ), + }); + + const customTrigger = wrapper.findCustomTrigger(); + expect(customTrigger).not.toBeNull(); + expect(customTrigger!.find('[data-testid="custom-trigger"]')).not.toBeNull(); + expect(wrapper.findCustomTrigger()!.getElement().textContent).toBe('Custom trigger'); + }); + + test('findTrigger returns null when renderCustomTrigger is set, and the default ButtonTrigger is not rendered', () => { + const wrapper = renderSelect({ + renderCustomTrigger: ({ triggerRef, ariaProps }) => ( + + ), + }); + expect(wrapper.findTrigger()).toBeNull(); + }); + + test('findCustomTrigger returns null for default Select', () => { + const wrapper = renderSelect({}); + expect(wrapper.findCustomTrigger()).toBeNull(); + // Default trigger still works + expect(wrapper.findTrigger()).not.toBeNull(); + }); + + test('clicking the custom trigger toggles the dropdown open', () => { + const wrapper = renderSelect({ + renderCustomTrigger: ({ triggerRef, ariaProps, onClick }) => ( + + ), + }); + + expect(wrapper.findDropdown().findOpenDropdown()).toBeNull(); + (wrapper.findCustomTrigger()!.find('button')!.getElement() as HTMLButtonElement).click(); + expect(wrapper.findDropdown().findOpenDropdown()).not.toBeNull(); + }); + + test('ariaProps include id, aria-expanded, and reflect the open state', () => { + let captured: { 'aria-expanded': boolean; id: string } | null = null; + const wrapper = renderSelect({ + renderCustomTrigger: ({ triggerRef, ariaProps, onClick }) => { + captured = { 'aria-expanded': ariaProps['aria-expanded'], id: ariaProps.id }; + return ( + + ); + }, + }); + + expect(captured).not.toBeNull(); + expect(captured!['aria-expanded']).toBe(false); + expect(typeof captured!.id).toBe('string'); + expect(captured!.id.length).toBeGreaterThan(0); + + (wrapper.findCustomTrigger()!.find('button')!.getElement() as HTMLButtonElement).click(); + expect(captured!['aria-expanded']).toBe(true); + }); +}); diff --git a/src/select/index.tsx b/src/select/index.tsx index ae0fcabb4e..6fc98623ad 100644 --- a/src/select/index.tsx +++ b/src/select/index.tsx @@ -24,6 +24,7 @@ const Select = React.forwardRef( statusType = 'finished', triggerVariant = 'label', renderOption, + renderCustomTrigger, ...restProps }: SelectProps, ref: React.Ref @@ -58,6 +59,7 @@ const Select = React.forwardRef( return ( React.ReactNode; } export namespace SelectProps { @@ -242,4 +253,21 @@ export namespace SelectProps { */ focus(): void; } + + export interface CustomTriggerProps { + /** Attach to the focusable element so Select can return focus on close. */ + triggerRef: React.Ref; + /** Whether the dropdown is currently open. */ + isOpen: boolean; + /** Toggles the dropdown open/closed. */ + onClick: () => void; + /** ARIA props the consumer must spread on its focusable element. The consumer must also apply `aria-haspopup="listbox"` themselves. */ + ariaProps: { + 'aria-expanded': boolean; + 'aria-labelledby': string | undefined; + 'aria-describedby': string | undefined; + 'aria-required'?: boolean; + id: string; + }; + } } diff --git a/src/select/internal.tsx b/src/select/internal.tsx index 88bfbb7541..9446995319 100644 --- a/src/select/internal.tsx +++ b/src/select/internal.tsx @@ -71,6 +71,7 @@ const InternalSelect = React.forwardRef( __inFilteringToken, __internalRootRef, renderOption, + renderCustomTrigger, ...restProps }: InternalSelectProps, externalRef: React.Ref @@ -173,6 +174,7 @@ const InternalSelect = React.forwardRef( const trigger = ( ); diff --git a/src/select/parts/styles.scss b/src/select/parts/styles.scss index ed758c33a0..4f03efae45 100644 --- a/src/select/parts/styles.scss +++ b/src/select/parts/styles.scss @@ -127,3 +127,7 @@ $checkbox-size: awsui.$size-control; .disabled-reason-tooltip { /* used in test-utils or tests */ } + +.custom-trigger { + /* used in test-utils */ +} diff --git a/src/select/parts/trigger.tsx b/src/select/parts/trigger.tsx index cb2c2a8677..4d61d09320 100644 --- a/src/select/parts/trigger.tsx +++ b/src/select/parts/trigger.tsx @@ -30,6 +30,8 @@ export interface TriggerProps extends FormFieldValidationControlProps { inFilteringToken?: 'root' | 'nested'; selectedOptions?: ReadonlyArray; renderOption?: SelectProps.SelectOptionItemRenderer; + renderCustomTrigger?: SelectProps['renderCustomTrigger']; + ariaRequired?: boolean; } const Trigger = React.forwardRef( @@ -51,6 +53,8 @@ const Trigger = React.forwardRef( disabled, readOnly, renderOption, + renderCustomTrigger, + ariaRequired, }: TriggerProps, ref: React.Ref ) => { @@ -59,6 +63,28 @@ const Trigger = React.forwardRef( const id = controlId ?? generatedId; const triggerContentId = useUniqueId('trigger-content-'); + const mergedRef = useMergeRefs(triggerProps.ref, ref); + + if (renderCustomTrigger) { + const onClick = () => triggerProps.onMouseDown?.({ preventDefault: () => void 0 } as unknown as CustomEvent); + return ( +
+ {renderCustomTrigger({ + triggerRef: mergedRef as React.Ref, + isOpen: !!isOpen, + onClick, + ariaProps: { + id, + 'aria-expanded': !!isOpen, + 'aria-labelledby': ariaLabelledby, + 'aria-describedby': ariaDescribedby, + ...(ariaRequired !== undefined ? { 'aria-required': ariaRequired } : {}), + }, + })} +
+ ); + } + let ariaLabelledbyIds = joinStrings(ariaLabelledby, triggerContentId); let triggerContent = null; @@ -129,7 +155,6 @@ const Trigger = React.forwardRef( ); } - const mergedRef = useMergeRefs(triggerProps.ref, ref); const triggerButton = (