Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add floating ui to dropdown #16492

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ea5971a
feat: implement floating ui to dropdown
preetibansalui May 17, 2024
9d08d0c
feat: adds floatingStyles to dropdown & demo according example
2nikhiltom May 21, 2024
573b9db
fix: update snapShots
2nikhiltom May 21, 2024
9493f0c
feat: added demo example in DataTable
preetibansalui May 22, 2024
9f2e7a2
feat: added demo example in Modal popup
preetibansalui May 22, 2024
3443ca2
feat: added autoalign class in case autoalign is true
preetibansalui May 22, 2024
b6b372e
Merge branch 'main' into 15865-add-floatin-ui-to-dropdown
preetibansalui May 22, 2024
dae7420
fix: act issue in test cases
preetibansalui Jun 5, 2024
2e5d109
Merge branch 'main' into 15865-add-floatin-ui-to-dropdown
preetibansalui Jun 5, 2024
1e5f8e2
fix: test case fail in FluidDropdown
preetibansalui Jun 5, 2024
26ae290
fix: removed dropdown example from dataTable
preetibansalui Jun 5, 2024
4188d60
Merge branch 'main' into 15865-add-floatin-ui-to-dropdown
preetibansalui Jun 5, 2024
c21843b
fix: remove modal popup example
preetibansalui Jun 10, 2024
fc5e639
fix: remove modal popup example css
preetibansalui Jun 10, 2024
bae24bf
fix: remove modal popup example css
preetibansalui Jun 10, 2024
bbd3ff4
fix: removed example from Accordion
preetibansalui Jun 10, 2024
c9b8bd6
Merge branch 'main' into 15865-add-floatin-ui-to-dropdown
preetibansalui Jun 10, 2024
1f599f4
Merge branch 'main' into 15865-add-floatin-ui-to-dropdown
preetibansalui Jun 14, 2024
549b05b
fix: apply size and other changes as suggested by PR review
preetibansalui Jun 14, 2024
866e04b
Merge branch 'main' into 15865-add-floatin-ui-to-dropdown
tay1orjones Jun 20, 2024
f070c59
Merge branch 'main' into 15865-add-floatin-ui-to-dropdown
preetibansalui Jun 21, 2024
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
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,
guidari marked this conversation as resolved.
Show resolved Hide resolved
});

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
Loading