Skip to content
Open
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
84 changes: 83 additions & 1 deletion src/internal/components/dropdown/__tests__/dropdown.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useState } from 'react';
import React, { useRef, useState } from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';

import { warnOnce } from '@cloudscape-design/component-toolkit/internal';

import Dropdown from '../../../../../lib/components/internal/components/dropdown';
import { calculatePosition } from '../../../../../lib/components/internal/components/dropdown/dropdown-fit-handler';
import customCssProps from '../../../../../lib/components/internal/generated/custom-css-properties';
import { nodeBelongs } from '../../../../../lib/components/internal/utils/node-belongs';
import DropdownWrapper from '../../../../../lib/components/test-utils/dom/internal/dropdown';

const outsideId = 'outside';
Expand Down Expand Up @@ -433,6 +436,85 @@ describe('Dropdown Component', () => {
});
});

jest.mock('@cloudscape-design/component-toolkit/internal', () => ({
...jest.requireActual('@cloudscape-design/component-toolkit/internal'),
warnOnce: jest.fn(),
}));

afterEach(() => {
(warnOnce as jest.Mock).mockReset();
});

describe('triggerRef prop', () => {
test('renders trigger without a wrapper div when triggerRef is provided', () => {
function TestComponent() {
const ref = useRef<HTMLButtonElement>(null);
return (
<Dropdown trigger={<button id="my-trigger" ref={ref} data-testid="trigger" />} triggerRef={ref} open={false} />
);
}
const { container } = render(<TestComponent />);
// The trigger should be a direct child of the root div, not wrapped in another div
const root = container.firstElementChild!;
expect(root.firstElementChild!.tagName).toBe('BUTTON');
});

test('wraps trigger in a div when triggerRef is not provided', () => {
const { container } = render(<Dropdown trigger={<button data-testid="trigger" />} open={false} />);
const root = container.firstElementChild!;
expect(root.firstElementChild!.tagName).toBe('DIV');
});

test('warns when triggerRef is provided but trigger element has no id', async () => {
function TestComponent() {
const ref = useRef<HTMLButtonElement>(null);
return <Dropdown trigger={<button ref={ref} />} triggerRef={ref} open={false} />;
}
render(<TestComponent />);
await act(() => Promise.resolve());
expect(warnOnce).toHaveBeenCalledWith('Dropdown', expect.stringContaining('id'));
});

test('does not warn when triggerRef is provided and trigger element has an id', async () => {
function TestComponent() {
const ref = useRef<HTMLButtonElement>(null);
return <Dropdown trigger={<button id="my-trigger" ref={ref} />} triggerRef={ref} open={false} />;
}
render(<TestComponent />);
await act(() => Promise.resolve());
expect(warnOnce).not.toHaveBeenCalled();
});

test('portal container references the trigger id via data-awsui-referrer-id when expandToViewport is used', () => {
function TestComponent() {
const ref = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
return (
<>
<button data-testid="open-btn" onClick={() => setOpen(true)} />
<Dropdown
trigger={<button id="my-trigger" ref={ref} data-testid="trigger" />}
triggerRef={ref}
open={open}
expandToViewport={true}
content={<div data-testid="dropdown-content">content</div>}
/>
</>
);
}
render(<TestComponent />);

act(() => screen.getByTestId('open-btn').click());

const trigger = screen.getByTestId('trigger');
const content = screen.getByTestId('dropdown-content');

// The portal container should have data-awsui-referrer-id pointing to the trigger's id,
// allowing nodeBelongs to trace the portaled content back to the trigger
expect(nodeBelongs(trigger, content)).toBe(true);
});
});

