Skip to content

Commit

Permalink
Change unique value selector dropdown menu to be space aware (#444)
Browse files Browse the repository at this point in the history
  • Loading branch information
jasdom authored Mar 1, 2024
1 parent 49770b9 commit 8b741f1
Show file tree
Hide file tree
Showing 5 changed files with 119 additions and 12 deletions.
5 changes: 5 additions & 0 deletions .changeset/proud-numbers-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@itwin/presentation-components": patch
---

Updated UniqueValueSelector dropdown menu to open upwards when there is not enough space below.
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
/** @packageDocumentation
* @module Core
*/

import { createContext, PropsWithChildren, useContext } from "react";

/**
* Context that stores a portal target. It will be used to portal popovers opened by presentation components.
* @beta
*/
export interface PortalTargetContext {
portalTarget: HTMLElement | null;
}

const portalTargetContext = createContext<PortalTargetContext>({} as PortalTargetContext);

/**
* Props for [[PortalTargetContextProvider]]
* @beta
*/
export interface PortalTargetContextProviderProps {
portalTarget: HTMLElement | null;
}

/**
* Provides a portal target for components.
* @beta
*/
export function PortalTargetContextProvider({ portalTarget, children }: PropsWithChildren<PortalTargetContextProviderProps>) {
return <portalTargetContext.Provider value={{ portalTarget }}>{children}</portalTargetContext.Provider>;
}

/**
* Returns context provided by [[PortalTargetContextProvider]].
* @beta
*/
export function usePortalTargetContext() {
return useContext(portalTargetContext);
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { InstanceFilterBuilder, usePresentationInstanceFilteringProps } from "./
import { PresentationInstanceFilterInfo } from "./PresentationFilterBuilder";
import { PresentationInstanceFilter } from "./PresentationInstanceFilter";
import { filterRuleValidator, isFilterNonEmpty } from "./Utils";
import { PortalTargetContextProvider } from "../common/PortalTargetContext";

/**
* Data structure that describes source to gather properties from.
Expand Down Expand Up @@ -88,9 +89,11 @@ export interface FilteringDialogToolbarHandlers {
*/
export function PresentationInstanceFilterDialog(props: PresentationInstanceFilterDialogProps) {
const { isOpen, title, ...restProps } = props;
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);

return (
<Dialog
ref={setPortalTarget}
className="presentation-instance-filter-dialog"
isOpen={isOpen}
onClose={props.onClose}
Expand All @@ -101,13 +104,15 @@ export function PresentationInstanceFilterDialog(props: PresentationInstanceFilt
isResizable
portal={true}
>
<Dialog.Backdrop />
<Dialog.Main className="presentation-instance-filter-dialog-content-container">
<Dialog.TitleBar className="presentation-instance-filter-title" titleText={title ? title : translate("instance-filter-builder.filter")} />
<ErrorBoundary fallback={<ErrorState />}>
<FilterDialogContent {...restProps} />
</ErrorBoundary>
</Dialog.Main>
<PortalTargetContextProvider portalTarget={portalTarget}>
<Dialog.Backdrop />
<Dialog.Main className="presentation-instance-filter-dialog-content-container">
<Dialog.TitleBar className="presentation-instance-filter-title" titleText={title ? title : translate("instance-filter-builder.filter")} />
<ErrorBoundary fallback={<ErrorState />}>
<FilterDialogContent {...restProps} />
</ErrorBoundary>
</Dialog.Main>
</PortalTargetContextProvider>
</Dialog>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import "./AsyncSelect.scss";
import classnames from "classnames";
import { useRef } from "react";
import { useRef, useState } from "react";
import {
ClearIndicatorProps,
components,
Expand All @@ -24,6 +24,9 @@ import { AsyncPaginate, AsyncPaginateProps } from "react-select-async-paginate";
import { SvgCaretDownSmall, SvgCheckmarkSmall, SvgCloseSmall } from "@itwin/itwinui-icons-react";
import { List, ListItem, Tag, TagContainer } from "@itwin/itwinui-react";
import { translate, useMergedRefs, useResizeObserver } from "../../common/Utils";
import { usePortalTargetContext } from "../../common/PortalTargetContext";

const MAX_SELECT_MENU_HEIGHT = 300;

function SelectContainer<TOption, IsMulti extends boolean = boolean>({ children, ...props }: ContainerProps<TOption, IsMulti>) {
return (
Expand Down Expand Up @@ -114,17 +117,26 @@ function ClearIndicator<TOption, IsMulti extends boolean = boolean>({ children:

/** @internal */
export function AsyncSelect<OptionType, Group extends GroupBase<OptionType>, Additional>(props: AsyncPaginateProps<OptionType, Group, Additional, true>) {
const divRef = useRef<HTMLDivElement>(null);
const [dropdownUp, setDropdownUp] = useState(false);
const { ref: resizeRef, width } = useResizeObserver();
const { portalTarget } = usePortalTargetContext();
const divRef = useRef<HTMLDivElement>(null);

const onMenuOpen = () => {
const { top, height } = divRef.current!.getBoundingClientRect();
const space = window.innerHeight - top - height;
setDropdownUp(space < MAX_SELECT_MENU_HEIGHT);
};

return (
<div ref={useMergedRefs(divRef, resizeRef)}>
<AsyncPaginate
{...props}
styles={{
control: () => ({}),
container: () => ({}),
menuPortal: (base) => ({ ...base, zIndex: 9999, width }),
menu: () => ({}),
menuPortal: (base) => ({ ...base, zIndex: 9999, width, pointerEvents: "auto" }),
menu: (base) => (dropdownUp ? { ...base, top: "auto", bottom: "100%" } : {}),
indicatorsContainer: () => ({}),
indicatorSeparator: (base) => ({ ...base, marginTop: undefined, marginBottom: undefined, margin: "0 var(--iui-size-xs)" }),
clearIndicator: () => ({}),
Expand All @@ -144,8 +156,10 @@ export function AsyncSelect<OptionType, Group extends GroupBase<OptionType>, Add
SelectContainer,
NoOptionsMessage,
}}
onMenuOpen={onMenuOpen}
menuPortalTarget={portalTarget}
menuPlacement={dropdownUp ? "top" : "bottom"}
isMulti={true}
menuPortalTarget={divRef.current?.ownerDocument.body.querySelector(".iui-root") ?? divRef.current?.ownerDocument.body}
/>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,22 @@ import {
} from "../../_helpers/Content";
import { createTestECInstancesNodeKey } from "../../_helpers/Hierarchy";
import { render, waitFor } from "../../TestUtils";
import { PropsWithChildren, useState } from "react";
import { PortalTargetContextProvider } from "../../../presentation-components/common/PortalTargetContext";

function TestComponentWithPortalTarget({ children }: PropsWithChildren) {
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);

return (
<div ref={setPortalTarget}>
<PortalTargetContextProvider portalTarget={portalTarget}>{children}</PortalTargetContextProvider>
</div>
);
}

describe("UniquePropertyValuesSelector", () => {
beforeEach(async () => {
window.innerHeight = 1000;
const localization = new EmptyLocalization();
sinon.stub(IModelApp, "initialized").get(() => true);
sinon.stub(IModelApp, "localization").get(() => localization);
Expand Down Expand Up @@ -71,6 +84,33 @@ describe("UniquePropertyValuesSelector", () => {

const testImodel = {} as IModelConnection;

it("opens menu upwards when not enough space below", async () => {
window.innerHeight = 0;
sinon.stub(Presentation.presentation, "getPagedDistinctValues").resolves({
total: 2,
items: [
{ displayValue: "TestValue1", groupedRawValues: ["TestValue1"] },
{ displayValue: "TestValue2", groupedRawValues: ["TestValue2"] },
],
});

const { getByText, user } = render(
<TestComponentWithPortalTarget>
<UniquePropertyValuesSelector property={propertyDescription} onChange={() => {}} imodel={testImodel} descriptor={descriptor} />,
</TestComponentWithPortalTarget>,
);

// open menu
const selector = await waitFor(() => getByText("unique-values-property-editor.select-values"));
await user.click(selector);

// click on menu item
const menuItem1 = await waitFor(() => getByText("TestValue1"));
const menuItem2 = await waitFor(() => getByText("TestValue2"));
expect(menuItem1).to.not.be.null;
expect(menuItem2).to.not.be.null;
});

it("invokes `onChange` when item from the menu is selected", async () => {
const spy = sinon.spy();

Expand Down

0 comments on commit 8b741f1

Please sign in to comment.