Skip to content

Commit

Permalink
Merge pull request #215 from eccenca/feature/multisuggest-maxheight-C…
Browse files Browse the repository at this point in the history
…MEM-5868

Add option to limit height of MultiSuggestField plus its dropdown
  • Loading branch information
haschek authored Nov 14, 2024
2 parents 9885f99 + abf1040 commit 4edf55b
Show file tree
Hide file tree
Showing 6 changed files with 172 additions and 28 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- `noTogglerContentSuffix`: Allows to add non-string elements at the end of the content if the full description is shown, i.e. no toggler is necessary. This allows to add non-string elements to both the full-view content and the pure string content.
- `<MultiSuggestField />`
- An optional custom search function property has been added, it defines how to filter elements.
- Added a prop `limitHeightOpened` to limit the height of the dropdown by automatically calculating the available height in vh.
- `<FlexibleLayoutContainer />` and `<FlexibleLayoutItem />`
- helper components to create flex layouts for positioning sub elements
- stop misusing `Toolbar*` components to do that (anti pattern)
Expand Down
37 changes: 37 additions & 0 deletions src/components/MultiSelect/MultiSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ interface MultiSelectCommonProps<T>
* If not provided, values are filtered by their labels
*/
searchPredicate?: (item: T, query: string) => boolean;
/**
* Limits the height of the input target plus its dropdown menu when it is opened.
* Need to be a `number not greater than 100` (as `vh`, a unit describing a length relative to the viewport height) or `true` (equals 100).
* If not set than the dropdown menu cannot be larger that appr. the half of the available viewport hight.
*/
limitHeightOpened?: boolean | number;
}

