Skip to content

Commit

Permalink
feat: Add floating ui to dropdown (#16492)
Browse files Browse the repository at this point in the history
* feat: implement floating ui to dropdown

* feat: adds floatingStyles to dropdown & demo according example

* fix: update snapShots

* feat: added demo example in DataTable

* feat: added demo example in Modal popup

* feat: added autoalign class in case autoalign is true

* fix: act issue in test cases

* fix: test case fail in FluidDropdown

* fix: removed dropdown example from dataTable

* fix: remove modal popup example

* fix: remove modal popup example css

* fix: remove modal popup example css

* fix: removed example from Accordion

* fix: apply size and other changes as suggested by PR review

---------

Co-authored-by: Nikhil Tomar <[email protected]>
Co-authored-by: Taylor Jones <[email protected]>
  • Loading branch information
3 people authored Jun 21, 2024
1 parent 757a4af commit 854c889
Show file tree
Hide file tree
Showing 5 changed files with 120 additions and 21 deletions.
3 changes: 3 additions & 0 deletions packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2824,6 +2824,9 @@ Map {
"type": "string",
},
"ariaLabel": [Function],
"autoAlign": Object {
"type": "bool",
},
"className": Object {
"type": "string",
},
Expand Down
32 changes: 21 additions & 11 deletions packages/react/src/components/Dropdown/Dropdown-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
openMenu,
generateItems,
generateGenericItem,
waitForPosition,
} from '../ListBox/test-helpers';
import Dropdown from '../Dropdown';
import DropdownSkeleton from '../Dropdown/Dropdown.Skeleton';
Expand All @@ -35,8 +36,9 @@ describe('Dropdown', () => {
};
});

