diff --git a/CHANGELOG.md b/CHANGELOG.md index 68702ec7..6a1b437e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - `` - 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. - `` and `` - helper components to create flex layouts for positioning sub elements - stop misusing `Toolbar*` components to do that (anti pattern) diff --git a/src/components/MultiSelect/MultiSelect.tsx b/src/components/MultiSelect/MultiSelect.tsx index 3adfb6ac..091d83c9 100644 --- a/src/components/MultiSelect/MultiSelect.tsx +++ b/src/components/MultiSelect/MultiSelect.tsx @@ -119,6 +119,12 @@ interface MultiSelectCommonProps * 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 */ @@ -172,6 +178,7 @@ function MultiSelect({ "data-testid": dataTestid, wrapperProps, searchPredicate, + limitHeightOpened, ...otherMultiSelectProps }: MultiSelectProps) { // Options created by a user @@ -184,6 +191,8 @@ function MultiSelect({ const [selectedItems, setSelectedItems] = React.useState(() => prePopulateWithItems ? [...items] : externalSelectedItems ? [...externalSelectedItems] : [] ); + // Max height of the menu + const [calculatedMaxHeight, setCalculatedMaxHeight] = React.useState(null); //currently focused element in popover list const [focusedItem, setFocusedItem] = React.useState(null); @@ -244,6 +253,29 @@ function MultiSelect({ 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 @@ -514,6 +546,11 @@ function MultiSelect({ { "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["popoverContentProps"] } /> diff --git a/src/components/MultiSuggestField/MultiSuggestField.stories.tsx b/src/components/MultiSuggestField/MultiSuggestField.stories.tsx index 23fb1cb0..370d87b7 100644 --- a/src/components/MultiSuggestField/MultiSuggestField.stories.tsx +++ b/src/components/MultiSuggestField/MultiSuggestField.stories.tsx @@ -1,5 +1,6 @@ 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"; @@ -7,7 +8,7 @@ import { MultiSuggestField, MultiSuggestFieldSelectionProps, SimpleDialog } from const testLabels = loremIpsum({ p: 1, - avgSentencesPerParagraph: 5, + avgSentencesPerParagraph: 50, avgWordsPerSentence: 1, startWithLoremIpsum: false, random: false, @@ -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` }; }); @@ -36,9 +37,9 @@ export default { const Template: StoryFn = (args) => { return ( -
+ -
+ ); }; @@ -91,7 +92,7 @@ const DeferredSelectionTemplate: StoryFn = () => { const identity = useCallback((item: string): string => item, []); return ( - <> +
Selected items loaded: {loaded.toString()}

@@ -107,7 +108,7 @@ const DeferredSelectionTemplate: StoryFn = () => {
- +
); }; @@ -142,14 +143,16 @@ const CreationTemplate: StoryFn = () => { }, []); return ( - - items={items} - selectedItems={selectedValues} - onSelection={handleOnSelect} - itemId={identity} - itemLabel={identity} - createNewItemFromQuery={identity} - /> + + + items={items} + selectedItems={selectedValues} + onSelection={handleOnSelect} + itemId={identity} + itemLabel={identity} + createNewItemFromQuery={identity} + /> + ); }; @@ -173,7 +176,7 @@ const WithResetButtonComponent = (): JSX.Element => { }; return ( -
+

@@ -185,7 +188,7 @@ const WithResetButtonComponent = (): JSX.Element => { itemLabel={({ testLabel }) => testLabel} createNewItemFromQuery={(query) => ({ testId: `${query}-id`, testLabel: query })} /> -
+ ); }; @@ -215,7 +218,7 @@ const WithinModal = (): JSX.Element => { }; return ( - <> + setIsOpen(false)} canOutsideClickClose> @@ -233,7 +236,7 @@ const WithinModal = (): JSX.Element => { /> - + ); }; diff --git a/src/components/MultiSuggestField/_multisuggestfield.scss b/src/components/MultiSuggestField/_multisuggestfield.scss new file mode 100644 index 00000000..265621b7 --- /dev/null +++ b/src/components/MultiSuggestField/_multisuggestfield.scss @@ -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); + } +} diff --git a/src/components/MultiSuggestField/tests/MultiSuggestField.test.tsx b/src/components/MultiSuggestField/tests/MultiSuggestField.test.tsx index 0a440a2f..c01e1b52 100644 --- a/src/components/MultiSuggestField/tests/MultiSuggestField.test.tsx +++ b/src/components/MultiSuggestField/tests/MultiSuggestField.test.tsx @@ -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` }; }); @@ -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"); @@ -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"); @@ -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"); @@ -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"); @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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(); + }); + }); }); }); diff --git a/src/components/index.scss b/src/components/index.scss index c9ea4fd1..04fcfce0 100644 --- a/src/components/index.scss +++ b/src/components/index.scss @@ -40,3 +40,4 @@ @import "./Accordion/accordion"; @import "./Badge/badge"; @import "./PropertyValuePair/propertyvalue"; +@import "./MultiSuggestField/multisuggestfield";