Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions pages/select/render-custom-trigger.page.tsx
Original file line number Diff line number Diff line change
@@ -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<SelectProps.Option | null>(options[0]);

return (
<ScreenshotArea>
<Box variant="h1">Select — renderCustomTrigger</Box>
<Box padding="l">
<Box variant="p" color="text-body-secondary">
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.
</Box>
<Box margin={{ top: 'm' }}>
<Select
selectedOption={selectedOption}
onChange={({ detail }: { detail: SelectProps.ChangeDetail }) => setSelectedOption(detail.selectedOption)}
options={options}
renderCustomTrigger={({ triggerRef, isOpen, onClick, ariaProps }: CustomTriggerProps) => (
<button
ref={triggerRef as React.Ref<HTMLButtonElement>}
onClick={onClick}
aria-haspopup="listbox"
{...ariaProps}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
border: 'none',
background: 'transparent',
color: '#0972d3',
cursor: 'pointer',
padding: '0',
font: 'inherit',
fontWeight: isOpen ? 'bold' : 'normal',
textDecoration: 'underline',
}}
>
{selectedOption?.iconName && (
<span aria-hidden="true" style={{ fontSize: '1em' }}>
</span>
)}
{selectedOption?.label ?? 'Choose mode'}
</button>
)}
/>
</Box>
</Box>
</ScreenshotArea>
);
}
134 changes: 134 additions & 0 deletions src/select/__integ__/render-custom-trigger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import useBrowser from '@cloudscape-design/browser-test-tools/use-browser';

import createWrapper from '../../../lib/components/test-utils/selectors';

const select = createWrapper().findSelect();
// The dev page renders the consumer's <button> as a direct child of the wrapper.
const customTriggerButton = select.findCustomTrigger().find('button');

function setup(testFn: (browser: WebdriverIO.Browser) => Promise<void>) {
return useBrowser(async browser => {
await browser.url('/#/light/select/render-custom-trigger');
await browser.waitUntil(() => browser.$(customTriggerButton.toSelector()).isExisting(), {
timeout: 5_000,
timeoutMsg: 'Custom trigger button never appeared on the dev page',
});
await testFn(browser);
});
}

describe('Select renderCustomTrigger', () => {
test(
'click on the custom trigger opens the dropdown',
setup(async browser => {
// Closed initially.
await expect(browser.$(select.findDropdown().findOpenDropdown().toSelector()).isExisting()).resolves.toBe(false);

await browser.$(customTriggerButton.toSelector()).click();
await expect(browser.$(select.findDropdown().findOpenDropdown().toSelector()).isExisting()).resolves.toBe(true);
})
);

test(
'clicking the custom trigger again closes the dropdown',
setup(async browser => {
await browser.$(customTriggerButton.toSelector()).click();
await expect(browser.$(select.findDropdown().findOpenDropdown().toSelector()).isExisting()).resolves.toBe(true);

await browser.$(customTriggerButton.toSelector()).click();
await expect(browser.$(select.findDropdown().findOpenDropdown().toSelector()).isExisting()).resolves.toBe(false);
})
);

test(
'selecting an option closes the dropdown and updates the trigger label',
setup(async browser => {
// Initial label reflects the first option ("Create a case") — the dev page initialises selectedOption to options[0].
await expect(browser.$(customTriggerButton.toSelector()).getText()).resolves.toContain('Create a case');

await browser.$(customTriggerButton.toSelector()).click();
// Click the second option ("AI assist, then create a case").
await browser.$(select.findDropdown().findOption(2).toSelector()).click();

// Dropdown closes after selection.
await expect(browser.$(select.findDropdown().findOpenDropdown().toSelector()).isExisting()).resolves.toBe(false);
// Trigger label updates.
await expect(browser.$(customTriggerButton.toSelector()).getText()).resolves.toContain('AI assist');
})
);

test(
'Enter key on focused custom trigger opens the dropdown (native button behavior)',
setup(async browser => {
// Focus the custom trigger via keyboard tabbing from document body.
await browser.execute((selector: string) => {
document.querySelector<HTMLButtonElement>(selector)!.focus();
}, customTriggerButton.toSelector());

await browser.keys(['Enter']);
await expect(browser.$(select.findDropdown().findOpenDropdown().toSelector()).isExisting()).resolves.toBe(true);
})
);

test(
'Space key on focused custom trigger opens the dropdown (native button behavior)',
setup(async browser => {
await browser.execute((selector: string) => {
document.querySelector<HTMLButtonElement>(selector)!.focus();
}, customTriggerButton.toSelector());

await browser.keys(['Space']);
await expect(browser.$(select.findDropdown().findOpenDropdown().toSelector()).isExisting()).resolves.toBe(true);
})
);

test(
'Escape closes the dropdown',
setup(async browser => {
await browser.$(customTriggerButton.toSelector()).click();
await expect(browser.$(select.findDropdown().findOpenDropdown().toSelector()).isExisting()).resolves.toBe(true);

await browser.keys(['Escape']);
await expect(browser.$(select.findDropdown().findOpenDropdown().toSelector()).isExisting()).resolves.toBe(false);
})
);

test(
'focus returns to the custom trigger after the dropdown closes',
setup(async browser => {
await browser.$(customTriggerButton.toSelector()).click();
await expect(browser.$(select.findDropdown().findOpenDropdown().toSelector()).isExisting()).resolves.toBe(true);

await browser.keys(['Escape']);

// The custom <button> we render in the dev page should be the active element.
const focusedSelector = await browser.execute((selector: string) => {
const expected = document.querySelector<HTMLButtonElement>(selector);
return expected !== null && document.activeElement === expected;
}, customTriggerButton.toSelector());
expect(focusedSelector).toBe(true);
})
);

test(
'aria-expanded reflects the dropdown open state on the consumer element',
setup(async browser => {
await expect(browser.$(customTriggerButton.toSelector()).getAttribute('aria-expanded')).resolves.toBe('false');

await browser.$(customTriggerButton.toSelector()).click();
await expect(browser.$(customTriggerButton.toSelector()).getAttribute('aria-expanded')).resolves.toBe('true');

await browser.keys(['Escape']);
await expect(browser.$(customTriggerButton.toSelector()).getAttribute('aria-expanded')).resolves.toBe('false');
})
);

test(
'aria-haspopup="listbox" is set on the consumer element (consumer contract)',
setup(async browser => {
await expect(browser.$(customTriggerButton.toSelector()).getAttribute('aria-haspopup')).resolves.toBe('listbox');
})
);
});
103 changes: 103 additions & 0 deletions src/select/__tests__/render-custom-trigger.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';
import { render } from '@testing-library/react';