it('should initially render with the menu not open', () => {
it('should initially render with the menu not open', async () => {
render(<Dropdown {...mockProps} />);
await waitForPosition();
assertMenuClosed();
});

Expand Down Expand Up @@ -65,7 +67,7 @@ describe('Dropdown', () => {
expect(itemToElement).toHaveBeenCalled();
});

it('should render selectedItem as an element', () => {
it('should render selectedItem as an element', async () => {
render(
<Dropdown
{...mockProps}
Expand All @@ -81,6 +83,7 @@ describe('Dropdown', () => {
)}
/>
);
await waitForPosition();
// custom element should be rendered for the selected item
expect(
// eslint-disable-next-line testing-library/no-node-access
Expand All @@ -92,24 +95,27 @@ describe('Dropdown', () => {
});

describe('title', () => {
it('renders a title', () => {
it('renders a title', async () => {
render(<Dropdown {...mockProps} titleText="Email Input" />);
await waitForPosition();
expect(screen.getByText('Email Input')).toBeInTheDocument();
});

it('has the expected classes', () => {
it('has the expected classes', async () => {
render(<Dropdown {...mockProps} titleText="Email Input" />);
await waitForPosition();
expect(screen.getByText('Email Input')).toHaveClass(`${prefix}--label`);
});
});

describe('helper', () => {
it('renders a helper', () => {
it('renders a helper', async () => {
render(<Dropdown helperText="Email Input" {...mockProps} />);
await waitForPosition();
expect(screen.getByText('Email Input')).toBeInTheDocument();
});

it('renders children as expected', () => {
it('renders children as expected', async () => {
render(
<Dropdown
helperText={
Expand All @@ -120,6 +126,7 @@ describe('Dropdown', () => {
{...mockProps}
/>
);
await waitForPosition();

expect(screen.getByRole('link')).toBeInTheDocument();
});
Expand All @@ -128,7 +135,6 @@ describe('Dropdown', () => {
it('should let the user select an option by clicking on the option node', async () => {
render(<Dropdown {...mockProps} />);
await openMenu();

await userEvent.click(screen.getByText('Item 0'));
expect(mockProps.onChange).toHaveBeenCalledTimes(1);
expect(mockProps.onChange).toHaveBeenCalledWith({
Expand Down Expand Up @@ -161,15 +167,16 @@ describe('Dropdown', () => {
});

describe('should display initially selected item found in `initialSelectedItem`', () => {
it('using an object type for the `initialSelectedItem` prop', () => {
it('using an object type for the `initialSelectedItem` prop', async () => {
render(
<Dropdown {...mockProps} initialSelectedItem={mockProps.items[0]} />
);
await waitForPosition();

expect(screen.getByText(mockProps.items[0].label)).toBeInTheDocument();
});

it('using a string type for the `initialSelectedItem` prop', () => {
it('using a string type for the `initialSelectedItem` prop', async () => {
// Replace the 'items' property in mockProps with a list of strings
mockProps = {
...mockProps,
Expand All @@ -179,20 +186,23 @@ describe('Dropdown', () => {
render(
<Dropdown {...mockProps} initialSelectedItem={mockProps.items[1]} />
);
await waitForPosition();

expect(screen.getByText(mockProps.items[1])).toBeInTheDocument();
});
});

describe('Component API', () => {
it('should accept a `ref` for the underlying button element', () => {
it('should accept a `ref` for the underlying button element', async () => {
const ref = React.createRef();
render(<Dropdown {...mockProps} ref={ref} />);
await waitForPosition();
expect(ref.current).toHaveAttribute('aria-haspopup', 'listbox');
});

it('should respect slug prop', () => {
it('should respect slug prop', async () => {
const { container } = render(<Dropdown {...mockProps} slug={<Slug />} />);
await waitForPosition();
expect(container.firstChild).toHaveClass(
`${prefix}--list-box__wrapper--slug`
);
Expand Down
18 changes: 18 additions & 0 deletions packages/react/src/components/Dropdown/Dropdown.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,24 @@ const items = [
},
];

export const ExperimentalAutoAlign = () => (
<div style={{ width: 400 }}>
<div style={{ height: 300 }}></div>
<Dropdown
autoAlign={true}
id="default"
titleText="Dropdown label"
helperText="This is some helper text"
initialSelectedItem={items[1]}
label="Option 1"
items={items}
itemToString={(item) => (item ? item.text : '')}
direction="top"
/>
<div style={{ height: 800 }}></div>
</div>
);

export const Playground = (args) => (
<div style={{ width: 400 }}>
<Dropdown
Expand Down
60 changes: 59 additions & 1 deletion packages/react/src/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import React, {
ForwardedRef,
MouseEvent,
ReactNode,
useEffect,
} from 'react';
import {
useSelect,
Expand Down Expand Up @@ -40,6 +41,12 @@ import { usePrefix } from '../../internal/usePrefix';
import { FormContext } from '../FluidForm';
import { ReactAttr } from '../../types/common';
import setupGetInstanceId from '../../tools/setupGetInstanceId';
import {
useFloating,
flip,
autoUpdate,
size as floatingSize,
} from '@floating-ui/react';

const getInstanceId = setupGetInstanceId();

Expand Down Expand Up @@ -92,6 +99,11 @@ export interface DropdownProps<ItemType>
*/
ariaLabel?: string;

/**
* **Experimental**: Will attempt to automatically align the floating element to avoid collisions with the viewport and being clipped by ancestor elements.
*/
autoAlign?: boolean;

/**
* Specify the direction of the dropdown. Can be either top or bottom.
*/
Expand Down Expand Up @@ -238,6 +250,7 @@ export type DropdownTranslationKey = ListBoxMenuIconTranslationKey;
const Dropdown = React.forwardRef(
<ItemType,>(
{
autoAlign = false,
className: containerClassName,
disabled = false,
direction = 'bottom',
Expand Down Expand Up @@ -270,6 +283,43 @@ const Dropdown = React.forwardRef(
}: DropdownProps<ItemType>,
ref: ForwardedRef<HTMLButtonElement>
) => {
const { refs, floatingStyles } = useFloating(
autoAlign
? {
placement: direction,

// The floating element is positioned relative to its nearest
// containing block (usually the viewport). It will in many cases also
// “break” the floating element out of a clipping ancestor.
// https://floating-ui.com/docs/misc#clipping
strategy: 'fixed',

// Middleware order matters, arrow should be last
middleware: [
floatingSize({
apply({ rects, elements }) {
Object.assign(elements.floating.style, {
width: `${rects.reference.width}px`,
});
},
}),
flip(),
],
whileElementsMounted: autoUpdate,
}
: {} // When autoAlign is turned off, floating-ui will not be used
);

useEffect(() => {
if (autoAlign) {
Object.keys(floatingStyles).forEach((style) => {
if (refs.floating.current) {
refs.floating.current.style[style] = floatingStyles[style];
}
});
}
}, [floatingStyles, autoAlign, refs.floating]);

const prefix = usePrefix();
const { isFluid } = useContext(FormContext);

Expand Down Expand Up @@ -340,6 +390,7 @@ const Dropdown = React.forwardRef(
[`${prefix}--dropdown--readonly`]: readOnly,
[`${prefix}--dropdown--${size}`]: size,
[`${prefix}--list-box--up`]: direction === 'top',
[`${prefix}--dropdown--autoalign`]: autoAlign,
});

const titleClasses = cx(`${prefix}--label`, {
Expand Down Expand Up @@ -447,6 +498,7 @@ const Dropdown = React.forwardRef(
};

const menuProps = getMenuProps();
const menuRef = mergeRefs(menuProps.ref, refs.setFloating);

// Slug is always size `mini`
let normalizedSlug;
Expand Down Expand Up @@ -475,6 +527,7 @@ const Dropdown = React.forwardRef(
warnText={warnText}
light={light}
isOpen={isOpen}
ref={refs.setReference}
id={id}>
{invalid && (
<WarningFilled className={`${prefix}--list-box__invalid-icon`} />
Expand Down Expand Up @@ -514,7 +567,7 @@ const Dropdown = React.forwardRef(
/>
</button>
{normalizedSlug}
<ListBox.Menu {...menuProps}>
<ListBox.Menu {...menuProps} ref={menuRef}>
{isOpen &&
items.map((item, index) => {
const isObject = item !== null && typeof item === 'object';
Expand Down Expand Up @@ -592,6 +645,11 @@ Dropdown.propTypes = {
'This prop syntax has been deprecated. Please use the new `aria-label`.'
),

/**
* **Experimental**: Will attempt to automatically align the floating element to avoid collisions with the viewport and being clipped by ancestor elements.
*/
autoAlign: PropTypes.bool,

/**
* Provide a custom className to be applied on the cds--dropdown node
*/
Expand Down
Loading

0 comments on commit 854c889

Please sign in to comment.