/** @deprecated (v25) use MultiSuggestFieldProps */
Expand Down Expand Up @@ -172,6 +178,7 @@ function MultiSelect<T>({
"data-testid": dataTestid,
wrapperProps,
searchPredicate,
limitHeightOpened,
...otherMultiSelectProps
}: MultiSelectProps<T>) {
// Options created by a user
Expand All @@ -184,6 +191,8 @@ function MultiSelect<T>({
const [selectedItems, setSelectedItems] = React.useState<T[]>(() =>
prePopulateWithItems ? [...items] : externalSelectedItems ? [...externalSelectedItems] : []
);
// Max height of the menu
const [calculatedMaxHeight, setCalculatedMaxHeight] = React.useState<string | null>(null);

//currently focused element in popover list
const [focusedItem, setFocusedItem] = React.useState<T | null>(null);
Expand Down Expand Up @@ -244,6 +253,29 @@ function MultiSelect<T>({
setSelectedItems(externalSelectedItems);
}, [externalSelectedItems?.map((item) => itemId(item)).join("|")]);

React.useEffect(() => {
const calculateMaxHeight = () => {
if (inputRef.current) {
// Get the height of the input target
const inputTargetHeight = inputRef.current.getBoundingClientRect().height;
// Calculate the menu dropdown by using the limited height reduced by the target height
setCalculatedMaxHeight(`calc(${maxHeightToProcess}vh - ${inputTargetHeight}px)`);
}
};

const removeListener = () => {
window.removeEventListener("resize", calculateMaxHeight);
};

if (!limitHeightOpened || (typeof limitHeightOpened === "number" && limitHeightOpened > 100))
return removeListener;
const maxHeightToProcess = typeof limitHeightOpened === "number" ? limitHeightOpened : 100;

calculateMaxHeight();
window.addEventListener("resize", calculateMaxHeight);
return removeListener;
}, [limitHeightOpened, selectedItems]);

/**
* using the equality prop specified checks if an item has already been selected
* @param matcher
Expand Down Expand Up @@ -514,6 +546,11 @@ function MultiSelect<T>({
{
"data-test-id": dataTestId ? dataTestId + "_drowpdown" : undefined,
"data-testid": dataTestid ? dataTestid + "_dropdown" : undefined,
style: calculatedMaxHeight
? ({
"--eccgui-multisuggestfield-max-height": `${calculatedMaxHeight}`,
} as React.CSSProperties)
: undefined,
} as BlueprintMultiSelectProps<T>["popoverContentProps"]
}
/>
Expand Down
41 changes: 22 additions & 19 deletions src/components/MultiSuggestField/MultiSuggestField.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import React, { useCallback, useMemo, useState } from "react";
import { loremIpsum } from "react-lorem-ipsum";
import { OverlaysProvider } from "@blueprintjs/core";
import { Meta, StoryFn } from "@storybook/react";
import { fn } from "@storybook/test";

import { MultiSuggestField, MultiSuggestFieldSelectionProps, SimpleDialog } from "./../../../index";

const testLabels = loremIpsum({
p: 1,
avgSentencesPerParagraph: 5,
avgSentencesPerParagraph: 50,
avgWordsPerSentence: 1,
startWithLoremIpsum: false,
random: false,
Expand All @@ -16,8 +17,8 @@ const testLabels = loremIpsum({
.split(".")
.map((item) => item.trim());

const items = new Array(5).fill(undefined).map((_, id) => {
const testLabel = testLabels[id];
const items = new Array(50).fill(undefined).map((_, id) => {
const testLabel = `${testLabels[id]}${id + 1}`;
return { testLabel, testId: `${testLabel}-id` };
});

Expand All @@ -36,9 +37,9 @@ export default {

const Template: StoryFn<typeof MultiSuggestField> = (args) => {
return (
<div>
<OverlaysProvider>
<MultiSuggestField {...args} />
</div>
</OverlaysProvider>
);
};

Expand Down Expand Up @@ -91,7 +92,7 @@ const DeferredSelectionTemplate: StoryFn = () => {
const identity = useCallback((item: string): string => item, []);

return (
<>
<OverlaysProvider>
<div>Selected items loaded: {loaded.toString()}</div>

<br />
Expand All @@ -107,7 +108,7 @@ const DeferredSelectionTemplate: StoryFn = () => {
<br />

<button onClick={() => setLoaded((prev) => !prev)}>Toggle selected</button>
</>
</OverlaysProvider>
);
};

Expand Down Expand Up @@ -142,14 +143,16 @@ const CreationTemplate: StoryFn = () => {
}, []);

return (
<MultiSuggestField<string>
items={items}
selectedItems={selectedValues}
onSelection={handleOnSelect}
itemId={identity}
itemLabel={identity}
createNewItemFromQuery={identity}
/>
<OverlaysProvider>
<MultiSuggestField<string>
items={items}
selectedItems={selectedValues}
onSelection={handleOnSelect}
itemId={identity}
itemLabel={identity}
createNewItemFromQuery={identity}
/>
</OverlaysProvider>
);
};

Expand All @@ -173,7 +176,7 @@ const WithResetButtonComponent = (): JSX.Element => {
};

return (
<div>
<OverlaysProvider>
<button onClick={handleReset}>Reset</button>
<br />
<br />
Expand All @@ -185,7 +188,7 @@ const WithResetButtonComponent = (): JSX.Element => {
itemLabel={({ testLabel }) => testLabel}
createNewItemFromQuery={(query) => ({ testId: `${query}-id`, testLabel: query })}
/>
</div>
</OverlaysProvider>
);
};

Expand Down Expand Up @@ -215,7 +218,7 @@ const WithinModal = (): JSX.Element => {
};

return (
<>
<OverlaysProvider>
<button onClick={() => setIsOpen(true)}>open modal</button>

<SimpleDialog isOpen={isOpen} onClose={() => setIsOpen(false)} canOutsideClickClose>
Expand All @@ -233,7 +236,7 @@ const WithinModal = (): JSX.Element => {
/>
</div>
</SimpleDialog>
</>
</OverlaysProvider>
);
};

Expand Down
8 changes: 8 additions & 0 deletions src/components/MultiSuggestField/_multisuggestfield.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
@import "~@blueprintjs/select/src/components/multi-select/multi-select";

.#{$ns}-multi-select-popover {
.#{$ns}-menu {
max-width: unset;
max-height: var(--eccgui-multisuggestfield-max-height, 45vh);
}
}
112 changes: 103 additions & 9 deletions src/components/MultiSuggestField/tests/MultiSuggestField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import "@testing-library/jest-dom";
import { MultiSuggestField } from "../../../../index";
import { CustomSearch, Default, dropdownOnFocus, predefinedNotControlledValues } from "../MultiSuggestField.stories";

const testLabels = ["label1", "label2", "label3", "label4", "label5"];
//const testLabels = ["label1", "label2", "label3", "label4", "label5"];

const items = new Array(5).fill(undefined).map((_, id) => {
const testLabel = testLabels[id];
const items = new Array(50).fill(undefined).map((_, id) => {
const testLabel = `label${id + 1}`;
return { testLabel, testId: `${testLabel}-id` };
});

Expand Down Expand Up @@ -108,7 +108,7 @@ describe("MultiSuggestField", () => {
expect(menuItems.length).toBe(dropdownOnFocus.args.items.length);
});

fireEvent.change(input, { target: { value: "ex" } });
fireEvent.change(input, { target: { value: "cras" } });

await waitFor(() => {
const listbox = screen.getByRole("listbox");
Expand Down Expand Up @@ -277,7 +277,7 @@ describe("MultiSuggestField", () => {
expect(menuItems.length).toBe(CustomSearch.args.items.length);
});

fireEvent.change(input, { target: { value: "label1" } });
fireEvent.change(input, { target: { value: "label11" } });

await waitFor(() => {
const listbox = screen.getByRole("listbox");
Expand All @@ -289,10 +289,10 @@ describe("MultiSuggestField", () => {
const item = menuItems[0];

const [div] = item.getElementsByTagName("div");
expect(div.textContent).toBe("label1");
expect(div.textContent).toBe("label11");
});

fireEvent.change(input, { target: { value: "label1-id" } });
fireEvent.change(input, { target: { value: "label11-id" } });

await waitFor(() => {
const listbox = screen.getByRole("listbox");
Expand All @@ -304,10 +304,10 @@ describe("MultiSuggestField", () => {
const item = menuItems[0];

const [div] = item.getElementsByTagName("div");
expect(div.textContent).toBe("label1");
expect(div.textContent).toBe("label11");
});

fireEvent.change(input, { target: { value: "label1-id-other" } });
fireEvent.change(input, { target: { value: "label11-id-other" } });

await waitFor(() => {
const listbox = screen.getByRole("listbox");
Expand Down Expand Up @@ -549,5 +549,99 @@ describe("MultiSuggestField", () => {
const tagsAfterRemove = container.querySelectorAll("span[data-tag-index]");
expect(tagsAfterRemove.length).toBe(0);
});

it("should not contain the custom css property when limitHeightOpened not provided", async () => {
const { container } = render(
<MultiSuggestField {...Default.args} openOnKeyDown={false} data-testid="multi-suggest-field" />
);

const [inputTargetContainer] = container.getElementsByClassName("eccgui-multiselect");

fireEvent.click(inputTargetContainer);

await waitFor(() => {
const dropdown = screen.getByTestId("multi-suggest-field_dropdown");
const customProperty = (dropdown as HTMLElement)?.style?.getPropertyValue(
"--eccgui-multisuggestfield-max-height"
);

expect(customProperty).toBeFalsy();
});
});

it("should notcontain the custom css property when limitHeightOpened greater than 100", async () => {
const { container } = render(
<MultiSuggestField
{...Default.args}
openOnKeyDown={false}
limitHeightOpened={110}
data-testid="multi-suggest-field"
/>
);

const [inputTargetContainer] = container.getElementsByClassName("eccgui-multiselect");

fireEvent.click(inputTargetContainer);

await waitFor(() => {
const dropdown = screen.getByTestId("multi-suggest-field_dropdown");

const customProperty = (dropdown as HTMLElement)?.style?.getPropertyValue(
"--eccgui-multisuggestfield-max-height"
);

expect(customProperty).toBeFalsy();
});
});

it("should contain the custom css property when limitHeightOpened is true", async () => {
const { container } = render(
<MultiSuggestField
{...Default.args}
openOnKeyDown={false}
limitHeightOpened
data-testid="multi-suggest-field"
/>
);

const [inputTargetContainer] = container.getElementsByClassName("eccgui-multiselect");

fireEvent.click(inputTargetContainer);

await waitFor(() => {
const dropdown = screen.getByTestId("multi-suggest-field_dropdown");

const customProperty = (dropdown as HTMLElement)?.style?.getPropertyValue(
"--eccgui-multisuggestfield-max-height"
);

expect(customProperty).toBeDefined();
});
});

it("should contain the custom css property when limitHeightOpened a valid number value", async () => {
const { container } = render(
<MultiSuggestField
{...Default.args}
openOnKeyDown={false}
limitHeightOpened={80}
data-testid="multi-suggest-field"
/>
);

const [inputTargetContainer] = container.getElementsByClassName("eccgui-multiselect");

fireEvent.click(inputTargetContainer);

await waitFor(() => {
const dropdown = screen.getByTestId("multi-suggest-field_dropdown");

const customProperty = (dropdown as HTMLElement)?.style?.getPropertyValue(
"--eccgui-multisuggestfield-max-height"
);

expect(customProperty).toBeDefined();
});
});
});
});
1 change: 1 addition & 0 deletions src/components/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@
@import "./Accordion/accordion";
@import "./Badge/badge";
@import "./PropertyValuePair/propertyvalue";
@import "./MultiSuggestField/multisuggestfield";

0 comments on commit 4edf55b

Please sign in to comment.