import Select, { SelectProps } from '../../../lib/components/select';
import createWrapper from '../../../lib/components/test-utils/dom';

const defaultOptions: SelectProps.Options = [
{ value: 'a', label: 'Option A' },
{ value: 'b', label: 'Option B' },
];

function renderSelect(props: Partial<SelectProps>) {
const { container } = render(<Select selectedOption={null} options={defaultOptions} {...props} />);
return createWrapper(container).findSelect()!;
}

describe('Select renderCustomTrigger', () => {
test('renders the consumer-provided trigger element when renderCustomTrigger is set', () => {
const wrapper = renderSelect({
renderCustomTrigger: ({ triggerRef, ariaProps }) => (
<button
ref={triggerRef as React.Ref<HTMLButtonElement>}
aria-haspopup="listbox"
{...ariaProps}
data-testid="custom-trigger"
>
Custom trigger
</button>
),
});

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 }) => (
<button ref={triggerRef as React.Ref<HTMLButtonElement>} aria-haspopup="listbox" {...ariaProps}>
Custom
</button>
),
});
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 }) => (
<button
ref={triggerRef as React.Ref<HTMLButtonElement>}
onClick={onClick}
aria-haspopup="listbox"
{...ariaProps}
>
Open
</button>
),
});

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 (
<button
ref={triggerRef as React.Ref<HTMLButtonElement>}
onClick={onClick}
aria-haspopup="listbox"
{...ariaProps}
>
T
</button>
);
},
});

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);
});
});
2 changes: 2 additions & 0 deletions src/select/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const Select = React.forwardRef(
statusType = 'finished',
triggerVariant = 'label',
renderOption,
renderCustomTrigger,
...restProps
}: SelectProps,
ref: React.Ref<SelectProps.Ref>
Expand Down Expand Up @@ -58,6 +59,7 @@ const Select = React.forwardRef(
return (
<InternalSelect
renderOption={renderOption}
renderCustomTrigger={renderCustomTrigger}
options={options}
filteringType={filteringType}
statusType={statusType}
Expand Down
28 changes: 28 additions & 0 deletions src/select/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export interface BaseSelectProps
name?: string;
/**
* Specifies the hint text that's displayed in the field when no option has been selected.
* When `renderCustomTrigger` is provided, this property is not used; the consumer is responsible for rendering their own empty state.
*/
placeholder?: string;
/**
Expand Down Expand Up @@ -191,6 +192,16 @@ export interface SelectProps extends BaseSelectProps {
* Specifies a render function to render custom options in the dropdown menu or trigger.
*/
renderOption?: SelectProps.SelectOptionItemRenderer;
/**
* Render function that fully replaces Select's default trigger element.
* The returned element must wire the provided props to its outermost focusable
* element to preserve dropdown behavior, focus return, and ARIA wiring. The consumer
* must also render `aria-haspopup="listbox"` on the focusable element themselves.
*
* When this prop is provided, the `placeholder` prop is not consumed by Select;
* the consumer is responsible for rendering their own empty state for an unselected option.
*/
renderCustomTrigger?: (props: SelectProps.CustomTriggerProps) => React.ReactNode;
}

export namespace SelectProps {
Expand Down Expand Up @@ -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<HTMLElement>;
/** 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;
};
}
}
Loading
Loading