/**
* This function causes a zero-time delay in order
* to allow events that are queued in the event loop
Expand Down
2 changes: 1 addition & 1 deletion src/internal/components/dropdown/dropdown-fit-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ const getInteriorDropdownPosition = (

export const calculatePosition = (
dropdownElement: HTMLDivElement,
triggerElement: HTMLDivElement,
triggerElement: HTMLElement,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trigger can be any HTML element

verticalContainerElement: HTMLDivElement,
interior: boolean,
expandToViewport: boolean,
Expand Down
40 changes: 32 additions & 8 deletions src/internal/components/dropdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import clsx from 'clsx';

import { useMergeRefs, useResizeObserver, useUniqueId } from '@cloudscape-design/component-toolkit/internal';
import { useMergeRefs, useResizeObserver, useUniqueId, warnOnce } from '@cloudscape-design/component-toolkit/internal';
import { getLogicalBoundingClientRect } from '@cloudscape-design/component-toolkit/internal';

import { fireNonCancelableEvent } from '../../events';
Expand Down Expand Up @@ -170,6 +170,7 @@ const TransitionContent = ({
const Dropdown = ({
content,
trigger,
triggerRef: externalTriggerRef,
open,
onOutsideClick,
onMouseDown,
Expand Down Expand Up @@ -199,7 +200,8 @@ const Dropdown = ({
ariaDescribedby,
}: DropdownProps) => {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLDivElement | null>(null);
const internalTriggerRef = useRef<HTMLDivElement | null>(null);
const triggerRef = externalTriggerRef || internalTriggerRef;
const dropdownRef = useRef<HTMLDivElement | null>(null);
const dropdownContainerRef = useRef<HTMLDivElement | null>(null);
const verticalContainerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -411,7 +413,7 @@ const Dropdown = ({
return () => {
window.removeEventListener('click', clickListener, true);
};
}, [open, onOutsideClick]);
}, [open, onOutsideClick, triggerRef]);

// subscribe to Escape key press
useEffect(() => {
Expand Down Expand Up @@ -457,9 +459,23 @@ const Dropdown = ({
return () => {
controller.abort();
};
}, [open, expandToViewport, isMobile]);
}, [open, expandToViewport, isMobile, triggerRef]);

const referrerId = useUniqueId();
const generatedReferrerId = useUniqueId();

// Warn when an external triggerRef is provided but the trigger element has no id
useEffect(() => {
if (externalTriggerRef && !externalTriggerRef.current?.id) {
warnOnce(
'Dropdown',
'triggerRef was provided but the trigger element has no `id` attribute. A unique `id` on the trigger element is required for the dropdown to work correctly.'
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// When an external triggerRef is provided, we use the trigger element's id as the referrer id.
const referrerId = externalTriggerRef ? externalTriggerRef.current?.id : generatedReferrerId;

// Compute CSS variable values for min/max width
// These will be used by the use-flexible-width CSS class
Expand Down Expand Up @@ -496,9 +512,17 @@ const Dropdown = ({
onFocus={focusHandler}
onBlur={blurHandler}
>
<div id={referrerId} className={clsx(stretchTriggerHeight && styles['stretch-trigger-height'])} ref={triggerRef}>
{trigger}
</div>
{externalTriggerRef ? (
trigger
) : (
<div
id={generatedReferrerId}
className={clsx(stretchTriggerHeight && styles['stretch-trigger-height'])}
ref={internalTriggerRef}
>
{trigger}
</div>
)}

<TabTrap
focusNextCallback={() => dropdownRef.current && getFirstFocusable(dropdownRef.current)?.focus()}
Expand Down
7 changes: 7 additions & 0 deletions src/internal/components/dropdown/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ export interface DropdownProps extends ExpandToViewport {
*/
trigger: React.ReactNode;

/**
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might update this comment after API proposal for trigger ref.

* Optional ref to the trigger element. When provided, the trigger will not be wrapped in a div,
* and the element's `id` attribute will be used as the referrer ID for portal-mode dropdowns.
* The trigger element must have a unique `id` set.
*/
triggerRef?: React.RefObject<HTMLElement>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use getTrigger() instead?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would be the purpose of getTrigger?

By the way: After the proposal, we don't want the triggerRef to be public, but we could still merge this since @ernst-dev needs it.


/**
* "Sticky" header of the dropdown content
*/
Expand Down
Loading